diff --git a/apps/server/src/terminal/Layers/Manager.test.ts b/apps/server/src/terminal/Layers/Manager.test.ts index 825bcbded3..3a9cfc05d8 100644 --- a/apps/server/src/terminal/Layers/Manager.test.ts +++ b/apps/server/src/terminal/Layers/Manager.test.ts @@ -580,20 +580,9 @@ describe("TerminalManager", () => { expect(snapshot.status).toBe("running"); expect(ptyAdapter.spawnInputs.length).toBeGreaterThanOrEqual(2); expect(ptyAdapter.spawnInputs[0]?.shell).toBe("/definitely/missing-shell"); - - if (process.platform === "win32") { - expect( - ptyAdapter.spawnInputs.some( - (input) => input.shell === "cmd.exe" || input.shell === "powershell.exe", - ), - ).toBe(true); - } else { - expect( - ptyAdapter.spawnInputs.some((input) => - ["/bin/zsh", "/bin/bash", "/bin/sh", "zsh", "bash", "sh"].includes(input.shell), - ), - ).toBe(true); - } + expect( + ptyAdapter.spawnInputs.slice(1).some((input) => input.shell !== "/definitely/missing-shell"), + ).toBe(true); manager.dispose(); }); diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index cf1e798912..3c75b5a7f3 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -2,19 +2,63 @@ import { memo } from "react"; import { CopyIcon, CheckIcon } from "lucide-react"; import { Button } from "../ui/button"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { cn } from "~/lib/utils"; -export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { - const { copyToClipboard, isCopied } = useCopyToClipboard(); +type CopyCallbacks = { + onCopy?: () => void; + onError?: (error: Error) => void; +}; + +export const MessageCopyButton = memo(function MessageCopyButton({ + text, + label, + title = "Copy message", + disabled = false, + disabledTitle, + size = "xs", + variant = "outline", + className, + onCopy, + onError, +}: { + text: string; + label?: string; + title?: string; + disabled?: boolean; + disabledTitle?: string; + size?: "xs" | "icon-xs"; + variant?: "outline" | "ghost"; + className?: string; + onCopy?: () => void; + onError?: (error: Error) => void; +}) { + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onCopy: (callbacks) => { + callbacks.onCopy?.(); + }, + onError: (error, callbacks) => { + callbacks.onError?.(error); + }, + }); + const buttonTitle = disabled ? (disabledTitle ?? title) : isCopied ? "Copied" : title; + const copyCallbacks = { + ...(onCopy ? { onCopy } : {}), + ...(onError ? { onError } : {}), + }; return ( ); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index dee42a8586..0f4dd9c973 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { + computeMessageDurationStart, + normalizeCompactToolLabel, + resolveAssistantMessageCopyState, +} from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { @@ -143,3 +147,57 @@ describe("normalizeCompactToolLabel", () => { expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file"); }); }); + +describe("resolveAssistantMessageCopyState", () => { + it("returns enabled copy state for completed assistant messages", () => { + expect( + resolveAssistantMessageCopyState({ + text: "Ship it", + streaming: false, + }), + ).toEqual({ + disabled: false, + text: "Ship it", + visible: true, + }); + }); + + it("keeps copy visible but disabled for streaming assistant messages", () => { + expect( + resolveAssistantMessageCopyState({ + text: "Still streaming", + streaming: true, + }), + ).toEqual({ + disabled: true, + text: "Still streaming", + visible: true, + }); + }); + + it("hides copy for empty completed assistant messages", () => { + expect( + resolveAssistantMessageCopyState({ + text: " ", + streaming: false, + }), + ).toEqual({ + disabled: false, + text: null, + visible: false, + }); + }); + + it("keeps copy visible while an empty assistant message is streaming", () => { + expect( + resolveAssistantMessageCopyState({ + text: null, + streaming: true, + }), + ).toEqual({ + disabled: true, + text: null, + visible: true, + }); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 726d61888e..b14cc73d31 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -27,3 +27,18 @@ export function computeMessageDurationStart( export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } + +export function resolveAssistantMessageCopyState({ + text, + streaming, +}: { + text: string | null; + streaming: boolean; +}) { + const hasText = text !== null && text.trim().length > 0; + return { + disabled: streaming, + text: hasText ? text : null, + visible: streaming || hasText, + }; +} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index e694faa0f2..8469b7a302 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1,46 +1,6 @@ import { MessageId } from "@t3tools/contracts"; import { renderToStaticMarkup } from "react-dom/server"; -import { beforeAll, describe, expect, it, vi } from "vitest"; - -function matchMedia() { - return { - matches: false, - addEventListener: () => {}, - removeEventListener: () => {}, - }; -} - -beforeAll(() => { - const classList = { - add: () => {}, - remove: () => {}, - toggle: () => {}, - contains: () => false, - }; - - vi.stubGlobal("localStorage", { - getItem: () => null, - setItem: () => {}, - removeItem: () => {}, - clear: () => {}, - }); - vi.stubGlobal("window", { - matchMedia, - addEventListener: () => {}, - removeEventListener: () => {}, - desktopBridge: undefined, - }); - vi.stubGlobal("document", { - documentElement: { - classList, - offsetHeight: 0, - }, - }); - vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { - callback(0); - return 0; - }); -}); +import { describe, expect, it } from "vitest"; describe("MessagesTimeline", () => { it("renders inline terminal labels with the composer chip UI", async () => { @@ -96,4 +56,64 @@ describe("MessagesTimeline", () => { expect(markup).toContain("lucide-terminal"); expect(markup).toContain("yoo what's "); }); + + it("shows assistant copy disabled while streaming and enabled once complete", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect((markup.match(/title="Copy assistant response"/g) ?? []).length).toBe(1); + expect((markup.match(/title="Copy available when response completes"/g) ?? []).length).toBe(1); + expect((markup.match(/disabled=""/g) ?? []).length).toBe(1); + expect(markup).not.toContain(">Copy<"); + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f3e462f7fe..2e34df34f9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -41,7 +41,11 @@ import { ProposedPlanCard } from "./ProposedPlanCard"; import { ChangedFilesTree } from "./ChangedFilesTree"; import { DiffStatLabel, hasNonZeroStat } from "./DiffStatLabel"; import { MessageCopyButton } from "./MessageCopyButton"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { + computeMessageDurationStart, + normalizeCompactToolLabel, + resolveAssistantMessageCopyState, +} from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; import { deriveDisplayedUserMessageState, @@ -50,6 +54,7 @@ import { import { cn } from "~/lib/utils"; import { type TimestampFormat } from "../../appSettings"; import { formatTimestamp } from "../../timestampFormat"; +import { toastManager } from "../ui/toast"; import { buildInlineTerminalContextText, formatInlineTerminalContextLabel, @@ -437,6 +442,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ row.message.role === "assistant" && (() => { const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + const assistantCopyState = resolveAssistantMessageCopyState({ + text: row.message.text ?? null, + streaming: row.message.streaming, + }); return ( <> {row.showCompletionDivider && ( @@ -510,15 +519,43 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); })()} -

- {formatMessageMeta( - row.message.createdAt, - row.message.streaming - ? formatElapsed(row.durationStart, nowIso) - : formatElapsed(row.durationStart, row.message.completedAt), - timestampFormat, - )} -

+
+
+ {assistantCopyState.visible ? ( + { + toastManager.add({ + type: "success", + title: "Assistant response copied", + }); + }} + onError={(error) => { + toastManager.add({ + type: "error", + title: "Failed to copy assistant response", + description: error.message, + }); + }} + /> + ) : null} +
+

+ {formatMessageMeta( + row.message.createdAt, + row.message.streaming + ? formatElapsed(row.durationStart, nowIso) + : formatElapsed(row.durationStart, row.message.completedAt), + timestampFormat, + )} +

+
); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 6afe83dfe3..ca5a75953c 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -8,25 +8,36 @@ type ThemeSnapshot = { const STORAGE_KEY = "t3code:theme"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; +const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { + theme: "system", + systemDark: false, +}; let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let lastDesktopTheme: Theme | null = null; + function emitChange() { for (const listener of listeners) listener(); } -function getSystemDark(): boolean { - return window.matchMedia(MEDIA_QUERY).matches; +function hasThemeStorage() { + return typeof window !== "undefined" && typeof localStorage !== "undefined"; +} + +function getSystemDark() { + return typeof window !== "undefined" && window.matchMedia(MEDIA_QUERY).matches; } function getStored(): Theme { + if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; const raw = localStorage.getItem(STORAGE_KEY); if (raw === "light" || raw === "dark" || raw === "system") return raw; - return "system"; + return DEFAULT_THEME_SNAPSHOT.theme; } function applyTheme(theme: Theme, suppressTransitions = false) { + if (typeof document === "undefined" || typeof window === "undefined") return; if (suppressTransitions) { document.documentElement.classList.add("no-transitions"); } @@ -44,6 +55,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { } function syncDesktopTheme(theme: Theme) { + if (typeof window === "undefined") return; const bridge = window.desktopBridge; if (!bridge || lastDesktopTheme === theme) { return; @@ -58,9 +70,12 @@ function syncDesktopTheme(theme: Theme) { } // Apply immediately on module load to prevent flash -applyTheme(getStored()); +if (typeof document !== "undefined" && hasThemeStorage()) { + applyTheme(getStored()); +} function getSnapshot(): ThemeSnapshot { + if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT; const theme = getStored(); const systemDark = theme === "system" ? getSystemDark() : false; @@ -72,7 +87,12 @@ function getSnapshot(): ThemeSnapshot { return lastSnapshot; } +function getServerSnapshot() { + return DEFAULT_THEME_SNAPSHOT; +} + function subscribe(listener: () => void): () => void { + if (typeof window === "undefined") return () => {}; listeners.push(listener); // Listen for system preference changes @@ -100,13 +120,14 @@ function subscribe(listener: () => void): () => void { } export function useTheme() { - const snapshot = useSyncExternalStore(subscribe, getSnapshot); + const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); const theme = snapshot.theme; const resolvedTheme: "light" | "dark" = theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; const setTheme = useCallback((next: Theme) => { + if (!hasThemeStorage()) return; localStorage.setItem(STORAGE_KEY, next); applyTheme(next, true); emitChange(); diff --git a/apps/web/src/terminalStateStore.test.ts b/apps/web/src/terminalStateStore.test.ts index e7e240cf25..7a42888ced 100644 --- a/apps/web/src/terminalStateStore.test.ts +++ b/apps/web/src/terminalStateStore.test.ts @@ -4,11 +4,15 @@ import { beforeEach, describe, expect, it } from "vitest"; import { selectThreadTerminalState, useTerminalStateStore } from "./terminalStateStore"; const THREAD_ID = ThreadId.makeUnsafe("thread-1"); +const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; describe("terminalStateStore actions", () => { beforeEach(() => { - if (typeof localStorage !== "undefined") { - localStorage.clear(); + const storage = globalThis.localStorage; + if (typeof storage?.clear === "function") { + storage.clear(); + } else if (typeof storage?.removeItem === "function") { + storage.removeItem(TERMINAL_STATE_STORAGE_KEY); } useTerminalStateStore.setState({ terminalStateByThreadId: {} }); }); diff --git a/apps/web/src/terminalStateStore.ts b/apps/web/src/terminalStateStore.ts index b2cea6d560..0a89609d0b 100644 --- a/apps/web/src/terminalStateStore.ts +++ b/apps/web/src/terminalStateStore.ts @@ -7,7 +7,7 @@ import type { ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; -import { createJSONStorage, persist } from "zustand/middleware"; +import { createJSONStorage, persist, type StateStorage } from "zustand/middleware"; import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID, @@ -27,6 +27,34 @@ interface ThreadTerminalState { const TERMINAL_STATE_STORAGE_KEY = "t3code:terminal-state:v1"; +function createMemoryStorage(): StateStorage { + const store = new Map(); + return { + getItem: (name) => store.get(name) ?? null, + setItem: (name, value) => { + store.set(name, value); + }, + removeItem: (name) => { + store.delete(name); + }, + }; +} + +function createTerminalStateStorage() { + if ( + typeof localStorage !== "undefined" && + typeof localStorage.getItem === "function" && + typeof localStorage.setItem === "function" && + typeof localStorage.removeItem === "function" + ) { + return localStorage; + } + + return createMemoryStorage(); +} + +const terminalStateStorage = createTerminalStateStorage(); + function normalizeTerminalIds(terminalIds: string[]): string[] { const ids = [...new Set(terminalIds.map((id) => id.trim()).filter((id) => id.length > 0))]; return ids.length > 0 ? ids : [DEFAULT_THREAD_TERMINAL_ID]; @@ -542,7 +570,7 @@ export const useTerminalStateStore = create()( { name: TERMINAL_STATE_STORAGE_KEY, version: 1, - storage: createJSONStorage(() => localStorage), + storage: createJSONStorage(() => terminalStateStorage), partialize: (state) => ({ terminalStateByThreadId: state.terminalStateByThreadId, }),