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) {...@@ -3353,6 +3353,7 @@ export default function ChatView(props: ChatViewProps) {
3353 hasMessages={timelineEntries.length > 0}3353 hasMessages={timelineEntries.length > 0}
3354 isWorking={isWorking}3354 isWorking={isWorking}
3355 activeTurnInProgress={isWorking || !latestTurnSettled}3355 activeTurnInProgress={isWorking || !latestTurnSettled}
3356 activeTurnId={activeLatestTurn?.turnId ?? null}
3356 activeTurnStartedAt={activeWorkStartedAt}3357 activeTurnStartedAt={activeWorkStartedAt}
3357 scrollContainer={messagesScrollElement}3358 scrollContainer={messagesScrollElement}
3358 timelineEntries={timelineEntries}3359 timelineEntries={timelineEntries}
apps/web/src/components/chat/MessageCopyButton.tsx+74-12
...@@ -1,20 +1,82 @@...@@ -1,20 +1,82 @@
1import { memo } from "react";1import { memo, useRef } from "react";
2import { CopyIcon, CheckIcon } from "lucide-react";2import { CopyIcon, CheckIcon } from "lucide-react";
3import { Button } from "../ui/button";3import { Button } from "../ui/button";
4import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";4import { 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 }) {9const ANCHORED_TOAST_TIMEOUT_MS = 1000;
7 const { copyToClipboard, isCopied } = useCopyToClipboard();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
9 return (59 return (
10 <Button60 <Tooltip>
11 type="button"61 <TooltipTrigger
12 size="xs"62 render={
13 variant="outline"63 <Button
14 onClick={() => copyToClipboard(text)}64 aria-label="Copy link"
15 title="Copy message"65 disabled={isCopied}
16 >66 onClick={() => copyToClipboard(text)}
17 {isCopied ? <CheckIcon className="size-3 text-success" /> : <CopyIcon className="size-3" />}67 ref={ref}
18 </Button>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>
19 );81 );
20});82});
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+123-1
...@@ -1,5 +1,10 @@...@@ -1,5 +1,10 @@
1import { describe, expect, it } from "vitest";1import { 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
4describe("computeMessageDurationStart", () => {9describe("computeMessageDurationStart", () => {
5 it("returns message createdAt when there is no preceding user message", () => {10 it("returns message createdAt when there is no preceding user message", () => {
...@@ -143,3 +148,120 @@ describe("normalizeCompactToolLabel", () => {...@@ -143,3 +148,120 @@ describe("normalizeCompactToolLabel", () => {
143 expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file");148 expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file");
144 });149 });
145});150});
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 =...@@ -27,6 +27,7 @@ export type MessagesTimelineRow =
27 message: ChatMessage;27 message: ChatMessage;
28 durationStart: string;28 durationStart: string;
29 showCompletionDivider: boolean;29 showCompletionDivider: boolean;
30 showAssistantCopyButton: boolean;
30 }31 }
31 | {32 | {
32 kind: "proposed-plan";33 kind: "proposed-plan";
...@@ -59,6 +60,48 @@ export function normalizeCompactToolLabel(value: string): string {...@@ -59,6 +60,48 @@ export function normalizeCompactToolLabel(value: string): string {
59 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();60 return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim();
60}61}
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
62export function deriveMessagesTimelineRows(input: {105export function deriveMessagesTimelineRows(input: {
63 timelineEntries: ReadonlyArray<TimelineEntry>;106 timelineEntries: ReadonlyArray<TimelineEntry>;
64 completionDividerBeforeEntryId: string | null;107 completionDividerBeforeEntryId: string | null;
...@@ -69,6 +112,7 @@ export function deriveMessagesTimelineRows(input: {...@@ -69,6 +112,7 @@ export function deriveMessagesTimelineRows(input: {
69 const durationStartByMessageId = computeMessageDurationStart(112 const durationStartByMessageId = computeMessageDurationStart(
70 input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),113 input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])),
71 );114 );
115 const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries);
72116
73 for (let index = 0; index < input.timelineEntries.length; index += 1) {117 for (let index = 0; index < input.timelineEntries.length; index += 1) {
74 const timelineEntry = input.timelineEntries[index];118 const timelineEntry = input.timelineEntries[index];
...@@ -115,6 +159,9 @@ export function deriveMessagesTimelineRows(input: {...@@ -115,6 +159,9 @@ export function deriveMessagesTimelineRows(input: {
115 showCompletionDivider:159 showCompletionDivider:
116 timelineEntry.message.role === "assistant" &&160 timelineEntry.message.role === "assistant" &&
117 input.completionDividerBeforeEntryId === timelineEntry.id,161 input.completionDividerBeforeEntryId === timelineEntry.id,
162 showAssistantCopyButton:
163 timelineEntry.message.role === "assistant" &&
164 terminalAssistantMessageIds.has(timelineEntry.message.id),
118 });165 });
119 }166 }
120167
apps/web/src/components/chat/MessagesTimeline.tsx+39-10
...@@ -45,6 +45,7 @@ import {...@@ -45,6 +45,7 @@ import {
45 deriveMessagesTimelineRows,45 deriveMessagesTimelineRows,
46 estimateMessagesTimelineRowHeight,46 estimateMessagesTimelineRowHeight,
47 normalizeCompactToolLabel,47 normalizeCompactToolLabel,
48 resolveAssistantMessageCopyState,
48 type MessagesTimelineRow,49 type MessagesTimelineRow,
49} from "./MessagesTimeline.logic";50} from "./MessagesTimeline.logic";
50import { TerminalContextInlineChip } from "./TerminalContextInlineChip";51import { TerminalContextInlineChip } from "./TerminalContextInlineChip";
...@@ -56,6 +57,7 @@ import {...@@ -56,6 +57,7 @@ import {
56import { cn } from "~/lib/utils";57import { cn } from "~/lib/utils";
57import { type TimestampFormat } from "@t3tools/contracts/settings";58import { type TimestampFormat } from "@t3tools/contracts/settings";
58import { formatTimestamp } from "../../timestampFormat";59import { formatTimestamp } from "../../timestampFormat";
60
59import {61import {
60 buildInlineTerminalContextText,62 buildInlineTerminalContextText,
61 formatInlineTerminalContextLabel,63 formatInlineTerminalContextLabel,
...@@ -68,6 +70,7 @@ interface MessagesTimelineProps {...@@ -68,6 +70,7 @@ interface MessagesTimelineProps {
68 hasMessages: boolean;70 hasMessages: boolean;
69 isWorking: boolean;71 isWorking: boolean;
70 activeTurnInProgress: boolean;72 activeTurnInProgress: boolean;
73 activeTurnId?: TurnId | null;
71 activeTurnStartedAt: string | null;74 activeTurnStartedAt: string | null;
72 scrollContainer: HTMLDivElement | null;75 scrollContainer: HTMLDivElement | null;
73 timelineEntries: ReturnType<typeof deriveTimelineEntries>;76 timelineEntries: ReturnType<typeof deriveTimelineEntries>;
...@@ -106,6 +109,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -106,6 +109,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
106 hasMessages,109 hasMessages,
107 isWorking,110 isWorking,
108 activeTurnInProgress,111 activeTurnInProgress,
112 activeTurnId,
109 activeTurnStartedAt,113 activeTurnStartedAt,
110 scrollContainer,114 scrollContainer,
111 timelineEntries,115 timelineEntries,
...@@ -303,7 +307,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -303,7 +307,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({
303307
304 const renderRowContent = (row: TimelineRow) => (308 const renderRowContent = (row: TimelineRow) => (
305 <div309 <div
306 className="pb-4"310 className={cn(
311 "pb-4",
312 row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null,
313 )}
307 data-timeline-row-id={row.id}314 data-timeline-row-id={row.id}
308 data-timeline-row-kind={row.kind}315 data-timeline-row-kind={row.kind}
309 data-message-id={row.kind === "message" ? row.message.id : undefined}316 data-message-id={row.kind === "message" ? row.message.id : undefined}
...@@ -436,6 +443,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -436,6 +443,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({
436 row.message.role === "assistant" &&443 row.message.role === "assistant" &&
437 (() => {444 (() => {
438 const messageText = row.message.text || (row.message.streaming ? "" : "(empty response)");445 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 });
439 return (456 return (
440 <>457 <>
441 {row.showCompletionDivider && (458 {row.showCompletionDivider && (
...@@ -512,15 +529,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -512,15 +529,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({
512 </div>529 </div>
513 );530 );
514 })()}531 })()}
515 <p className="mt-1.5 text-xs text-muted-foreground/50">532 <div className="mt-1.5 flex items-center gap-2">
516 {formatMessageMeta(533 <p className="text-[10px] text-muted-foreground/30">
517 row.message.createdAt,534 {formatMessageMeta(
518 row.message.streaming535 row.message.createdAt,
519 ? formatElapsed(row.durationStart, nowIso)536 row.message.streaming
520 : formatElapsed(row.durationStart, row.message.completedAt),537 ? formatElapsed(row.durationStart, nowIso)
521 timestampFormat,538 : formatElapsed(row.durationStart, row.message.completedAt),
522 )}539 timestampFormat,
523 </p>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>
524 </div>553 </div>
525 </>554 </>
526 );555 );
apps/web/src/components/timelineHeight.test.ts+13
...@@ -135,4 +135,17 @@ describe("estimateTimelineMessageHeight", () => {...@@ -135,4 +135,17 @@ describe("estimateTimelineMessageHeight", () => {
135 expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(154.75);135 expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(154.75);
136 expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(86.5);136 expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 768 })).toBe(86.5);
137 });137 });
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 });
138});151});
apps/web/src/components/timelineHeight.ts+25-2
...@@ -21,6 +21,9 @@ const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4;...@@ -21,6 +21,9 @@ const USER_MONO_AVG_CHAR_WIDTH_PX = 8.4;
21const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2;21const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2;
22const MIN_USER_CHARS_PER_LINE = 4;22const MIN_USER_CHARS_PER_LINE = 4;
23const MIN_ASSISTANT_CHARS_PER_LINE = 20;23const 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
25interface TimelineMessageHeightInput {28interface TimelineMessageHeightInput {
26 role: "user" | "assistant" | "system";29 role: "user" | "assistant" | "system";
...@@ -71,13 +74,30 @@ function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): numbe...@@ -71,13 +74,30 @@ function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): numbe
71 );74 );
72}75}
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
74export function estimateTimelineMessageHeight(91export function estimateTimelineMessageHeight(
75 message: TimelineMessageHeightInput,92 message: TimelineMessageHeightInput,
76 layout: TimelineHeightEstimateLayout = { timelineWidthPx: null },93 layout: TimelineHeightEstimateLayout = { timelineWidthPx: null },
77): number {94): number {
78 if (message.role === "assistant") {95 if (message.role === "assistant") {
79 const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);96 const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);
80 const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine);97 const estimatedLines = estimateWrappedLineCount(
98 expandAssistantInlineCodeForEstimate(message.text),
99 charsPerLine,
100 );
81 return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX;101 return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX;
82 }102 }
83103
...@@ -103,6 +123,9 @@ export function estimateTimelineMessageHeight(...@@ -103,6 +123,9 @@ export function estimateTimelineMessageHeight(
103 // `system` messages are not rendered in the chat timeline, but keep a stable123 // `system` messages are not rendered in the chat timeline, but keep a stable
104 // explicit branch in case they are present in timeline data.124 // explicit branch in case they are present in timeline data.
105 const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);125 const charsPerLine = estimateCharsPerLineForAssistant(layout.timelineWidthPx);
106 const estimatedLines = estimateWrappedLineCount(message.text, charsPerLine);126 const estimatedLines = estimateWrappedLineCount(
127 expandAssistantInlineCodeForEstimate(message.text),
128 charsPerLine,
129 );
107 return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX;130 return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX;
108}131}
apps/web/src/hooks/useTheme.ts+26-6
...@@ -8,24 +8,34 @@ type ThemeSnapshot = {...@@ -8,24 +8,34 @@ type ThemeSnapshot = {
88
9const STORAGE_KEY = "t3code:theme";9const STORAGE_KEY = "t3code:theme";
10const MEDIA_QUERY = "(prefers-color-scheme: dark)";10const MEDIA_QUERY = "(prefers-color-scheme: dark)";
11const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = {
12 theme: "system",
13 systemDark: false,
14};
11const THEME_COLOR_META_NAME = "theme-color";15const THEME_COLOR_META_NAME = "theme-color";
12const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;16const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`;
1317
14let listeners: Array<() => void> = [];18let listeners: Array<() => void> = [];
15let lastSnapshot: ThemeSnapshot | null = null;19let lastSnapshot: ThemeSnapshot | null = null;
16let lastDesktopTheme: Theme | null = null;20let lastDesktopTheme: Theme | null = null;
21
17function emitChange() {22function emitChange() {
18 for (const listener of listeners) listener();23 for (const listener of listeners) listener();
19}24}
2025
21function getSystemDark(): boolean {26function hasThemeStorage() {
22 return window.matchMedia(MEDIA_QUERY).matches;27 return typeof window !== "undefined" && typeof localStorage !== "undefined";
28}
29
30function getSystemDark() {
31 return typeof window !== "undefined" && window.matchMedia(MEDIA_QUERY).matches;
23}32}
2433
25function getStored(): Theme {34function getStored(): Theme {
35 if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme;
26 const raw = localStorage.getItem(STORAGE_KEY);36 const raw = localStorage.getItem(STORAGE_KEY);
27 if (raw === "light" || raw === "dark" || raw === "system") return raw;37 if (raw === "light" || raw === "dark" || raw === "system") return raw;
28 return "system";38 return DEFAULT_THEME_SNAPSHOT.theme;
29}39}
3040
31function ensureThemeColorMetaTag(): HTMLMetaElement {41function ensureThemeColorMetaTag(): HTMLMetaElement {
...@@ -78,7 +88,7 @@ export function syncBrowserChromeTheme() {...@@ -78,7 +88,7 @@ export function syncBrowserChromeTheme() {
78}88}
7989
80function applyTheme(theme: Theme, suppressTransitions = false) {90function applyTheme(theme: Theme, suppressTransitions = false) {
81 if (typeof document === "undefined") return;91 if (typeof document === "undefined" || typeof window === "undefined") return;
82 if (suppressTransitions) {92 if (suppressTransitions) {
83 document.documentElement.classList.add("no-transitions");93 document.documentElement.classList.add("no-transitions");
84 }94 }
...@@ -97,6 +107,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) {...@@ -97,6 +107,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) {
97}107}
98108
99function syncDesktopTheme(theme: Theme) {109function syncDesktopTheme(theme: Theme) {
110 if (typeof window === "undefined") return;
100 const bridge = window.desktopBridge;111 const bridge = window.desktopBridge;
101 if (!bridge || lastDesktopTheme === theme) {112 if (!bridge || lastDesktopTheme === theme) {
102 return;113 return;
...@@ -111,9 +122,12 @@ function syncDesktopTheme(theme: Theme) {...@@ -111,9 +122,12 @@ function syncDesktopTheme(theme: Theme) {
111}122}
112123
113// Apply immediately on module load to prevent flash124// Apply immediately on module load to prevent flash
114applyTheme(getStored());125if (typeof document !== "undefined" && hasThemeStorage()) {
126 applyTheme(getStored());
127}
115128
116function getSnapshot(): ThemeSnapshot {129function getSnapshot(): ThemeSnapshot {
130 if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT;
117 const theme = getStored();131 const theme = getStored();
118 const systemDark = theme === "system" ? getSystemDark() : false;132 const systemDark = theme === "system" ? getSystemDark() : false;
119133
...@@ -125,7 +139,12 @@ function getSnapshot(): ThemeSnapshot {...@@ -125,7 +139,12 @@ function getSnapshot(): ThemeSnapshot {
125 return lastSnapshot;139 return lastSnapshot;
126}140}
127141
142function getServerSnapshot() {
143 return DEFAULT_THEME_SNAPSHOT;
144}
145
128function subscribe(listener: () => void): () => void {146function subscribe(listener: () => void): () => void {
147 if (typeof window === "undefined") return () => {};
129 listeners.push(listener);148 listeners.push(listener);
130149
131 // Listen for system preference changes150 // Listen for system preference changes
...@@ -153,13 +172,14 @@ function subscribe(listener: () => void): () => void {...@@ -153,13 +172,14 @@ function subscribe(listener: () => void): () => void {
153}172}
154173
155export function useTheme() {174export function useTheme() {
156 const snapshot = useSyncExternalStore(subscribe, getSnapshot);175 const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
157 const theme = snapshot.theme;176 const theme = snapshot.theme;
158177
159 const resolvedTheme: "light" | "dark" =178 const resolvedTheme: "light" | "dark" =
160 theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme;179 theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme;
161180
162 const setTheme = useCallback((next: Theme) => {181 const setTheme = useCallback((next: Theme) => {
182 if (!hasThemeStorage()) return;
163 localStorage.setItem(STORAGE_KEY, next);183 localStorage.setItem(STORAGE_KEY, next);
164 applyTheme(next, true);184 applyTheme(next, true);
165 emitChange();185 emitChange();