| 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,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 @@ |
| 1 | import { memo } from "react"; | 1 | import { memo, useRef } from "react"; |
| 2 | import { CopyIcon, CheckIcon } from "lucide-react"; | 2 | import { CopyIcon, CheckIcon } from "lucide-react"; |
| 3 | import { Button } from "../ui/button"; | 3 | import { Button } from "../ui/button"; |
| 4 | import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; | 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 }) { | 9 | const ANCHORED_TOAST_TIMEOUT_MS = 1000; |
| 7 | const { copyToClipboard, isCopied } = useCopyToClipboard(); | 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 | return ( | 59 | return ( |
| 10 | <Button | 60 | <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 @@ |
| 1 | import { describe, expect, it } from "vitest"; | 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 | describe("computeMessageDurationStart", () => { | 9 | describe("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 | |||
| 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,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 | } |
| 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 | export function deriveMessagesTimelineRows(input: { | 105 | export 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); | ||
| 72 | 116 | ||
| 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 | } |
| 120 | 167 |
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"; |
| 50 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; | 51 | import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; |
| ... | @@ -56,6 +57,7 @@ import { | ... | @@ -56,6 +57,7 @@ import { |
| 56 | import { cn } from "~/lib/utils"; | 57 | import { cn } from "~/lib/utils"; |
| 57 | import { type TimestampFormat } from "@t3tools/contracts/settings"; | 58 | import { type TimestampFormat } from "@t3tools/contracts/settings"; |
| 58 | import { formatTimestamp } from "../../timestampFormat"; | 59 | import { formatTimestamp } from "../../timestampFormat"; |
| 60 | |||
| 59 | import { | 61 | import { |
| 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({ |
| 303 | 307 | ||
| 304 | const renderRowContent = (row: TimelineRow) => ( | 308 | const renderRowContent = (row: TimelineRow) => ( |
| 305 | <div | 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 | 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.streaming | 535 | 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; |
| 21 | const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; | 21 | const ASSISTANT_AVG_CHAR_WIDTH_PX = 7.2; |
| 22 | const MIN_USER_CHARS_PER_LINE = 4; | 22 | const MIN_USER_CHARS_PER_LINE = 4; |
| 23 | const MIN_ASSISTANT_CHARS_PER_LINE = 20; | 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 | interface TimelineMessageHeightInput { | 28 | interface 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 | } |
| 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 | export function estimateTimelineMessageHeight( | 91 | export 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 | } |
| 83 | 103 | ||
| ... | @@ -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 stable | 123 | // `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 = { |
| 8 | 8 | ||
| 9 | const STORAGE_KEY = "t3code:theme"; | 9 | const STORAGE_KEY = "t3code:theme"; |
| 10 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; | 10 | const MEDIA_QUERY = "(prefers-color-scheme: dark)"; |
| 11 | const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { | ||
| 12 | theme: "system", | ||
| 13 | systemDark: false, | ||
| 14 | }; | ||
| 11 | const THEME_COLOR_META_NAME = "theme-color"; | 15 | const THEME_COLOR_META_NAME = "theme-color"; |
| 12 | const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; | 16 | const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; |
| 13 | 17 | ||
| 14 | let listeners: Array<() => void> = []; | 18 | let listeners: Array<() => void> = []; |
| 15 | let lastSnapshot: ThemeSnapshot | null = null; | 19 | let lastSnapshot: ThemeSnapshot | null = null; |
| 16 | let lastDesktopTheme: Theme | null = null; | 20 | let lastDesktopTheme: Theme | null = null; |
| 21 | |||
| 17 | function emitChange() { | 22 | function emitChange() { |
| 18 | for (const listener of listeners) listener(); | 23 | for (const listener of listeners) listener(); |
| 19 | } | 24 | } |
| 20 | 25 | ||
| 21 | function getSystemDark(): boolean { | 26 | function hasThemeStorage() { |
| 22 | return window.matchMedia(MEDIA_QUERY).matches; | 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 | function getStored(): Theme { | 34 | function 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 | } |
| 30 | 40 | ||
| 31 | function ensureThemeColorMetaTag(): HTMLMetaElement { | 41 | function ensureThemeColorMetaTag(): HTMLMetaElement { |
| ... | @@ -78,7 +88,7 @@ export function syncBrowserChromeTheme() { | ... | @@ -78,7 +88,7 @@ export function syncBrowserChromeTheme() { |
| 78 | } | 88 | } |
| 79 | 89 | ||
| 80 | function applyTheme(theme: Theme, suppressTransitions = false) { | 90 | function 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 | } |
| 98 | 108 | ||
| 99 | function syncDesktopTheme(theme: Theme) { | 109 | function 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 | } |
| 112 | 123 | ||
| 113 | // Apply immediately on module load to prevent flash | 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 | function getSnapshot(): ThemeSnapshot { | 129 | function 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; |
| 119 | 133 | ||
| ... | @@ -125,7 +139,12 @@ function getSnapshot(): ThemeSnapshot { | ... | @@ -125,7 +139,12 @@ function getSnapshot(): ThemeSnapshot { |
| 125 | return lastSnapshot; | 139 | return lastSnapshot; |
| 126 | } | 140 | } |
| 127 | 141 | ||
| 142 | function getServerSnapshot() { | ||
| 143 | return DEFAULT_THEME_SNAPSHOT; | ||
| 144 | } | ||
| 145 | |||
| 128 | function subscribe(listener: () => void): () => void { | 146 | function subscribe(listener: () => void): () => void { |
| 147 | if (typeof window === "undefined") return () => {}; | ||
| 129 | listeners.push(listener); | 148 | listeners.push(listener); |
| 130 | 149 | ||
| 131 | // Listen for system preference changes | 150 | // 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 | } |
| 154 | 173 | ||
| 155 | export function useTheme() { | 174 | export 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; |
| 158 | 177 | ||
| 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; |
| 161 | 180 | ||
| 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(); |