diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 44ad594ff9..636ca86598 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -3347,6 +3347,7 @@ export default function ChatView(props: ChatViewProps) { hasMessages={timelineEntries.length > 0} isWorking={isWorking} activeTurnInProgress={isWorking || !latestTurnSettled} + activeTurnId={activeLatestTurn?.turnId ?? null} activeTurnStartedAt={activeWorkStartedAt} scrollContainer={messagesScrollElement} timelineEntries={timelineEntries} diff --git a/apps/web/src/components/chat/MessageCopyButton.tsx b/apps/web/src/components/chat/MessageCopyButton.tsx index cf1e798912..ad5d56dd5a 100644 --- a/apps/web/src/components/chat/MessageCopyButton.tsx +++ b/apps/web/src/components/chat/MessageCopyButton.tsx @@ -1,20 +1,82 @@ -import { memo } from "react"; +import { memo, useRef } from "react"; import { CopyIcon, CheckIcon } from "lucide-react"; import { Button } from "../ui/button"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { cn } from "~/lib/utils"; +import { anchoredToastManager } from "../ui/toast"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { - const { copyToClipboard, isCopied } = useCopyToClipboard(); +const ANCHORED_TOAST_TIMEOUT_MS = 1000; +const onCopy = (ref: React.RefObject) => { + if (ref.current) { + anchoredToastManager.add({ + data: { + tooltipStyle: true, + }, + positionerProps: { + anchor: ref.current, + }, + timeout: ANCHORED_TOAST_TIMEOUT_MS, + title: "Copied!", + }); + } +}; + +const onCopyError = (ref: React.RefObject, error: Error) => { + if (ref.current) { + anchoredToastManager.add({ + data: { + tooltipStyle: true, + }, + positionerProps: { + anchor: ref.current, + }, + timeout: ANCHORED_TOAST_TIMEOUT_MS, + title: "Failed to copy", + description: error.message, + }); + } +}; + +export const MessageCopyButton = memo(function MessageCopyButton({ + text, + size = "xs", + variant = "outline", + className, +}: { + text: string; + size?: "xs" | "icon-xs"; + variant?: "outline" | "ghost"; + className?: string; +}) { + const ref = useRef(null); + const { copyToClipboard, isCopied } = useCopyToClipboard({ + onCopy: () => onCopy(ref), + onError: (error: Error) => onCopyError(ref, error), + timeout: ANCHORED_TOAST_TIMEOUT_MS, + }); return ( - + + copyToClipboard(text)} + ref={ref} + type="button" + size={size} + variant={variant} + className={cn(className)} + /> + } + > + {isCopied ? : } + + +

Copy to clipboard

+
+
); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index dee42a8586..2910547122 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; +import { + computeMessageDurationStart, + deriveMessagesTimelineRows, + normalizeCompactToolLabel, + resolveAssistantMessageCopyState, +} from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { it("returns message createdAt when there is no preceding user message", () => { @@ -143,3 +148,120 @@ describe("normalizeCompactToolLabel", () => { expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file"); }); }); + +describe("resolveAssistantMessageCopyState", () => { + it("returns enabled copy state for completed assistant messages", () => { + expect( + resolveAssistantMessageCopyState({ + showCopyButton: true, + text: "Ship it", + streaming: false, + }), + ).toEqual({ + text: "Ship it", + visible: true, + }); + }); + + it("hides copy while an assistant message is still streaming", () => { + expect( + resolveAssistantMessageCopyState({ + showCopyButton: true, + text: "Still streaming", + streaming: true, + }), + ).toEqual({ + text: "Still streaming", + visible: false, + }); + }); + + it("hides copy for empty completed assistant messages", () => { + expect( + resolveAssistantMessageCopyState({ + showCopyButton: true, + text: " ", + streaming: false, + }), + ).toEqual({ + text: null, + visible: false, + }); + }); + + it("hides copy for non-terminal assistant messages", () => { + expect( + resolveAssistantMessageCopyState({ + showCopyButton: false, + text: "Interim thought", + streaming: false, + }), + ).toEqual({ + text: "Interim thought", + visible: false, + }); + }); +}); + +describe("deriveMessagesTimelineRows", () => { + it("only enables assistant copy for the terminal assistant message in a turn", () => { + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + { + id: "user-1-entry", + kind: "message", + createdAt: "2026-01-01T00:00:00Z", + message: { + id: "user-1" as never, + role: "user", + text: "Write a poem", + turnId: null, + createdAt: "2026-01-01T00:00:00Z", + streaming: false, + }, + }, + { + id: "assistant-thought-entry", + kind: "message", + createdAt: "2026-01-01T00:00:10Z", + message: { + id: "assistant-thought" as never, + role: "assistant", + text: "I should ground this first.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:10Z", + completedAt: "2026-01-01T00:00:11Z", + streaming: false, + }, + }, + { + id: "assistant-final-entry", + kind: "message", + createdAt: "2026-01-01T00:00:20Z", + message: { + id: "assistant-final" as never, + role: "assistant", + text: "Here is the poem.", + turnId: "turn-1" as never, + createdAt: "2026-01-01T00:00:20Z", + completedAt: "2026-01-01T00:00:30Z", + streaming: false, + }, + }, + ], + completionDividerBeforeEntryId: "assistant-final-entry", + isWorking: false, + activeTurnStartedAt: null, + }); + + const assistantRows = rows.filter( + (row): row is Extract<(typeof rows)[number], { kind: "message" }> => + row.kind === "message" && row.message.role === "assistant", + ); + + expect(assistantRows).toHaveLength(2); + expect(assistantRows[0]?.showAssistantCopyButton).toBe(false); + expect(assistantRows[1]?.showAssistantCopyButton).toBe(true); + expect(assistantRows[1]?.showCompletionDivider).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 16b02ea9b7..25debf5066 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -27,6 +27,7 @@ export type MessagesTimelineRow = message: ChatMessage; durationStart: string; showCompletionDivider: boolean; + showAssistantCopyButton: boolean; } | { kind: "proposed-plan"; @@ -59,6 +60,48 @@ export function normalizeCompactToolLabel(value: string): string { return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); } +export function resolveAssistantMessageCopyState({ + text, + showCopyButton, + streaming, +}: { + text: string | null; + showCopyButton: boolean; + streaming: boolean; +}) { + const hasText = text !== null && text.trim().length > 0; + return { + text: hasText ? text : null, + visible: showCopyButton && hasText && !streaming, + }; +} + +function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray) { + const lastAssistantMessageIdByResponseKey = new Map(); + let nullTurnResponseIndex = 0; + + for (const timelineEntry of timelineEntries) { + if (timelineEntry.kind !== "message") { + continue; + } + const { message } = timelineEntry; + if (message.role === "user") { + nullTurnResponseIndex += 1; + continue; + } + if (message.role !== "assistant") { + continue; + } + + const responseKey = message.turnId + ? `turn:${message.turnId}` + : `unkeyed:${nullTurnResponseIndex}`; + lastAssistantMessageIdByResponseKey.set(responseKey, message.id); + } + + return new Set(lastAssistantMessageIdByResponseKey.values()); +} + export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; completionDividerBeforeEntryId: string | null; @@ -69,6 +112,7 @@ export function deriveMessagesTimelineRows(input: { const durationStartByMessageId = computeMessageDurationStart( input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), ); + const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries); for (let index = 0; index < input.timelineEntries.length; index += 1) { const timelineEntry = input.timelineEntries[index]; @@ -115,6 +159,9 @@ export function deriveMessagesTimelineRows(input: { showCompletionDivider: timelineEntry.message.role === "assistant" && input.completionDividerBeforeEntryId === timelineEntry.id, + showAssistantCopyButton: + timelineEntry.message.role === "assistant" && + terminalAssistantMessageIds.has(timelineEntry.message.id), }); } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 085131eaa4..f08d544cc1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -45,6 +45,7 @@ import { deriveMessagesTimelineRows, estimateMessagesTimelineRowHeight, normalizeCompactToolLabel, + resolveAssistantMessageCopyState, type MessagesTimelineRow, } from "./MessagesTimeline.logic"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; @@ -56,6 +57,7 @@ import { import { cn } from "~/lib/utils"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatTimestamp } from "../../timestampFormat"; + import { buildInlineTerminalContextText, formatInlineTerminalContextLabel, @@ -68,6 +70,7 @@ interface MessagesTimelineProps { hasMessages: boolean; isWorking: boolean; activeTurnInProgress: boolean; + activeTurnId?: TurnId | null; activeTurnStartedAt: string | null; scrollContainer: HTMLDivElement | null; timelineEntries: ReturnType; @@ -106,6 +109,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, activeTurnInProgress, + activeTurnId, activeTurnStartedAt, scrollContainer, timelineEntries, @@ -303,7 +307,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const renderRowContent = (row: TimelineRow) => (
{ const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)"); + const assistantTurnStillInProgress = + activeTurnInProgress && + activeTurnId !== null && + activeTurnId !== undefined && + row.message.turnId === activeTurnId; + const assistantCopyState = resolveAssistantMessageCopyState({ + text: row.message.text ?? null, + showCopyButton: row.showAssistantCopyButton, + streaming: row.message.streaming || assistantTurnStillInProgress, + }); return ( <> {row.showCompletionDivider && ( @@ -512,15 +529,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({
); })()} -

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

+
+

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

+ {assistantCopyState.visible ? ( +
+ +
+ ) : null} +
); diff --git a/apps/web/src/components/timelineHeight.test.ts b/apps/web/src/components/timelineHeight.test.ts index 35c90d0120..5317a5fc68 100644 --- a/apps/web/src/components/timelineHeight.test.ts +++ b/apps/web/src/components/timelineHeight.test.ts @@ -135,4 +135,17 @@ describe("estimateTimelineMessageHeight", () => { expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(154.75); expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(86.5); }); + + it("treats inline code as wider when estimating assistant markdown wrapping", () => { + const message = { + role: "assistant" as const, + text: [ + "Typecheck found one exact-optional-property issue in the browser harness:", + "I was always passing `onVirtualizerSnapshot`, including `undefined`.", + "I'm tightening that object construction and rerunning the checks.", + ].join(" "), + }; + + expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(109.25); + }); }); diff --git a/apps/web/src/components/timelineHeight.ts b/apps/web/src/components/timelineHeight.ts index 776fe9ad88..3cb2aebb88 100644 --- a/apps/web/src/components/timelineHeight.ts +++ b/apps/web/src/components/timelineHeight.ts @@ -21,6 +21,9 @@ const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4; const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; const MIN_USER_CHARS_PER_LINE = 4; const MIN_ASSISTANT_CHARS_PER_LINE = 20; +const ASSISTANT_INLINE_CODE_WIDTH_MULTIPLIER = 1.2; +const ASSISTANT_INLINE_CODE_WRAP_OVERHEAD_CHARS = 2; +const INLINE_CODE_SPAN_REGEX = /`([^`\n]+)`/g; interface TimelineMessageHeightInput { role: "user" | "assistant" | "system"; @@ -71,13 +74,30 @@ function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): numbe ); } +function expandAssistantInlineCodeForEstimate(text: string) { + return text.replace(INLINE_CODE_SPAN_REGEX, (_match, code: string) => + "x".repeat( + Math.max( + code.length + 2, + Math.ceil( + code.length * ASSISTANT_INLINE_CODE_WIDTH_MULTIPLIER + + ASSISTANT_INLINE_CODE_WRAP_OVERHEAD_CHARS, + ), + ), + ), + ); +} + export function estimateTimelineMessageHeight( message: TimelineMessageHeightInput, layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, ): number { if (message.role === "assistant") { const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); - const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + const estimatedLines = estimateWrappedLineCount( + expandAssistantInlineCodeForEstimate(message.text), + charsPerLine, + ); return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; } @@ -103,6 +123,9 @@ export function estimateTimelineMessageHeight( // `system` messages are not rendered in the chat timeline, but keep a stable // explicit branch in case they are present in timeline data. const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx); - const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine); + const estimatedLines = estimateWrappedLineCount( + expandAssistantInlineCodeForEstimate(message.text), + charsPerLine, + ); return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; } diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index d903151e81..cd254c9754 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -8,24 +8,34 @@ type ThemeSnapshot = { const STORAGE_KEY = "t3code:theme"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; +const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { + theme: "system", + systemDark: false, +}; const THEME_COLOR_META_NAME = "theme-color"; const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; 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 ensureThemeColorMetaTag(): HTMLMetaElement { @@ -78,7 +88,7 @@ export function syncBrowserChromeTheme() { } function applyTheme(theme: Theme, suppressTransitions = false) { - if (typeof document === "undefined") return; + if (typeof document === "undefined" || typeof window === "undefined") return; if (suppressTransitions) { document.documentElement.classList.add("no-transitions"); } @@ -97,6 +107,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; @@ -111,9 +122,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; @@ -125,7 +139,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 @@ -153,13 +172,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();