authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-04-11 07:57:29+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-11 02:27:29+00:00
log26cc1fffb33ca4aad36341cacb57424570c69bec
treedd2b306bfd65ea934017d1bf1cbef8147e5fc5b8
parent97880e88705fb74648677a850aadc7b6aeac5253
signature Signed by PGP key B5690EEEBB952194

Add assistant message copy action and harden related test/storage fallbacks (#1211)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

8 files changed, 348 insertions(+), 31 deletions(-)

apps/web/src/components/ChatView.tsx+1
......@@ -3353,6 +3353,7 @@ export default function ChatView(props: ChatViewProps) {
33533353 hasMessages={timelineEntries.length > 0}
33543354 isWorking={isWorking}
33553355 activeTurnInProgress={isWorking || !latestTurnSettled}
3356 activeTurnId={activeLatestTurn?.turnId ?? null}
33563357 activeTurnStartedAt={activeWorkStartedAt}
33573358 scrollContainer={messagesScrollElement}
33583359 timelineEntries={timelineEntries}
apps/web/src/components/chat/MessageCopyButton.tsx+74-12
......@@ -1,20 +1,82 @@
1import { memo } from "react";
1import { memo, useRef } from "react";
22import { CopyIcon, CheckIcon } from "lucide-react";
33import { Button } from "../ui/button";
44import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
5import { cn } from "~/lib/utils";
6import { anchoredToastManager } from "../ui/toast";
7import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
58
6export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) {
7 const { copyToClipboard, isCopied } = useCopyToClipboard();
9const ANCHORED_TOAST_TIMEOUT_MS = 1000;
10const onCopy = (ref: React.RefObject<HTMLButtonElement | null>) => {
11 if (ref.current) {
12 anchoredToastManager.add({
13 data: {
14 tooltipStyle: true,
15 },
16 positionerProps: {
17 anchor: ref.current,
18 },
19 timeout: ANCHORED_TOAST_TIMEOUT_MS,
20 title: "Copied!",
21 });
22 }
23};
24
25const onCopyError = (ref: React.RefObject<HTMLButtonElement | null>, error: Error) => {
26 if (ref.current) {
27 anchoredToastManager.add({
28 data: {
29 tooltipStyle: true,
30 },
31 positionerProps: {
32 anchor: ref.current,
33 },
34 timeout: ANCHORED_TOAST_TIMEOUT_MS,
35 title: "Failed to copy",
36 description: error.message,
37 });
38 }
39};
40
41export const MessageCopyButton = memo(function MessageCopyButton({
42 text,
43 size = "xs",
44 variant = "outline",
45 className,
46}: {
47 text: string;
48 size?: "xs" | "icon-xs";
49 variant?: "outline" | "ghost";
50 className?: string;
51}) {
52 const ref = useRef<HTMLButtonElement>(null);
53 const { copyToClipboard, isCopied } = useCopyToClipboard<void>({
54 onCopy: () => onCopy(ref),
55 onError: (error: Error) => onCopyError(ref, error),
56 timeout: ANCHORED_TOAST_TIMEOUT_MS,
57 });
858
959 return (
10 <Button
11 type="button"
12 size="xs"
13 variant="outline"
14 onClick={() => copyToClipboard(text)}
15 title="Copy message"
16 >
17 {isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
18 </Button>
60 <Tooltip>
61 <TooltipTrigger
62 render={
63 <Button
64 aria-label="Copy link"
65 disabled={isCopied}
66 onClick={() => copyToClipboard(text)}
67 ref={ref}
68 type="button"
69 size={size}
70 variant={variant}
71 className={cn(className)}
72 />
73 }
74 >
75 {isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}
76 </TooltipTrigger>
77 <TooltipPopup>
78 <p>Copy to clipboard</p>
79 </TooltipPopup>
80 </Tooltip>
1981 );
2082});
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+123-1
......@@ -1,5 +1,10 @@
11import { describe, expect, it } from "vitest";
2import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic";
2import {
3 computeMessageDurationStart,
4 deriveMessagesTimelineRows,
5 normalizeCompactToolLabel,
6 resolveAssistantMessageCopyState,
7} from "./MessagesTimeline.logic";
38
49describe("computeMessageDurationStart", () => {
510 it("returns message createdAt when there is no preceding user message", () => {
......@@ -143,3 +148,120 @@ describe("normalizeCompactToolLabel", () => {
143148 expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file");
144149 });
145150});
151
152describe("resolveAssistantMessageCopyState", () => {
153 it("returns enabled copy state for completed assistant messages", () => {
154 expect(
155 resolveAssistantMessageCopyState({
156 showCopyButton: true,
157 text: "Ship it",
158 streaming: false,
159 }),
160 ).toEqual({
161 text: "Ship it",
162 visible: true,
163 });
164 });
165
166 it("hides copy while an assistant message is still streaming", () => {
167 expect(
168 resolveAssistantMessageCopyState({
169 showCopyButton: true,
170 text: "Still streaming",
171 streaming: true,
172 }),
173 ).toEqual({
174 text: "Still streaming",
175 visible: false,
176 });
177 });
178
179 it("hides copy for empty completed assistant messages", () => {
180 expect(
181 resolveAssistantMessageCopyState({
182 showCopyButton: true,
183 text: " ",
184 streaming: false,
185 }),
186 ).toEqual({
187 text: null,
188 visible: false,
189 });
190 });
191
192 it("hides copy for non-terminal assistant messages", () => {
193 expect(
194 resolveAssistantMessageCopyState({
195 showCopyButton: false,
196 text: "Interim thought",
197 streaming: false,
198 }),
199 ).toEqual({
200 text: "Interim thought",
201 visible: false,
202 });
203 });
204});
205
206describe("deriveMessagesTimelineRows", () => {
207 it("only enables assistant copy for the terminal assistant message in a turn", () => {
208 const rows = deriveMessagesTimelineRows({
209 timelineEntries: [
210 {
211 id: "user-1-entry",
212 kind: "message",
213 createdAt: "2026-01-01T00:00:00Z",
214 message: {
215 id: "user-1" as never,
216 role: "user",
217 text: "Write a poem",
218 turnId: null,
219 createdAt: "2026-01-01T00:00:00Z",
220 streaming: false,
221 },
222 },
223 {
224 id: "assistant-thought-entry",
225 kind: "message",
226 createdAt: "2026-01-01T00:00:10Z",
227 message: {
228 id: "assistant-thought" as never,
229 role: "assistant",
230 text: "I should ground this first.",
231 turnId: "turn-1" as never,
232 createdAt: "2026-01-01T00:00:10Z",
233 completedAt: "2026-01-01T00:00:11Z",
234 streaming: false,
235 },
236 },
237 {
238 id: "assistant-final-entry",
239 kind: "message",
240 createdAt: "2026-01-01T00:00:20Z",
241 message: {
242 id: "assistant-final" as never,
243 role: "assistant",
244 text: "Here is the poem.",
245 turnId: "turn-1" as never,
246 createdAt: "2026-01-01T00:00:20Z",
247 completedAt: "2026-01-01T00:00:30Z",
248 streaming: false,
249 },
250 },
251 ],
252 completionDividerBeforeEntryId: "assistant-final-entry",
253 isWorking: false,
254 activeTurnStartedAt: null,
255 });
256
257 const assistantRows = rows.filter(
258 (row): row is Extract<(typeof rows)[number], { kind: "message" }> =>
259 row.kind === "message" && row.message.role === "assistant",
260 );
261
262 expect(assistantRows).toHaveLength(2);
263 expect(assistantRows[0]?.showAssistantCopyButton).toBe(false);
264 expect(assistantRows[1]?.showAssistantCopyButton).toBe(true);
265 expect(assistantRows[1]?.showCompletionDivider).toBe(true);
266 });
267});
apps/web/src/components/chat/MessagesTimeline.logic.ts+47
......@@ -27,6 +27,7 @@ export type MessagesTimelineRow =
2727 message: ChatMessage;
2828 durationStart: string;
2929 showCompletionDivider: boolean;
30 showAssistantCopyButton: boolean;
3031 }
3132 | {
3233 kind: "proposed-plan";
......@@ -59,6 +60,48 @@ export function normalizeCompactToolLabel(value: string): string {
5960 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
6061}
6162
63export function resolveAssistantMessageCopyState({
64 text,
65 showCopyButton,
66 streaming,
67}: {
68 text: string | null;
69 showCopyButton: boolean;
70 streaming: boolean;
71}) {
72 const hasText = text !== null && text.trim().length > 0;
73 return {
74 text: hasText ? text : null,
75 visible: showCopyButton && hasText && !streaming,
76 };
77}
78
79function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray<TimelineEntry>) {
80 const lastAssistantMessageIdByResponseKey = new Map<string, string>();
81 let nullTurnResponseIndex = 0;
82
83 for (const timelineEntry of timelineEntries) {
84 if (timelineEntry.kind !== "message") {
85 continue;
86 }
87 const { message } = timelineEntry;
88 if (message.role === "user") {
89 nullTurnResponseIndex += 1;
90 continue;
91 }
92 if (message.role !== "assistant") {
93 continue;
94 }
95
96 const responseKey = message.turnId
97 ? `turn:${message.turnId}`
98 : `unkeyed:${nullTurnResponseIndex}`;
99 lastAssistantMessageIdByResponseKey.set(responseKey, message.id);
100 }
101
102 return new Set(lastAssistantMessageIdByResponseKey.values());
103}
104
62105export function deriveMessagesTimelineRows(input: {
63106 timelineEntries: ReadonlyArray<TimelineEntry>;
64107 completionDividerBeforeEntryId: string | null;
......@@ -69,6 +112,7 @@ export function deriveMessagesTimelineRows(input: {
69112 const durationStartByMessageId = computeMessageDurationStart(
70113 input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
71114 );
115 const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries);
72116
73117 for (let index = 0; index < input.timelineEntries.length; index += 1) {
74118 const timelineEntry = input.timelineEntries[index];
......@@ -115,6 +159,9 @@ export function deriveMessagesTimelineRows(input: {
115159 showCompletionDivider:
116160 timelineEntry.message.role === "assistant" &&
117161 input.completionDividerBeforeEntryId === timelineEntry.id,
162 showAssistantCopyButton:
163 timelineEntry.message.role === "assistant" &&
164 terminalAssistantMessageIds.has(timelineEntry.message.id),
118165 });
119166 }
120167
apps/web/src/components/chat/MessagesTimeline.tsx+39-10
......@@ -45,6 +45,7 @@ import {
4545 deriveMessagesTimelineRows,
4646 estimateMessagesTimelineRowHeight,
4747 normalizeCompactToolLabel,
48 resolveAssistantMessageCopyState,
4849 type MessagesTimelineRow,
4950} from "./MessagesTimeline.logic";
5051import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
......@@ -56,6 +57,7 @@ import {
5657import { cn } from "~/lib/utils";
5758import { type TimestampFormat } from "@t3tools/contracts/settings";
5859import { formatTimestamp } from "../../timestampFormat";
60
5961import {
6062 buildInlineTerminalContextText,
6163 formatInlineTerminalContextLabel,
......@@ -68,6 +70,7 @@ interface MessagesTimelineProps {
6870 hasMessages: boolean;
6971 isWorking: boolean;
7072 activeTurnInProgress: boolean;
73 activeTurnId?: TurnId | null;
7174 activeTurnStartedAt: string | null;
7275 scrollContainer: HTMLDivElement | null;
7376 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
......@@ -106,6 +109,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
106109 hasMessages,
107110 isWorking,
108111 activeTurnInProgress,
112 activeTurnId,
109113 activeTurnStartedAt,
110114 scrollContainer,
111115 timelineEntries,
......@@ -303,7 +307,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
303307
304308 const renderRowContent = (row: TimelineRow) => (
305309 <div
306 className="pb-4"
310 className={cn(
311 "pb-4",
312 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
313 )}
307314 data-timeline-row-id={row.id}
308315 data-timeline-row-kind={row.kind}
309316 data-message-id={row.kind === "message" ? row.message.id : undefined}
......@@ -436,6 +443,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
436443 row.message.role === "assistant" &&
437444 (() => {
438445 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");
446 const assistantTurnStillInProgress =
447 activeTurnInProgress &&
448 activeTurnId !== null &&
449 activeTurnId !== undefined &&
450 row.message.turnId === activeTurnId;
451 const assistantCopyState = resolveAssistantMessageCopyState({
452 text: row.message.text ?? null,
453 showCopyButton: row.showAssistantCopyButton,
454 streaming: row.message.streaming || assistantTurnStillInProgress,
455 });
439456 return (
440457 <>
441458 {row.showCompletionDivider && (
......@@ -512,15 +529,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({
512529 </div>
513530 );
514531 })()}
515 <p className="mt-1.5 text-xs text-muted-foreground/50">
516 {formatMessageMeta(
517 row.message.createdAt,
518 row.message.streaming
519 ? formatElapsed(row.durationStart, nowIso)
520 : formatElapsed(row.durationStart, row.message.completedAt),
521 timestampFormat,
522 )}
523 </p>
532 <div className="mt-1.5 flex items-center gap-2">
533 <p className="text-[10px] text-muted-foreground/30">
534 {formatMessageMeta(
535 row.message.createdAt,
536 row.message.streaming
537 ? formatElapsed(row.durationStart, nowIso)
538 : formatElapsed(row.durationStart, row.message.completedAt),
539 timestampFormat,
540 )}
541 </p>
542 {assistantCopyState.visible ? (
543 <div className="flex items-center opacity-0 transition-opacity duration-200 group-hover/assistant:opacity-100">
544 <MessageCopyButton
545 text={assistantCopyState.text ?? ""}
546 size="icon-xs"
547 variant="outline"
548 className="border-border/50 bg-background/35 text-muted-foreground/45 shadow-none hover:border-border/70 hover:bg-background/55 hover:text-muted-foreground/70"
549 />
550 </div>
551 ) : null}
552 </div>
524553 </div>
525554 </>
526555 );
apps/web/src/components/timelineHeight.test.ts+13
......@@ -135,4 +135,17 @@ describe("estimateTimelineMessageHeight", () => {
135135 expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(154.75);
136136 expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(86.5);
137137 });
138
139 it("treats inline code as wider when estimating assistant markdown wrapping", () => {
140 const message = {
141 role: "assistant" as const,
142 text: [
143 "Typecheck found one exact-optional-property issue in the browser harness:",
144 "I was always passing `onVirtualizerSnapshot`, including `undefined`.",
145 "I'm tightening that object construction and rerunning the checks.",
146 ].join(" "),
147 };
148
149 expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(109.25);
150 });
138151});
apps/web/src/components/timelineHeight.ts+25-2
......@@ -21,6 +21,9 @@ const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4;
2121const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2;
2222const MIN_USER_CHARS_PER_LINE = 4;
2323const MIN_ASSISTANT_CHARS_PER_LINE = 20;
24const ASSISTANT_INLINE_CODE_WIDTH_MULTIPLIER = 1.2;
25const ASSISTANT_INLINE_CODE_WRAP_OVERHEAD_CHARS = 2;
26const INLINE_CODE_SPAN_REGEX = /`([^`\n]+)`/g;
2427
2528interface TimelineMessageHeightInput {
2629 role: "user" | "assistant" | "system";
......@@ -71,13 +74,30 @@ function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): numbe
7174 );
7275}
7376
77function expandAssistantInlineCodeForEstimate(text: string) {
78 return text.replace(INLINE_CODE_SPAN_REGEX, (_match, code: string) =>
79 "x".repeat(
80 Math.max(
81 code.length + 2,
82 Math.ceil(
83 code.length * ASSISTANT_INLINE_CODE_WIDTH_MULTIPLIER +
84 ASSISTANT_INLINE_CODE_WRAP_OVERHEAD_CHARS,
85 ),
86 ),
87 ),
88 );
89}
90
7491export function estimateTimelineMessageHeight(
7592 message: TimelineMessageHeightInput,
7693 layout: TimelineHeightEstimateLayout = { timelineWidthPx: null },
7794): number {
7895 if (message.role === "assistant") {
7996 const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);
80 const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine);
97 const estimatedLines = estimateWrappedLineCount(
98 expandAssistantInlineCodeForEstimate(message.text),
99 charsPerLine,
100 );
81101 return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX;
82102 }
83103
......@@ -103,6 +123,9 @@ export function estimateTimelineMessageHeight(
103123 // `system` messages are not rendered in the chat timeline, but keep a stable
104124 // explicit branch in case they are present in timeline data.
105125 const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);
106 const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine);
126 const estimatedLines = estimateWrappedLineCount(
127 expandAssistantInlineCodeForEstimate(message.text),
128 charsPerLine,
129 );
107130 return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX;
108131}
apps/web/src/hooks/useTheme.ts+26-6
......@@ -8,24 +8,34 @@ type ThemeSnapshot = {
88
99const STORAGE_KEY = "t3code:theme";
1010const MEDIA_QUERY = "(prefers-color-scheme: dark)";
11const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = {
12 theme: "system",
13 systemDark: false,
14};
1115const THEME_COLOR_META_NAME = "theme-color";
1216const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;
1317
1418let listeners: Array<() => void> = [];
1519let lastSnapshot: ThemeSnapshot | null = null;
1620let lastDesktopTheme: Theme | null = null;
21
1722function emitChange() {
1823 for (const listener of listeners) listener();
1924}
2025
21function getSystemDark(): boolean {
22 return window.matchMedia(MEDIA_QUERY).matches;
26function hasThemeStorage() {
27 return typeof window !== "undefined" && typeof localStorage !== "undefined";
28}
29
30function getSystemDark() {
31 return typeof window !== "undefined" && window.matchMedia(MEDIA_QUERY).matches;
2332}
2433
2534function getStored(): Theme {
35 if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme;
2636 const raw = localStorage.getItem(STORAGE_KEY);
2737 if (raw === "light" || raw === "dark" || raw === "system") return raw;
28 return "system";
38 return DEFAULT_THEME_SNAPSHOT.theme;
2939}
3040
3141function ensureThemeColorMetaTag(): HTMLMetaElement {
......@@ -78,7 +88,7 @@ export function syncBrowserChromeTheme() {
7888}
7989
8090function applyTheme(theme: Theme, suppressTransitions = false) {
81 if (typeof document === "undefined") return;
91 if (typeof document === "undefined" || typeof window === "undefined") return;
8292 if (suppressTransitions) {
8393 document.documentElement.classList.add("no-transitions");
8494 }
......@@ -97,6 +107,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
97107}
98108
99109function syncDesktopTheme(theme: Theme) {
110 if (typeof window === "undefined") return;
100111 const bridge = window.desktopBridge;
101112 if (!bridge || lastDesktopTheme === theme) {
102113 return;
......@@ -111,9 +122,12 @@ function syncDesktopTheme(theme: Theme) {
111122}
112123
113124// Apply immediately on module load to prevent flash
114applyTheme(getStored());
125if (typeof document !== "undefined" && hasThemeStorage()) {
126 applyTheme(getStored());
127}
115128
116129function getSnapshot(): ThemeSnapshot {
130 if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT;
117131 const theme = getStored();
118132 const systemDark = theme === "system" ? getSystemDark() : false;
119133
......@@ -125,7 +139,12 @@ function getSnapshot(): ThemeSnapshot {
125139 return lastSnapshot;
126140}
127141
142function getServerSnapshot() {
143 return DEFAULT_THEME_SNAPSHOT;
144}
145
128146function subscribe(listener: () => void): () => void {
147 if (typeof window === "undefined") return () => {};
129148 listeners.push(listener);
130149
131150 // Listen for system preference changes
......@@ -153,13 +172,14 @@ function subscribe(listener: () => void): () => void {
153172}
154173
155174export function useTheme() {
156 const snapshot = useSyncExternalStore(subscribe, getSnapshot);
175 const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
157176 const theme = snapshot.theme;
158177
159178 const resolvedTheme: "light" | "dark" =
160179 theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme;
161180
162181 const setTheme = useCallback((next: Theme) => {
182 if (!hasThemeStorage()) return;
163183 localStorage.setItem(STORAGE_KEY, next);
164184 applyTheme(next, true);
165185 emitChange();