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} - + )} {[ ...new Set([ + ...Object.keys(props.answer.questionTextById ?? {}), ...Object.keys(props.answer.answers), ...Object.keys(props.answer.attachmentsByQuestionId), ]), @@ -51,12 +53,11 @@ export function QuestionAnswerHistory(props: { {props.answer.questionTextById[questionId]} ) : null} - - {[props.answer.answers[questionId]] - .flat() - .filter((value): value is string => typeof value === "string") - .join(", ")} - + {getQuestionAnswerText(props.answer.answers[questionId]) ? ( + + {getQuestionAnswerText(props.answer.answers[questionId])} + + ) : null} {(props.answer.attachmentsByQuestionId[questionId] ?? []).map((attachment) => ( setQuickQuestionOpenScopeKey(quickQuestionScopeKey)} /> ) : null} - - } - label={currentModelOption?.label ?? currentModelSelection.model} - maxWidth={152} - 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 ? ( {displayText} + {answerPreview ? ( + {` ${answerPreview}`} + ) : null} )} 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/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 3c5a3a498..9577e267a 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,4 +1,5 @@ import * as Option from "effect/Option"; +import { foldUserInputActivities } from "@t3tools/client-runtime/work-log/user-input"; import * as Schema from "effect/Schema"; import { requestKindFromRequestType, @@ -414,7 +415,7 @@ function deriveWorkLogEntries( ): DerivedWorkLogEntry[] { const ordered = Arr.sort(activities, activityOrder); const entries: DerivedWorkLogEntry[] = []; - for (const activity of ordered) { + for (const activity of foldUserInputActivities(ordered)) { if (activity.tone !== "error" && isWorktreeSetupActivity(activity.kind)) continue; if (activity.kind === "tool.started") continue; // Like web: an agent's task.started row anchors its batch. It has a fixed @@ -959,6 +960,7 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { if (entry.agentSpawn) return "agent"; if ( + entry.questionAnswer || entry.sourceActivityKind === "user-input.requested" || entry.sourceActivityKind === "user-input.resolved" ) { @@ -2232,31 +2234,40 @@ export function buildThreadFeed( const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; const reportedTurnCosts = deriveReportedTurnCosts(thread.activities); - const activityEntries = getThreadFeedActivityEntries(thread.activities); + const activityEntries = getThreadFeedActivityEntries(thread.activities).filter( + (entry) => + oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt, + ); + const foldedAnswerMessageIds = new Set( + activityEntries.flatMap((entry) => + entry.activity.workEntry.questionAnswer + ? [`async-answer:${entry.activity.workEntry.questionAnswer.requestId}`] + : [], + ), + ); const entries = Arr.sortWith( [ - ...messages.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.filter( - (entry) => - oldestLoadedMessageCreatedAt === null || entry.createdAt >= oldestLoadedMessageCreatedAt, - ), + ...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), Order.Date, 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/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 98294e63b..43db2cfcc 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -1,3 +1,4 @@ +import { projectQuestionToolInput } from "@t3tools/shared/toolActivity"; import type { OrchestrationEvent, OrchestrationThreadActivity, @@ -370,18 +371,19 @@ export function projectActivityPayload( payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") ? { ...payload, status: itemStatus } : payload; + const questionInput = projectQuestionToolInput(data, payload.title); if (payload.itemType === "mcp_tool_call") { return { ...activity, payload: { ...projectedPayload, - data: projectMcpToolCallData(data), + data: { ...projectMcpToolCallData(data), ...questionInput }, }, }; } - const projectedData: Record = {}; + const projectedData: Record = { ...questionInput }; const item = projectCommandData(data); if (item) { projectedData.item = item; diff --git a/apps/web/package.json b/apps/web/package.json index f98107798..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", @@ -61,6 +63,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 +73,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.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 5eba1cde5..f11c131a1 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -69,6 +69,8 @@ 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"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; @@ -122,6 +124,8 @@ import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; import { GitHubIcon } from "./Icons"; +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"; @@ -1028,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 ( @@ -1048,6 +1057,7 @@ function SuspenseShikiCodeBlock({ themeName={themeName} cacheKey={cacheKey} isStreaming={isStreaming} + preserveLines={isStreaming || hasStreamed} /> ); } @@ -1058,6 +1068,7 @@ interface UncachedShikiCodeBlockProps { themeName: DiffThemeName; cacheKey: string; isStreaming: boolean; + preserveLines: boolean; } function UncachedShikiCodeBlock({ @@ -1066,11 +1077,20 @@ function UncachedShikiCodeBlock({ themeName, cacheKey, isStreaming, + preserveLines, }: UncachedShikiCodeBlockProps) { const highlighter = use(getSyntaxHighlighterPromise(language)); - const highlightedHtml = useMemo(() => { + const incrementalHighlight = useMemo( + () => + preserveLines ? createIncrementalHighlightedDocument(highlighter, language, themeName) : null, + [highlighter, preserveLines, language, themeName], + ); + const highlighted = useMemo(() => { try { - return highlighter.codeToHtml(code, { lang: language, theme: themeName }); + 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 console.warn( @@ -1078,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, 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" ? ( +
+ ) : ( +
+ +
); } @@ -3178,13 +3205,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/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 b6cebf00f..3d379a537 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, @@ -649,6 +655,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])', @@ -1441,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 @@ -3442,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; @@ -3616,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 @@ -5089,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; @@ -9452,58 +9508,82 @@ 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/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 58ce6cbd0..79e18a356 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -3,6 +3,7 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools import type { Project, Thread } from "../types"; import { buildBrowseGroups, + buildCommandPaletteProjectMetadata, buildProjectActionItems, buildThreadActionItems, enumerateCommandPaletteItems, @@ -12,6 +13,124 @@ import { type CommandPaletteGroup, } from "./CommandPalette.logic"; +describe("buildCommandPaletteProjectMetadata", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-build-box"); + const locations = new Map([ + [localEnvironmentId, { kind: "local" as const, label: "Local", machine: "laptop" as const }], + [ + remoteEnvironmentId, + { kind: "remote" as const, label: "Build box", machine: "server" as const }, + ], + ]); + + it("makes every member environment and path searchable", () => { + const metadata = buildCommandPaletteProjectMetadata({ + projects: [ + { + environmentId: localEnvironmentId, + title: "T3 Code", + workspaceRoot: "/Users/theo/Projects/t3code", + }, + { + environmentId: remoteEnvironmentId, + title: "t3code", + workspaceRoot: "/srv/t3code", + }, + ], + locationByEnvironmentId: locations, + }); + + expect(metadata.searchTerms).toEqual([ + "T3 Code", + "/Users/theo/Projects/t3code", + "Local", + "t3code", + "/srv/t3code", + "Build box", + ]); + expect(metadata.environmentLabels).toEqual(["Local", "Build box"]); + + const [filteredGroup] = filterCommandPaletteGroups({ + activeGroups: [], + query: "build box", + isInSubmenu: false, + projectSearchItems: [ + { + kind: "action", + value: "project:t3code", + title: "T3 Code", + searchTerms: metadata.searchTerms, + icon: null, + run: async () => undefined, + }, + ], + threadSearchItems: [], + }); + expect(filteredGroup?.items).toHaveLength(1); + }); + + it("deduplicates grouped checkouts by environment", () => { + const metadata = buildCommandPaletteProjectMetadata({ + projects: [ + { + environmentId: remoteEnvironmentId, + title: "T3 Code", + workspaceRoot: "/srv/t3code", + }, + { + environmentId: remoteEnvironmentId, + title: "T3 Code worktree", + workspaceRoot: "/srv/t3code-feature", + }, + ], + locationByEnvironmentId: locations, + }); + + expect(metadata.environmentLabels).toEqual(["Build box"]); + }); + + it("deduplicates distinct environments with the same label", () => { + const secondRemoteEnvironmentId = EnvironmentId.make("environment-build-box-2"); + const metadata = buildCommandPaletteProjectMetadata({ + projects: [ + { + environmentId: remoteEnvironmentId, + title: "T3 Code", + workspaceRoot: "/srv/t3code", + }, + { + environmentId: secondRemoteEnvironmentId, + title: "T3 Code mirror", + workspaceRoot: "/srv/mirror/t3code", + }, + ], + locationByEnvironmentId: new Map([ + [remoteEnvironmentId, { label: "Build box" }], + [secondRemoteEnvironmentId, { label: "Build box" }], + ]), + }); + + expect(metadata.environmentLabels).toEqual(["Build box"]); + }); + + it("uses a human-readable fallback when presentation data is unavailable", () => { + const metadata = buildCommandPaletteProjectMetadata({ + projects: [ + { + environmentId: remoteEnvironmentId, + title: "T3 Code", + workspaceRoot: "/srv/t3code", + }, + ], + locationByEnvironmentId: new Map(), + }); + + expect(metadata.searchTerms).toContain("Remote"); + expect(metadata.environmentLabels).toEqual(["Remote"]); + }); +}); + describe("reduceCommandPaletteUiState", () => { const closedState = { open: false, mode: "command", openIntent: null } as const; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index e91de65d5..001d462e0 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,4 +1,5 @@ import { + type EnvironmentId, type FilesystemBrowseEntry, type KeybindingCommand, THREAD_JUMP_KEYBINDING_COMMANDS, @@ -145,6 +146,22 @@ export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-b // every other surface uses the real title, so overriding it desyncs the icon. export type CommandPaletteProject = Project & { readonly displayName: string }; +export function buildCommandPaletteProjectMetadata(input: { + readonly projects: ReadonlyArray>; + readonly locationByEnvironmentId: ReadonlyMap; +}) { + const searchTerms: string[] = []; + const environmentLabels = new Set(); + + for (const project of input.projects) { + const label = input.locationByEnvironmentId.get(project.environmentId)?.label ?? "Remote"; + searchTerms.push(project.title, project.workspaceRoot, label); + environmentLabels.add(label); + } + + return { searchTerms, environmentLabels: [...environmentLabels] }; +} + export function buildProjectActionItems(input: { projects: ReadonlyArray; valuePrefix: string; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index ec5ed31bd..44b6405b0 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -133,6 +133,7 @@ import { ADDON_ICON_CLASS, browseInputEndPaddingClass, buildBrowseGroups, + buildCommandPaletteProjectMetadata, buildProjectActionItems, buildRootGroups, buildThreadActionItems, @@ -191,6 +192,38 @@ function projectFavicon(project: Project) { return ; } +function ProjectSearchDescription(props: { + readonly environmentLabels: ReadonlyArray; + readonly grouped: boolean; + readonly location: { + readonly kind: "local" | "remote"; + readonly label: string; + readonly machine: EnvironmentMachineKind; + }; + readonly workspaceRoot: string; +}) { + if (!props.grouped) { + return ( + + + {props.location.kind === "remote" ? ( + + ) : null} + {props.location.label} + + + {props.workspaceRoot} + + ); + } + + return {props.environmentLabels.join(" · ")}; +} + function getEnvironmentBrowsePlatform(os: string | null | undefined): string { if (os === "windows") { return "Win32"; @@ -1104,15 +1137,43 @@ function OpenCommandPaletteDialog(props: { projects: pickerProjects, valuePrefix: "project", searchTerms: (project) => { - const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const members = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`) + ?.memberProjects ?? [project]; + return buildCommandPaletteProjectMetadata({ + projects: members, + locationByEnvironmentId: projectEnvironmentLocationById, + }).searchTerms; + }, + renderDescription: (project) => { + const members = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`) + ?.memberProjects ?? [project]; + const metadata = buildCommandPaletteProjectMetadata({ + projects: members, + locationByEnvironmentId: projectEnvironmentLocationById, + }); + const location = projectEnvironmentLocationById.get(project.environmentId) ?? { + kind: "remote" as const, + label: "Remote", + machine: "server" as const, + }; return ( - group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] + 1} + location={location} + workspaceRoot={project.workspaceRoot} + /> ); }, icon: projectFavicon, runProject: openProjectFromSearch, }), - [openProjectFromSearch, pickerProjects, projectGroupByTargetKey], + [ + openProjectFromSearch, + pickerProjects, + projectEnvironmentLocationById, + projectGroupByTargetKey, + ], ); const projectThreadItems = useMemo( @@ -1201,6 +1262,9 @@ function OpenCommandPaletteDialog(props: { - 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}
@@ -1878,9 +1876,9 @@ export default function GitActionsControl({ Excluded ) : ( <> - +{file.insertions} + +{file.insertions} / - -{file.deletions} + -{file.deletions} )} @@ -1891,11 +1889,11 @@ export default function GitActionsControl({
- + +{selectedFiles.reduce((sum, f) => sum + f.insertions, 0)} / - + -{selectedFiles.reduce((sum, f) => sum + f.deletions, 0)}
@@ -1932,7 +1930,7 @@ export default function GitActionsControl({ disabled={noneSelected} onClick={runDialogActionOnNewBranch} > - Commit on new refName + Commit on new branch 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/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} ) : null} {projectLabel} + {props.environmentLabel ? ( + <> + + {props.environmentLabel} + + ) : null} ) : null} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c75f9d788..32db7c561 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5061,7 +5061,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) : null} -