From f3e2b5dee4ddc99648ff64880f96098d4453224f Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 9 Sep 2026 16:26:13 -0500 Subject: [PATCH 01/27] fix(web): allow expanding duplicate tool call commands (#10981) (cherry picked from commit 50f918c57a2df85bd97612b958386aaca035dcab) --- apps/web/src/components/chat/MessagesTimeline.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5e0f55369..54a5d6a84 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3336,18 +3336,14 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workspaceRoot, }) : null; - const commandMatchesVisibleLabel = workEntry.command?.trim() === previewText.trim(); const canExpand = (showFailedIndicator && previewText.trim().length > 0) || (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || Boolean( workEntryRawCommand(workEntry) || - (!commandMatchesVisibleLabel && workEntry.command?.trim()) || - (workEntry.detail?.trim() && - (workEntry.command || workEntry.detail.trim() !== previewText.trim())) || - workEntry.changedFiles?.some( - (path) => formatWorkspaceRelativePath(path, workspaceRoot) !== previewText.trim(), - ) || + workEntry.command?.trim() || + workEntry.detail?.trim() || + workEntry.changedFiles?.length || viewedImagePath, ); const expandedBody = expanded @@ -3433,9 +3429,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { Date: Wed, 9 Sep 2026 20:38:19 -0300 Subject: [PATCH 02/27] feat(web): zoom and pan expanded images (#10869) (cherry picked from commit 8d8189e67dc091ffb91df451c9a957bbd68a5364) --- apps/web/src/components/ChatView.tsx | 1 + .../components/chat/ExpandedImageDialog.tsx | 15 +- .../web/src/components/chat/ZoomableImage.tsx | 239 ++++++++++++++++++ 3 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/chat/ZoomableImage.tsx diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b6cebf00f..cadd127ea 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -649,6 +649,7 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [ '[role="tab"]', ].join(","); const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ + '[role="dialog"][aria-modal="true"]', '[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])', '[data-slot="command-dialog-popup"]:is([data-open],[data-ending-style])', '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])', diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx index 8844a695e..e960e484d 100644 --- a/apps/web/src/components/chat/ExpandedImageDialog.tsx +++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx @@ -17,6 +17,7 @@ import { } from "./SnapShotAttachmentDetails"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { composerFloatingLayerProps } from "./composerEventScope"; +import { ZoomableImage, type ZoomableImageHandle } from "./ZoomableImage"; interface ExpandedImageDialogProps { preview: ExpandedImagePreview; @@ -67,6 +68,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ onClose, }: ExpandedImageDialogProps) { const [imageOffset, setImageOffset] = useState(0); + const zoomableImageRef = useRef(null); const [failedImageSrc, setFailedImageSrc] = useState(null); const [accessibilityDetailsSrc, setAccessibilityDetailsSrc] = useState(null); const index = (preview.index + imageOffset + preview.images.length) % preview.images.length; @@ -121,6 +123,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ onClose(); return; } + if (zoomableImageRef.current?.pan(event.key)) { + event.preventDefault(); + event.stopPropagation(); + return; + } if (preview.images.length <= 1) return; if (event.key === "ArrowLeft") { event.preventDefault(); @@ -210,11 +217,11 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({ {openOriginalLink} ) : ( - {item.name} setFailedImageSrc(item.src)} /> )} diff --git a/apps/web/src/components/chat/ZoomableImage.tsx b/apps/web/src/components/chat/ZoomableImage.tsx new file mode 100644 index 000000000..944c5204e --- /dev/null +++ b/apps/web/src/components/chat/ZoomableImage.tsx @@ -0,0 +1,239 @@ +import { + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, + type Ref, +} from "react"; + +const MAX_ZOOM = 8; + +export interface ZoomableImageHandle { + pan: (key: string) => boolean; +} + +/** Zooms around the pointer and keeps the whole image accessible by dragging or scrolling. */ +export function ZoomableImage({ + src, + name, + onError, + ref, +}: { + src: string; + name: string; + onError: () => void; + ref?: Ref; +}) { + const viewportRef = useRef(null); + const [naturalSize, setNaturalSize] = useState({ width: 0, height: 0 }); + const [windowSize, setWindowSize] = useState(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + const [zoom, setZoom] = useState(1); + const zoomRef = useRef(1); + const anchorRef = useRef<{ x: number; y: number; clientX: number; clientY: number } | null>(null); + const dragRef = useRef<{ + pointerId: number; + x: number; + y: number; + left: number; + top: number; + } | null>(null); + const suppressClickRef = useRef(false); + const [dragging, setDragging] = useState(false); + const maxHeight = Math.max(1, Math.min(windowSize.height * 0.86, windowSize.height - 80)); + const fit = Math.min( + 1, + (windowSize.width * 0.92) / (naturalSize.width || 1), + maxHeight / (naturalSize.height || 1), + ); + const width = naturalSize.width * fit * zoom; + const height = naturalSize.height * fit * zoom; + + useImperativeHandle( + ref, + () => ({ + pan(key) { + const viewport = viewportRef.current; + if (!viewport || zoomRef.current <= 1) return false; + switch (key) { + case "ArrowLeft": + viewport.scrollLeft -= 40; + break; + case "ArrowRight": + viewport.scrollLeft += 40; + break; + case "ArrowUp": + viewport.scrollTop -= 40; + break; + case "ArrowDown": + viewport.scrollTop += 40; + break; + default: + return false; + } + return true; + }, + }), + [], + ); + + const changeZoom = useCallback((next: number, point?: { x: number; y: number }) => { + const viewport = viewportRef.current; + const previous = zoomRef.current; + const clamped = Math.min(MAX_ZOOM, Math.max(1, next)); + if (!viewport || previous === clamped) return; + const bounds = viewport.getBoundingClientRect(); + const x = point ? point.x - bounds.left : viewport.clientWidth / 2; + const y = point ? point.y - bounds.top : viewport.clientHeight / 2; + anchorRef.current = { + x: (viewport.scrollLeft + x) / previous, + y: (viewport.scrollTop + y) / previous, + clientX: bounds.left + x, + clientY: bounds.top + y, + }; + zoomRef.current = clamped; + setZoom(clamped); + }, []); + + useLayoutEffect(() => { + const viewport = viewportRef.current; + const anchor = anchorRef.current; + if (!viewport || !anchor) return; + const bounds = viewport.getBoundingClientRect(); + viewport.scrollLeft = anchor.x * zoom - (anchor.clientX - bounds.left); + viewport.scrollTop = anchor.y * zoom - (anchor.clientY - bounds.top); + anchorRef.current = null; + }, [zoom]); + + useEffect(() => { + const resize = () => { + setWindowSize({ width: window.innerWidth, height: window.innerHeight }); + changeZoom(1); + }; + window.addEventListener("resize", resize); + return () => window.removeEventListener("resize", resize); + }, [changeZoom]); + + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const wheel = (event: WheelEvent) => { + if (event.deltaY === 0) return; + event.preventDefault(); + const delta = + event.deltaY * + (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? viewport.clientHeight : 1); + changeZoom(zoomRef.current * Math.exp(-delta * (event.ctrlKey ? 0.01 : 0.002)), { + x: event.clientX, + y: event.clientY, + }); + }; + viewport.addEventListener("wheel", wheel, { passive: false }); + return () => viewport.removeEventListener("wheel", wheel); + }, [changeZoom]); + + return ( +
+
1 ? (dragging ? "grabbing" : "grab") : "zoom-in", + }} + onClick={(event) => { + // Pointer capture also produces a click after dragging; leave the image zoomed. + if (suppressClickRef.current || event.detail > 1) return; + changeZoom(zoomRef.current > 1 ? 1 : 2, { x: event.clientX, y: event.clientY }); + }} + onKeyDown={(event) => { + if (event.ctrlKey || event.metaKey || event.altKey) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (!event.repeat) changeZoom(zoomRef.current > 1 ? 1 : 2); + } else if (event.key === "+" || event.key === "=") { + event.preventDefault(); + changeZoom(zoomRef.current * 1.5); + } else if (event.key === "-") { + event.preventDefault(); + changeZoom(zoomRef.current / 1.5); + } else if (event.key === "0") { + event.preventDefault(); + changeZoom(1); + } + }} + onPointerDown={(event) => { + if (dragRef.current) return; + suppressClickRef.current = false; + if (event.pointerType !== "mouse" || event.button !== 0 || zoomRef.current <= 1) return; + const viewport = event.currentTarget; + const bounds = viewport.getBoundingClientRect(); + if ( + event.clientX - bounds.left >= viewport.clientWidth || + event.clientY - bounds.top >= viewport.clientHeight + ) + return; + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + left: viewport.scrollLeft, + top: viewport.scrollTop, + }; + viewport.setPointerCapture(event.pointerId); + setDragging(true); + }} + onPointerMove={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) > 4) { + suppressClickRef.current = true; + } + event.currentTarget.scrollLeft = drag.left - (event.clientX - drag.x); + event.currentTarget.scrollTop = drag.top - (event.clientY - drag.y); + }} + onPointerUp={(event) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + dragRef.current = null; + setDragging(false); + }} + onLostPointerCapture={(event) => { + if (dragRef.current?.pointerId !== event.pointerId) return; + dragRef.current = null; + setDragging(false); + }} + > + {name} { + setNaturalSize({ + width: event.currentTarget.naturalWidth, + height: event.currentTarget.naturalHeight, + }); + }} + onError={onError} + /> +
+ + {Math.round(zoom * 100)}% zoom + +
+ ); +} From 007a85c5231bb997735c7f6a1c2737efb75faab3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 9 Sep 2026 16:53:53 -0700 Subject: [PATCH 03/27] fix(ui): use available space for composer model names (#11002) (cherry picked from commit b7b3ef1e6fcb5c22a9790d2578fe8af7ce396835) --- .../mobile/src/components/ComposerToolbar.tsx | 2 +- .../features/threads/NewTaskDraftScreen.tsx | 35 ++++++++++--------- .../src/features/threads/ThreadComposer.tsx | 4 ++- apps/web/src/components/chat/ChatComposer.tsx | 1 - .../components/chat/ProviderModelPicker.tsx | 5 ++- 5 files changed, 24 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/components/ComposerToolbar.tsx b/apps/mobile/src/components/ComposerToolbar.tsx index b60f9a131..ed74ce420 100644 --- a/apps/mobile/src/components/ComposerToolbar.tsx +++ b/apps/mobile/src/components/ComposerToolbar.tsx @@ -35,7 +35,7 @@ export function ComposerInlineControl(props: { readonly icon?: ComponentProps["name"]; readonly iconNode?: ReactNode; readonly label: string; - readonly maxWidth?: number; + readonly maxWidth?: ViewStyle["maxWidth"]; readonly onPress?: () => void; readonly selected?: boolean; readonly static?: boolean; diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index 45699c245..cdfba8e2c 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -32,7 +32,6 @@ import { ComposerActionButton, ComposerInlineControl, ComposerToolbarRow, - ComposerToolbarScroller, } from "../../components/ComposerToolbar"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { ComposerAttachmentButton } from "../../components/ComposerAttachmentButton"; @@ -1349,21 +1348,23 @@ export function NewTaskDraftScreen(props: { onPickMedia={handlePickMedia} onPickFiles={handlePickFiles} /> - - - } - label={flow.selectedModelOption?.label ?? "Choose model"} - maxWidth={152} - onPress={settingsSheetPresentation.open} - /> + + + + } + label={flow.selectedModelOption?.label ?? "Choose model"} + maxWidth="100%" + onPress={settingsSheetPresentation.open} + /> + {flow.planModeEnabled ? ( ) : null} - + )} setQuickQuestionOpenScopeKey(quickQuestionScopeKey)} /> ) : null} + } label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth={152} + maxWidth="100%" disabled={props.sessionInputBlocked} accessibilityHint={ props.sessionInputBlocked @@ -1879,6 +1880,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } onPress={openSettings} /> + {sessionHarnessRefinementActions.length > 0 ? ( Date: Wed, 9 Sep 2026 23:25:23 -0300 Subject: [PATCH 04/27] fix(web): show message copy buttons on touch devices (#11020) Co-authored-by: Claude Opus 5 (1M context) (cherry picked from commit 385cc0a4c669aeae786ad47bfe3ecbbc352eff24) --- apps/web/src/components/chat/MessagesTimeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 54a5d6a84..ace9c3379 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1577,7 +1577,7 @@ function UserTimelineRow({ row }: { row: Extract -
+
}> @@ -1735,7 +1735,7 @@ function AssistantMessageMeta({ "flex items-center gap-2 text-xs tabular-nums transition-opacity duration-200", alwaysVisible || reportedCostLabel ? "opacity-100" - : "opacity-0 focus-within:opacity-100 group-hover/assistant:opacity-100", + : "opacity-0 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover/assistant:opacity-100", className, )} > From 24ba2139dd17fb97e16c7d240058198509476c27 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 9 Sep 2026 23:25:56 -0300 Subject: [PATCH 05/27] fix(web): middle-click pastes in the terminal on Linux (#11018) Co-authored-by: Claude Opus 5 (1M context) (cherry picked from commit d1eeb16247a0bd2eca8bbbfa5ab777e096af949c) --- apps/web/src/terminal/ghostty/surface.test.ts | 29 ++++++++++- apps/web/src/terminal/ghostty/surface.ts | 50 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 7174261e6..59150ee32 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -167,13 +167,13 @@ describe("GhosttyTerminalSurface visibility", () => { resize() { for (const callback of resizeCallbacks) callback(); }, - pointer(type: string, clientX: number, buttons: number, shiftKey = false) { + pointer(type: string, clientX: number, buttons: number, shiftKey = false, button = 0) { canvas.dispatchEvent( Object.assign(new Event(type, { cancelable: true }), { clientX, clientY: 5, pointerId: 1, - button: 0, + button, buttons, shiftKey, }), @@ -280,6 +280,31 @@ describe("GhosttyTerminalSurface visibility", () => { expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false); }); + it("pastes the terminal selection, and only that, on a Linux middle click", async () => { + const harness = createHarness(); + const readText = vi.fn(async () => "clipboard text"); + vi.stubGlobal("navigator", { platform: "Linux x86_64", clipboard: { readText } }); + const surface = await harness.create(); + surface.write("hello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 37, 1); + harness.pointer("pointerup", 37, 0); + expect(surface.getSelection()).toBe("hello"); + + harness.onData.mockClear(); + harness.pointer("pointerdown", 5, 4, false, 1); + await vi.waitFor(() => expect(harness.onData).toHaveBeenCalled()); + expect(harness.onData.mock.calls.at(-1)?.[0]).toBe("hello"); + expect(surface.getSelection()).toBe("hello"); + + // Without a selection there is no primary buffer to paste; the clipboard + // holds what the user copied and must not be substituted. + surface.clearSelection(); + harness.pointer("pointerdown", 5, 4, false, 1); + expect(readText).not.toHaveBeenCalled(); + }); + it("starts a selection when dragging from a link", async () => { const harness = createHarness(); const onLinkActivate = vi.fn(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index bf43b207d..c9230f1a0 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -395,6 +395,15 @@ export function isTerminalPasteShortcut( return isMacPlatform(platform) ? event.metaKey : event.ctrlKey && event.shiftKey; } +/** + * Middle-click paste is an X11/Wayland convention. macOS and Windows have no + * primary selection and use the button for autoscroll, so only desktops that + * expect the gesture get it. + */ +function isMiddleClickPastePlatform(): boolean { + return /linux|bsd/i.test(navigator.platform); +} + export function isTerminalCompositionCommitInput(event: Pick): boolean { return ( event.inputType === "" || @@ -938,6 +947,20 @@ export class GhosttyTerminalSurface { if (encoded.length > 0) this.options.onData(encoded); } + /** + * Middle-click pastes the terminal's own selection, which is the only + * primary-selection-like buffer a browser can read. It goes through + * pasteFromClipboard so it joins the same paste race as every other path. + * With nothing selected here there is no buffer to paste, and CLIPBOARD is + * deliberately not substituted: middle-click must never emit text the user + * only ever copied. + */ + private pasteTerminalSelection(): void { + const selection = this.getSelection(); + if (selection.length === 0) return; + void this.pasteFromClipboard(() => Promise.resolve(selection)); + } + hasSelection(): boolean { return this.core.selectionText().length > 0; } @@ -1272,6 +1295,12 @@ export class GhosttyTerminalSurface { this.canvas.setPointerCapture(event.pointerId); return; } + if (event.button === 1 && isMiddleClickPastePlatform()) { + // Left uncancelled on purpose: cancelling pointerdown drops the + // compatibility mousedown, which is what activates a split pane. + this.pasteTerminalSelection(); + return; + } if (event.button !== 0) return; const clickCount = this.recordSelectionClick(event); const link = this.linkAt(event.clientX, event.clientY); @@ -1515,6 +1544,10 @@ export class GhosttyTerminalSurface { if (this.canvas.hasPointerCapture(event.pointerId)) { this.canvas.releasePointerCapture(event.pointerId); } + if (event.button === 1 && isMiddleClickPastePlatform()) { + event.preventDefault(); + return; + } if (event.button !== 0) return; if (!this.selectionMoved && this.selectionMode === "cell") { this.clearSelection(); @@ -1551,10 +1584,23 @@ export class GhosttyTerminalSurface { }; private readonly onMouseDown = (event: MouseEvent) => { - if (event.button === 0) event.preventDefault(); + // Cancelling the middle button here stops autoscroll while still letting + // the event bubble to the drawer handler that activates a split pane. + if (event.button === 0 || (event.button === 1 && isMiddleClickPastePlatform())) { + event.preventDefault(); + } this.focus(); }; + /** + * Chromium pastes PRIMARY into the focused editable on a middle mouseup, and + * the hidden textarea is focused, so leaving the default alive would deliver + * a second paste through onPaste on top of the one onPointerDown sent. + */ + private readonly onMouseUp = (event: MouseEvent) => { + if (event.button === 1 && isMiddleClickPastePlatform()) event.preventDefault(); + }; + private readonly onContextMenu = (event: MouseEvent) => { if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { event.preventDefault(); @@ -1644,6 +1690,7 @@ export class GhosttyTerminalSurface { this.canvas.addEventListener("pointercancel", this.onPointerUp); this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); this.canvas.addEventListener("mousedown", this.onMouseDown); + this.canvas.addEventListener("mouseup", this.onMouseUp); this.canvas.addEventListener("contextmenu", this.onContextMenu); this.scrollbar.addEventListener("pointerdown", this.onScrollbarPointerDown); this.scrollbar.addEventListener("pointermove", this.onScrollbarPointerMove); @@ -1669,6 +1716,7 @@ export class GhosttyTerminalSurface { this.canvas.removeEventListener("pointercancel", this.onPointerUp); this.canvas.removeEventListener("wheel", this.onWheel); this.canvas.removeEventListener("mousedown", this.onMouseDown); + this.canvas.removeEventListener("mouseup", this.onMouseUp); this.canvas.removeEventListener("contextmenu", this.onContextMenu); this.scrollbar.removeEventListener("pointerdown", this.onScrollbarPointerDown); this.scrollbar.removeEventListener("pointermove", this.onScrollbarPointerMove); From 605594320fc193776542a57d05e331c8d0337969 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 02:18:01 -0300 Subject: [PATCH 06/27] feat: add blue and orange diff color palette (#10671) (cherry picked from commit bb5e824c9fcbd76c93ef15b304f89f8b6999f32c) --- apps/web/src/components/GitActionsControl.tsx | 8 +-- apps/web/src/components/Sidebar.tsx | 4 +- .../web/src/components/chat/DiffStatLabel.tsx | 4 +- .../pullRequest/pullRequestPresentation.tsx | 6 +-- .../components/settings/SettingsPanels.tsx | 51 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 6 +++ apps/web/src/index.css | 26 ++++++++++ apps/web/src/lib/diffRendering.ts | 24 ++++----- apps/web/src/routes/__root.tsx | 5 ++ packages/contracts/src/settings.test.ts | 17 +++++++ packages/contracts/src/settings.ts | 6 +++ 11 files changed, 133 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 36026396e..1706a6a70 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1878,9 +1878,9 @@ export default function GitActionsControl({ Excluded ) : ( <> - +{file.insertions} + +{file.insertions} / - -{file.deletions} + -{file.deletions} )} @@ -1891,11 +1891,11 @@ export default function GitActionsControl({
- + +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / - + -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d2a48ccc9..ee34365cd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1967,8 +1967,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {prBadge} {diff ? ( - +{diff.insertions}{" "} - −{diff.deletions} + +{diff.insertions}{" "} + −{diff.deletions} ) : null} -
- {workEntry.questionAnswer ? ( + {expanded && workEntry.questionAnswer ? ( ) : null} - {expanded && canExpand && (expandedBody || viewedImage) ? ( + {expanded && canExpand && (expandedBody || viewedImage) && !workEntry.questionAnswer ? (
{[ ...new Set([ + ...Object.keys(answer.questionTextById ?? {}), ...Object.keys(answer.answers), ...Object.keys(answer.attachmentsByQuestionId), ]), ].map((questionId) => (
{answer.questionTextById?.[questionId] ? ( -

{answer.questionTextById[questionId]}

+

+ {answer.questionTextById[questionId]} +

+ ) : null} + {getQuestionAnswerText(answer.answers[questionId]) ? ( +

+ {getQuestionAnswerText(answer.answers[questionId])} +

) : null} -

- {[answer.answers[questionId]] - .flat() - .filter((value): value is string => typeof value === "string") - .join(", ")} -

{(answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => { const url = urls[attachments.indexOf(attachment)]; diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 07d3d68e9..e43ec1d3a 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -3,6 +3,7 @@ import { type PendingApproval, } from "@t3tools/client-runtime/pending-requests"; import { UserInputAttachmentAnswerPayload } from "@t3tools/contracts"; +import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Arr from "effect/Array"; @@ -549,7 +550,7 @@ export function deriveWorkLogEntries( ): WorkLogEntry[] { const ordered = [...activities].toSorted(compareActivitiesByOrder); const entries: DerivedWorkLogEntry[] = []; - for (const activity of ordered) { + for (const activity of foldUserInputActivities(ordered)) { if ( activity.kind === "interaction.requested" || activity.kind === "interaction.resolved" || @@ -1736,14 +1737,25 @@ export function deriveTimelineEntriesWithState( const entries = replaceStreamingTimelineMessages(messages, previous); if (entries !== null) return { messages, proposedPlans, workEntries, entries }; } + const foldedAnswerMessageIds = new Set( + workEntries.flatMap((entry) => + entry.questionAnswer ? [`async-answer:${entry.questionAnswer.requestId}`] : [], + ), + ); + const showMessage = (message: ChatMessage) => + message.role !== "user" || !foldedAnswerMessageIds.has(message.id); const canAppend = previous !== null && + !previous.entries.some((entry) => entry.kind === "message" && !showMessage(entry.message)) && hasExactArrayPrefix(previous.messages, messages) && hasExactArrayPrefix(previous.proposedPlans, proposedPlans) && hasExactArrayPrefix(previous.workEntries, workEntries); if (canAppend) { - const messageRows = messages.slice(previous.messages.length).map(timelineEntryFromMessage); + const messageRows = messages + .slice(previous.messages.length) + .filter(showMessage) + .map(timelineEntryFromMessage); const proposedPlanRows = proposedPlans .slice(previous.proposedPlans.length) .map(timelineEntryFromProposedPlan); @@ -1759,7 +1771,7 @@ export function deriveTimelineEntriesWithState( }; } - const messageRows = messages.map(timelineEntryFromMessage); + const messageRows = messages.filter(showMessage).map(timelineEntryFromMessage); const proposedPlanRows = proposedPlans.map(timelineEntryFromProposedPlan); const workRows = workEntries.map(timelineEntryFromWork); return { diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index abbb36051..a3c8b4686 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -255,6 +255,10 @@ "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" }, + "./work-log/user-input": { + "types": "./src/work-log/userInput.ts", + "default": "./src/work-log/userInput.ts" + }, "./work-log/presentation": { "types": "./src/work-log/presentation.ts", "default": "./src/work-log/presentation.ts" diff --git a/packages/client-runtime/src/work-log/userInput.ts b/packages/client-runtime/src/work-log/userInput.ts new file mode 100644 index 000000000..355083c04 --- /dev/null +++ b/packages/client-runtime/src/work-log/userInput.ts @@ -0,0 +1,186 @@ +import { projectQuestionToolInput } from "@t3tools/shared/toolActivity"; +import { + type OrchestrationThreadActivity, + UserInputAttachmentAnswerPayload, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +const isQuestionAnswer = Schema.is(UserInputAttachmentAnswerPayload); + +function displayOptionAnswer(value: unknown, labels: ReadonlyMap): unknown { + if (typeof value === "string") return labels.get(value) ?? value; + if (Array.isArray(value)) return value.map((answer) => displayOptionAnswer(answer, labels)); + const nested = record(value); + return nested && "answers" in nested + ? { ...nested, answers: displayOptionAnswer(nested.answers, labels) } + : value; +} + +function questionFingerprint( + turnId: string, + questions: ReadonlyArray, +): string | undefined { + const texts = questions.map((question) => (typeof question === "string" ? question.trim() : "")); + return texts.length > 0 && texts.every(Boolean) + ? JSON.stringify([turnId, texts.toSorted()]) + : undefined; +} + +function withoutDuplicateQuestionTools( + activities: ReadonlyArray, +): ReadonlyArray { + const questions = new Set(); + for (const activity of activities) { + if (activity.kind !== "user-input.answer-submitted" || !activity.turnId) continue; + const payload = record(activity.payload); + const texts = Object.values(record(payload?.questionTextById) ?? {}); + const fingerprint = questionFingerprint(activity.turnId, texts); + if (fingerprint) questions.add(fingerprint); + } + if (questions.size === 0) return activities; + const duplicateToolIds = new Set(); + for (const activity of activities) { + if (!activity.kind.startsWith("tool.") || !activity.turnId) continue; + const payload = record(activity.payload); + if (typeof payload?.toolCallId !== "string") continue; + const input = projectQuestionToolInput(record(payload.data) ?? {}, payload.title).input; + if (!input) continue; + const fingerprint = questionFingerprint( + activity.turnId, + input.questions.map((question) => record(question)?.question), + ); + if (fingerprint && questions.has(fingerprint)) { + duplicateToolIds.add(JSON.stringify([activity.turnId, payload.toolCallId])); + } + } + return activities.filter((activity) => { + const payload = record(activity.payload); + const toolCallId = payload?.toolCallId; + return ( + activity.tone === "error" || + /^(failed|declined|stopped|cancelled)$/.test(String(payload?.status)) || + !activity.kind.startsWith("tool.") || + typeof toolCallId !== "string" || + !duplicateToolIds.has(JSON.stringify([activity.turnId, toolCallId])) + ); + }); +} + +/** Keep a question and its answer at the original tool position in the work log. */ +export function foldUserInputActivities( + activities: ReadonlyArray, +): ReadonlyArray { + const requests = new Map(); + for (const activity of activities) { + if ( + activity.kind !== "user-input.requested" && + activity.kind !== "user-input.resolved" && + activity.kind !== "user-input.answer-submitted" + ) + continue; + const requestId = record(activity.payload)?.requestId; + if (typeof requestId !== "string" || !requestId) continue; + const group = requests.get(requestId) ?? []; + group.push(activity); + requests.set(requestId, group); + } + const replacements = new Map(); + for (const [requestId, group] of requests) { + const payloads = group.map((activity) => record(activity.payload)!); + const questions = new Map>(); + const texts = new Map(); + for (const payload of payloads) { + for (const [id, text] of Object.entries(record(payload.questionTextById) ?? {})) + texts.set(id, text); + for (const value of Array.isArray(payload.questions) ? payload.questions : []) { + const question = record(value); + if (typeof question?.id !== "string") continue; + questions.set(question.id, question); + if (typeof question.question === "string") texts.set(question.id, question.question); + } + } + const questionTextById = Object.fromEntries(texts); + const submitted = group.findLast( + (activity) => + activity.kind === "user-input.answer-submitted" && + record(record(activity.payload)?.answers), + ); + const rawAnswers = + record(record(submitted?.payload)?.answers) ?? + payloads.map((payload) => record(payload.answers)).findLast(Boolean) ?? + {}; + const answers = Object.fromEntries( + Object.entries(rawAnswers).map(([id, value]) => { + const options = questions.get(id)?.options; + const labels = new Map(); + for (const candidate of Array.isArray(options) ? options : []) { + const option = record(candidate); + if (typeof option?.value === "string" && typeof option.label === "string") + labels.set(option.value, option.label); + } + return [id, displayOptionAnswer(value, labels)]; + }), + ); + const attachmentsByQuestionId = Object.fromEntries( + payloads.flatMap((payload) => Object.entries(record(payload.attachmentsByQuestionId) ?? {})), + ); + const answer = { requestId, questionTextById, answers, attachmentsByQuestionId }; + if (!isQuestionAnswer(answer)) continue; + const submittedAnswer = + Object.keys(answers).length > 0 || Object.keys(attachmentsByQuestionId).length > 0; + for (const activity of group) replacements.set(activity, null); + replacements.set(group[0]!, { + ...group[0]!, + kind: "user-input.answer-submitted", + tone: "tool", + summary: submittedAnswer + ? "User input submitted" + : group.some((activity) => activity.kind === "user-input.resolved") + ? "User input dismissed" + : "User input requested", + payload: answer, + }); + } + return withoutDuplicateQuestionTools( + activities.flatMap((activity) => { + const replacement = replacements.get(activity); + return replacement === null ? [] : [replacement ?? activity]; + }), + ); +} + +export function getQuestionAnswerText(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.map(getQuestionAnswerText).filter(Boolean).join(", "); + const nested = record(value); + return nested ? getQuestionAnswerText(nested.answers) : ""; +} + +export function getQuestionAnswerPreview(answer: UserInputAttachmentAnswerPayload): string { + const answers = Object.values(answer.answers).map(getQuestionAnswerText).filter(Boolean); + const attachments = Object.values(answer.attachmentsByQuestionId) + .flat() + .map((attachment) => attachment.name); + return ( + answers.length > 0 + ? answers.join(" · ") + : attachments.length > 0 + ? attachments.join(", ") + : Object.values(answer.questionTextById ?? {}).join(" · ") + ) + .replace(/\s+/g, " ") + .trim(); +} + +export function hasQuestionAnswer(answer: UserInputAttachmentAnswerPayload): boolean { + return ( + Object.values(answer.answers).some(getQuestionAnswerText) || + Object.values(answer.attachmentsByQuestionId).some((attachments) => attachments.length > 0) + ); +} diff --git a/packages/shared/src/toolActivity.ts b/packages/shared/src/toolActivity.ts index 2fd04e766..8c1c65565 100644 --- a/packages/shared/src/toolActivity.ts +++ b/packages/shared/src/toolActivity.ts @@ -259,3 +259,36 @@ export function deriveToolActivityPresentation( summary: title ?? fallbackSummary, }; } + +export function projectQuestionToolInput(data: Record, title: unknown) { + const item = asRecord(data.item); + const toolName = data.toolName ?? data.tool ?? item?.tool ?? title; + if (typeof toolName !== "string") return {}; + const name = toolName + .split(/__|[./]/) + .at(-1) + ?.replace(/[_\s]/g, "") + .toLowerCase(); + if (!name || !/^(askuserquestion|requestuserinput(?:async)?|askquestion|question)$/.test(name)) + return {}; + const input = asRecord( + data.input ?? data.rawInput ?? asRecord(data.state)?.input ?? item?.arguments, + ); + const questions = input?.questions ?? asRecord(input?.params)?.questions; + if (!Array.isArray(questions)) return {}; + // Clients match native tools to the canonical question; choices and answers + // already live on the user-input activities and need not cross the wire twice. + return { + toolName, + input: { + questions: questions.map((value) => { + const question = asRecord(value); + return { + question: asTrimmedString( + question?.question ?? question?.question_text ?? question?.prompt ?? question?.title, + ), + }; + }), + }, + }; +} From 0fd362a733b69c23f04047304b4f20af3785b499 Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 10 Sep 2026 23:39:21 -0300 Subject: [PATCH 12/27] fix(usage): flag unpriced model activity instead of showing $0.00 (#11021) Co-authored-by: Claude Opus 5 (1M context) (cherry picked from commit 48654c1182c41dbd5e99ef0d6620a13ba2400f26) --- .../src/features/usage/UsageRouteScreen.tsx | 9 +++-- .../src/components/usage/UsagePage.test.tsx | 24 +++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 19 ++++++++--- packages/shared/src/usageMerge.test.ts | 34 ++++++++++++++++++- packages/shared/src/usageMerge.ts | 24 ++++++++++++- 5 files changed, 101 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 59487a160..6244d5ff7 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -2,6 +2,7 @@ import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { useNavigation } from "@react-navigation/native"; import { isCompatibleUsageContractVersion, + isModelCostUnknown, type DailyTotals, type MergedUsage, } from "@t3tools/shared/usageMerge"; @@ -607,10 +608,14 @@ function ModelsSection(props: { readonly merged: MergedUsage }) { {model.model} - {formatPercent(model.costShare)} of cost · {formatTokens(model.totalTokens)} tokens + {isModelCostUnknown(model) + ? `no known rates · ${formatTokens(model.totalTokens)} tokens` + : `${formatPercent(model.costShare)} of cost · ${formatTokens(model.totalTokens)} tokens`} - {formatUsd(model.costUsd)} + + {isModelCostUnknown(model) ? "Unpriced" : formatUsd(model.costUsd)} + ))} diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index d649c5dac..3b4c66b69 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -86,6 +86,7 @@ const modelTotals = Object.freeze([ costUsd: 10, totalTokens: 100, records: 1, + unpricedRecords: 0, costShare: 10 / 16, }, { @@ -94,6 +95,7 @@ const modelTotals = Object.freeze([ costUsd: 5, totalTokens: 1_000, records: 1, + unpricedRecords: 0, costShare: 5 / 16, }, { @@ -102,8 +104,18 @@ const modelTotals = Object.freeze([ costUsd: 1, totalTokens: 1_000, records: 1, + unpricedRecords: 0, costShare: 1 / 16, }, + { + model: "unpriced-model", + provider: "codex" as const, + costUsd: 0, + totalTokens: 500, + records: 2, + unpricedRecords: 2, + costShare: 0, + }, ]); beforeEach(() => { @@ -177,6 +189,17 @@ describe("UsagePage model breakdown", () => { expect(body).toMatch(/expensive-model.*token-heavy-model.*token-heavy-cheaper-model/); }); + it("flags a model with no known rates instead of showing it as free", () => { + testState.breakdown = "model"; + + const markup = renderToStaticMarkup(); + const body = markup.match(/(.*?)<\/tbody>/)?.[1] ?? ""; + const unpricedRow = body.split(" row.includes("unpriced-model")) ?? ""; + + expect(unpricedRow).toContain("Unpriced"); + expect(unpricedRow).not.toContain("$0.00"); + }); + it("sorts models by token usage when the token metric is selected", () => { testState.metric = "tokens"; testState.breakdown = "model"; @@ -189,6 +212,7 @@ describe("UsagePage model breakdown", () => { "expensive-model", "token-heavy-model", "token-heavy-cheaper-model", + "unpriced-model", ]); }); }); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index d89904d6c..d4b3fb66b 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -15,6 +15,7 @@ import { useMemo, useRef, useState } from "react"; import { isCompatibleUsageContractVersion, + isModelCostUnknown, type DailyTotals, type HourlyTotals, } from "@t3tools/shared/usageMerge"; @@ -367,9 +368,13 @@ export function UsagePage() { : formatTokens(merged.totalTokens)} - {metric === "cost" - ? `${formatCount(merged.sessions)} sessions · API estimate` - : `${formatCount(merged.sessions)} sessions`} + {metric !== "cost" + ? `${formatCount(merged.sessions)} sessions` + : merged.costQuality.unpricedShare > 0 + ? `${formatCount(merged.sessions)} sessions · API estimate excludes ${formatPercent( + merged.costQuality.unpricedShare, + )} unpriced records` + : `${formatCount(merged.sessions)} sessions · API estimate`}
@@ -515,10 +520,14 @@ export function UsagePage() { - {formatUsd(model.costUsd)} + {isModelCostUnknown(model) ? ( + Unpriced + ) : ( + formatUsd(model.costUsd) + )} - {formatPercent(model.costShare)} + {isModelCostUnknown(model) ? "—" : formatPercent(model.costShare)} {formatTokens(model.totalTokens)} diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 6c706395c..24bbc3b7c 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -8,7 +8,7 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; +import { isModelCostUnknown, mergeUsage, type EnvironmentUsage } from "./usageMerge.ts"; function bucket(overrides: Partial = {}): UsageBucket { return { @@ -221,6 +221,38 @@ describe("mergeUsage", () => { expect(merged.costQuality.cacheSavingsUsd).toBe(4); }); + it("marks a model with no known rates as unpriced rather than free", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ costUsd: 75 }), + bucket({ + provider: "codex", + model: "unknown-model", + costUsd: 0, + costSource: "unpriced", + unpricedRecords: 5, + }), + ], + [ + { provider: "claude", hostId: "mac", homePath: "/a/.claude" }, + { provider: "codex", hostId: "mac", homePath: "/a/.codex" }, + ], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.models.find((model) => model.model === "unknown-model")?.unpricedRecords).toBe(5); + expect(merged.models.filter(isModelCostUnknown).map((model) => model.model)).toEqual([ + "unknown-model", + ]); + }); + it("keeps two machines apart when hostname and home path collide", () => { // Every Mac resolves /Users/theo/.claude, so a hostname clash used to make // one machine's usage vanish. Filesystem identity separates them. diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index 95982bf50..e0cb0510e 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -37,9 +37,22 @@ export interface ModelTotals { readonly costUsd: number; readonly totalTokens: number; readonly records: number; + /** + * Records whose tokens are counted here but which contributed nothing to + * `costUsd`. When it equals `records` the cost is unknown, not zero. + */ + readonly unpricedRecords: number; readonly costShare: number; } +/** + * A model whose every record lacked rates has an unknown cost, not a zero one. + * Clients must not present its `costUsd` as a real dollar figure. + */ +export function isModelCostUnknown(model: ModelTotals): boolean { + return model.records > 0 && model.unpricedRecords >= model.records; +} + export interface DailyTotals { readonly day: string; readonly costUsd: number; @@ -249,7 +262,13 @@ export function mergeUsage( >(); const modelAccumulator = new Map< string, - { provider: UsageProviderKind; costUsd: number; totalTokens: number; records: number } + { + provider: UsageProviderKind; + costUsd: number; + totalTokens: number; + records: number; + unpricedRecords: number; + } >(); const dailyAccumulator = new Map< string, @@ -319,10 +338,12 @@ export function mergeUsage( costUsd: 0, totalTokens: 0, records: 0, + unpricedRecords: 0, }; model.costUsd += bucket.costUsd; model.totalTokens += tokens; model.records += bucket.records; + model.unpricedRecords += bucket.unpricedRecords; modelAccumulator.set(modelKey, model); const day = dailyAccumulator.get(bucket.day) ?? { @@ -381,6 +402,7 @@ export function mergeUsage( costUsd: totals.costUsd, totalTokens: totals.totalTokens, records: totals.records, + unpricedRecords: totals.unpricedRecords, costShare: costUsd === 0 ? 0 : totals.costUsd / costUsd, })) .sort((a, b) => b.costUsd - a.costUsd || b.totalTokens - a.totalTokens); From cd1bc881ee550229598b46e0b5f6df0ccdb65a6a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 23:50:31 -0700 Subject: [PATCH 13/27] perf(web): format minimap previews only when opened (#11181) (cherry picked from commit 8fc253605e6d203c3654dbb1ce22fa1fd6fa0767) --- .../src/components/chat/MessagesTimeline.tsx | 65 +++------------ .../chat/timelineMinimapItems.test.ts | 81 +++++++++++++++++++ .../components/chat/timelineMinimapItems.ts | 66 +++++++++++++++ 3 files changed, 159 insertions(+), 53 deletions(-) create mode 100644 apps/web/src/components/chat/timelineMinimapItems.test.ts create mode 100644 apps/web/src/components/chat/timelineMinimapItems.ts diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index ff102381b..3386f1650 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -6,6 +6,11 @@ import { getQuestionAnswerText, hasQuestionAnswer, } from "@t3tools/client-runtime/work-log/user-input"; +import { + deriveTimelineMinimapItems, + resolveTimelineMinimapPreview, + type TimelineMinimapItem, +} from "./timelineMinimapItems"; import { type AssistantCitation, type EnvironmentId, @@ -951,13 +956,6 @@ function getItemType(item: MessagesTimelineRow) { return item.kind === "message" ? `message:${item.message.role}` : item.kind; } -interface TimelineMinimapItem { - readonly id: string; - readonly rowIndex: number; - readonly userText: string | null; - readonly assistantText: string | null; -} - interface TimelinePositionState { readonly contentLength?: number; readonly scroll?: number; @@ -966,51 +964,6 @@ interface TimelinePositionState { readonly sizeAtIndex?: (index: number) => number | undefined; } -function deriveTimelineMinimapItems( - rows: ReadonlyArray, -): TimelineMinimapItem[] { - const items: TimelineMinimapItem[] = []; - for (let index = 0; index < rows.length; index += 1) { - const row = rows[index]; - if (row?.kind !== "message" || row.message.role !== "user") { - continue; - } - - items.push({ - id: row.id, - rowIndex: index, - userText: compactMinimapPreview(row.message.text), - assistantText: compactMinimapPreview(resolveFinalAssistantTextForTurn(rows, index)), - }); - } - return items; -} - -function resolveFinalAssistantTextForTurn( - rows: ReadonlyArray, - userRowIndex: number, -) { - let finalAssistantText: string | null = null; - for (let index = userRowIndex + 1; index < rows.length; index += 1) { - const row = rows[index]; - if (row?.kind !== "message") { - continue; - } - if (row.message.role === "user") { - break; - } - if (row.message.role === "assistant") { - finalAssistantText = row.message.text ?? null; - } - } - return finalAssistantText; -} - -function compactMinimapPreview(text: string | null | undefined) { - const compact = text?.replace(/\s+/g, " ").trim() ?? ""; - return compact.length > 0 ? compact : null; -} - function resolveTimelineRowTop(state: TimelinePositionState, rowIndex: number) { const top = state.positionAtIndex?.(rowIndex); return typeof top === "number" && Number.isFinite(top) ? top : null; @@ -1044,7 +997,13 @@ function TimelineMinimap({ const resolvedActiveIndex = activeIndex !== null && activeIndex < items.length ? activeIndex : null; - const activeItem = resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null); + const activeItem = useMemo( + () => + resolveTimelineMinimapPreview( + resolvedActiveIndex === null ? null : (items[resolvedActiveIndex] ?? null), + ), + [items, resolvedActiveIndex], + ); const activeTopPercent = resolvedActiveIndex === null ? 0 diff --git a/apps/web/src/components/chat/timelineMinimapItems.test.ts b/apps/web/src/components/chat/timelineMinimapItems.test.ts new file mode 100644 index 000000000..f7c40aa71 --- /dev/null +++ b/apps/web/src/components/chat/timelineMinimapItems.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vite-plus/test"; +import { MessageId } from "@t3tools/contracts"; +import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; +import { deriveTimelineMinimapItems, resolveTimelineMinimapPreview } from "./timelineMinimapItems"; +import type { ChatMessage } from "../../types"; + +function rows( + entries: ReadonlyArray, +): MessagesTimelineRow[] { + const messages: ChatMessage[] = entries.map(([role, text], index) => ({ + id: MessageId.make(`message-${index}`), + role, + text, + streaming: false, + turnId: null, + createdAt: new Date(index * 1000).toISOString(), + updatedAt: new Date(index * 1000).toISOString(), + })); + return messages.map((message) => ({ + kind: "message", + id: message.id, + createdAt: message.createdAt, + message, + durationStart: message.createdAt, + showAssistantMeta: false, + showAssistantCopyButton: false, + assistantCopyStreaming: false, + })); +} + +describe("timeline minimap previews", () => { + it("previews the last assistant response before the next prompt and retains jump targets", () => { + const source = rows([ + ["user", " Inspect\n this "], + ["assistant", "Working"], + ["assistant", " Done\t now "], + ["user", "Next"], + ["assistant", "Second answer"], + ]); + const items = deriveTimelineMinimapItems(source); + expect(items).toHaveLength(2); + expect(resolveTimelineMinimapPreview(items[0]!)).toEqual({ + ...items[0], + userText: "Inspect this", + assistantText: "Done now", + }); + expect(source[items[0]!.rowIndex]!.id).toBe(items[0]!.id); + expect(resolveTimelineMinimapPreview(items[1]!)?.assistantText).toBe("Second answer"); + expect(items[0]?.assistantText).toBe(" Done\t now "); + }); + + it("handles an unanswered prompt, empty responses, and a closed preview", () => { + const items = deriveTimelineMinimapItems( + rows([ + ["user", "First"], + ["assistant", " \n\t"], + ["user", "Next"], + ]), + ); + expect(items.map((item) => resolveTimelineMinimapPreview(item)?.assistantText)).toEqual([ + null, + null, + ]); + expect(resolveTimelineMinimapPreview(null)).toBeNull(); + }); + + it("shows fresh streaming text without changing the jump target", () => { + const first = deriveTimelineMinimapItems( + rows([ + ["user", "Explain"], + ["assistant", "First"], + ]), + )[0]!; + const next = { ...first, assistantText: "First\n second" }; + expect(resolveTimelineMinimapPreview(next)).toEqual({ + ...first, + assistantText: "First second", + }); + expect(resolveTimelineMinimapPreview(first)?.assistantText).toBe("First"); + }); +}); diff --git a/apps/web/src/components/chat/timelineMinimapItems.ts b/apps/web/src/components/chat/timelineMinimapItems.ts new file mode 100644 index 000000000..0a37c686e --- /dev/null +++ b/apps/web/src/components/chat/timelineMinimapItems.ts @@ -0,0 +1,66 @@ +import type { MessagesTimelineRow } from "./MessagesTimeline.logic"; + +export interface TimelineMinimapItem { + readonly id: string; + readonly rowIndex: number; + readonly userText: string | null; + readonly assistantText: string | null; +} + +/** Keep full source text untouched until a minimap preview is opened. */ +export function deriveTimelineMinimapItems( + rows: ReadonlyArray, +): TimelineMinimapItem[] { + const items: TimelineMinimapItem[] = []; + for (let index = 0; index < rows.length; index += 1) { + const row = rows[index]; + if (row?.kind !== "message" || row.message.role !== "user") { + continue; + } + + items.push({ + id: row.id, + rowIndex: index, + userText: row.message.text, + assistantText: resolveFinalAssistantTextForTurn(rows, index), + }); + } + return items; +} + +function resolveFinalAssistantTextForTurn( + rows: ReadonlyArray, + userRowIndex: number, +) { + let finalAssistantText: string | null = null; + for (let index = userRowIndex + 1; index < rows.length; index += 1) { + const row = rows[index]; + if (row?.kind !== "message") { + continue; + } + if (row.message.role === "user") { + break; + } + if (row.message.role === "assistant") { + finalAssistantText = row.message.text ?? null; + } + } + return finalAssistantText; +} + +function compactMinimapPreview(text: string | null | undefined) { + const compact = text?.replace(/\s+/g, " ").trim() ?? ""; + return compact.length > 0 ? compact : null; +} + +export function resolveTimelineMinimapPreview( + item: TimelineMinimapItem | null, +): TimelineMinimapItem | null { + return item === null + ? null + : { + ...item, + userText: compactMinimapPreview(item.userText), + assistantText: compactMinimapPreview(item.assistantText), + }; +} From 41b7ec41a677559df1d05bd7b30645889107cbfe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 23:50:32 -0700 Subject: [PATCH 14/27] perf(web): reuse completed Markdown prefixes while streaming (#11193) (cherry picked from commit a9dabbf100d1f6c0b2ed7b5e879d469ac14fd186) --- apps/web/package.json | 2 + apps/web/src/components/ChatMarkdown.tsx | 9 +- apps/web/src/markdown-incremental.test.tsx | 136 +++++++++++++++++++++ apps/web/src/markdown-incremental.ts | 107 ++++++++++++++++ pnpm-lock.yaml | 6 + 5 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/markdown-incremental.test.tsx create mode 100644 apps/web/src/markdown-incremental.ts diff --git a/apps/web/package.json b/apps/web/package.json index f98107798..a6bb6a691 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -61,6 +61,7 @@ "@types/babel__core": "^7.20.5", "@types/compression": "^1.8.1", "@types/culori": "^4.0.1", + "@types/mdast": "^4.0.4", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", "@types/react-test-renderer": "19.1.0", @@ -70,6 +71,7 @@ "compression": "^1.8.1", "react-test-renderer": "19.2.6", "tailwindcss": "^4.0.0", + "unified": "^11.0.5", "vite": "catalog:", "vite-plus": "catalog:" } diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 5eba1cde5..d4147f55b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -69,6 +69,7 @@ import React, { } from "react"; import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; +import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; import { defaultUrlTransform } from "react-markdown"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; @@ -3178,13 +3179,17 @@ function ChatMarkdown({ localMediaPreview, setLocalMediaPreview, } = useChatMarkdownState({ text, ...props }); - + const incrementalParsing = + props.isStreaming === true && + extraRemarkPlugins.length === 0 && + /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); const remarkPlugins = useMemo( () => [ ...(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS), ...extraRemarkPlugins, + ...(incrementalParsing ? [createIncrementalMarkdownPlugin()] : []), ], - [extraRemarkPlugins, lineBreaks], + [extraRemarkPlugins, incrementalParsing, lineBreaks], ); // react-markdown converts unparsed HTML nodes to text when skipHtml is false. diff --git a/apps/web/src/markdown-incremental.test.tsx b/apps/web/src/markdown-incremental.test.tsx new file mode 100644 index 000000000..cf06355f3 --- /dev/null +++ b/apps/web/src/markdown-incremental.test.tsx @@ -0,0 +1,136 @@ +import type { Root } from "mdast"; +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; +import type { Plugin } from "unified"; +import { describe, expect, it } from "vite-plus/test"; + +import { remarkCodexDirectives } from "@t3tools/client-runtime/codex-markdown-directives"; +import { remarkGithubAlerts } from "./markdown-github-alerts"; +import { createIncrementalMarkdownPlugin } from "./markdown-incremental"; +import { remarkNormalizeListItemIndentation } from "./markdown-list-indentation"; + +function render(source: string, incremental?: Plugin<[], Root>, parsedSources?: string[]) { + let tree: Root | undefined; + const observeParsing: Plugin<[], Root> = function () { + const original = this.parser; + if (original) { + this.parser = (text, file) => { + parsedSources?.push(text); + return original(text, file); + }; + } + }; + const capture: Plugin<[], Root> = () => (root) => { + tree = structuredClone(root); + }; + const html = renderToStaticMarkup( + + {source} + , + ); + return { html, tree }; +} + +const prefix = "# Before\n\n```ts\nconst values = [1, 2];\n```\n\n"; + +describe("incremental Markdown parsing", () => { + it("keeps the document prefix cached when list recovery parses contain fences", () => { + const source = + prefix + + "- first block\n\n ```ts\n const nested = 1;\n ```\n\n tail"; + const incremental = createIncrementalMarkdownPlugin(); + const parsedSources: string[] = []; + expect(render(source, incremental, parsedSources)).toEqual(render(source)); + parsedSources.length = 0; + const next = source + " more"; + expect(render(next, incremental, parsedSources)).toEqual(render(next)); + expect(parsedSources).not.toContain(next); + expect(parsedSources.some((text) => text.startsWith("t3-markdown-inline-prefix:"))).toBe(true); + }); + + it.each([ + "a\n===\n\nb\n---\n", + "- first\n\n continued\n\n- next\n", + "> quoted\n>\n> ```js\n> abc\n> ```\n\nend", + "
\nhello\n\n
\n\nend", + "[ref]\n\n[ref]: /later", + "a[^x]\n\n[^x]: note", + "a | b\n--|--\na | b\n", + "```\na\n```\n\nnext\n\n~~~\nb\n~~~\n\nmore", + "\n\n\tcode\n\nmore", + "text *bold*", + "> [!NOTE]\n> alert\n\n- [ ] task", + "\uFEFFtext after a byte-order mark", + ])("preserves the parse tree, positions, and HTML while streaming %j", (tail) => { + const source = prefix + tail; + const incremental = createIncrementalMarkdownPlugin(); + for (let end = 0; end <= source.length; end++) { + const text = source.slice(0, end); + expect(render(text, incremental), `prefix ${end}`).toEqual(render(text)); + } + }); + + it.each(["\r\n", "\r"])("preserves partial %j line endings", (newline) => { + const source = (prefix + "next\n\n```\nlast\n```\n\nend").replaceAll("\n", newline); + const incremental = createIncrementalMarkdownPlugin(); + for (let end = 0; end <= source.length; end++) { + const text = source.slice(0, end); + expect(render(text, incremental)).toEqual(render(text)); + } + }); + + it("updates earlier references when definitions arrive after the cached prefix", () => { + const before = "[later] and footnote[^note]\n\n" + prefix; + const incremental = createIncrementalMarkdownPlugin(); + for (const tail of ["text", "[later]: /target", "[later]: /target\n\n[^note]: a note"]) { + expect(render(before + tail, incremental)).toEqual(render(before + tail)); + } + }); + + it("handles edits, replacements, and repeated renders without leaking transformed nodes", () => { + const incremental = createIncrementalMarkdownPlugin(); + const documents = [ + prefix + "- first\n - second", + prefix + "> [!NOTE]\n> transformed alert", + prefix + "plain text", + "replacement without fences", + prefix.replace("Before", "Edited") + "edited prefix", + prefix + "plain text", + prefix + "plain text", + ]; + for (const document of documents) { + expect(render(document, incremental)).toEqual(render(document)); + } + }); + + it("does not freeze unclosed, nested, indented, or mismatched fences", () => { + const prefixes = [ + "```\nopen\n\n", + "````\n```\n\n", + "> ```\n> code\n> ```\n\n", + "- ```\n code\n ```\n\n", + " ```\n code\n ```\n\n", + "\n\n", + markdown: "# heading\n\n```ts\nconst a = 1;\n```\n\ntext\n", + rust: 'fn main() {\n let x = r#"multi\nline"#;\n}\n', + tsx: 'const element = \n{value}\n
;\n', + json: '{\n "value": [1,\n 2, 3]\n}\n', + yaml: "key: |\n multiline\n value\nnext: true\n", + css: '/* comment\n continued */\np::before {\n content: "text";\n}\n', + sql: "SELECT 'multi\nline'\nFROM table_name;\n", +} as const; + +const highlighterPromise = getSharedHighlighter({ + langs: Object.keys(samples) as Array, + themes: ["pierre-dark", "pierre-light"], + preferredHighlighter: "shiki-wasm", +}); + +describe("incremental code highlighting", () => { + it.each(Object.entries(samples))( + "matches full HTML at every streaming prefix in %s", + async (language, code) => { + const highlighter = await highlighterPromise; + for (const theme of ["pierre-dark", "pierre-light"] as const) { + const highlight = createIncrementalHighlighter(highlighter, language, theme); + for (let end = 0; end <= code.length; end++) { + const text = code.slice(0, end); + expect(highlight(text), `${theme}, prefix ${end}`).toBe( + highlighter.codeToHtml(text, { lang: language, theme }), + ); + } + } + }, + ); + + it("resets after edits and truncation, including edits to a completed line", async () => { + const highlighter = await highlighterPromise; + const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark"); + const inputs = [ + "/* open\ncomment\n", + "/* open\ncomment\n*/\nconst x = 1;", + "const edited = 2;\nconst x = 1;", + "const edited = 2;\nconst x = 10;", + "const edited = 2;\n", + "", + "\n\n\nconst fresh = true;\n", + ]; + for (const text of inputs) { + expect(highlight(text)).toBe( + highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), + ); + } + }); + + it.each(["text", "plaintext", "plain", "txt", "ansi"])( + "preserves %s without requesting grammar state", + async (language) => { + const highlighter = await highlighterPromise; + const highlight = createIncrementalHighlighter(highlighter, language, "pierre-dark"); + for (const text of ["plain\ntext", "\u001b[31mred\ncontinued", "\n"]) { + expect(highlight(text)).toBe( + highlighter.codeToHtml(text, { lang: language, theme: "pierre-dark" }), + ); + } + }, + ); + + it("preserves partial CRLF and CR line endings", async () => { + const highlighter = await highlighterPromise; + const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark"); + const code = "/* multi\r\nline */\r\nconst x = 1;\r\n"; + for (let end = 0; end <= code.length; end++) { + const text = code.slice(0, end); + expect(highlight(text)).toBe( + highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), + ); + } + }); +}); diff --git a/apps/web/src/lib/incrementalHighlighting.ts b/apps/web/src/lib/incrementalHighlighting.ts new file mode 100644 index 000000000..a2f90b693 --- /dev/null +++ b/apps/web/src/lib/incrementalHighlighting.ts @@ -0,0 +1,74 @@ +import type { DiffsHighlighter } from "@pierre/diffs"; + +import type { DiffThemeName } from "./diffRendering"; + +function codeChildren(root: ReturnType) { + const pre = root.children.find((node) => node.type === "element" && node.tagName === "pre"); + if (pre?.type !== "element") throw new Error("Missing highlighted pre element"); + const code = pre.children.find((node) => node.type === "element" && node.tagName === "code"); + if (code?.type !== "element") throw new Error("Missing highlighted code element"); + return code.children; +} + +/** Resume tokenization after the last completed line. Keep its grammar state so + * multiline strings, comments, and embedded languages continue to highlight as + * they do in a full pass. The current line is always highlighted again. + */ +export function createIncrementalHighlighter( + highlighter: DiffsHighlighter, + language: string, + theme: DiffThemeName, +) { + const options = { lang: language, theme }; + const newline = { type: "text" as const, value: "\n" }; + let cached: + | { + prefix: string; + state: ReturnType; + children: ReturnType; + } + | undefined; + + return (code: string): string => { + // Plain text and ANSI do not have a TextMate grammar state. A CR at the end + // of a chunk can still become a CRLF, so keep that input on the full path. + if ( + !language || + ["text", "plaintext", "plain", "txt", "ansi"].includes(language) || + code.includes("\r") + ) { + return highlighter.codeToHtml(code, options); + } + if (cached && !code.startsWith(cached.prefix)) cached = undefined; + const end = code.lastIndexOf("\n") + 1; + if (end > (cached?.prefix.length ?? 0)) { + // Omit the final newline: Shiki would tokenize an extra empty line and + // advance the grammar state twice before we process the following line. + const root = highlighter.codeToHast(code.slice(cached?.prefix.length ?? 0, end - 1), { + ...options, + ...(cached ? { grammarState: cached.state } : {}), + }); + const state = highlighter.getLastGrammarState(root); + if (!state) { + cached = undefined; + return highlighter.codeToHtml(code, options); + } + cached = { + prefix: code.slice(0, end), + state, + children: [...(cached ? [...cached.children, newline] : []), ...codeChildren(root)], + }; + } + const prefix = cached; + if (!prefix) return highlighter.codeToHtml(code, options); + return highlighter.codeToHtml(code.slice(prefix.prefix.length), { + ...options, + grammarState: prefix.state, + transformers: [ + { + code: (node) => ({ ...node, children: [...prefix.children, newline, ...node.children] }), + }, + ], + }); + }; +} From a0bfbc94cbcc1bbbab7425d36de32dfdd9c6e6c0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 23:50:32 -0700 Subject: [PATCH 16/27] perf(web): preserve completed code-line DOM while streaming (#11198) (cherry picked from commit 8078c532ceeee5cb951325031f216171507f3d5e) --- apps/web/package.json | 2 + apps/web/src/components/ChatMarkdown.test.tsx | 31 ++++++++++-- apps/web/src/components/ChatMarkdown.tsx | 43 ++++++++++++----- .../chat/HighlightedCodeLines.test.tsx | 26 ++++++++++ .../components/chat/HighlightedCodeLines.tsx | 48 +++++++++++++++++++ .../src/lib/incrementalHighlighting.test.ts | 27 +++++++---- apps/web/src/lib/incrementalHighlighting.ts | 12 ++--- pnpm-lock.yaml | 6 +++ 8 files changed, 164 insertions(+), 31 deletions(-) create mode 100644 apps/web/src/components/chat/HighlightedCodeLines.test.tsx create mode 100644 apps/web/src/components/chat/HighlightedCodeLines.tsx diff --git a/apps/web/package.json b/apps/web/package.json index a6bb6a691..ca2b4f65e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,8 @@ "class-variance-authority": "^0.7.1", "culori": "^4.0.2", "effect": "catalog:", + "hast-util-to-html": "^9.0.5", + "hast-util-to-jsx-runtime": "^2.3.6", "heic-to": "^1.5.2", "jose": "catalog:", "jsonc-parser": "3.3.1", diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 6acd94647..702e68122 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -117,13 +117,36 @@ describe("ChatMarkdown favicon privacy", () => { }); describe("ChatMarkdown streaming", () => { + it("does not retokenize completed lines when streaming finishes", async () => { + const highlighter = await getSyntaxHighlighterPromise("typescript"); + const highlight = vi.spyOn(highlighter, "codeToHast"); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + let renderer: ReactTestRenderer | undefined; + const text = "```typescript\nconst completed = 1;\nconst current = 2;"; + try { + await act(async () => { + renderer = create(); + }); + expect(highlight).toHaveBeenCalled(); + highlight.mockClear(); + await act(async () => { + renderer!.update(); + }); + expect(highlight.mock.calls.every(([code]) => !code.includes("const completed"))).toBe(true); + } finally { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + } + }); + it("recovers highlighting after a failed fence changes without resetting its controls", async () => { const highlighter = await getSyntaxHighlighterPromise("text"); - const codeToHtml = highlighter.codeToHtml.bind(highlighter); + const codeToHast = highlighter.codeToHast.bind(highlighter); let fail = true; - vi.spyOn(highlighter, "codeToHtml").mockImplementation((...args) => { + vi.spyOn(highlighter, "codeToHast").mockImplementation((...args) => { if (fail) throw new Error("Temporary highlighter failure"); - return codeToHtml(...args); + return codeToHast(...args); }); vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "warn").mockImplementation(() => {}); @@ -163,7 +186,7 @@ describe("ChatMarkdown streaming", () => { it("preserves code controls and details without highlighting an unchanged fence again", async () => { const highlighter = await getSyntaxHighlighterPromise("text"); - const highlight = vi.spyOn(highlighter, "codeToHtml"); + const highlight = vi.spyOn(highlighter, "codeToHast"); const writeText = vi.fn(async (_text: string) => {}); vi.stubGlobal("navigator", { clipboard: { writeText } }); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 340a878cf..f11c131a1 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -69,6 +69,7 @@ import React, { } from "react"; import type { Components, Options as ReactMarkdownOptions } from "react-markdown"; import ReactMarkdown from "react-markdown"; +import { toHtml } from "hast-util-to-html"; import { createIncrementalMarkdownPlugin } from "../markdown-incremental"; import { defaultUrlTransform } from "react-markdown"; import rehypeRaw from "rehype-raw"; @@ -123,7 +124,8 @@ import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { GitHubIcon } from "./Icons"; -import { createIncrementalHighlighter } from "../lib/incrementalHighlighting"; +import { createIncrementalHighlightedDocument } from "../lib/incrementalHighlighting"; +import { HighlightedCodeLines } from "./chat/HighlightedCodeLines"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; @@ -1030,9 +1032,14 @@ function SuspenseShikiCodeBlock({ themeName, isStreaming, }: SuspenseShikiCodeBlockProps) { + const [hasStreamed, setHasStreamed] = useState(isStreaming); + if (isStreaming && !hasStreamed) setHasStreamed(true); const language = extractFenceLanguage(className); const cacheKey = createHighlightCacheKey(code, language, themeName); - const cachedHighlightedHtml = !isStreaming ? highlightedCodeCache.get(cacheKey) : null; + // Once lines are mounted individually, keep that renderer when streaming + // finishes so switching to cached HTML cannot clear an existing selection. + const cachedHighlightedHtml = + !isStreaming && !hasStreamed ? highlightedCodeCache.get(cacheKey) : null; if (cachedHighlightedHtml != null) { return ( @@ -1050,6 +1057,7 @@ function SuspenseShikiCodeBlock({ themeName={themeName} cacheKey={cacheKey} isStreaming={isStreaming} + preserveLines={isStreaming || hasStreamed} /> ); } @@ -1060,6 +1068,7 @@ interface UncachedShikiCodeBlockProps { themeName: DiffThemeName; cacheKey: string; isStreaming: boolean; + preserveLines: boolean; } function UncachedShikiCodeBlock({ @@ -1068,16 +1077,19 @@ function UncachedShikiCodeBlock({ themeName, cacheKey, isStreaming, + preserveLines, }: UncachedShikiCodeBlockProps) { const highlighter = use(getSyntaxHighlighterPromise(language)); const incrementalHighlight = useMemo( - () => (isStreaming ? createIncrementalHighlighter(highlighter, language, themeName) : null), - [highlighter, isStreaming, language, themeName], + () => + preserveLines ? createIncrementalHighlightedDocument(highlighter, language, themeName) : null, + [highlighter, preserveLines, language, themeName], ); - const highlightedHtml = useMemo(() => { + const highlighted = useMemo(() => { try { - return incrementalHighlight - ? incrementalHighlight(code) + if (incrementalHighlight) return incrementalHighlight(code); + return preserveLines + ? highlighter.codeToHast(code, { lang: language, theme: themeName }) : highlighter.codeToHtml(code, { lang: language, theme: themeName }); } catch (error) { // Log highlighting failures for debugging while falling back to plain text @@ -1086,22 +1098,29 @@ function UncachedShikiCodeBlock({ error instanceof Error ? error.message : error, ); // If highlighting fails for this language, render as plain text - return highlighter.codeToHtml(code, { lang: "text", theme: themeName }); + return preserveLines + ? highlighter.codeToHast(code, { lang: "text", theme: themeName }) + : highlighter.codeToHtml(code, { lang: "text", theme: themeName }); } - }, [code, highlighter, incrementalHighlight, language, themeName]); + }, [code, highlighter, incrementalHighlight, language, preserveLines, themeName]); useEffect(() => { if (!isStreaming) { + const highlightedHtml = typeof highlighted === "string" ? highlighted : toHtml(highlighted); highlightedCodeCache.set( cacheKey, highlightedHtml, estimateHighlightedSize(highlightedHtml, code), ); } - }, [cacheKey, code, highlightedHtml, isStreaming]); + }, [cacheKey, code, highlighted, isStreaming]); - return ( -
+ return typeof highlighted === "string" ? ( +
+ ) : ( +
+ +
); } diff --git a/apps/web/src/components/chat/HighlightedCodeLines.test.tsx b/apps/web/src/components/chat/HighlightedCodeLines.test.tsx new file mode 100644 index 000000000..66af6dcc5 --- /dev/null +++ b/apps/web/src/components/chat/HighlightedCodeLines.test.tsx @@ -0,0 +1,26 @@ +import { getSharedHighlighter } from "@pierre/diffs"; +import { toHtml } from "hast-util-to-html"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { createIncrementalHighlightedDocument } from "../../lib/incrementalHighlighting"; +import { HighlightedCodeLines } from "./HighlightedCodeLines"; + +describe("highlighted code lines", () => { + it("preserves Shiki HTML, including colors, escaping, whitespace, and blank lines", async () => { + const highlighter = await getSharedHighlighter({ + langs: ["typescript"], + themes: ["pierre-dark", "pierre-light"], + preferredHighlighter: "shiki-wasm", + }); + for (const theme of ["pierre-dark", "pierre-light"] as const) { + const highlight = createIncrementalHighlightedDocument(highlighter, "typescript", theme); + const code = + 'const html = "";\n\n/* multi\nline */\n\tconst x = 1;\n'; + for (let end = 0; end <= code.length; end++) { + const root = highlight(code.slice(0, end)); + expect(renderToStaticMarkup()).toBe(toHtml(root)); + } + } + }); +}); diff --git a/apps/web/src/components/chat/HighlightedCodeLines.tsx b/apps/web/src/components/chat/HighlightedCodeLines.tsx new file mode 100644 index 000000000..f0f971681 --- /dev/null +++ b/apps/web/src/components/chat/HighlightedCodeLines.tsx @@ -0,0 +1,48 @@ +import type { DiffsHighlighter } from "@pierre/diffs"; +import { toHtml } from "hast-util-to-html"; +import { toJsxRuntime } from "hast-util-to-jsx-runtime"; +import { cloneElement, isValidElement, memo, type DOMAttributes } from "react"; +import { Fragment, jsx, jsxs } from "react/jsx-runtime"; + +type HighlightedRoot = ReturnType; +type HighlightedNode = HighlightedRoot["children"][number]; +const runtime = { Fragment, jsx, jsxs }; + +function elementShell(node: Extract) { + const element = toJsxRuntime({ ...node, children: [] }, runtime); + if (!isValidElement>(element)) { + throw new Error("Expected a highlighted code element"); + } + return element; +} + +const HighlightedLine = memo(function HighlightedLine({ node }: { node: HighlightedNode }) { + if (node.type !== "element") return toJsxRuntime(node, runtime); + return cloneElement(elementShell(node), { + dangerouslySetInnerHTML: { __html: toHtml({ type: "root", children: node.children }) }, + }); +}); + +/** Completed line nodes retain their identity in the incremental highlighter. + * Keep their DOM mounted too: replacing the entire pre makes the browser parse + * and resolve styles for thousands of unchanged token spans on each update. + */ +export function HighlightedCodeLines({ root }: { root: HighlightedRoot }) { + const pre = root.children[0]; + if (pre?.type !== "element" || pre.tagName !== "pre") return toJsxRuntime(root, runtime); + const code = pre.children[0]; + if (code?.type !== "element" || code.tagName !== "code") return toJsxRuntime(root, runtime); + return cloneElement( + elementShell(pre), + undefined, + cloneElement( + elementShell(code), + undefined, + code.children.map((node, index) => ( + // A line's position is stable as tokens and new lines are appended. + // oxlint-disable-next-line react/no-array-index-key + + )), + ), + ); +} diff --git a/apps/web/src/lib/incrementalHighlighting.test.ts b/apps/web/src/lib/incrementalHighlighting.test.ts index b61cfc3b4..36c27cea1 100644 --- a/apps/web/src/lib/incrementalHighlighting.test.ts +++ b/apps/web/src/lib/incrementalHighlighting.test.ts @@ -1,7 +1,8 @@ +import { toHtml } from "hast-util-to-html"; import { getSharedHighlighter } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; -import { createIncrementalHighlighter } from "./incrementalHighlighting"; +import { createIncrementalHighlightedDocument } from "./incrementalHighlighting"; const samples = { typescript: "/* multi\nline comment */\nconst x = `template\n${1 + 2}`;\nconst re = /abc/;\n", @@ -29,10 +30,10 @@ describe("incremental code highlighting", () => { async (language, code) => { const highlighter = await highlighterPromise; for (const theme of ["pierre-dark", "pierre-light"] as const) { - const highlight = createIncrementalHighlighter(highlighter, language, theme); + const highlight = createIncrementalHighlightedDocument(highlighter, language, theme); for (let end = 0; end <= code.length; end++) { const text = code.slice(0, end); - expect(highlight(text), `${theme}, prefix ${end}`).toBe( + expect(toHtml(highlight(text)), `${theme}, prefix ${end}`).toBe( highlighter.codeToHtml(text, { lang: language, theme }), ); } @@ -42,7 +43,11 @@ describe("incremental code highlighting", () => { it("resets after edits and truncation, including edits to a completed line", async () => { const highlighter = await highlighterPromise; - const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark"); + const highlight = createIncrementalHighlightedDocument( + highlighter, + "typescript", + "pierre-dark", + ); const inputs = [ "/* open\ncomment\n", "/* open\ncomment\n*/\nconst x = 1;", @@ -53,7 +58,7 @@ describe("incremental code highlighting", () => { "\n\n\nconst fresh = true;\n", ]; for (const text of inputs) { - expect(highlight(text)).toBe( + expect(toHtml(highlight(text))).toBe( highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), ); } @@ -63,9 +68,9 @@ describe("incremental code highlighting", () => { "preserves %s without requesting grammar state", async (language) => { const highlighter = await highlighterPromise; - const highlight = createIncrementalHighlighter(highlighter, language, "pierre-dark"); + const highlight = createIncrementalHighlightedDocument(highlighter, language, "pierre-dark"); for (const text of ["plain\ntext", "\u001b[31mred\ncontinued", "\n"]) { - expect(highlight(text)).toBe( + expect(toHtml(highlight(text))).toBe( highlighter.codeToHtml(text, { lang: language, theme: "pierre-dark" }), ); } @@ -74,11 +79,15 @@ describe("incremental code highlighting", () => { it("preserves partial CRLF and CR line endings", async () => { const highlighter = await highlighterPromise; - const highlight = createIncrementalHighlighter(highlighter, "typescript", "pierre-dark"); + const highlight = createIncrementalHighlightedDocument( + highlighter, + "typescript", + "pierre-dark", + ); const code = "/* multi\r\nline */\r\nconst x = 1;\r\n"; for (let end = 0; end <= code.length; end++) { const text = code.slice(0, end); - expect(highlight(text)).toBe( + expect(toHtml(highlight(text))).toBe( highlighter.codeToHtml(text, { lang: "typescript", theme: "pierre-dark" }), ); } diff --git a/apps/web/src/lib/incrementalHighlighting.ts b/apps/web/src/lib/incrementalHighlighting.ts index a2f90b693..fef3598a2 100644 --- a/apps/web/src/lib/incrementalHighlighting.ts +++ b/apps/web/src/lib/incrementalHighlighting.ts @@ -14,7 +14,7 @@ function codeChildren(root: ReturnType) { * multiline strings, comments, and embedded languages continue to highlight as * they do in a full pass. The current line is always highlighted again. */ -export function createIncrementalHighlighter( +export function createIncrementalHighlightedDocument( highlighter: DiffsHighlighter, language: string, theme: DiffThemeName, @@ -29,7 +29,7 @@ export function createIncrementalHighlighter( } | undefined; - return (code: string): string => { + return (code: string) => { // Plain text and ANSI do not have a TextMate grammar state. A CR at the end // of a chunk can still become a CRLF, so keep that input on the full path. if ( @@ -37,7 +37,7 @@ export function createIncrementalHighlighter( ["text", "plaintext", "plain", "txt", "ansi"].includes(language) || code.includes("\r") ) { - return highlighter.codeToHtml(code, options); + return highlighter.codeToHast(code, options); } if (cached && !code.startsWith(cached.prefix)) cached = undefined; const end = code.lastIndexOf("\n") + 1; @@ -51,7 +51,7 @@ export function createIncrementalHighlighter( const state = highlighter.getLastGrammarState(root); if (!state) { cached = undefined; - return highlighter.codeToHtml(code, options); + return highlighter.codeToHast(code, options); } cached = { prefix: code.slice(0, end), @@ -60,8 +60,8 @@ export function createIncrementalHighlighter( }; } const prefix = cached; - if (!prefix) return highlighter.codeToHtml(code, options); - return highlighter.codeToHtml(code.slice(prefix.prefix.length), { + if (!prefix) return highlighter.codeToHast(code, options); + return highlighter.codeToHast(code.slice(prefix.prefix.length), { ...options, grammarState: prefix.state, transformers: [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e2486acb..d334dfce2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -656,6 +656,12 @@ importers: effect: specifier: 4.0.0-rc.112 version: 4.0.0-rc.112(patch_hash=8bef799f35729cf3465eb428196f617b434a7c9f658d56acb2874d74b21b98b2) + hast-util-to-html: + specifier: ^9.0.5 + version: 9.0.5 + hast-util-to-jsx-runtime: + specifier: ^2.3.6 + version: 2.3.6 heic-to: specifier: ^1.5.2 version: 1.5.2 From d5fae19d95a050d3bf8c7bea248a4c4b3bf3ef83 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 10 Sep 2026 23:50:33 -0700 Subject: [PATCH 17/27] perf(web): huge-thread switch no longer blanks the chat pane (#11169) Co-authored-by: Cursor Agent Co-authored-by: Julius Marminge (cherry picked from commit 211618fd9fe39d3dde01171a6856ce9f633571c9) --- .../web/src/components/ChatView.logic.test.ts | 181 ++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 141 ++++++++++++++ apps/web/src/components/ChatView.tsx | 141 ++++++++++---- .../src/components/chat/MessagesTimeline.tsx | 59 ++++-- 4 files changed, 474 insertions(+), 48 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 2d684885f..fe0fd2141 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -53,6 +53,14 @@ import { resolveSendEnvMode, threadShellHasStarted, resolveDraftHeroState, + isPaintOnlyThreadTimeline, + peekHeldThreadTimeline, + peekRememberedThreadTimeline, + rememberReadyThreadTimeline, + resetHeldThreadTimeline, + resolveThreadSwitchTimeline, + threadKeysShareEnvironment, + timelineHasEphemeralPreviewUrls, scheduleEnvironmentReconnectWarning, startNewThreadForProject, codexArtifactTemplatePromptToAppend, @@ -476,6 +484,179 @@ describe("draft hero submission transition", () => { }); }); +describe("resolveThreadSwitchTimeline", () => { + afterEach(() => { + resetHeldThreadTimeline(); + }); + + const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] }; + + it("keeps the previous thread's entries while the next thread is loading", () => { + expect( + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-1:thread-b", + nextEntries: [], + lastReady: held, + }), + ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + }); + + it("shows the new thread once its detail is ready", () => { + expect( + resolveThreadSwitchTimeline({ + loading: false, + activeThreadKey: "env-1:thread-b", + nextEntries: ["b1"], + lastReady: held, + }), + ).toEqual({ entries: ["b1"], displayThreadKey: "env-1:thread-b" }); + }); + + it("does not invent a timeline on the first open of a thread", () => { + expect( + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-1:thread-a", + nextEntries: [], + lastReady: null, + }), + ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); + }); + + it("keeps the held thread workspace cwd with the snapshot", () => { + rememberReadyThreadTimeline({ + ...held, + markdownCwd: "/repo/a", + workspaceRoot: "/repo/a", + }); + expect(peekHeldThreadTimeline()).toEqual({ + ...held, + markdownCwd: "/repo/a", + workspaceRoot: "/repo/a", + }); + }); + + it("survives a ChatView remount by remembering the last ready timeline", () => { + rememberReadyThreadTimeline(held); + expect(peekHeldThreadTimeline()).toEqual(held); + expect( + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-1:thread-b", + nextEntries: [], + }), + ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + }); + + it("paints a remembered destination instead of the last-viewed thread", () => { + rememberReadyThreadTimeline(held); + rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["b1", "b2"] }); + expect(peekRememberedThreadTimeline("env-1:thread-a")).toEqual(["a1", "a2"]); + expect( + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-1:thread-a", + nextEntries: [], + }), + ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + }); + + it("prefers live entries over a remembered snapshot", () => { + rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["stale-b"] }); + expect( + resolveThreadSwitchTimeline({ + loading: false, + activeThreadKey: "env-1:thread-b", + nextEntries: ["fresh-b"], + }), + ).toEqual({ entries: ["fresh-b"], displayThreadKey: "env-1:thread-b" }); + }); + + it("does not keep a remembered snapshot on a resolved empty thread", () => { + rememberReadyThreadTimeline(held); + expect( + resolveThreadSwitchTimeline({ + loading: false, + activeThreadKey: "env-1:thread-a", + nextEntries: [], + }), + ).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" }); + }); + + it("does not hold another environment's timeline across a jump", () => { + expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false); + expect( + resolveThreadSwitchTimeline({ + loading: true, + activeThreadKey: "env-2:thread-b", + nextEntries: [], + lastReady: held, + }), + ).toEqual({ entries: [], displayThreadKey: "env-2:thread-b" }); + }); + + it("treats a foreign held timeline as paint-only", () => { + expect(isPaintOnlyThreadTimeline("env-1:thread-a", "env-1:thread-b")).toBe(true); + expect(isPaintOnlyThreadTimeline("env-1:thread-b", "env-1:thread-b")).toBe(false); + }); + + it("does not remember a timeline that still has handoff blob previews", () => { + expect( + timelineHasEphemeralPreviewUrls([ + { + kind: "message", + message: { + id: MessageId.make("preview-message"), + role: "user", + text: "Preview", + turnId: null, + streaming: false, + createdAt: "2026-09-10T12:00:00.000Z", + updatedAt: "2026-09-10T12:00:00.000Z", + attachments: [ + { + type: "image", + id: "preview", + name: "preview.png", + mimeType: "image/png", + sizeBytes: 1, + previewUrl: "blob:handoff", + }, + ], + }, + }, + ]), + ).toBe(true); + expect( + timelineHasEphemeralPreviewUrls([ + { + kind: "message", + message: { + id: MessageId.make("preview-message"), + role: "user", + text: "Preview", + turnId: null, + streaming: false, + createdAt: "2026-09-10T12:00:00.000Z", + updatedAt: "2026-09-10T12:00:00.000Z", + attachments: [ + { + type: "image", + id: "preview", + name: "preview.png", + mimeType: "image/png", + sizeBytes: 1, + previewUrl: "https://cdn.example/a.png", + }, + ], + }, + }, + ]), + ).toBe(false); + }); +}); + describe("shouldReleaseTimelineAnchorForToolActivity", () => { const activeTurnId = TurnId.make("active-turn"); const anchorMessageId = MessageId.make("anchored-message"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 81d97e5e8..35b1480d8 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -23,6 +23,7 @@ import { STARTED_THREAD_MODEL_CHANGE_DESCRIPTION, } from "@t3tools/shared/model"; import { getProviderAdmissionAvailability } from "@t3tools/client-runtime/providerAvailability"; +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure, @@ -244,6 +245,135 @@ export function resolveDraftHeroState(input: { ); } +/** + * Keep painted timelines on screen across thread jumps. Remounting LegendList + * (or handing it an empty first paint) punches a hole through the chat pane — + * white in light mode — so cmd+1/2/3 spam flashes even when the destination + * is already cached. + * + * Stored at module scope because ChatView remounts when the thread route + * changes (same pattern as the thread-error banner session dismissals). + * Remember more than the last thread so jumping back to cmd+1 does not show + * cmd+3's messages, and so a cached destination can paint on the first frame. + */ +export type HeldThreadTimeline = { + threadKey: string | null; + entries: T; + markdownCwd?: string | null; + workspaceRoot?: string | null; +}; + +const MAX_REMEMBERED_THREAD_TIMELINES = 16; + +let rememberedThreadTimelines = new Map>(); +let rememberedThreadTimelineOrder: string[] = []; +let lastReadyThreadKey: string | null = null; + +function rememberThreadTimelineEntries(held: HeldThreadTimeline): void { + if (held.threadKey === null) { + return; + } + rememberedThreadTimelines.set(held.threadKey, held); + rememberedThreadTimelineOrder = [ + ...rememberedThreadTimelineOrder.filter((key) => key !== held.threadKey), + held.threadKey, + ]; + while (rememberedThreadTimelineOrder.length > MAX_REMEMBERED_THREAD_TIMELINES) { + const evicted = rememberedThreadTimelineOrder.shift(); + if (evicted !== undefined) { + rememberedThreadTimelines.delete(evicted); + } + } + lastReadyThreadKey = held.threadKey; +} + +export function rememberReadyThreadTimeline( + held: HeldThreadTimeline, +): void { + if (held.threadKey === null || held.entries.length === 0) { + return; + } + rememberThreadTimelineEntries(held); +} + +export function peekRememberedThreadTimeline( + threadKey: string | null, +): T | null { + if (threadKey === null) { + return null; + } + return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null; +} + +export function peekHeldThreadTimeline< + T extends readonly unknown[], +>(): HeldThreadTimeline | null { + if (lastReadyThreadKey === null) { + return null; + } + const held = rememberedThreadTimelines.get(lastReadyThreadKey); + if (held === undefined || held.entries.length === 0) { + return null; + } + return held as HeldThreadTimeline; +} + +export function resetHeldThreadTimeline(): void { + rememberedThreadTimelines = new Map(); + rememberedThreadTimelineOrder = []; + lastReadyThreadKey = null; +} + +export function threadKeysShareEnvironment(left: string | null, right: string | null): boolean { + if (left === null || right === null) { + return false; + } + const leftRef = parseScopedThreadKey(left); + const rightRef = parseScopedThreadKey(right); + return leftRef !== null && rightRef !== null && leftRef.environmentId === rightRef.environmentId; +} + +/** True while we still paint another thread's last snapshot. */ +export function isPaintOnlyThreadTimeline( + displayThreadKey: string | null, + activeThreadKey: string | null, +): boolean { + return ( + displayThreadKey !== null && activeThreadKey !== null && displayThreadKey !== activeThreadKey + ); +} + +export function resolveThreadSwitchTimeline(input: { + loading: boolean; + activeThreadKey: string | null; + nextEntries: T; + rememberedForActive?: T | null; + lastReady?: HeldThreadTimeline | null; +}): { entries: T; displayThreadKey: string | null } { + if (input.nextEntries.length > 0) { + return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; + } + + const rememberedForActive = + input.rememberedForActive ?? peekRememberedThreadTimeline(input.activeThreadKey); + if (input.loading && rememberedForActive !== null && rememberedForActive.length > 0) { + return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey }; + } + + const lastReady = input.lastReady ?? peekHeldThreadTimeline(); + if ( + input.loading && + lastReady !== null && + lastReady.threadKey !== null && + lastReady.threadKey !== input.activeThreadKey && + lastReady.entries.length > 0 && + threadKeysShareEnvironment(lastReady.threadKey, input.activeThreadKey) + ) { + return { entries: lastReady.entries, displayThreadKey: lastReady.threadKey }; + } + return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; +} + export function resolveDraftPromotionNavigationTarget(input: { serverThreadRef: ScopedThreadRef | null; serverThread: Pick | null | undefined; @@ -550,6 +680,17 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void { } } +export function timelineHasEphemeralPreviewUrls( + entries: ReadonlyArray & { message?: ChatMessage }>, +): boolean { + return entries.some( + (entry) => + entry.kind === "message" && + entry.message !== undefined && + collectUserMessageBlobPreviewUrls(entry.message).length > 0, + ); +} + export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] { if (message.role !== "user" || !message.attachments) { return []; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cadd127ea..0a8ac0cce 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -479,6 +479,12 @@ import { rememberCheckoutIsRepo, resolveBackgroundDraftWorkspaceOptions, resolveDraftHeroState, + isPaintOnlyThreadTimeline, + peekHeldThreadTimeline, + peekRememberedThreadTimeline, + rememberReadyThreadTimeline, + resolveThreadSwitchTimeline, + timelineHasEphemeralPreviewUrls, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, revokeBlobPreviewUrl, @@ -1442,6 +1448,10 @@ function chatActionErrorMessage(error: unknown): string { } const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; +const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = []; +const noopHeldTurnDiff = (_turnId: TurnId, _filePath?: string) => {}; +const noopHeldRevert = (_messageId: MessageId) => {}; +const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; /** * Drops the send-time anchored end space. That space is what holds a sent @@ -3443,6 +3453,18 @@ export default function ChatView(props: ChatViewProps) { () => deriveReportedTurnCosts(activeThread?.activities ?? []), [activeThread?.activities], ); + const displayedTimeline = resolveThreadSwitchTimeline({ + loading: timelineEntries.length === 0 && threadSyncPhase !== null, + activeThreadKey, + nextEntries: timelineEntries, + rememberedForActive: peekRememberedThreadTimeline(activeThreadKey), + }); + const displayedTimelineKey = displayedTimeline.displayThreadKey ?? routeThreadKey; + const paintOnlyDisplayedTimeline = isPaintOnlyThreadTimeline( + displayedTimeline.displayThreadKey, + activeThreadKey, + ); + const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey); const [dockedDraftHeroThreadKey, setDockedDraftHeroThreadKey] = useState(null); const draftHeroDockRequested = activeThreadKey !== null && dockedDraftHeroThreadKey === activeThreadKey; @@ -3617,6 +3639,24 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; + useLayoutEffect(() => { + if ( + threadDetailLoading || + timelineEntries.length === 0 || + timelineHasEphemeralPreviewUrls(timelineEntries) + ) { + return; + } + rememberReadyThreadTimeline({ + threadKey: activeThreadKey, + entries: timelineEntries, + markdownCwd: gitCwd, + workspaceRoot: activeWorkspaceRoot ?? null, + }); + }, [activeThreadKey, activeWorkspaceRoot, gitCwd, threadDetailLoading, timelineEntries]); + const heldPaintContext = paintOnlyDisplayedTimeline + ? peekHeldThreadTimeline() + : null; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; // Git status arrives after the composer paints. A checkout seen earlier in @@ -5090,6 +5130,21 @@ export default function ChatView(props: ChatViewProps) { void legendListRef.current?.scrollToEnd?.({ animated }); }); }, []); + const displayedTimelineKeyRef = useRef(displayedTimeline.displayThreadKey); + useLayoutEffect(() => { + const displayKey = displayedTimeline.displayThreadKey; + if (displayKey === null || displayKey !== activeThreadKey) { + displayedTimelineKeyRef.current = displayKey; + return; + } + if (displayedTimelineKeyRef.current === displayKey) { + return; + } + displayedTimelineKeyRef.current = displayKey; + // Keep the list mounted across jumps; pin the newly displayed thread to + // its end the way a remount used to via initialScrollAtEnd. + scrollToEnd(); + }, [activeThreadKey, displayedTimeline.displayThreadKey, scrollToEnd]); useLayoutEffect(() => { if (timelineScrollModeRef.current !== "anchoring-new-turn") { return; @@ -9453,58 +9508,80 @@ export default function ChatView(props: ChatViewProps) { />
{/* Messages Wrapper */} -
+
{/* Messages — LegendList handles virtualization and scrolling internally */} {/* scroll to end pill — shown when user has scrolled away from the live edge */} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 3386f1650..51429bbff 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -342,6 +342,12 @@ interface MessagesTimelineProps { turnDiffSummaries: ReadonlyArray; reportedTurnCosts?: ReadonlyMap; routeThreadKey: string; + /** + * Thread whose entries are currently painted. Differs from `routeThreadKey` + * while a jump is still holding the previous list. Identity for row + * projection and list extraData — do not remount on this value. + */ + displayThreadKey?: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; supportsConversationRollback: boolean; /** Client-only message ids; see `collectLocalTimelineMessageIds`. */ @@ -406,6 +412,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaries, reportedTurnCosts, routeThreadKey, + displayThreadKey, onOpenTurnDiff, supportsConversationRollback, localMessageIds, @@ -435,17 +442,30 @@ export const MessagesTimeline = memo(function MessagesTimeline({ loadEarlier = null, }: MessagesTimelineProps) { const [expandedTurnIds, setExpandedTurnIds] = useState>(new Set()); + const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); + const listIdentityKey = displayThreadKey ?? routeThreadKey; + const listIdentityRef = useRef(listIdentityKey); + const previousLatestTurnRef = useRef(latestTurn); + let paintedExpandedTurnIds = expandedTurnIds; + let paintedExpandedWorkGroupIds = expandedWorkGroupIds; + if (listIdentityRef.current !== listIdentityKey) { + listIdentityRef.current = listIdentityKey; + previousLatestTurnRef.current = latestTurn; + paintedExpandedTurnIds = new Set(); + paintedExpandedWorkGroupIds = new Set(); + setExpandedTurnIds(paintedExpandedTurnIds); + setExpandedWorkGroupIds(paintedExpandedWorkGroupIds); + } const citationThreadRef = useMemo(() => parseScopedThreadKey(routeThreadKey), [routeThreadKey]); const expandCitedTurn = useCallback((turnId: TurnId) => { setExpandedTurnIds((current) => current.has(turnId) ? current : new Set([...current, turnId]), ); }, []); - const [expandedWorkGroupIds, setExpandedWorkGroupIds] = useState>(new Set()); // Scroll/disclosure state outlives virtualized rows, but never the current thread. const workGroupViewState = useMemo( () => ({ scrollPositions: new Map(), expandedEntries: new Set() }), - [routeThreadKey], + [listIdentityKey], ); const [disclosureToggleSettling, setDisclosureToggleSettling] = useState(false); const [minimapStripMap] = useState(() => new Map()); @@ -537,7 +557,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ // An in-session interrupt leaves its turn expanded so the user keeps their // place; the next turn (or a reload, since this is local state) folds it. - const previousLatestTurnRef = useRef(latestTurn); useEffect(() => { const previous = previousLatestTurnRef.current; previousLatestTurnRef.current = latestTurn; @@ -576,8 +595,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timelineEntries, latestTurn, runningTurnId, - expandedTurnIds, - expandedWorkGroupIds, + expandedTurnIds: paintedExpandedTurnIds, + expandedWorkGroupIds: paintedExpandedWorkGroupIds, isWorking, activeTurnStartedAt, turnDiffSummaries, @@ -585,21 +604,21 @@ export const MessagesTimeline = memo(function MessagesTimeline({ supportsConversationRollback, ...(localMessageIds === undefined ? {} : { localMessageIds }), }, - previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot + previous?.threadKey === listIdentityKey && previous.workspaceRoot === workspaceRoot ? previous.projection : null, ); - rowsProjectionRef.current = { threadKey: routeThreadKey, workspaceRoot, projection }; + rowsProjectionRef.current = { threadKey: listIdentityKey, workspaceRoot, projection }; return projection.rows; }, [ rowsProjectionRef, - routeThreadKey, + listIdentityKey, workspaceRoot, timelineEntries, latestTurn, runningTurnId, - expandedTurnIds, - expandedWorkGroupIds, + paintedExpandedTurnIds, + paintedExpandedWorkGroupIds, isWorking, activeTurnStartedAt, turnDiffSummaries, @@ -607,7 +626,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ supportsConversationRollback, localMessageIds, ]); - const rows = useStableRows(rawRows); + const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); const [timelineViewportElement, setTimelineViewportElement] = useState( null, @@ -855,7 +874,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ if (rows.length === 0 && !isWorking) { if (hideEmptyPlaceholder) { - return null; + // Occupy the pane with the theme surface so a thread switch cannot + // punch a hole through to the window chrome (white in light mode). + return
; } return (
@@ -882,7 +903,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ref={listRef} data={rows} - extraData={rows.length} + extraData={`${listIdentityKey}:${rows.length}`} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} @@ -2760,17 +2781,23 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte /** Returns a structurally-shared copy of `rows`: for each row whose content * hasn't changed since last call, the previous object reference is reused. */ -function useStableRows(rows: MessagesTimelineRow[]): MessagesTimelineRow[] { +function useStableRows(rows: MessagesTimelineRow[], identity: string): MessagesTimelineRow[] { const prevState = useRef({ byId: new Map(), result: [], }); + const prevIdentity = useRef(identity); return useMemo(() => { - const nextState = computeStableMessagesTimelineRows(rows, prevState.current); + const previous = + prevIdentity.current === identity + ? prevState.current + : { byId: new Map(), result: [] }; + prevIdentity.current = identity; + const nextState = computeStableMessagesTimelineRows(rows, previous); prevState.current = nextState; return nextState.result; - }, [rows]); + }, [identity, rows]); } // --------------------------------------------------------------------------- From 7f9dc0c4fe710a84be19bf61fa133afda1e7a2ac Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:17:59 +0300 Subject: [PATCH 18/27] fix(web): show platform file manager icons in Open menu (#11228) (cherry picked from commit fb3d165d33b155038de717851207d25fb52fe65e) --- apps/web/src/components/Icons.tsx | 28 +++++++++++++++++++ .../src/components/chat/OpenInPicker.test.ts | 26 +++++++++++++++++ apps/web/src/components/chat/OpenInPicker.tsx | 19 +++++++++---- apps/web/src/editorLabels.test.ts | 2 +- apps/web/src/lib/utils.test.ts | 2 +- apps/web/src/lib/utils.ts | 2 +- 6 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/chat/OpenInPicker.test.ts diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 5b3f9da2b..a19f8470a 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -2,6 +2,34 @@ import React, { type SVGProps, useId } from "react"; import { cn } from "~/lib/utils"; export type Icon = React.FC>; +export const FinderIcon: Icon = (props) => ( + + + + + +); + +export const FileExplorerIcon: Icon = (props) => ( + + + + + + +); + export const LinuxIcon: Icon = ({ className, ...props }) => ( diff --git a/apps/web/src/components/chat/OpenInPicker.test.ts b/apps/web/src/components/chat/OpenInPicker.test.ts new file mode 100644 index 000000000..32be49a9b --- /dev/null +++ b/apps/web/src/components/chat/OpenInPicker.test.ts @@ -0,0 +1,26 @@ +import { FolderClosedIcon } from "lucide-react"; +import { describe, expect, it } from "vite-plus/test"; + +import { FileExplorerIcon, FinderIcon } from "../Icons"; +import { resolveOpenInOptions } from "./OpenInPicker"; + +describe("resolveOpenInOptions", () => { + it.each([ + ["MacIntel", "Finder", FinderIcon], + ["Win32", "File Explorer", FileExplorerIcon], + ["Linux x86_64", "Files", FolderClosedIcon], + ] as const)("includes the file manager with its icon on %s", (platform, label, Icon) => { + expect(resolveOpenInOptions(platform, ["cursor", "vscode", "file-manager"])).toEqual([ + expect.objectContaining({ value: "cursor", label: "Cursor" }), + expect.objectContaining({ value: "vscode", label: "VS Code" }), + expect.objectContaining({ value: "file-manager", label, Icon }), + ]); + }); + + it("omits the file manager when unavailable or using remote editors", () => { + expect(resolveOpenInOptions("MacIntel", ["vscode"])).toEqual([ + expect.objectContaining({ value: "vscode" }), + ]); + expect(resolveOpenInOptions("MacIntel", [])).toEqual([]); + }); +}); diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 9f8e81bdb..2b1f252ef 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -22,6 +22,8 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu import { AntigravityIcon, CursorIcon, + FileExplorerIcon, + FinderIcon, Icon, KiroIcon, TraeIcon, @@ -44,7 +46,7 @@ import { RustRoverIcon, WebStormIcon, } from "../JetBrainsIcons"; -import { cn } from "~/lib/utils"; +import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { shellEnvironment } from "~/state/shell"; import { useAtomCommand } from "~/state/use-atom-command"; @@ -55,7 +57,10 @@ type OpenInOption = { kind: "brand" | "generic"; }; -const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { +export const resolveOpenInOptions = ( + platform: string, + availableEditors: ReadonlyArray, +) => { const baseOptions: ReadonlyArray> = [ { Icon: CursorIcon, @@ -158,9 +163,13 @@ const resolveOptions = (platform: string, availableEditors: ReadonlyArray resolveOptions(navigator.platform, effectiveEditors), + () => resolveOpenInOptions(navigator.platform, effectiveEditors), [effectiveEditors], ); const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; diff --git a/apps/web/src/editorLabels.test.ts b/apps/web/src/editorLabels.test.ts index 42d61deca..a0dceb1c7 100644 --- a/apps/web/src/editorLabels.test.ts +++ b/apps/web/src/editorLabels.test.ts @@ -10,7 +10,7 @@ describe("editorLabelForPlatform", () => { it.each([ ["MacIntel", "Finder"], - ["Win32", "Explorer"], + ["Win32", "File Explorer"], ["Linux x86_64", "Files"], ])("uses the platform file-manager name on %s", (platform, label) => { expect(editorLabelForPlatform("file-manager", platform)).toBe(label); diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts index bf7986dd4..a93085643 100644 --- a/apps/web/src/lib/utils.test.ts +++ b/apps/web/src/lib/utils.test.ts @@ -4,7 +4,7 @@ import { getLocalFileManagerName, isWindowsPlatform } from "./utils"; describe("getLocalFileManagerName", () => { it.each([ ["MacIntel", "Finder"], - ["Win32", "Explorer"], + ["Win32", "File Explorer"], ["Linux", "Files"], ])("uses the %s file manager name", (platform, expected) => { assert.strictEqual(getLocalFileManagerName(platform), expected); diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts index f4d81dfa0..3445a06bb 100644 --- a/apps/web/src/lib/utils.ts +++ b/apps/web/src/lib/utils.ts @@ -25,7 +25,7 @@ export function getLocalFileManagerName(platform: string): string { return "Finder"; } if (isWindowsPlatform(platform)) { - return "Explorer"; + return "File Explorer"; } return "Files"; } From 5812346cc45ec009b2e3c7b1d0f9c71bc4f4800a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 11 Sep 2026 12:04:43 -0700 Subject: [PATCH 19/27] perf(web): avoid scanning chat history for sidebar backgrounds (#11206) (cherry picked from commit 2b7d3a45e6ee9454e4dd46aaf0276ca05d8555d3) --- apps/web/src/components/ui/sidebar.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 425db6457..88162b41d 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -156,10 +156,8 @@ function SidebarProvider({ return (
Date: Fri, 11 Sep 2026 23:20:54 +0300 Subject: [PATCH 20/27] perf(client): reduce remote request and message sync overhead (#11029) (cherry picked from commit 6e8931d75615ec020e602e97df2694b0541a6272) --- .../src/authorization/remote.ts | 26 +-- .../src/environment/descriptor.ts | 6 +- .../src/remotePerformance.bench.ts | 165 ++++++++++++++++++ packages/client-runtime/src/rpc/http.ts | 14 ++ .../src/state/environmentHttpAuth.test.ts | 11 ++ .../src/state/environmentHttpAuth.ts | 22 ++- .../src/state/pullRequestDiffHttp.ts | 3 +- packages/client-runtime/src/state/session.ts | 3 +- .../src/state/shellSnapshotHttp.ts | 3 +- .../src/state/threadReducer.test.ts | 48 +++++ .../client-runtime/src/state/threadReducer.ts | 39 ++--- .../src/state/threadSnapshotHttp.ts | 3 +- 12 files changed, 296 insertions(+), 47 deletions(-) create mode 100644 packages/client-runtime/src/remotePerformance.bench.ts diff --git a/packages/client-runtime/src/authorization/remote.ts b/packages/client-runtime/src/authorization/remote.ts index 56ba67663..ea714d0f5 100644 --- a/packages/client-runtime/src/authorization/remote.ts +++ b/packages/client-runtime/src/authorization/remote.ts @@ -10,7 +10,7 @@ import * as Effect from "effect/Effect"; import { environmentEndpointUrl } from "../environment/endpoint.ts"; import { executeEnvironmentHttpRequest, - makeEnvironmentHttpApiClient, + makeEnvironmentHttpApiGroupClient, type RemoteEnvironmentRequestError, } from "../rpc/http.ts"; @@ -55,11 +55,11 @@ export const exchangeRemoteDpopAccessToken = Effect.fn( readonly dpopProof: string; readonly timeoutMs?: number; }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); const response = yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.token({ + client.token({ headers: { dpop: input.dpopProof }, payload: { grant_type: AuthTokenExchangeGrantType, @@ -83,11 +83,11 @@ export const bootstrapRemoteBearerSession = Effect.fn( readonly clientMetadata?: AuthClientPresentationMetadata; readonly timeoutMs?: number; }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); return yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/oauth/token"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.token({ + client.token({ headers: {}, payload: { grant_type: AuthTokenExchangeGrantType, @@ -108,11 +108,11 @@ export const fetchRemoteSessionState = Effect.fn( readonly bearerToken: string; readonly timeoutMs?: number; }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); return yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.session({ + client.session({ headers: { authorization: `Bearer ${input.bearerToken}`, }, @@ -128,11 +128,11 @@ export const fetchRemoteDpopSessionState = Effect.fn( readonly dpopProof: string; readonly timeoutMs?: number; }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); return yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/api/auth/session"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.session({ + client.session({ headers: { authorization: `DPoP ${input.accessToken}`, dpop: input.dpopProof, @@ -148,11 +148,11 @@ export const issueRemoteWebSocketTicket = Effect.fn( readonly bearerToken: string; readonly timeoutMs?: number; }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); return yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.webSocketTicket({ + client.webSocketTicket({ headers: { authorization: `Bearer ${input.bearerToken}`, }, @@ -168,11 +168,11 @@ export const issueRemoteDpopWebSocketTicket = Effect.fn( readonly dpopProof: string; readonly timeoutMs?: number; }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "auth"); return yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/api/auth/websocket-ticket"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.auth.webSocketTicket({ + client.webSocketTicket({ headers: { authorization: `DPoP ${input.accessToken}`, dpop: input.dpopProof, diff --git a/packages/client-runtime/src/environment/descriptor.ts b/packages/client-runtime/src/environment/descriptor.ts index d49a0d9a8..1f92b296c 100644 --- a/packages/client-runtime/src/environment/descriptor.ts +++ b/packages/client-runtime/src/environment/descriptor.ts @@ -1,17 +1,17 @@ import * as Effect from "effect/Effect"; import { environmentEndpointUrl } from "./endpoint.ts"; -import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiGroupClient } from "../rpc/http.ts"; const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 10_000; export const fetchRemoteEnvironmentDescriptor = Effect.fn( "clientRuntime.environment.fetchRemoteEnvironmentDescriptor", )(function* (input: { readonly httpBaseUrl: string; readonly timeoutMs?: number }) { - const client = yield* makeEnvironmentHttpApiClient(input.httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(input.httpBaseUrl, "metadata"); return yield* executeEnvironmentHttpRequest( environmentEndpointUrl(input.httpBaseUrl, "/.well-known/t3/environment"), input.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS, - client.metadata.descriptor(), + client.descriptor(), ); }); diff --git a/packages/client-runtime/src/remotePerformance.bench.ts b/packages/client-runtime/src/remotePerformance.bench.ts new file mode 100644 index 000000000..85b4cf732 --- /dev/null +++ b/packages/client-runtime/src/remotePerformance.bench.ts @@ -0,0 +1,165 @@ +import { + EnvironmentId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import { HttpClient, HttpClientResponse } from "effect/unstable/http"; +import { bench, describe } from "vite-plus/test"; + +import { issueRemoteWebSocketTicket } from "./authorization/remote.ts"; +import { PrimaryConnectionTarget } from "./connection/model.ts"; +import { fetchRemoteEnvironmentDescriptor } from "./environment/descriptor.ts"; +import type { RemoteEnvironmentRequestError } from "./rpc/http.ts"; +import { fetchEnvironmentThreadSnapshot } from "./state/threadSnapshotHttp.ts"; +import { applyThreadDetailEvent } from "./state/threadReducer.ts"; + +const timestamp = "2026-09-01T00:00:00.000Z"; +const thread: OrchestrationThread = { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Remote thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: timestamp, + updatedAt: timestamp, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + pullRequests: [], + messages: Array.from({ length: 100 }, (_, index) => ({ + id: MessageId.make(`message-${index}`), + role: "assistant", + text: "Message text. ".repeat(40), + turnId: null, + streaming: false, + createdAt: timestamp, + updatedAt: timestamp, + })), + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, +}; +const target = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("remote-1"), + label: "Remote", + httpBaseUrl: "https://remote.example.test", + wsBaseUrl: "wss://remote.example.test/ws", +}); +const responses = { + "/.well-known/t3/environment": { + environmentId: target.environmentId, + label: target.label, + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, + }, + "/api/auth/websocket-ticket": { ticket: "test-ticket", expiresAt: timestamp }, + "/api/orchestration/threads/thread-1": { snapshotSequence: 1, thread }, +}; +const httpClient = HttpClient.make((request) => + Effect.sync(() => { + const path = new URL(request.url).pathname as keyof typeof responses; + return HttpClientResponse.fromWeb(request, Response.json(responses[path])); + }), +); +const requests: Record< + string, + Effect.Effect +> = { + "read remote connection descriptor": fetchRemoteEnvironmentDescriptor({ + httpBaseUrl: target.httpBaseUrl, + }), + "issue remote WebSocket ticket": issueRemoteWebSocketTicket({ + httpBaseUrl: target.httpBaseUrl, + bearerToken: "test-token", + }), + "load remote snapshot with 100 messages": fetchEnvironmentThreadSnapshot({ + prepared: { + environmentId: target.environmentId, + label: target.label, + httpBaseUrl: target.httpBaseUrl, + socketUrl: target.wsBaseUrl, + httpAuthorization: null, + target, + }, + threadId: thread.id, + signer: Option.none(), + }), +}; + +describe("remote HTTP processing with an in-memory transport", () => { + for (const [name, request] of Object.entries(requests)) { + bench( + name, + async () => { + await Effect.runPromise( + request.pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), + ); + }, + { warmupTime: 1_000, time: 1_500 }, + ); + } +}); + +const delta: OrchestrationEvent = { + eventId: EventId.make("delta"), + sequence: 2, + aggregateKind: "thread", + aggregateId: thread.id, + occurredAt: timestamp, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + threadId: thread.id, + messageId: MessageId.make("message-99"), + role: "assistant", + text: " next", + turnId: null, + streaming: true, + createdAt: timestamp, + updatedAt: timestamp, + }, +}; + +describe("remote message replay", () => { + for (const count of [100, 1_000]) { + const loaded = { + ...thread, + messages: Array.from({ length: count }, (_, index) => ({ + ...thread.messages[0]!, + id: MessageId.make(`message-${index}`), + })), + }; + const event = { + ...delta, + payload: { ...delta.payload, messageId: loaded.messages.at(-1)!.id }, + }; + bench( + `apply 200 text deltas to ${count} loaded messages`, + () => { + let current: OrchestrationThread = loaded; + for (let index = 0; index < 200; index += 1) { + const result = applyThreadDetailEvent(current, event); + if (result.kind === "updated") current = result.thread; + } + }, + { warmupTime: 1_000, time: 1_500 }, + ); + } +}); diff --git a/packages/client-runtime/src/rpc/http.ts b/packages/client-runtime/src/rpc/http.ts index e52e01295..d1c470d17 100644 --- a/packages/client-runtime/src/rpc/http.ts +++ b/packages/client-runtime/src/rpc/http.ts @@ -99,6 +99,20 @@ export const makeEnvironmentHttpApiClient = (httpBaseUrl: string) => baseUrl: remoteApiBaseUrl(httpBaseUrl), }); +export const makeEnvironmentHttpApiGroupClient = < + Group extends keyof typeof EnvironmentHttpApi.groups, +>( + httpBaseUrl: string, + group: Group, +) => + Effect.flatMap(HttpClient.HttpClient, (httpClient) => + HttpApiClient.group(EnvironmentHttpApi, { + httpClient, + group, + baseUrl: remoteApiBaseUrl(httpBaseUrl), + }), + ); + /** Contract-derived request URLs for authentication proofs, tracing, and structured errors. */ export const makeEnvironmentHttpApiUrlBuilder = (httpBaseUrl: string) => HttpApiClient.urlBuilder(EnvironmentHttpApi, { diff --git a/packages/client-runtime/src/state/environmentHttpAuth.test.ts b/packages/client-runtime/src/state/environmentHttpAuth.test.ts index 114b2c67e..181bf99a8 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.test.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.test.ts @@ -214,6 +214,17 @@ const LOADERS: ReadonlyArray<{ ]; describe("authenticated environment HTTP requests", () => { + it.effect.each(LOADERS)("rejects an invalid $name response", (loader) => + Effect.gen(function* () { + const harness = makeHarness(() => Response.json({})); + const result = yield* loader + .load(harness.input) + .pipe(Effect.provide(harness.httpLayer), Effect.asVoid, Effect.flip); + expect(result._tag).toBe("RemoteEnvironmentAuthInvalidJsonError"); + expect(harness.calls).toHaveLength(1); + }), + ); + it.effect.each(LOADERS)("uses current relay authorization and endpoint for $name", (loader) => Effect.gen(function* () { const harness = makeHarness(() => Response.json(loader.response)); diff --git a/packages/client-runtime/src/state/environmentHttpAuth.ts b/packages/client-runtime/src/state/environmentHttpAuth.ts index 019b52ddd..29dd08f9f 100644 --- a/packages/client-runtime/src/state/environmentHttpAuth.ts +++ b/packages/client-runtime/src/state/environmentHttpAuth.ts @@ -1,14 +1,14 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; -import { FetchHttpClient, type HttpClient, type HttpMethod } from "effect/unstable/http"; +import { FetchHttpClient, type HttpMethod } from "effect/unstable/http"; import type { RemoteEnvironmentAuthorization } from "../authorization/service.ts"; import type { PreparedConnection, PreparedHttpAuthorization } from "../connection/model.ts"; import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; import { executeEnvironmentHttpRequest, - makeEnvironmentHttpApiClient, + makeEnvironmentHttpApiGroupClient, RemoteEnvironmentAuthFetchError, RemoteEnvironmentAuthTimeoutError, type RemoteEnvironmentRequestError, @@ -86,20 +86,30 @@ const buildEnvironmentAuthHeaders = ( */ export const executeAuthenticatedEnvironmentHttpRequest = Effect.fn( "clientRuntime.state.executeAuthenticatedEnvironmentHttpRequest", -)(function* (input: { +)(function* < + Group extends Parameters[1], + A, + E, + R, +>(input: { readonly prepared: PreparedConnection; readonly signer: Option.Option; readonly remoteAuthorization?: Option.Option; readonly method: HttpMethod.HttpMethod; readonly url: (httpBaseUrl: string) => string; readonly timeoutMs: number; + readonly group: Group; readonly request: (input: { - readonly client: Effect.Success>; + readonly client: Effect.Success>>; readonly headers: EnvironmentHttpAuthHeaders; }) => Effect.Effect; /** Some endpoints report rejected credentials in a successful response. */ readonly isUnauthorizedResponse?: (response: NoInfer) => boolean; -}): Effect.fn.Return { +}): Effect.fn.Return< + A, + RemoteEnvironmentRequestError, + Effect.Services>> | R +> { let httpBaseUrl = input.prepared.httpBaseUrl; return yield* Effect.gen(function* () { let rejectedAccessToken: string | undefined; @@ -132,7 +142,7 @@ export const executeAuthenticatedEnvironmentHttpRequest = Effect.fn( } const requestUrl = input.url(httpBaseUrl); - const client = yield* makeEnvironmentHttpApiClient(httpBaseUrl); + const client = yield* makeEnvironmentHttpApiGroupClient(httpBaseUrl, input.group); const headers = yield* buildEnvironmentAuthHeaders( authorization, input.method, diff --git a/packages/client-runtime/src/state/pullRequestDiffHttp.ts b/packages/client-runtime/src/state/pullRequestDiffHttp.ts index bdfaae047..db06cc1cc 100644 --- a/packages/client-runtime/src/state/pullRequestDiffHttp.ts +++ b/packages/client-runtime/src/state/pullRequestDiffHttp.ts @@ -50,10 +50,11 @@ export const fetchEnvironmentPullRequestDiff = Effect.fn( }) { return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, + group: "pullRequests", method: "POST", url: (httpBaseUrl) => makeEnvironmentHttpApiUrlBuilder(httpBaseUrl).pullRequests.diff(), timeoutMs: input.timeoutMs ?? DEFAULT_PULL_REQUEST_DIFF_TIMEOUT_MS, - request: ({ client, headers }) => client.pullRequests.diff({ payload: input.diff, headers }), + request: ({ client, headers }) => client.diff({ payload: input.diff, headers }), }).pipe( Effect.mapError((error) => error._tag === "EnvironmentAuthInvalidError" && error.reason === "invalid_credential" diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 10ec14a1c..10bc00bbc 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -49,10 +49,11 @@ export const fetchEnvironmentSessionState = Effect.fn( }) { return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, + group: "auth", method: "GET", url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/auth/session"), timeoutMs: input.timeoutMs ?? DEFAULT_SESSION_STATE_TIMEOUT_MS, - request: ({ client, headers }) => client.auth.session({ headers }), + request: ({ client, headers }) => client.session({ headers }), // This endpoint returns 200 with authenticated:false for expired credentials. isUnauthorizedResponse: (response) => !response.authenticated, }); diff --git a/packages/client-runtime/src/state/shellSnapshotHttp.ts b/packages/client-runtime/src/state/shellSnapshotHttp.ts index aa1ad9081..84ab1a3f1 100644 --- a/packages/client-runtime/src/state/shellSnapshotHttp.ts +++ b/packages/client-runtime/src/state/shellSnapshotHttp.ts @@ -32,10 +32,11 @@ export const fetchEnvironmentShellSnapshot = Effect.fn( }) { return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, + group: "orchestration", method: "GET", url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, "/api/orchestration/shell"), timeoutMs: input.timeoutMs ?? DEFAULT_SHELL_SNAPSHOT_TIMEOUT_MS, - request: ({ client, headers }) => client.orchestration.shellSnapshot({ headers }), + request: ({ client, headers }) => client.shellSnapshot({ headers }), }); }); diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 23afdf484..6c240ff2d 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -407,6 +407,54 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.message-sent", () => { + it.each([ + ["first", ["first+", "middle", "last"]], + ["middle", ["first", "middle+", "last"]], + ["last", ["first", "middle", "last+"]], + ["new", ["first", "middle", "last", "+"]], + ] as const)("applies a delta to %s without changing other messages", (id, texts) => { + const messages = Object.freeze( + ["first", "middle", "last"].map((name) => + Object.freeze({ + id: MessageId.make(name), + role: "assistant" as const, + text: name, + turnId: null, + streaming: false, + createdAt: baseThread.createdAt, + updatedAt: baseThread.updatedAt, + }), + ), + ); + const result = applyThreadDetailEvent( + { ...baseThread, messages }, + { + ...baseEventFields, + sequence: 6, + occurredAt: baseThread.updatedAt, + aggregateKind: "thread", + aggregateId: baseThread.id, + type: "thread.message-sent", + payload: { + threadId: baseThread.id, + messageId: MessageId.make(id), + role: "assistant", + text: "+", + turnId: null, + streaming: true, + createdAt: baseThread.createdAt, + updatedAt: baseThread.updatedAt, + }, + }, + ); + expect(result.kind).toBe("updated"); + if (result.kind !== "updated") return; + expect(result.thread.messages.map((message) => message.text)).toEqual(texts); + for (const [index, message] of messages.entries()) { + if (message.id !== id) expect(result.thread.messages[index]).toBe(message); + } + }); + it("appends a new message", () => { const result = applyThreadDetailEvent(baseThread, { ...baseEventFields, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 36f7eda73..181905e63 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -330,27 +330,24 @@ export function applyThreadDetailEvent( updatedAt: event.payload.updatedAt, }; - const existingMessage = thread.messages.find((entry) => entry.id === message.id); - const messages = existingMessage - ? Arr.map(thread.messages, (entry) => - entry.id !== message.id - ? entry - : { - ...entry, - text: message.streaming - ? `${entry.text}${message.text}` - : message.text.length > 0 - ? message.text - : entry.text, - streaming: message.streaming, - ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), - ...(message.streaming ? {} : { updatedAt: message.updatedAt }), - ...(message.attachments !== undefined - ? { attachments: message.attachments } - : {}), - }, - ) - : Arr.append(thread.messages, message); + let found = false; + const messages = thread.messages.map((entry) => { + if (entry.id !== message.id) return entry; + found = true; + return { + ...entry, + text: message.streaming + ? `${entry.text}${message.text}` + : message.text.length > 0 + ? message.text + : entry.text, + streaming: message.streaming, + ...(message.turnId !== undefined ? { turnId: message.turnId } : {}), + ...(message.streaming ? {} : { updatedAt: message.updatedAt }), + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + }; + }); + if (!found) messages.push(message); // Update latestTurn for assistant messages bound to a turn. A completed // assistant message only settles the turn once the session is no longer // running it — providers may emit several assistant messages per turn diff --git a/packages/client-runtime/src/state/threadSnapshotHttp.ts b/packages/client-runtime/src/state/threadSnapshotHttp.ts index a82d4c155..6af74067f 100644 --- a/packages/client-runtime/src/state/threadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/threadSnapshotHttp.ts @@ -45,12 +45,13 @@ export const fetchEnvironmentThreadSnapshot = Effect.fn( }) { return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, + group: "orchestration", method: "GET", url: (httpBaseUrl) => environmentEndpointUrl(httpBaseUrl, `/api/orchestration/threads/${input.threadId}`), timeoutMs: input.timeoutMs ?? DEFAULT_THREAD_SNAPSHOT_TIMEOUT_MS, request: ({ client, headers }) => - client.orchestration.threadSnapshot({ + client.threadSnapshot({ params: { threadId: input.threadId }, payload: { ...(input.window !== undefined ? { turnLimit: input.window.turnLimit } : {}), From 6750ff3777d5419c078550da253d9e118ff243ec Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:21:27 -0700 Subject: [PATCH 21/27] fix(web): refresh usage limit countdowns without switching tabs (#11187) Co-authored-by: Exotic <118054752+extoci@users.noreply.github.com> (cherry picked from commit e1c94f703f05b43f2935d09ed1ef882d0ee13ff8) --- apps/web/src/components/usage/UsageLimits.tsx | 8 +- .../usage/UsagePage.refresh.test.tsx | 179 ++++++++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 5 +- 3 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/usage/UsagePage.refresh.test.tsx diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 24a77dc73..9fe687cef 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -341,17 +341,17 @@ export function ResetCredits({ /** * Subscription quota across every connected environment's providers and hubs, - * pooled per provider. Countdowns anchor to render time rather than ticking: a - * live clock would repaint the page every minute for no decision-changing gain. + * pooled per provider. The page advances `now` on explicit refresh rather than + * ticking: a live clock would repaint the page for no decision-changing gain. */ export function UsageLimitsSection({ selectedEnvironmentIds, + now, }: { readonly selectedEnvironmentIds: ReadonlySet | null; + readonly now: number; }) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); - // Anchored once per mount on purpose: countdowns must not tick (see above). - const [now] = useState(() => Date.now()); const selected = selectedEnvironmentIds === null ? presentations diff --git a/apps/web/src/components/usage/UsagePage.refresh.test.tsx b/apps/web/src/components/usage/UsagePage.refresh.test.tsx new file mode 100644 index 000000000..d6d300ce3 --- /dev/null +++ b/apps/web/src/components/usage/UsagePage.refresh.test.tsx @@ -0,0 +1,179 @@ +import { EnvironmentId, ProviderInstanceId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; +import { mergeUsage } from "@t3tools/shared/usageMerge"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + presentations: new Map(), + refreshProviders: vi.fn(async () => undefined), +})); +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => state.presentations })); +vi.mock("../../state/presentation", () => ({ + environmentPresentations: { presentationsAtom: null }, +})); +vi.mock("../../state/server", () => ({ serverEnvironment: { refreshProviders: null } })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => state.refreshProviders })); +vi.mock("../../env", () => ({ isElectron: false })); +vi.mock("../../hooks/useSettings", () => ({ usePrimarySettings: () => "24h" })); +vi.mock("../../state/usage", () => ({ + useUsage: () => ({ + merged: mergeUsage([], USAGE_CONTRACT_VERSION), + environments: [ + { + environmentId: EnvironmentId.make("test"), + label: "Test", + isPending: false, + error: null, + summary: null, + }, + ], + selectedEnvironments: [ + { + environmentId: EnvironmentId.make("test"), + label: "Test", + isPending: false, + error: null, + summary: null, + }, + ], + isPending: false, + isPartial: false, + refresh: async () => undefined, + }), +})); +vi.mock("./usagePagePreferences", () => ({ + readUsagePagePreferences: () => ({ metric: "limits", windowDays: 30 }), + saveUsagePagePreferences: vi.fn(), +})); +vi.mock("../ui/button", () => ({ Button: "button" })); +vi.mock("../ui/scroll-area", () => ({ ScrollArea: "div" })); +vi.mock("../ui/select", () => ({ + Select: "select", + SelectItem: "option", + SelectPopup: "div", + SelectTrigger: "div", + SelectValue: "span", +})); +vi.mock("../ui/sidebar", () => ({ SidebarInset: "div" })); +vi.mock("../ui/toggle-group", () => ({ Toggle: "button", ToggleGroup: "div" })); +vi.mock("../ui/tooltip", () => ({ Tooltip: "div", TooltipPopup: "div", TooltipTrigger: "div" })); +vi.mock("../ui/popover", () => ({ Popover: "div", PopoverPopup: "div", PopoverTrigger: "div" })); +vi.mock("../ui/menu", () => ({ + Menu: "div", + MenuCheckboxItem: "div", + MenuItem: "div", + MenuPopup: "div", + MenuSeparator: "hr", + MenuTrigger: "div", +})); +vi.mock("../WorkspaceBreadcrumb", () => ({ + WorkspaceBreadcrumb: "div", + WorkspaceBreadcrumbItem: "div", + WorkspaceBreadcrumbSeparator: "span", +})); +vi.mock("../WorkspacePageContainer", () => ({ WorkspacePageContainer: "main" })); +vi.mock("../WorkspacePageHeader", () => ({ WorkspacePageHeader: "header" })); +vi.mock("./UsageProviderChart", () => ({ UsageProviderChart: "div" })); +vi.mock("./UsagePriceOverrides", () => ({ UsagePriceOverrides: () => null })); +vi.mock("../chat/ProviderInstanceIcon", () => ({ ProviderInstanceIcon: () => null })); +vi.mock("../settings/RedactedSensitiveText", () => ({ RedactedSensitiveText: "span" })); +vi.mock("../settings/providerDriverMeta", () => ({ getDriverOption: () => ({ label: "Codex" }) })); + +import { UsagePage } from "./UsagePage"; + +let renderer: ReactTestRenderer; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.spyOn(Date, "now").mockReturnValue(Date.parse("2026-09-11T12:00:00Z")); + state.refreshProviders.mockClear(); + state.presentations = new Map([ + [ + EnvironmentId.make("test"), + { + entry: { target: { label: "Test" } }, + connection: { phase: "connected" }, + serverConfig: { + providers: [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: "codex", + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-09-11T12:00:00Z", + models: [], + slashCommands: [], + skills: [], + usageLimits: { + checkedAt: "2026-09-11T12:00:00Z", + windows: [ + { + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: 40, + windowDurationMins: 300, + resetsAt: "2026-09-11T14:00:00Z", + }, + ], + }, + }, + ], + }, + }, + ], + ]); +}); +afterEach(async () => { + await act(() => renderer?.unmount()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +it.each([0, 1])( + "refreshes the visible limits countdown with refresh button %i without switching tabs, even when quota is unchanged", + async (buttonIndex) => { + await act(() => { + renderer = create(); + }); + expect( + JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), + ).toContain("in 2h 0m"); + vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T12:30:00Z")); + await act(async () => { + renderer.root + .findAllByProps({ "aria-label": "Refresh limits" }) + .filter((node) => node.type === "button") + .at(buttonIndex)! + .props.onClick(); + }); + expect(state.refreshProviders).toHaveBeenCalledWith({ environmentId: "test", input: {} }); + expect( + JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), + ).toContain("in 1h 30m"); + expect( + JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), + ).not.toContain("in 2h 0m"); + }, +); + +it("uses the current time when returning to limits from tokens", async () => { + await act(() => { + renderer = create(); + }); + const selectMetric = (metric: string) => { + renderer.root + .findAll((node) => node.type === "div" && node.props["aria-label"] === "Usage metric")[0]! + .props.onValueChange([metric]); + }; + await act(() => selectMetric("tokens")); + vi.mocked(Date.now).mockReturnValue(Date.parse("2026-09-11T13:00:00Z")); + await act(() => selectMetric("limits")); + expect( + JSON.stringify(renderer.toJSON(), (key, value) => (key === "props" ? undefined : value)), + ).toContain("in 1h 0m"); + expect(state.refreshProviders).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index d4b3fb66b..89effa6b4 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -105,6 +105,7 @@ export function UsagePage() { const metric = preferences.metric; const showingLimits = metric === "limits"; const [isRefreshing, setIsRefreshing] = useState(false); + const [limitsNow, setLimitsNow] = useState(() => Date.now()); const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = @@ -163,6 +164,7 @@ export function UsagePage() { }); }; const selectMetric = (nextMetric: UsageMetric) => { + if (nextMetric === "limits") setLimitsNow(Date.now()); const nextPreferences = { metric: nextMetric, windowDays }; setPreferences(nextPreferences); saveUsagePagePreferences(nextPreferences); @@ -181,6 +183,7 @@ export function UsagePage() { } }), ).finally(() => { + setLimitsNow(Date.now()); refreshingRef.current = false; setIsRefreshing(false); }); @@ -354,7 +357,7 @@ export function UsagePage() { : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}

) : showingLimits ? ( - + ) : isPending ? ( ) : ( From 5091a7abd5d772ceb4b313c6ce635ae1726d434f Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:40:27 +0300 Subject: [PATCH 22/27] perf(client-runtime): speed up message sync on desktop and mobile (#11302) (cherry picked from commit e145c5f22a10d7433420b447537ed3f29a66d7d9) --- .../src/state/threads-atoms.test.ts | 62 +++++++++++++++++++ .../src/state/threads-pagination.test.ts | 33 +++++++++- packages/client-runtime/src/state/threads.ts | 49 ++++++++++++++- 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index 27229a7af..fc6f7b76b 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -292,6 +292,68 @@ describe("createEnvironmentThreadStateAtoms", () => { }), ); + it.effect.each([1, 16, 500])("publishes each replay batch once (batch size: %i)", (batchSize) => + Effect.gen(function* () { + const h = yield* makeHarness(); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + let updates = 0; + const stop = h.registry.subscribe(h.details.messagesAtom(h.ref), () => updates++, { + immediate: true, + }); + updates = 0; + const events: OrchestrationThreadStreamItem[] = Array.from({ length: 500 }, (_, index) => ({ + kind: "event", + event: { + type: "thread.message-sent", + sequence: 8 + index, + eventId: EventId.make(`replay-${index}`), + aggregateKind: "thread", + aggregateId: THREAD_ID, + occurredAt: THREAD.createdAt, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + threadId: THREAD_ID, + messageId: MessageId.make("replayed-message"), + role: "assistant", + text: `${index},`, + turnId: null, + streaming: true, + createdAt: THREAD.createdAt, + updatedAt: THREAD.createdAt, + }, + }, + })); + for (let offset = 0; offset < events.length; offset += batchSize) { + yield* Queue.offerAll(first.events, events.slice(offset, offset + batchSize)); + const last = Math.min(offset + batchSize, events.length) - 1; + yield* observeState( + h.registry, + h.stateAtom, + (state) => Option.getOrNull(state.data)?.messages[0]?.text.endsWith(`${last},`) === true, + ); + } + yield* Queue.offerAll(first.events, [events[499]!, events[0]!]); + yield* Queue.offer(first.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + expect(currentThread(h.registry, h.stateAtom).messages[0]?.text).toBe( + Array.from({ length: 500 }, (_, index) => `${index},`).join(""), + ); + expect(updates).toBe(Math.ceil(500 / batchSize)); + stop(); + unmount(); + yield* Deferred.await(first.closed); + const remount = h.registry.mount(h.stateAtom); + const next = yield* Queue.take(h.subscriptions); + expect(next.afterSequence).toBe(507); + remount(); + yield* Deferred.await(next.closed); + }), + ); + it.effect("keeps warm data and resumes a completed cursor without loading another snapshot", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts index 2cede4f5b..5b9aced66 100644 --- a/packages/client-runtime/src/state/threads-pagination.test.ts +++ b/packages/client-runtime/src/state/threads-pagination.test.ts @@ -472,8 +472,37 @@ describe("thread pagination state", () => { ); // A live event at sequence 11 arrives; only then does the page merge. - yield* Queue.offer(harness.inputs, titleEvent("Advanced past watermark", 11)); - const state = yield* harness.awaitState((value) => hasMessage(value, "message-old")); + yield* Queue.offerAll(harness.inputs, [ + titleEvent("Advanced past watermark", 11), + { + kind: "event", + event: { + eventId: EventId.make("event-after-page"), + sequence: 12, + aggregateKind: "thread", + aggregateId: THREAD_ID, + occurredAt: BASE_THREAD.createdAt, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.message-sent", + payload: { + ...OLDER_MESSAGE, + threadId: THREAD_ID, + messageId: OLDER_MESSAGE.id, + text: " continued", + streaming: true, + }, + }, + }, + ]); + const state = yield* harness.awaitState( + (value) => Option.getOrNull(value.data)?.messages[0]?.text.endsWith(" continued") === true, + ); + expect(Option.getOrThrow(state.data).messages[0]?.text).toBe( + `${OLDER_MESSAGE.text} continued`, + ); expect(hasMessage(state, "message-recent")).toBe(true); expect(Option.getOrThrow(state.page).loadingOlder).toBe(false); }), diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 7e23a8c99..462501571 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -513,6 +513,49 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make yield* applyLock.withPermits(1)(applyItemLocked(item).pipe(Effect.andThen(remember))); }); + const applyItems = Effect.fn("EnvironmentThreadState.applyItems")(function* ( + items: ReadonlyArray, + ) { + yield* applyLock.withPermits(1)( + Effect.gen(function* () { + const current = yield* SubscriptionRef.get(state); + if ( + Option.isNone(current.data) || + (yield* Ref.get(pendingOlderPage)) !== null || + items.some( + (item) => + item.kind === "snapshot" || + (item.kind === "event" && + (item.event.type === "thread.reverted" || item.event.type === "thread.deleted")), + ) + ) { + for (const item of items) { + yield* applyItemLocked(item); + yield* remember; + } + return; + } + + let thread = current.data.value; + let sequence = yield* SubscriptionRef.get(lastSequence); + let synchronized = false; + for (const item of items) { + if (item.kind === "synchronized") { + synchronized = true; + } else if (item.kind === "event" && item.event.sequence > sequence) { + sequence = item.event.sequence; + const result = applyThreadDetailEvent(thread, item.event); + if (result.kind === "updated") thread = result.thread; + } + } + yield* SubscriptionRef.set(lastSequence, sequence); + if (thread !== current.data.value) yield* setThread(thread, "keep"); + if (synchronized) yield* applyItemLocked({ kind: "synchronized" }); + yield* remember; + }), + ); + }); + // Merges an older disjoint page below the currently loaded window. All four // windowed collections prepend; identity dedupe guards the (server-bug or // cursor-misuse) case of overlapping pages so a row never renders twice. @@ -773,7 +816,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make retryExpectedFailureAfter: "250 millis", resubscribe: foregroundResubscriptions, }, - ).pipe(Stream.runForEach(applyItem)), + ).pipe( + Stream.runForEachArray((items) => + items.length === 1 ? applyItem(items[0]!) : applyItems(items), + ), + ), ); // Expose loadOlderTurns to UI actions through the request registry. From c270806cc1d46b9eab7226c64265c3375c6fc8ab Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:20:56 -0700 Subject: [PATCH 23/27] fix(web): themed panel toggles show their disabled state (#11188) (cherry picked from commit 867eb9bffc1e46ce0f7493ae760f9eb9d9dec3a8) --- apps/web/src/index.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 4bbb820e6..1ef0e18d7 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1327,6 +1327,18 @@ html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigg color: var(--contrast-toolbar-foreground); } +html[data-theme-id] [data-panel-layout-controls] [data-slot="toggle"]:disabled, +html[data-theme-id] [data-panel-layout-controls] [data-slot="tooltip-trigger"]:disabled, +html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="toggle"]:disabled, +html[data-theme-id] [data-workspace-titlebar-controls] [data-slot="tooltip-trigger"]:disabled { + --control-icon-color: color-mix( + in oklab, + var(--contrast-toolbar-foreground) 55%, + var(--toolbar-background) + ); + color: color-mix(in oklab, var(--contrast-toolbar-foreground) 55%, var(--toolbar-background)); +} + html[data-theme-id] [data-chat-header] [data-slot="button"]:hover, html[data-theme-id] [data-chat-header] [data-slot="button"][data-pressed], html[data-theme-id] [data-chat-header] [data-slot="menu-trigger"]:hover, From f9ed45264a682a92028930490fb861b10bab28b3 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:51:26 +0530 Subject: [PATCH 24/27] fix(web): use branch wording in commit dialogs (#11281) (cherry picked from commit 50791a0530a9c4eed7924dbfedf2fcf430d4ed98) --- apps/web/src/components/GitActionsControl.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 1706a6a70..57c863609 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -303,7 +303,7 @@ function getMenuActionDisabledReason({ if (item.id === "push") { if (!hasBranch) { - return "Detached HEAD: checkout a refName before pushing."; + return "Detached HEAD: check out a branch before pushing."; } if (hasChanges) { return "Commit or stash local changes before pushing."; @@ -324,7 +324,7 @@ function getMenuActionDisabledReason({ return `View ${terminology.singular} is currently unavailable.`; } if (!hasBranch) { - return `Detached HEAD: checkout a refName before creating a ${terminology.singular}.`; + return `Detached HEAD: check out a branch before creating a ${terminology.singular}.`; } if (hasChanges) { return `Commit local changes before creating a ${terminology.singular}.`; @@ -1754,7 +1754,7 @@ export default function GitActionsControl({ ) : null} {gitStatusForActions?.refName === null && (

- Detached HEAD: create and checkout a refName to enable push and pull request + Detached HEAD: create and check out a branch to enable push and pull request actions.

)} @@ -1799,9 +1799,7 @@ export default function GitActionsControl({ {gitStatusForActions?.refName ?? "(detached HEAD)"} - {isDefaultRef && ( - Warning: default refName - )} + {isDefaultRef && Default branch}
@@ -1932,7 +1930,7 @@ export default function GitActionsControl({ disabled={noneSelected} onClick={runDialogActionOnNewBranch} > - Commit on new refName + Commit on new branch From 8737740f30cc517a3dc0406bd9ebcac4aa691847 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 11 Sep 2026 21:26:32 -0300 Subject: [PATCH 25/27] fix(test): drain worker broadcasts before restoring browser globals (#11349) (cherry picked from commit 8a2d5f545ae1f76d08a6a6515f6b6c383cada5ad) --- .../src/components/files/fileEditorLanguageReadiness.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts index f0b5a8f44..98cd31038 100644 --- a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts +++ b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts @@ -125,6 +125,8 @@ afterEach(async () => { pool?.terminate(); await Promise.all(terminationPromises); await disposeHighlighter(); + // Drain the pool's final state broadcast before removing the animation frame stubs. + await new Promise((resolve) => setImmediate(resolve)); for (const frame of animationFrames) clearImmediate(frame); animationFrames.clear(); vi.unstubAllGlobals(); From ddadd45f64f2458a74e47ad6bb23fff16c2593bd Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sat, 12 Sep 2026 13:16:01 +1000 Subject: [PATCH 26/27] fix(web): keep sidebar scroll position when pinning threads (#10757) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> (cherry picked from commit 38827789c4de97955315b459cb3b0b8207f692d8) --- apps/web/src/components/ui/sidebar.tsx | 3 ++- docs/user/thread-sidebar.md | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 88162b41d..6dba22031 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -706,8 +706,9 @@ function SidebarContent({ className="h-auto min-h-0 flex-1" >
Date: Sat, 12 Sep 2026 00:22:10 -0600 Subject: [PATCH 27/27] fix(upstream): preserve Pylon chat context and validate shared folding Adapt held timelines to message-ID rollback and retain mobile session controls and reported costs. Add projection/folding regressions, maintain worker teardown tracking, and adjust fixtures to the Pylon contract. Includes packages/client-runtime/src/work-log/userInput.ts Hermes sort compatibility from upstream 2ebc9fa4ed24e389eef24039f27c73ae6633858f; other source hunks belong to the mobile lane. --- .../src/features/threads/ThreadComposer.tsx | 32 +++---- apps/mobile/src/lib/threadActivity.ts | 41 +++++---- .../ActivityPayloadProjection.test.ts | 31 +++++++ apps/web/src/components/ChatView.tsx | 14 +-- .../src/components/usage/UsagePage.test.tsx | 45 +++++----- .../src/remotePerformance.bench.ts | 1 - .../src/work-log/userInput.test.ts | 87 +++++++++++++++++++ .../client-runtime/src/work-log/userInput.ts | 2 +- 8 files changed, 189 insertions(+), 64 deletions(-) create mode 100644 packages/client-runtime/src/work-log/userInput.test.ts diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 859c6f9a7..922b4c57a 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -1864,22 +1864,22 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer /> ) : null} - - } - label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth="100%" - disabled={props.sessionInputBlocked} - accessibilityHint={ - props.sessionInputBlocked - ? "Provider changes are blocked while this thread has a pending safety operation" - : undefined - } - onPress={openSettings} - /> + + } + label={currentModelOption?.label ?? currentModelSelection.model} + maxWidth="100%" + disabled={props.sessionInputBlocked} + accessibilityHint={ + props.sessionInputBlocked + ? "Provider changes are blocked while this thread has a pending safety operation" + : undefined + } + onPress={openSettings} + /> {sessionHarnessRefinementActions.length > 0 ? ( oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt, + (entry) => + oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt, ); const foldedAnswerMessageIds = new Set( activityEntries.flatMap((entry) => @@ -2246,24 +2247,26 @@ export function buildThreadFeed( ); const entries = Arr.sortWith( [ - ...messages.filter((message) => message.role !== "user" || !foldedAnswerMessageIds.has(message.id)).map((message) => { - const reportedCostLabel = - message.role === "assistant" && message.turnId !== null - ? (formatReportedTurnCost(reportedTurnCosts.get(message.turnId) ?? -1) ?? undefined) - : undefined; - let entry = messageEntriesCache.get(message); - if (!entry || entry.reportedCostLabel !== reportedCostLabel) { - entry = { - type: "message", - id: message.id, - createdAt: message.createdAt, - message, - reportedCostLabel, - }; - messageEntriesCache.set(message, entry); - } - return entry; - }), + ...messages + .filter((message) => message.role !== "user" || !foldedAnswerMessageIds.has(message.id)) + .map((message) => { + const reportedCostLabel = + message.role === "assistant" && message.turnId !== null + ? (formatReportedTurnCost(reportedTurnCosts.get(message.turnId) ?? -1) ?? undefined) + : undefined; + let entry = messageEntriesCache.get(message); + if (!entry || entry.reportedCostLabel !== reportedCostLabel) { + entry = { + type: "message", + id: message.id, + createdAt: message.createdAt, + message, + reportedCostLabel, + }; + messageEntriesCache.set(message, entry); + } + return entry; + }), ...activityEntries, ], (s) => new Date(s.createdAt), diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index bf09ed959..06b0095e4 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -21,6 +21,37 @@ function activity(payload: Record): OrchestrationThreadActivity * assertions are the tripwire. */ describe("projectActivityPayload", () => { + it.each(["mcp_tool_call", "dynamic_tool_call"])( + "keeps question matching text through %s payload slimming without duplicating choices", + (itemType) => { + const projected = projectActivityPayload( + activity({ + itemType, + title: "mcp__pylon__request_user_input_async", + data: { + input: { + questions: [ + { + id: "target", + question: "Where should this run?", + options: [{ label: "Local", value: "local" }], + }, + ], + }, + }, + }), + ); + expect(projected.payload).toMatchObject({ + data: { + toolName: "mcp__pylon__request_user_input_async", + input: { questions: [{ question: "Where should this run?" }] }, + }, + }); + expect(JSON.stringify(projected.payload)).not.toContain('"options"'); + expect(projectActivityPayload(projected)).toEqual(projected); + }, + ); + it("preserves tool attribution (agentId/parentToolUseId) through data slimming", () => { const projected = projectActivityPayload( activity({ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0a8ac0cce..3d379a537 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9519,6 +9519,8 @@ export default function ChatView(props: ChatViewProps) { agentPanelModel, onOpenAgents: addAgentsSurface, onUseArtifactTemplate: useArtifactTemplate, + reportedTurnCosts, + localMessageIds: localTimelineMessageIds, } : {})} isWorking={!paintOnlyDisplayedTimeline && isWorking} @@ -9540,14 +9542,14 @@ export default function ChatView(props: ChatViewProps) { routeThreadKey={displayedTimelineKey} displayThreadKey={displayedTimelineKey} onOpenTurnDiff={paintOnlyDisplayedTimeline ? noopHeldTurnDiff : onOpenTurnDiff} - supportsConversationRollback={ - !paintOnlyDisplayedTimeline && rollbackTargetIdle + supportsConversationRollback={!paintOnlyDisplayedTimeline && rollbackTargetIdle} + onRevertUserMessage={ + paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertUserMessage } - localMessageIds={paintOnlyDisplayedTimeline ? undefined : localTimelineMessageIds} - onRevertUserMessage={paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertUserMessage} workingStepLabel={paintOnlyDisplayedTimeline ? null : workingStepLabel} - activeTurnInProgress={!paintOnlyDisplayedTimeline && (isWorking || !latestTurnSettled)} - reportedTurnCosts={paintOnlyDisplayedTimeline ? undefined : reportedTurnCosts} + activeTurnInProgress={ + !paintOnlyDisplayedTimeline && (isWorking || !latestTurnSettled) + } isRevertingCheckpoint={!paintOnlyDisplayedTimeline && isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} onFileOpen={paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachment} diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 3b4c66b69..de7a0100d 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -13,28 +13,31 @@ vi.mock("react", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - useState: vi.fn((initial: unknown) => [ - typeof initial === "function" - ? "metric" in initial() + useState: vi.fn((initial: unknown) => { + const value = typeof initial === "function" ? initial() : initial; + return [ + typeof value === "object" && value !== null && "metric" in value ? { metric: testState.metric, windowDays: 1 } - : { - days: 1, - window: { - sinceDay: "2026-08-10", - untilDay: "2026-08-11", - timeZone: "UTC", - resolution: "hour", - sinceTime: "2026-08-10T12:37:00.000Z", - untilTime: "2026-08-11T12:37:00.000Z", - }, - } - : initial === "cost" - ? testState.metric - : initial === "model" - ? testState.breakdown - : initial, - vi.fn(), - ]), + : typeof value === "object" && value !== null && "window" in value + ? { + days: 1, + window: { + sinceDay: "2026-08-10", + untilDay: "2026-08-11", + timeZone: "UTC", + resolution: "hour", + sinceTime: "2026-08-10T12:37:00.000Z", + untilTime: "2026-08-11T12:37:00.000Z", + }, + } + : value === "cost" + ? testState.metric + : value === "model" + ? testState.breakdown + : value, + vi.fn(), + ]; + }), }; }); diff --git a/packages/client-runtime/src/remotePerformance.bench.ts b/packages/client-runtime/src/remotePerformance.bench.ts index 85b4cf732..9fedb2d3a 100644 --- a/packages/client-runtime/src/remotePerformance.bench.ts +++ b/packages/client-runtime/src/remotePerformance.bench.ts @@ -37,7 +37,6 @@ const thread: OrchestrationThread = { settledOverride: null, settledAt: null, deletedAt: null, - pullRequests: [], messages: Array.from({ length: 100 }, (_, index) => ({ id: MessageId.make(`message-${index}`), role: "assistant", diff --git a/packages/client-runtime/src/work-log/userInput.test.ts b/packages/client-runtime/src/work-log/userInput.test.ts new file mode 100644 index 000000000..70b9bf9d3 --- /dev/null +++ b/packages/client-runtime/src/work-log/userInput.test.ts @@ -0,0 +1,87 @@ +import { + ApprovalRequestId, + EventId, + TurnId, + type OrchestrationThreadActivity, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { foldUserInputActivities, getQuestionAnswerPreview } from "./userInput.ts"; + +const turnId = TurnId.make("turn-1"); +const createdAt = "2026-09-01T00:00:00.000Z"; +function activity(id: string, kind: string, payload: unknown): OrchestrationThreadActivity { + return { id: EventId.make(id), kind, payload, turnId, createdAt, tone: "tool", summary: kind }; +} + +describe("foldUserInputActivities", () => { + it("keeps the original question position and selected option labels", () => { + const request = activity("requested", "user-input.requested", { + requestId: "request-1", + questions: [ + { id: "target", question: "Where?", options: [{ value: "local", label: "This computer" }] }, + ], + }); + const resolved = activity("resolved", "user-input.resolved", { requestId: "request-1" }); + const submitted = activity("submitted", "user-input.answer-submitted", { + requestId: "request-1", + questionTextById: { target: "Where?" }, + answers: { target: ["local"] }, + }); + const folded = foldUserInputActivities([request, resolved, submitted]); + expect(folded).toHaveLength(1); + expect(folded[0]).toMatchObject({ + id: request.id, + createdAt, + kind: "user-input.answer-submitted", + payload: { + requestId: "request-1", + questionTextById: { target: "Where?" }, + answers: { target: ["This computer"] }, + attachmentsByQuestionId: {}, + }, + }); + expect( + getQuestionAnswerPreview({ + requestId: ApprovalRequestId.make("request-1"), + questionTextById: { target: "Where?" }, + answers: { target: ["This computer"] }, + attachmentsByQuestionId: {}, + }), + ).toBe("This computer"); + }); + + it("folds the corresponding native tool but retains failed tool results and other turns", () => { + const answer = activity("answer", "user-input.answer-submitted", { + requestId: "request-1", + questionTextById: { q: "Where?" }, + answers: { q: "Local" }, + attachmentsByQuestionId: {}, + }); + const tool = activity("tool", "tool.completed", { + toolCallId: "call-1", + title: "request_user_input", + data: { input: { questions: [{ question: "Where?" }] } }, + }); + const failed = { ...tool, id: EventId.make("failed"), tone: "error" as const }; + const otherTurn = { ...tool, id: EventId.make("other-turn"), turnId: TurnId.make("turn-2") }; + expect( + foldUserInputActivities([tool, failed, otherTurn, answer]).map((entry) => entry.id), + ).toEqual([failed.id, otherTurn.id, answer.id]); + }); + + it("keeps malformed input and distinguishes pending from dismissed requests", () => { + const malformed = activity("malformed", "user-input.requested", { questions: [] }); + const request = activity("request", "user-input.requested", { + requestId: "request-1", + questions: [{ id: "q", question: "Where?" }], + }); + expect(foldUserInputActivities([malformed])[0]).toBe(malformed); + expect(foldUserInputActivities([request])[0]?.summary).toBe("User input requested"); + expect( + foldUserInputActivities([ + request, + activity("resolved", "user-input.resolved", { requestId: "request-1" }), + ])[0]?.summary, + ).toBe("User input dismissed"); + }); +}); diff --git a/packages/client-runtime/src/work-log/userInput.ts b/packages/client-runtime/src/work-log/userInput.ts index 355083c04..19d598403 100644 --- a/packages/client-runtime/src/work-log/userInput.ts +++ b/packages/client-runtime/src/work-log/userInput.ts @@ -28,7 +28,7 @@ function questionFingerprint( ): string | undefined { const texts = questions.map((question) => (typeof question === "string" ? question.trim() : "")); return texts.length > 0 && texts.every(Boolean) - ? JSON.stringify([turnId, texts.toSorted()]) + ? JSON.stringify([turnId, texts.sort()]) : undefined; }