| author | |
| committer | |
| log | 26cc1fffb33ca4aad36341cacb57424570c69bec |
| tree | dd2b306bfd65ea934017d1bf1cbef8147e5fc5b8 |
| parent | 97880e88705fb74648677a850aadc7b6aeac5253 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 3353 | hasMessages={timelineEntries.length > 0} |
| 3354 | 3354 | isWorking={isWorking} |
| 3355 | 3355 | activeTurnInProgress={isWorking || !latestTurnSettled} |
| 3356 | activeTurnId={activeLatestTurn?.turnId ?? null} | |
| 3356 | 3357 | activeTurnStartedAt={activeWorkStartedAt} |
| 3357 | 3358 | scrollContainer={messagesScrollElement} |
| 3358 | 3359 | timelineEntries={timelineEntries} |
apps/web/src/components/chat/MessageCopyButton.tsx+74-12| ... | ... | @@ -1,20 +1,82 @@ |
| 1 | import { memo } from "react"; | |
| 1 | import { memo, useRef } from "react"; | |
| 2 | 2 | import { CopyIcon, CheckIcon } from "lucide-react"; |
| 3 | 3 | import { Button } from "../ui/button"; |
| 4 | 4 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; |
| 5 | import { cn } from "~/lib/utils"; | |
| 6 | import { anchoredToastManager } from "../ui/toast"; | |
| 7 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 5 | 8 | |
| 6 | export const MessageCopyButton = memo(function MessageCopyButton({ text }: { text: string }) { | |
| 7 | const { copyToClipboard, isCopied } = useCopyToClipboard(); | |
| 9 | const ANCHORED_TOAST_TIMEOUT_MS = 1000; | |
| 10 | const 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 | ||
| 25 | const 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 | ||
| 41 | export 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 | }); | |
| 8 | 58 | |
| 9 | 59 | 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> | |
| 19 | 81 | ); |
| 20 | 82 | }); |
apps/web/src/components/chat/MessagesTimeline.logic.test.ts+123-1| ... | ... | @@ -1,5 +1,10 @@ |
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | import { computeMessageDurationStart, normalizeCompactToolLabel } from "./MessagesTimeline.logic"; | |
| 2 | import { | |
| 3 | computeMessageDurationStart, | |
| 4 | deriveMessagesTimelineRows, | |
| 5 | normalizeCompactToolLabel, | |
| 6 | resolveAssistantMessageCopyState, | |
| 7 | } from "./MessagesTimeline.logic"; | |
| 3 | 8 | |
| 4 | 9 | describe("computeMessageDurationStart", () => { |
| 5 | 10 | it("returns message createdAt when there is no preceding user message", () => { |
| ... | ... | @@ -143,3 +148,120 @@ describe("normalizeCompactToolLabel", () => { |
| 143 | 148 | expect(normalizeCompactToolLabel("Read file completed")).toBe("Read file"); |
| 144 | 149 | }); |
| 145 | 150 | }); |
| 151 | ||
| 152 | describe("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 | ||
| 206 | describe("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 | 27 | message: ChatMessage; |
| 28 | 28 | durationStart: string; |
| 29 | 29 | showCompletionDivider: boolean; |
| 30 | showAssistantCopyButton: boolean; | |
| 30 | 31 | } |
| 31 | 32 | | { |
| 32 | 33 | kind: "proposed-plan"; |
| ... | ... | @@ -59,6 +60,48 @@ export function normalizeCompactToolLabel(value: string): string { |
| 59 | 60 | return value.replace(/\s+(?:complete|completed)\s*$/i, "").trim(); |
| 60 | 61 | } |
| 61 | 62 | |
| 63 | export 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 | ||
| 79 | function 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 | ||
| 62 | 105 | export function deriveMessagesTimelineRows(input: { |
| 63 | 106 | timelineEntries: ReadonlyArray<TimelineEntry>; |
| 64 | 107 | completionDividerBeforeEntryId: string | null; |
| ... | ... | @@ -69,6 +112,7 @@ export function deriveMessagesTimelineRows(input: { |
| 69 | 112 | const durationStartByMessageId = computeMessageDurationStart( |
| 70 | 113 | input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), |
| 71 | 114 | ); |
| 115 | const terminalAssistantMessageIds = deriveTerminalAssistantMessageIds(input.timelineEntries); | |
| 72 | 116 | |
| 73 | 117 | for (let index = 0; index < input.timelineEntries.length; index += 1) { |
| 74 | 118 | const timelineEntry = input.timelineEntries[index]; |
| ... | ... | @@ -115,6 +159,9 @@ export function deriveMessagesTimelineRows(input: { |
| 115 | 159 | showCompletionDivider: |
| 116 | 160 | timelineEntry.message.role === "assistant" && |
| 117 | 161 | input.completionDividerBeforeEntryId === timelineEntry.id, |
| 162 | showAssistantCopyButton: | |
| 163 | timelineEntry.message.role === "assistant" && | |
| 164 | terminalAssistantMessageIds.has(timelineEntry.message.id), | |
| 118 | 165 | }); |
| 119 | 166 | } |
| 120 | 167 |
apps/web/src/components/chat/MessagesTimeline.tsx+39-10| ... | ... | @@ -45,6 +45,7 @@ import { |
| 45 | 45 | deriveMessagesTimelineRows, |
| 46 | 46 | estimateMessagesTimelineRowHeight, |
| 47 | 47 | normalizeCompactToolLabel, |
| 48 | resolveAssistantMessageCopyState, | |
| 48 | 49 | type MessagesTimelineRow, |
| 49 | 50 | } from "./MessagesTimeline.logic"; |
| 50 | 51 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| ... | ... | @@ -56,6 +57,7 @@ import { |
| 56 | 57 | import { cn } from "~/lib/utils"; |
| 57 | 58 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| 58 | 59 | import { formatTimestamp } from "../../timestampFormat"; |
| 60 | ||
| 59 | 61 | import { |
| 60 | 62 | buildInlineTerminalContextText, |
| 61 | 63 | formatInlineTerminalContextLabel, |
| ... | ... | @@ -68,6 +70,7 @@ interface MessagesTimelineProps { |
| 68 | 70 | hasMessages: boolean; |
| 69 | 71 | isWorking: boolean; |
| 70 | 72 | activeTurnInProgress: boolean; |
| 73 | activeTurnId?: TurnId | null; | |
| 71 | 74 | activeTurnStartedAt: string | null; |
| 72 | 75 | scrollContainer: HTMLDivElement | null; |
| 73 | 76 | timelineEntries: ReturnType<typeof deriveTimelineEntries>; |
| ... | ... | @@ -106,6 +109,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 106 | 109 | hasMessages, |
| 107 | 110 | isWorking, |
| 108 | 111 | activeTurnInProgress, |
| 112 | activeTurnId, | |
| 109 | 113 | activeTurnStartedAt, |
| 110 | 114 | scrollContainer, |
| 111 | 115 | timelineEntries, |
| ... | ... | @@ -303,7 +307,10 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 303 | 307 | |
| 304 | 308 | const renderRowContent = (row: TimelineRow) => ( |
| 305 | 309 | <div |
| 306 | className="pb-4" | |
| 310 | className={cn( | |
| 311 | "pb-4", | |
| 312 | row.kind === "message" && row.message.role === "assistant" ? "group/assistant" : null, | |
| 313 | )} | |
| 307 | 314 | data-timeline-row-id={row.id} |
| 308 | 315 | data-timeline-row-kind={row.kind} |
| 309 | 316 | data-message-id={row.kind === "message" ? row.message.id : undefined} |
| ... | ... | @@ -436,6 +443,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 436 | 443 | row.message.role === "assistant" && |
| 437 | 444 | (() => { |
| 438 | 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 | 456 | return ( |
| 440 | 457 | <> |
| 441 | 458 | {row.showCompletionDivider && ( |
| ... | ... | @@ -512,15 +529,27 @@ export const MessagesTimeline = memo(function MessagesTimeline({ |
| 512 | 529 | </div> |
| 513 | 530 | ); |
| 514 | 531 | })()} |
| 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> | |
| 524 | 553 | </div> |
| 525 | 554 | </> |
| 526 | 555 | ); |
apps/web/src/components/timelineHeight.test.ts+13| ... | ... | @@ -135,4 +135,17 @@ describe("estimateTimelineMessageHeight", () => { |
| 135 | 135 | expect(estimateTimelineMessageHeight(message, { timelineWidthPx: 320 })).toBe(154.75); |
| 136 | 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 | 21 | const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; |
| 22 | 22 | const MIN_USER_CHARS_PER_LINE = 4; |
| 23 | 23 | const MIN_ASSISTANT_CHARS_PER_LINE = 20; |
| 24 | const ASSISTANT_INLINE_CODE_WIDTH_MULTIPLIER = 1.2; | |
| 25 | const ASSISTANT_INLINE_CODE_WRAP_OVERHEAD_CHARS = 2; | |
| 26 | const INLINE_CODE_SPAN_REGEX = /`([^`\n]+)`/g; | |
| 24 | 27 | |
| 25 | 28 | interface TimelineMessageHeightInput { |
| 26 | 29 | role: "user" | "assistant" | "system"; |
| ... | ... | @@ -71,13 +74,30 @@ function estimateCharsPerLineForAssistant(timelineWidthPx: number | null): numbe |
| 71 | 74 | ); |
| 72 | 75 | } |
| 73 | 76 | |
| 77 | function 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 | ||
| 74 | 91 | export function estimateTimelineMessageHeight( |
| 75 | 92 | message: TimelineMessageHeightInput, |
| 76 | 93 | layout: TimelineHeightEstimateLayout = { timelineWidthPx: null }, |
| 77 | 94 | ): number { |
| 78 | 95 | if (message.role === "assistant") { |
| 79 | 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 | 101 | return ASSISTANT_BASE_HEIGHT_PX + estimatedLines * ASSISTANT_LINE_HEIGHT_PX; |
| 82 | 102 | } |
| 83 | 103 | |
| ... | ... | @@ -103,6 +123,9 @@ export function estimateTimelineMessageHeight( |
| 103 | 123 | // `system` messages are not rendered in the chat timeline, but keep a stable |
| 104 | 124 | // explicit branch in case they are present in timeline data. |
| 105 | 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 | 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 | 8 | |
| 9 | 9 | const STORAGE_KEY = "t3code:theme"; |
| 10 | 10 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; |
| 11 | const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { | |
| 12 | theme: "system", | |
| 13 | systemDark: false, | |
| 14 | }; | |
| 11 | 15 | const THEME_COLOR_META_NAME = "theme-color"; |
| 12 | 16 | const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; |
| 13 | 17 | |
| 14 | 18 | let listeners: Array<() => void> = []; |
| 15 | 19 | let lastSnapshot: ThemeSnapshot | null = null; |
| 16 | 20 | let lastDesktopTheme: Theme | null = null; |
| 21 | ||
| 17 | 22 | function emitChange() { |
| 18 | 23 | for (const listener of listeners) listener(); |
| 19 | 24 | } |
| 20 | 25 | |
| 21 | function getSystemDark(): boolean { | |
| 22 | return window.matchMedia(MEDIA_QUERY).matches; | |
| 26 | function hasThemeStorage() { | |
| 27 | return typeof window !== "undefined" && typeof localStorage !== "undefined"; | |
| 28 | } | |
| 29 | ||
| 30 | function getSystemDark() { | |
| 31 | return typeof window !== "undefined" && window.matchMedia(MEDIA_QUERY).matches; | |
| 23 | 32 | } |
| 24 | 33 | |
| 25 | 34 | function getStored(): Theme { |
| 35 | if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; | |
| 26 | 36 | const raw = localStorage.getItem(STORAGE_KEY); |
| 27 | 37 | if (raw === "light" || raw === "dark" || raw === "system") return raw; |
| 28 | return "system"; | |
| 38 | return DEFAULT_THEME_SNAPSHOT.theme; | |
| 29 | 39 | } |
| 30 | 40 | |
| 31 | 41 | function ensureThemeColorMetaTag(): HTMLMetaElement { |
| ... | ... | @@ -78,7 +88,7 @@ export function syncBrowserChromeTheme() { |
| 78 | 88 | } |
| 79 | 89 | |
| 80 | 90 | function applyTheme(theme: Theme, suppressTransitions = false) { |
| 81 | if (typeof document === "undefined") return; | |
| 91 | if (typeof document === "undefined" || typeof window === "undefined") return; | |
| 82 | 92 | if (suppressTransitions) { |
| 83 | 93 | document.documentElement.classList.add("no-transitions"); |
| 84 | 94 | } |
| ... | ... | @@ -97,6 +107,7 @@ function applyTheme(theme: Theme, suppressTransitions = false) { |
| 97 | 107 | } |
| 98 | 108 | |
| 99 | 109 | function syncDesktopTheme(theme: Theme) { |
| 110 | if (typeof window === "undefined") return; | |
| 100 | 111 | const bridge = window.desktopBridge; |
| 101 | 112 | if (!bridge || lastDesktopTheme === theme) { |
| 102 | 113 | return; |
| ... | ... | @@ -111,9 +122,12 @@ function syncDesktopTheme(theme: Theme) { |
| 111 | 122 | } |
| 112 | 123 | |
| 113 | 124 | // Apply immediately on module load to prevent flash |
| 114 | applyTheme(getStored()); | |
| 125 | if (typeof document !== "undefined" && hasThemeStorage()) { | |
| 126 | applyTheme(getStored()); | |
| 127 | } | |
| 115 | 128 | |
| 116 | 129 | function getSnapshot(): ThemeSnapshot { |
| 130 | if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT; | |
| 117 | 131 | const theme = getStored(); |
| 118 | 132 | const systemDark = theme === "system" ? getSystemDark() : false; |
| 119 | 133 | |
| ... | ... | @@ -125,7 +139,12 @@ function getSnapshot(): ThemeSnapshot { |
| 125 | 139 | return lastSnapshot; |
| 126 | 140 | } |
| 127 | 141 | |
| 142 | function getServerSnapshot() { | |
| 143 | return DEFAULT_THEME_SNAPSHOT; | |
| 144 | } | |
| 145 | ||
| 128 | 146 | function subscribe(listener: () => void): () => void { |
| 147 | if (typeof window === "undefined") return () => {}; | |
| 129 | 148 | listeners.push(listener); |
| 130 | 149 | |
| 131 | 150 | // Listen for system preference changes |
| ... | ... | @@ -153,13 +172,14 @@ function subscribe(listener: () => void): () => void { |
| 153 | 172 | } |
| 154 | 173 | |
| 155 | 174 | export function useTheme() { |
| 156 | const snapshot = useSyncExternalStore(subscribe, getSnapshot); | |
| 175 | const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); | |
| 157 | 176 | const theme = snapshot.theme; |
| 158 | 177 | |
| 159 | 178 | const resolvedTheme: "light" | "dark" = |
| 160 | 179 | theme === "system" ? (snapshot.systemDark ? "dark" : "light") : theme; |
| 161 | 180 | |
| 162 | 181 | const setTheme = useCallback((next: Theme) => { |
| 182 | if (!hasThemeStorage()) return; | |
| 163 | 183 | localStorage.setItem(STORAGE_KEY, next); |
| 164 | 184 | applyTheme(next, true); |
| 165 | 185 | emitChange(); |