diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts index 75eea216df21..8ba8082f0e57 100644 --- a/apps/desktop/src/electron/ElectronShell.test.ts +++ b/apps/desktop/src/electron/ElectronShell.test.ts @@ -93,10 +93,16 @@ describe("ElectronShell", () => { openExternalMock.mockResolvedValue(undefined); const electronShell = yield* ElectronShell.ElectronShell; - const result = yield* electronShell.openExternal("zed://ssh/example.com/home/user/project"); + const results = yield* Effect.all([ + electronShell.openExternal("zed://ssh/example.com/home/user/project"), + electronShell.openExternal("zed://ssh/example.com/"), + ]); - assert.equal(result, true); - assert.deepEqual(openExternalMock.mock.calls, [["zed://ssh/example.com/home/user/project"]]); + assert.deepEqual(results, [true, true]); + assert.deepEqual(openExternalMock.mock.calls, [ + ["zed://ssh/example.com/home/user/project"], + ["zed://ssh/example.com/"], + ]); }).pipe(Effect.provide(ElectronShell.layer)), ); diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts index 2089be58c0dc..aa97c018bd21 100644 --- a/apps/desktop/src/electron/ElectronShell.ts +++ b/apps/desktop/src/electron/ElectronShell.ts @@ -36,7 +36,7 @@ const REMOTE_EDITOR_PROTOCOLS = new Set( ); // Zed's host sits in the first path segment, so it needs its own userinfo ban. -const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.+$/; +const ZED_SSH_PATHNAME = /^\/[^/@:]+\/.*$/; const isRemoteEditorUrl = (url: URL) => REMOTE_EDITOR_PROTOCOLS.has(url.protocol) && diff --git a/apps/mobile/src/features/threads/QuestionAnswerHistory.tsx b/apps/mobile/src/features/threads/QuestionAnswerHistory.tsx index 2ff0a3b370f8..2a08ee65bb08 100644 --- a/apps/mobile/src/features/threads/QuestionAnswerHistory.tsx +++ b/apps/mobile/src/features/threads/QuestionAnswerHistory.tsx @@ -4,6 +4,7 @@ import type { UserInputAttachments, } from "@t3tools/contracts"; import { Image, Linking, Pressable, View } from "react-native"; +import { getQuestionAnswerText } from "@t3tools/client-runtime/work-log/user-input"; import { AppText as Text } from "../../components/AppText"; import { useAssetUrl } from "../../state/assets"; @@ -41,6 +42,7 @@ export function QuestionAnswerHistory(props: { {[ ...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) => ( , + pr: Pick, colorScheme: "light" | "dark", ) { const dark = colorScheme === "dark"; - if (pr.others > 0 || (pr.state === "open" && pr.isDraft === true)) { + if (pr.state === "open" && pr.isDraft === true) { return dark ? "#a1a1aa" : "#71717a"; } switch (pr.state) { @@ -56,8 +56,12 @@ function pullRequestTintColor( return dark ? "#34d399" : "#059669"; case "merged": return dark ? "#a78bfa" : "#7c3aed"; - case null: case "closed": + if (pr.kind === "stack" || pr.others > 0) { + return dark ? "#fb7185" : "#e11d48"; + } + return dark ? "#a1a1aa" : "#71717a"; + case null: return dark ? "#a1a1aa" : "#71717a"; } } diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 937ca5f909a8..a234147471c6 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -880,7 +880,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ? materialYouStyleLayoutActive ? "accent-thread-selected-foreground" : "accent-user-bubble-foreground" - : "accent-foreground-muted" + : pr.state === null || pr.isDraft + ? "accent-foreground-muted" + : pr.state === "open" + ? "accent-adaptive-emerald-600-400" + : pr.state === "closed" + ? "accent-adaptive-rose-600-400" + : "accent-adaptive-violet-600-400" } /> ) : null} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 593a49755376..21147bc739b9 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1,4 +1,8 @@ import { QuestionAnswerHistory } from "./QuestionAnswerHistory"; +import { + getQuestionAnswerPreview, + hasQuestionAnswer, +} from "@t3tools/client-runtime/work-log/user-input"; import * as Haptics from "expo-haptics"; import { Image } from "expo-image"; import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; @@ -745,6 +749,10 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( const viewedImagePath = workEntryViewedImagePath(row.workEntry); const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); const previewText = workEntryRowLabel(row.workEntry); + const answerPreview = row.workEntry.questionAnswer + ? getQuestionAnswerPreview(row.workEntry.questionAnswer) + : null; + const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": "); const displayText = workEntryRowLabel(row.workEntry, expanded); const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; const failed = row.status === "failure"; @@ -759,7 +767,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( > {displayText} + {answerPreview ? ( + {` ${answerPreview}`} + ) : null} )} diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 3e5cd0fc9e3d..ad54d6e323ee 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 c7d27af11d5e..b7d5018edd98 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, @@ -405,7 +406,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 @@ -936,6 +937,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" ) { @@ -2184,21 +2186,30 @@ export function buildThreadFeed( : loadedMessages; const oldestLoadedMessageCreatedAt = options?.loadedMessages !== undefined ? (loadedMessages[0]?.createdAt ?? null) : null; - 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) => { - let entry = messageEntriesCache.get(message); - if (!entry) { - entry = { type: "message", id: message.id, createdAt: message.createdAt, message }; - 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) => { + let entry = messageEntriesCache.get(message); + if (!entry) { + entry = { type: "message", id: message.id, createdAt: message.createdAt, message }; + messageEntriesCache.set(message, entry); + } + return entry; + }), + ...activityEntries, ], (s) => new Date(s.createdAt), Order.Date, diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index fc310d070acc..21234b7ff150 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -63,9 +63,16 @@ export function presentThreadLinkedPullRequests( const badge = resolveThreadPullRequestBadge(links); if (link === null || badge === null) return null; const snapshot = link.snapshot; - const state = badge.kind === "stack" ? badge.state : (snapshot?.state ?? null); - const isDraft = snapshot?.isDraft === true && state === "open"; const linkedCount = badge.kind === "pull-request" && badge.others > 0 ? badge.others + 1 : null; + const isMultiple = badge.kind === "stack" || linkedCount !== null; + const state = isMultiple + ? badge.state === "draft" + ? "open" + : badge.state + : (snapshot?.state ?? null); + const isDraft = isMultiple + ? badge.state === "draft" + : snapshot?.isDraft === true && state === "open"; const label = badge.kind === "stack" ? String(badge.layers) @@ -83,12 +90,16 @@ export function presentThreadLinkedPullRequests( label, accessibilityLabel: badge.kind === "stack" - ? `${badge.layers} pull requests in stack, ${state ?? "status pending"}` - : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}${badge.others > 0 ? `, ${badge.others} more linked` : ""}`, + ? `${badge.layers} pull requests in stack, ${isDraft ? "draft" : (state ?? "status pending")}` + : linkedCount !== null + ? `${linkedCount} linked pull requests, overall ${badge.state}` + : `#${link.number} pull request ${state === null ? "status pending" : isDraft ? "draft" : state}`, textClassName: - linkedCount !== null || state === null || isDraft + state === null || isDraft ? "text-foreground-muted" - : PR_STATE_TEXT_CLASS[state], + : isMultiple && state === "closed" + ? "text-adaptive-rose-600-400" + : PR_STATE_TEXT_CLASS[state], }; } diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index e36bd7d81434..75ec6743ac59 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -89,10 +89,42 @@ describe("presentThreadLinkedPullRequests", () => { kind: "pull-request", label: "+2", others: 1, - textClassName: "text-foreground-muted", + state: "open", + isDraft: false, + textClassName: "text-adaptive-emerald-600-400", }); }); + it.each([ + ["closed", false, "closed", false, "closed", false, "text-adaptive-rose-600-400"], + ["open", true, "open", true, "open", true, "text-foreground-muted"], + ["open", true, "open", false, "open", false, "text-adaptive-emerald-600-400"], + ["closed", false, "open", false, "open", false, "text-adaptive-emerald-600-400"], + ["merged", false, "merged", false, "merged", false, "text-adaptive-violet-600-400"], + ["closed", false, "merged", false, "closed", false, "text-adaptive-rose-600-400"], + ] as const)( + "colors linked %s (draft %s) and %s (draft %s) by their aggregate state", + (firstState, firstDraft, secondState, secondDraft, state, isDraft, textClassName) => { + const first = linkedPr(1); + const second = linkedPr(2); + expect( + presentThreadLinkedPullRequests([ + { ...first, snapshot: { ...first.snapshot!, state: firstState, isDraft: firstDraft } }, + { + ...second, + snapshot: { ...second.snapshot!, state: secondState, isDraft: secondDraft }, + }, + ]), + ).toMatchObject({ + label: "+2", + state, + isDraft, + textClassName, + accessibilityLabel: `2 linked pull requests, overall ${isDraft ? "draft" : state}`, + }); + }, + ); + it("uses the top of a derived stack even when its bottom was linked later", () => { const bottom = linkedPr(1, { linkedAt: "2026-09-09T00:00:00.000Z" }); const top = linkedPr(2); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 885629930706..9cbc3fde2977 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -250,7 +250,10 @@ it.effect.each([ const { accessibilityTree: _tree, ...boundedMetadata } = metadata; expect(snapshot.isError).toBe(false); expect(snapshot.structuredContent).toEqual(metadata); - const [text, ...rest] = snapshot.content; + const [identity, text, ...rest] = snapshot.content; + expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({ + url: page.url, + }); expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual(boundedMetadata); expect(rest).toEqual([ { @@ -279,7 +282,12 @@ it.effect.each([ Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), ); - expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "text", "image"]); + expect(nextDefault.content.map((content) => content.type)).toEqual([ + "text", + "text", + "text", + "image", + ]); expect(nextDefault.structuredContent).toEqual({ ...page, title: "Snapshot 7", screenshot }); expect(requests).toBe(7); }), @@ -329,7 +337,7 @@ it.effect("saves the snapshot PNG on request and reports its path", () => /^browser-screenshot-example-test-[0-9a-z]+-[0-9a-f]{8}\.png$/, ); expect(Buffer.from(yield* fileSystem.readFile(screenshotPath!)).toString()).toBe("png"); - const text = snapshot.content.find((content) => content.type === "text"); + const [, text] = snapshot.content; expect(text?.type === "text" ? text.text : "").toContain(screenshotPath); const unsaved = yield* callSnapshot({}); @@ -429,7 +437,10 @@ it.effect("keeps the snapshot text under the agent's output ceiling", () => const snapshot = yield* callSnapshot({ includeImage: false }); expect(snapshot.isError).toBe(false); - const [text, notice] = snapshot.content; + const [identity, text, notice] = snapshot.content; + expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({ + url: oversized.url, + }); expect(text?.type).toBe("text"); const body = text?.type === "text" ? text.text : ""; expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( @@ -471,7 +482,7 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are const snapshot = yield* callSnapshot({ includeImage: false }); - const [text] = snapshot.content; + const [, text] = snapshot.content; const body = text?.type === "text" ? text.text : ""; expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, @@ -482,7 +493,7 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are }; expect(parsed.title.length).toBe(2_049); expect(parsed.consoleEntries[0]?.text.length).toBe(501); - const notice = snapshot.content[1]; + const notice = snapshot.content[2]; const noticeText = notice?.type === "text" ? notice.text : ""; expect(noticeText).toContain("url or title after 2048 characters"); expect(noticeText).toContain("console entries text after 500 characters"); @@ -533,7 +544,7 @@ it.effect("sheds log entries before locators when every list is full", () => const snapshot = yield* callSnapshot({ includeImage: false }); - const [text, notice] = snapshot.content; + const [, text, notice] = snapshot.content; const body = text?.type === "text" ? text.text : ""; expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, @@ -615,6 +626,10 @@ it.effect("registers annotated tools and preserves authenticated request context Effect.gen(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const toolIcon = { + _tag: "website" as const, + pageUrl: "http://example.test/", + }; const routedRequests: Array<{ readonly operation: string; readonly tabId?: string | undefined; @@ -664,7 +679,7 @@ it.effect("registers annotated tools and preserves authenticated request context expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); - expect(clickTool?.tool.outputSchema).toEqual({ + expect(clickTool?.tool.outputSchema).toMatchObject({ type: "object", additionalProperties: false, description: "The preview action completed successfully.", @@ -721,10 +736,12 @@ it.effect("registers annotated tools and preserves authenticated request context Effect.provideService(McpSchema.McpServerClient, client), ); expect(evaluated.isError).toBe(false); - expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"] }); - expect(evaluated.content).toEqual([ - { type: "text", text: '{"value":["Connect","Continue"]}' }, - ]); + expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"], toolIcon }); + const evaluatedText = evaluated.content[0]; + expect(evaluatedText?.type === "text" ? decodeJsonText(evaluatedText.text) : null).toEqual({ + toolIcon, + value: ["Connect", "Continue"], + }); const actionRequests = [ { name: "preview_click", arguments: { x: 10, y: 10 } }, @@ -741,8 +758,10 @@ it.effect("registers annotated tools and preserves authenticated request context Effect.provideService(McpSchema.McpServerClient, client), ); expect(result.isError).toBe(false); - expect(result.structuredContent).toEqual({}); - expect(result.content).toEqual([{ type: "text", text: "{}" }]); + expect(result.structuredContent).toEqual({ toolIcon }); + expect(routedRequests.at(-1)?.operation).toBe("status"); + const text = result.content[0]; + expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual({ toolIcon }); } }), ).pipe(Effect.provide(TestLayer)), diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index bf7cf0520668..5a8cb573ad88 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -410,6 +410,13 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot isError: false, structuredContent: metadata, content: [ + // Keep the page identity readable even if a provider truncates the snapshot. + { + type: "text", + text: encodeJsonText({ + url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS), + }), + }, { type: "text", text: bounded.text }, ...(bounded.omitted.length === 0 ? [] diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts index 42f849f5edf3..27557d43b701 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.test.ts @@ -127,50 +127,70 @@ it.effect("targets multiple tabs explicitly while retaining a default tab", () = ), ); -it.effect("does not let an older response replace a newer explicit tab target", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const olderTabId = PreviewTabId.make("tab-older-request"); - const newerTabId = PreviewTabId.make("tab-newer-request"); - const releaseOlderResponse = yield* Deferred.make(); - const routedRequests: RoutedRequest[] = []; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => { - routedRequests.push(request); - const response = Effect.gen(function* () { - if (request.tabId === olderTabId) { - yield* Deferred.await(releaseOlderResponse); - } - yield* broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: { url: "http://localhost:3200" }, +it.effect.each([true, false])( + "keeps an older target stable while a newer explicit tab responds (implicit: %s)", + (implicit) => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* makeBroker; + const olderTabId = PreviewTabId.make("tab-older-request"); + const newerTabId = PreviewTabId.make("tab-newer-request"); + const releaseOlderResponse = yield* Deferred.make(); + const routedRequests: RoutedRequest[] = []; + const requests = requestsFrom(yield* broker.connect(makeHost())); + yield* Stream.runForEach(requests, (request) => { + routedRequests.push(request); + const response = Effect.gen(function* () { + if (request.tabId === olderTabId && request.operation === "snapshot") { + yield* Deferred.await(releaseOlderResponse); + } + yield* broker.respond({ + clientId: "client-1", + connectionId: request.connectionId, + requestId: request.requestId, + ok: true, + result: { url: "http://localhost:3200" }, + }); + if (request.tabId === newerTabId) { + yield* Deferred.succeed(releaseOlderResponse, undefined); + } }); - if (request.tabId === newerTabId) { - yield* Deferred.succeed(releaseOlderResponse, undefined); - } + return response.pipe(Effect.forkScoped, Effect.asVoid); + }).pipe(Effect.forkScoped); + yield* Effect.yieldNow; + + yield* broker.invoke({ scope, operation: "status", input: {}, tabId: olderTabId }); + let capturedTabId: PreviewTabId | undefined; + const older = yield* broker + .invoke({ + scope, + operation: "snapshot", + input: {}, + ...(implicit ? {} : { tabId: olderTabId }), + onTargetTab: (tabId) => { + capturedTabId = tabId; + }, + }) + .pipe(Effect.forkScoped); + yield* Effect.yieldNow; + const newer = yield* broker + .invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId }) + .pipe(Effect.forkScoped); + yield* Fiber.join(newer); + yield* Fiber.join(older); + yield* broker.invoke({ + scope, + operation: "status", + input: {}, + tabId: olderTabId, + updateCurrentTab: false, }); - return response.pipe(Effect.forkScoped, Effect.asVoid); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const older = yield* broker - .invoke({ scope, operation: "snapshot", input: {}, tabId: olderTabId }) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - const newer = yield* broker - .invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId }) - .pipe(Effect.forkScoped); - yield* Fiber.join(newer); - yield* Fiber.join(older); - yield* broker.invoke({ scope, operation: "snapshot", input: {} }); + yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); - }), - ), + expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); + expect(capturedTabId).toBe(olderTabId); + }), + ), ); it.effect("tracks the tab returned by a targeted recording stop", () => diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts index d8f17973c218..8d92059bde8a 100644 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ b/apps/server/src/mcp/PreviewAutomationBroker.ts @@ -44,6 +44,10 @@ export interface PreviewAutomationInvokeInput { readonly input: unknown; readonly tabId?: PreviewTabId; readonly timeoutMs?: number; + /** Background metadata reads must not change the agent's current tab. */ + readonly updateCurrentTab?: boolean; + /** Capture the routed tab before another request changes the current assignment. */ + readonly onTargetTab?: (tabId: PreviewTabId | undefined) => void; } export class PreviewAutomationBroker extends Context.Service< @@ -541,6 +545,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); } const { connection, requestId, requestContext, requestSequence } = route; + input.onTargetTab?.(requestContext.tabId); const removePending = SynchronizedRef.update(state, (next) => { if (!next.pending.has(requestId)) return next; const pending = new Map(next.pending); @@ -575,6 +580,7 @@ export const make = Effect.gen(function* PreviewAutomationBrokerMake() { }); }); const result = yield* awaitResponse().pipe(Effect.ensuring(removePending)); + if (input.updateCurrentTab === false) return result; const responseTabId = readResultTabId(result); const resultTabId = responseTabId === undefined ? input.tabId : responseTabId; if (resultTabId === undefined) return result; diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index d34c2d3ba3af..caa4cbd157cf 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -7,6 +7,7 @@ import { PreviewAutomationRecordingTransferError, PreviewAutomationRecordingDesktopUpdateRequiredError, PreviewAutomationRecordingArtifact, + type ToolActivityIcon, type ThreadId, type PreviewAutomationOperation, type PreviewAutomationOpenInput, @@ -55,22 +56,47 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( timeoutMs?: number, tabId?: PreviewTabId, ): Effect.fn.Return< - A, + { result: A; toolIcon?: ToolActivityIcon }, import("@t3tools/contracts").PreviewAutomationError, McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker > { const scope = yield* McpInvocationContext.requireMcpCapability("preview"); const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - return yield* broker.invoke({ + let targetTabId = tabId; + const result = yield* broker.invoke({ + onTargetTab: (resolvedTabId) => { + targetTabId = resolvedTabId; + }, scope, operation, input, ...(timeoutMs === undefined ? {} : { timeoutMs }), ...(tabId === undefined ? {} : { tabId }), }); + if (["status", "open", "navigate", "snapshot"].includes(operation)) return { result }; + const statusTabId = + (operation !== "evaluate" && typeof result === "object" && result !== null + ? (result as { tabId?: PreviewTabId }).tabId + : undefined) ?? targetTabId; + const page = yield* broker + .invoke({ + scope, + operation: "status", + input: {}, + timeoutMs: 500, + updateCurrentTab: false, + ...(statusTabId === undefined ? {} : { tabId: statusTabId }), + }) + .pipe(Effect.catch(() => Effect.succeed(null))); + return { + result, + ...(page?.url && /^https?:\/\//i.test(page.url) && page.url.length <= 4096 + ? { toolIcon: { _tag: "website" as const, pageUrl: page.url } } + : {}), + }; }); -const invokeTargeted = ( +const invokeTargeted = ( operation: PreviewAutomationOperation, input: { readonly tabId?: PreviewTabId | undefined; @@ -79,7 +105,12 @@ const invokeTargeted = ( timeoutMs?: number, ) => { const { tabId, ...operationInput } = input; - return invoke(operation, operationInput, timeoutMs, tabId); + return invoke(operation, operationInput, timeoutMs, tabId).pipe( + Effect.map(({ result, toolIcon }) => ({ + ...result, + ...(toolIcon ? { toolIcon } : {}), + })), + ); }; const UploadedRecordingArtifact = Schema.Struct({ @@ -170,28 +201,32 @@ const handlers = { const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {}; return invokeTargeted("snapshot", operationInput); }, - preview_click: (input) => - invokeTargeted("click", input, input.timeoutMs).pipe(Effect.as({})), - preview_type: (input) => invokeTargeted("type", input, input.timeoutMs).pipe(Effect.as({})), - preview_press: (input) => invokeTargeted("press", input).pipe(Effect.as({})), - preview_scroll: (input) => invokeTargeted("scroll", input).pipe(Effect.as({})), - preview_evaluate: (input) => - invokeTargeted("evaluate", input).pipe( - Effect.map((result) => ({ value: result ?? null })), + preview_click: (input) => invokeTargeted("click", input, input.timeoutMs), + preview_type: (input) => invokeTargeted("type", input, input.timeoutMs), + preview_press: (input) => invokeTargeted("press", input), + preview_scroll: (input) => invokeTargeted("scroll", input), + preview_evaluate: ({ tabId, ...input }) => + invoke("evaluate", input, undefined, tabId).pipe( + Effect.map(({ result, toolIcon }) => ({ + value: result ?? null, + ...(toolIcon ? { toolIcon } : {}), + })), ), - preview_wait_for: (input) => - invokeTargeted("waitFor", input, input.timeoutMs).pipe(Effect.as({})), + preview_wait_for: (input) => invokeTargeted("waitFor", input, input.timeoutMs), preview_recording_start: (input) => invokeTargeted("recordingStart", input ?? {}), preview_recording_stop: (input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.requireMcpCapability("preview"); - const response = yield* invokeTargeted( + const { tabId, ...operationInput } = input; + const response = yield* invoke( "recordingStop", - { ...input, transferToEnvironment: true }, + { ...operationInput, transferToEnvironment: true }, PREVIEW_RECORDING_STOP_TIMEOUT_MS, + tabId, ); - return yield* claimPreviewRecording(scope.threadId, response); + const artifact = yield* claimPreviewRecording(scope.threadId, response.result); + return { ...artifact, ...(response.toolIcon ? { toolIcon: response.toolIcon } : {}) }; }), } satisfies Parameters[0]; diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts index 2cdc67ad7d92..3deef11f671e 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.test.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -67,6 +67,7 @@ it("exports exact object result schemas for preview actions", () => { for (const name of actionNames) { expect(Tool.getJsonSchemaFromSchema(PreviewToolkit.tools[name].successSchema)).toEqual({ type: "object", + properties: { toolIcon: expect.any(Object) }, additionalProperties: false, description: "The preview action completed successfully.", }); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 28a2b96228b5..3f80e84e9a59 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -1,4 +1,5 @@ import { + ToolActivityIcon, PreviewAutomationClickInput, PreviewAutomationError, PreviewAutomationEvaluateInput, @@ -31,7 +32,9 @@ const dependencies = [ PreviewAutomationBroker.PreviewAutomationBroker, ]; -const PreviewActionResult = Schema.Record(Schema.String, Schema.Never).annotate({ +const presentationFields = { toolIcon: Schema.optional(ToolActivityIcon) }; + +const PreviewActionResult = Schema.Struct(presentationFields).annotate({ description: "The preview action completed successfully.", }); @@ -89,7 +92,7 @@ const PreviewResizeTool = safeBrowserTool( description: "Resize a collaborative browser tab, optionally selected by tabId. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.", parameters: PreviewAutomationResizeInput, - success: PreviewAutomationResizeResult, + success: Schema.Struct({ ...PreviewAutomationResizeResult.fields, ...presentationFields }), failure: PreviewAutomationError, dependencies, }) @@ -102,7 +105,10 @@ const PreviewSetAppearanceTool = safeBrowserTool( description: "Emulate prefers-color-scheme in a collaborative browser tab, optionally selected by tabId. Use {colorScheme:'dark'} or {colorScheme:'light'} to preview the page in that appearance, and {colorScheme:'system'} to clear the override and follow the OS appearance.", parameters: PreviewAutomationSetColorSchemeInput, - success: PreviewAutomationSetColorSchemeResult, + success: Schema.Struct({ + ...PreviewAutomationSetColorSchemeResult.fields, + ...presentationFields, + }), failure: PreviewAutomationError, dependencies, }) @@ -185,6 +191,7 @@ const PreviewScrollTool = safeBrowserTool( * null valid instead of failing only for non-object expressions. */ export const PreviewEvaluateResult = Schema.Struct({ + ...presentationFields, value: Schema.Unknown.annotate({ description: "The JSON-serializable value the expression produced, or null.", }), @@ -217,7 +224,7 @@ const PreviewRecordingStartTool = safeBrowserTool( description: "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.", parameters: PreviewAutomationTabTargetInput, - success: PreviewAutomationRecordingStatus, + success: Schema.Struct({ ...PreviewAutomationRecordingStatus.fields, ...presentationFields }), failure: PreviewAutomationError, dependencies, }).annotate(Tool.Title, "Start browser recording"), @@ -228,7 +235,7 @@ const PreviewRecordingStopTool = safeBrowserTool( description: "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.", parameters: PreviewAutomationTabTargetInput, - success: PreviewAutomationRecordingArtifact, + success: Schema.Struct({ ...PreviewAutomationRecordingArtifact.fields, ...presentationFields }), failure: PreviewAutomationError, dependencies: [...dependencies, FileSystem.FileSystem, ServerConfig.ServerConfig], }).annotate(Tool.Title, "Stop browser recording"), diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts index bf09ed959e17..e6468ff8f789 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.test.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.test.ts @@ -249,6 +249,84 @@ describe("projectActivityPayload", () => { expect(JSON.stringify(projected.payload).length).toBeLessThan(500); }); + it.each([ + { + item: { + server: "t3-code", + tool: "preview_open", + result: { structuredContent: { url: "https://example.com/" } }, + }, + }, + { + toolName: "mcp__t3-code__preview_navigate", + result: { content: '{"url":"https://example.com/"}' }, + }, + { tool: "t3-code_preview_status", state: { output: '{"url":"https://example.com/"}' } }, + { + toolName: "mcp__t3_code__preview_snapshot", + result: { + content: [ + { type: "text", text: '{"url":"https://example.com/"}' }, + { type: "text", text: "Snapshot text was bounded. Omitted: accessibilityTree." }, + ], + }, + }, + { + toolName: "mcp__t3-code__preview_click", + result: { content: '{"toolIcon":{"_tag":"website","pageUrl":"https://example.com/"}}' }, + }, + { + toolName: "mcp__t3_code__preview_snapshot", + result: { content: '{"url":"https://example.com/"}\n{"accessibilityTree":"truncated' }, + }, + ...[false, true].map((truncated) => ({ + toolName: "mcp__t3_code__preview_snapshot", + result: { + content: JSON.stringify({ + content: [{ type: "text", text: '{"url":"https://example.com/"}' }], + structuredContent: { url: "https://example.com/", visibleText: "page" }, + }).slice(0, truncated ? -5 : undefined), + }, + })), + ...[ + "type", + "press", + "scroll", + "resize", + "set_appearance", + "evaluate", + "wait_for", + "recording_start", + "recording_stop", + ].map((action) => ({ + toolName: `mcp__t3_code__preview_${action}`, + result: { content: '{"toolIcon":{"_tag":"website","pageUrl":"https://example.com/"}}' }, + })), + ])("preserves the preview page favicon through result slimming", (data) => { + const projected = projectActivityPayload(activity({ itemType: "mcp_tool_call", data })); + const icon = { _tag: "website", pageUrl: "https://example.com/" }; + expect(projected.payload).toMatchObject({ toolIcon: icon }); + expect(projectActivityPayload(projected).payload).toMatchObject({ toolIcon: icon }); + }); + + it.each([ + { toolName: "mcp__other__preview_open", result: { content: '{"url":"https://example.com/"}' } }, + { + toolName: "mcp__t3-code__preview_evaluate", + result: { content: '{"url":"https://example.com/"}' }, + }, + { + toolName: "mcp__t3-code__preview_open", + result: { isError: true, content: '{"url":"https://example.com/"}' }, + }, + { toolName: "mcp__t3-code__preview_open", result: { content: "malformed JSON" } }, + { toolName: "mcp__t3-code__preview_open", result: { content: '{"url":"about:blank"}' } }, + ])("keeps the fallback for unrelated tools, failed navigation, and missing page URLs", (data) => { + expect( + projectActivityPayload(activity({ itemType: "mcp_tool_call", data })).payload, + ).not.toHaveProperty("toolIcon"); + }); + it("passes task lifecycle payloads (no data field) through untouched", () => { const source = activity({ taskId: "task-9", diff --git a/apps/server/src/orchestration/ActivityPayloadProjection.ts b/apps/server/src/orchestration/ActivityPayloadProjection.ts index 98294e63b35c..0525aae7b72b 100644 --- a/apps/server/src/orchestration/ActivityPayloadProjection.ts +++ b/apps/server/src/orchestration/ActivityPayloadProjection.ts @@ -1,9 +1,11 @@ +import { projectQuestionToolInput } from "@t3tools/shared/toolActivity"; import type { OrchestrationEvent, OrchestrationThreadActivity, OrchestrationThreadDetailSnapshot, } from "@t3tools/contracts"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import { extractJsonObject } from "@t3tools/shared/schemaJson"; function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) @@ -240,6 +242,70 @@ function summarizeMcpResult(result: unknown): Record | undefine return summary ? { content: summary } : undefined; } +/** Reuse the page URL already returned by preview tools before slimming their output. */ +function projectPreviewToolMetadata(data: Record, status: unknown) { + const item = asRecord(data.item); + const name = item ? `mcp__${item.server}__${item.tool}` : (data.toolName ?? data.tool); + if ( + typeof name !== "string" || + !/^(?:mcp__)?(?:t3-code|t3_code|t3code)_{1,2}preview_(?:open|navigate|status|snapshot|click|type|press|scroll|resize|set_appearance|evaluate|wait_for|recording_start|recording_stop)$/.test( + name, + ) + ) + return {}; + const state = asRecord(data.state); + const result = item?.result ?? data.result ?? state?.output; + const record = asRecord(result); + if ( + status === "failed" || + status === "declined" || + state?.status === "error" || + item?.error != null || + record?.isError === true || + record?.is_error === true + ) + return {}; + + let page = record; + let output: unknown = result; + for (let depth = 0; depth < 3; depth += 1) { + if (page?.isError === true || page?.is_error === true) return {}; + const structured = asRecord(page?.structuredContent); + if (structured) { + page = structured; + break; + } + const text = extractMcpResultText(output)?.slice(0, 2 * 1024 * 1024); + if (!text) break; + try { + page = asRecord(JSON.parse(extractJsonObject(text))); + } catch { + // A truncated MCP envelope can still contain a complete first text block. + const firstBlock = /^\s*\{\s*"content"\s*:\s*\[\s*/.exec(text); + if (!firstBlock) return {}; + try { + const block = asRecord(JSON.parse(extractJsonObject(text.slice(firstBlock[0].length)))); + page = block?.type === "text" ? { content: [block] } : null; + } catch { + return {}; + } + } + output = page; + } + const rawUrl = asTrimmedString( + asRecord(page?.toolIcon)?.pageUrl ?? + (/preview_(?:open|navigate|status|snapshot)$/.test(name) ? page?.url : undefined), + ); + if (!rawUrl || rawUrl.length > 4096) return {}; + try { + const url = new URL(rawUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") return {}; + return { toolIcon: { _tag: "website", pageUrl: url.href } }; + } catch { + return {}; + } +} + /** * MCP tool calls carry full tool results (`data.item.result` on Codex, * `data.result` on Claude/OpenCode) that used to bypass slimming entirely to @@ -366,22 +432,27 @@ export function projectActivityPayload( } const itemStatus = asRecord(data.item)?.status; - const projectedPayload = + const statusPayload = payload.status === "completed" && (itemStatus === "failed" || itemStatus === "declined") ? { ...payload, status: itemStatus } : payload; + const projectedPayload = { + ...projectPreviewToolMetadata(data, statusPayload.status), + ...statusPayload, + }; + 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/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d93fec5a3cf6..c927e72fe737 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -175,6 +175,8 @@ describe("ProviderCommandReactor", () => { readonly titleRegenerationBeforeStart?: "one" | "two"; readonly serverActivation?: Effect.Effect; readonly beforeReadySessionDispatch?: () => Effect.Effect; + readonly beforeTurnStartDispatch?: () => Effect.Effect; + readonly afterTurnStartDispatch?: () => Effect.Effect; readonly compactThreadEffect?: () => Effect.Effect; readonly interruptTurnEffect?: () => Effect.Effect; readonly stopSessionEffect?: () => Effect.Effect; @@ -431,11 +433,21 @@ describe("ProviderCommandReactor", () => { return Effect.die(new Error("Injected title regeneration completion failure")); } } - return ( + const isReplay = + command.type === "thread.turn.start" && + command.commandId.startsWith("server:after-compaction:"); + const before = command.type === "thread.session.set" && command.session.status === "ready" - ? (input?.beforeReadySessionDispatch?.() ?? Effect.void) - : Effect.void - ).pipe(Effect.andThen(engine.dispatch(command))); + ? input?.beforeReadySessionDispatch + : isReplay + ? input?.beforeTurnStartDispatch + : undefined; + return (before?.() ?? Effect.void).pipe( + Effect.andThen(engine.dispatch(command)), + Effect.tap(() => + isReplay ? (input?.afterTurnStartDispatch?.() ?? Effect.void) : Effect.void, + ), + ); }, get streamDomainEvents() { return engine.streamDomainEvents; @@ -973,86 +985,203 @@ describe("ProviderCommandReactor", () => { }), ); - effectIt.effect("keeps turns blocked until compaction restores the session", () => - Effect.gen(function* () { - const readyDispatchStarted = yield* Deferred.make(); - const releaseReadyDispatch = yield* Deferred.make(); - let blockReadyDispatch = false; - const harness = yield* Effect.promise(() => - createHarness({ - beforeReadySessionDispatch: () => - blockReadyDispatch - ? Deferred.succeed(readyDispatchStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseReadyDispatch)), - ) - : Effect.void, - }), - ); - const threadId = ThreadId.make("thread-1"); - const now = "2026-01-01T00:00:00.000Z"; - const dispatchTurn = (id: string, text: string, createdAt: string) => - harness.engine.dispatch({ - type: "thread.turn.start", - commandId: CommandId.make(`cmd-${id}`), + effectIt.effect.each(["resume", "stop before resume", "stop after send"])( + "queues messages until compaction restores the session (%s)", + (scenario) => + Effect.gen(function* () { + const stopBeforeResume = scenario === "stop before resume"; + const readyDispatchStarted = yield* Deferred.make(); + const releaseReadyDispatch = yield* Deferred.make(); + const firstSent = yield* Deferred.make(); + const queuedSent = yield* Deferred.make(); + const resumeStarted = yield* Deferred.make(); + const releaseResume = yield* Deferred.make(); + const resumeDispatched = yield* Deferred.make(); + const queuedSendStarted = yield* Deferred.make(); + const releaseQueuedSend = yield* Deferred.make(); + let blockReadyDispatch = false; + const harness = yield* Effect.promise(() => + createHarness({ + beforeTurnStartDispatch: () => + stopBeforeResume + ? Deferred.succeed(resumeStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseResume)), + ) + : Effect.void, + afterTurnStartDispatch: () => Deferred.succeed(resumeDispatched, undefined), + beforeReadySessionDispatch: () => + blockReadyDispatch + ? Deferred.succeed(readyDispatchStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseReadyDispatch)), + ) + : Effect.void, + }), + ); + const threadId = ThreadId.make("thread-1"); + let sentCount = 0; + harness.sendTurn.mockImplementation(() => + Effect.succeed({ threadId, turnId: asTurnId("turn-1") }).pipe( + Effect.tap(() => { + sentCount++; + return sentCount === 1 + ? Deferred.succeed(firstSent, undefined) + : sentCount === 2 && scenario === "stop after send" + ? Deferred.succeed(queuedSendStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseQueuedSend)), + ) + : sentCount === 3 + ? Deferred.succeed(queuedSent, undefined) + : Effect.void; + }), + ), + ); + const now = "2026-01-01T00:00:00.000Z"; + const dispatchTurn = (id: string, text: string, createdAt: string) => + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-${id}`), + threadId, + message: { + messageId: asMessageId(`user-message-${id}`), + role: "user", + text, + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + + yield* dispatchTurn("before-blocked-compact", "hello", now); + yield* Deferred.await(firstSent); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-ready-before-blocked-compact"), threadId, - message: { - messageId: asMessageId(`user-message-${id}`), - role: "user", - text, - attachments: [], + session: { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: now, }, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - createdAt, + createdAt: now, }); - yield* dispatchTurn("before-blocked-compact", "hello", now); - yield* Effect.promise(() => waitFor(() => harness.sendTurn.mock.calls.length === 1)); - yield* harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-ready-before-blocked-compact"), - threadId, - session: { - threadId, - status: "ready", - providerName: "codex", - providerInstanceId: ProviderInstanceId.make("codex"), - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - createdAt: now, - }); - - blockReadyDispatch = true; - yield* dispatchTurn("blocked-compact", "/compact", "2026-01-01T00:00:01.000Z"); - yield* Deferred.await(readyDispatchStarted); + blockReadyDispatch = true; + yield* dispatchTurn("blocked-compact", "/compact", "2026-01-01T00:00:01.000Z"); + yield* Deferred.await(readyDispatchStarted); - yield* dispatchTurn("during-compact-recovery", "too soon", "2026-01-01T00:00:02.000Z"); - yield* Effect.promise(() => - waitFor(async () => { - const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); - return ( - thread?.activities.some( - (activity) => activity.kind === "provider.turn.start.failed", - ) === true + yield* harness.engine.dispatch({ + type: "thread.interaction-mode.set", + commandId: CommandId.make("cmd-queued-mode-plan"), + threadId, + interactionMode: "plan", + createdAt: now, + }); + yield* dispatchTurn("during-compact-recovery", "first queued", "2026-01-01T00:00:02.000Z"); + yield* harness.engine.dispatch({ + type: "thread.interaction-mode.set", + commandId: CommandId.make("cmd-queued-mode-default"), + threadId, + interactionMode: "default", + createdAt: now, + }); + yield* dispatchTurn( + "during-compact-recovery-2", + "second queued", + "2026-01-01T00:00:03.000Z", + ); + yield* Effect.promise(() => harness.drain()); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + const beforeRestore = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect( + beforeRestore?.activities.filter( + (activity) => activity.kind === "provider.turn.start.failed", + ), + ).toEqual([]); + expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([ + { threadId: "thread-1" }, + ]); + + yield* Deferred.succeed(releaseReadyDispatch, undefined); + if (scenario === "stop after send") { + yield* Deferred.await(queuedSendStarted); + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-after-queued-send"), + threadId, + createdAt: "2026-01-01T00:00:04.000Z", + }); + yield* Effect.promise(() => harness.drain()); + const stoppedThread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, ); - }), - ); - expect(harness.sendTurn).toHaveBeenCalledTimes(1); - expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([ - { threadId: "thread-1" }, - ]); - - yield* Deferred.succeed(releaseReadyDispatch, undefined); - yield* Effect.promise(() => - waitFor(async () => { - const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); - return thread?.session?.status === "ready"; - }), - ); - }), + expect(stoppedThread?.session?.status).toBe("stopped"); + expect( + stoppedThread?.activities.filter( + (activity) => activity.summary === "Queued message was not sent", + ), + ).toEqual([ + expect.objectContaining({ + payload: { + requestId: "user-message-during-compact-recovery-2", + detail: expect.any(String), + }, + }), + ]); + expect(harness.sendTurn).toHaveBeenCalledTimes(2); + yield* Deferred.succeed(releaseQueuedSend, undefined); + return; + } + if (stopBeforeResume) { + yield* Deferred.await(resumeStarted); + yield* dispatchTurn("compact-during-resume", "/compact", "2026-01-01T00:00:04.000Z"); + yield* Effect.promise(() => harness.drain()); + expect(harness.compactThread).toHaveBeenCalledTimes(1); + yield* harness.engine.dispatch({ + type: "thread.session.stop", + commandId: CommandId.make("cmd-stop-before-queued-resume"), + threadId, + createdAt: "2026-01-01T00:00:04.000Z", + }); + yield* Effect.promise(() => harness.drain()); + yield* Deferred.succeed(releaseResume, undefined); + yield* Deferred.await(resumeDispatched); + yield* Effect.promise(() => harness.drain()); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + const stoppedThread = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect(stoppedThread?.session?.status).toBe("stopped"); + expect(yield* Effect.promise(() => harness.readPendingTurnStarts())).toEqual([]); + expect( + stoppedThread?.activities.filter( + (activity) => activity.summary === "Queued message was not sent", + ), + ).toHaveLength(2); + return; + } + yield* Deferred.await(queuedSent); + expect(harness.sendTurn.mock.calls.slice(1).map(([request]) => request)).toEqual([ + expect.objectContaining({ input: "first queued", interactionMode: "plan" }), + expect.objectContaining({ input: "second queued", interactionMode: "default" }), + ]); + const afterRestore = (yield* Effect.promise(() => harness.readModel())).threads.find( + (entry) => entry.id === threadId, + ); + expect( + afterRestore?.messages.filter((message) => message.text === "first queued"), + ).toHaveLength(1); + expect( + afterRestore?.messages.filter((message) => message.text === "second queued"), + ).toHaveLength(1); + }), ); effectIt.effect("does not overwrite concurrent session state after compaction failure", () => @@ -1136,6 +1265,21 @@ describe("ProviderCommandReactor", () => { (entry) => entry.id === threadId, ); expect(compactingThread?.session?.status).toBe("starting"); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-queued-before-stop"), + threadId, + message: { + messageId: asMessageId("user-message-queued-before-stop"), + role: "user", + text: "do not restart after stopping", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + yield* Effect.promise(() => harness.drain()); yield* harness.engine.dispatch({ type: "thread.session.stop", commandId: CommandId.make("cmd-stop-during-compact"), @@ -1151,7 +1295,7 @@ describe("ProviderCommandReactor", () => { ); return ( compactingThread?.activities.some( - (activity) => activity.kind === "provider.turn.start.failed", + (activity) => activity.summary === "Context compaction failed", ) === true ); }), @@ -1167,6 +1311,14 @@ describe("ProviderCommandReactor", () => { (entry) => entry.id === threadId, ); expect(recoveredThread?.session?.status).toBe("ready"); + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + expect( + recoveredThread?.activities.find( + (activity) => activity.summary === "Queued message was not sent", + ), + ).toMatchObject({ + payload: { requestId: "user-message-queued-before-stop" }, + }); expect( recoveredThread?.activities.find( (activity) => activity.kind === "provider.session.stop.failed", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 6df08bfadb9c..9b125922137c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -18,6 +18,7 @@ import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; @@ -346,6 +347,19 @@ const make = Effect.gen(function* () { const threadModelSelections = new Map(); const compactingThreadIds = new Set(); + type QueuedTurnStart = Extract; + // Turn starts received while a thread compacts, replayed in order once its session is restored. + const turnsAfterCompaction = new Map>(); + // Replay command id → the queued turn start it re-requests. `sent` settles once the replay's + // provider send finishes, which is what lets the next queued turn follow it in order. + const resumedTurnStarts = new Map< + CommandId, + { + readonly event: QueuedTurnStart; + readonly queued: Array; + readonly sent: Deferred.Deferred; + } + >(); const stoppingThreadIds = new Set(); const appendProviderFailureActivity = (input: { @@ -388,6 +402,71 @@ const make = Effect.gen(function* () { ), ); + const cancelTurnsAfterCompaction = Effect.fn("cancelTurnsAfterCompaction")(function* ( + threadId: ThreadId, + detail: string, + ) { + const queued = turnsAfterCompaction.get(threadId) ?? []; + turnsAfterCompaction.delete(threadId); + for (const event of queued) { + yield* appendProviderFailureActivity({ + threadId, + kind: "provider.turn.start.failed", + summary: "Queued message was not sent", + detail, + turnId: null, + createdAt: DateTime.formatIso(yield* DateTime.now), + requestId: event.payload.messageId, + }).pipe(Effect.ignore({ log: true, message: "failed to report canceled queued message" })); + } + }); + + const resumeTurnsAfterCompaction = Effect.fn("resumeTurnsAfterCompaction")(function* ( + threadId: ThreadId, + ) { + const queued = turnsAfterCompaction.get(threadId) ?? []; + while (queued.length > 0 && turnsAfterCompaction.get(threadId) === queued) { + const event = queued[0]!; + const turnStart = yield* projectionSnapshotQuery.getTurnStartMessage({ + threadId, + messageId: event.payload.messageId, + }); + if (turnsAfterCompaction.get(threadId) !== queued) return; + // In flight from here on: a cancellation reports it when the replay runs, not from the queue. + queued.shift(); + if (Option.isNone(turnStart)) continue; + // Reissue the durable request after restoration clears compaction's + // pending slot. Reusing the message id preserves a single user bubble. + const commandId = yield* serverCommandId("after-compaction"); + const sent = yield* Deferred.make(); + resumedTurnStarts.set(commandId, { event, queued, sent }); + const { messageId, ...request } = event.payload; + yield* orchestrationEngine + .dispatch({ + type: "thread.turn.start", + commandId, + ...request, + message: { + messageId, + role: "user", + text: turnStart.value.message.text, + attachments: turnStart.value.message.attachments ?? [], + }, + }) + .pipe( + Effect.onError(() => + Effect.sync(() => { + resumedTurnStarts.delete(commandId); + queued.unshift(event); + }), + ), + ); + yield* Deferred.await(sent); + resumedTurnStarts.delete(commandId); + } + if (turnsAfterCompaction.get(threadId) === queued) turnsAfterCompaction.delete(threadId); + }); + const formatFailureDetail = (cause: Cause.Cause): string => { const failReason = cause.reasons.find(Cause.isFailReason); if (isProviderAdapterRequestError(failReason?.error)) { @@ -1181,8 +1260,11 @@ const make = Effect.gen(function* () { ); const processTurnStartRequested = Effect.fn("processTurnStartRequested")(function* ( - event: Extract, + receivedEvent: Extract, ) { + const resumed = + receivedEvent.commandId !== null ? resumedTurnStarts.get(receivedEvent.commandId) : undefined; + const event = resumed ? { ...receivedEvent, payload: resumed.event.payload } : receivedEvent; const key = turnStartKeyForEvent(event); if (yield* hasHandledTurnStartRecently(key)) { return; @@ -1219,6 +1301,12 @@ const make = Effect.gen(function* () { createdAt: event.payload.createdAt, requestId: event.payload.messageId, }); + if (resumed && turnsAfterCompaction.get(event.payload.threadId) !== resumed.queued) { + return yield* appendTurnStartFailure( + "Queued message was not sent", + "The queued message was canceled before it could resume. Send it again to continue.", + ); + } const handleTurnStartFailure = (cause: Cause.Cause) => { if (Cause.hasInterruptsOnly(cause)) { @@ -1381,6 +1469,7 @@ const make = Effect.gen(function* () { const latestThread = yield* resolveThreadShell(event.payload.threadId); if ( compactingThreadIds.has(event.payload.threadId) || + turnsAfterCompaction.has(event.payload.threadId) || latestThread?.session?.status === "starting" || latestThread?.session?.status === "running" ) { @@ -1391,6 +1480,9 @@ const make = Effect.gen(function* () { return; } compactingThreadIds.add(event.payload.threadId); + const clearCompacting = Effect.sync( + () => void compactingThreadIds.delete(event.payload.threadId), + ); yield* Effect.gen(function* () { yield* ensureSessionForThread( event.payload.threadId, @@ -1410,17 +1502,32 @@ const make = Effect.gen(function* () { ); }).pipe( Effect.andThen(restoreCompaction(event.payload.threadId, true)), - Effect.catchCause(recoverCompactionFailure), - Effect.ensuring(Effect.sync(() => void compactingThreadIds.delete(event.payload.threadId))), + Effect.andThen(clearCompacting), + Effect.andThen(resumeTurnsAfterCompaction(event.payload.threadId)), + Effect.catchCause((cause) => + recoverCompactionFailure(cause).pipe( + Effect.ensuring(clearCompacting), + Effect.andThen( + cancelTurnsAfterCompaction( + event.payload.threadId, + "Context compaction failed. Send this message again to continue.", + ), + ), + ), + ), Effect.forkScoped, ); return; } - if (compactingThreadIds.has(event.payload.threadId)) { - return yield* appendTurnStartFailure( - "Provider turn start failed", - "Wait for context compaction to finish before sending another message.", - ); + if ( + !resumed && + (compactingThreadIds.has(event.payload.threadId) || + turnsAfterCompaction.has(event.payload.threadId)) + ) { + const queued = turnsAfterCompaction.get(event.payload.threadId) ?? []; + queued.push(event); + turnsAfterCompaction.set(event.payload.threadId, queued); + return; } const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, @@ -1440,14 +1547,24 @@ const make = Effect.gen(function* () { return; } - yield* providerService + const send = providerService .sendTurn(sendTurnRequest.value) - .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); + .pipe(Effect.asVoid, Effect.catchCause(recoverTurnStartFailure)); + // The forked send settles `sent` from here on, so drop the entry the post-processing hook uses. + if (resumed && event.commandId !== null) resumedTurnStarts.delete(event.commandId); + yield* send.pipe( + Effect.ensuring(resumed ? Deferred.succeed(resumed.sent, undefined) : Effect.void), + Effect.forkScoped, + ); }); const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( event: Extract, ) { + yield* cancelTurnsAfterCompaction( + event.payload.threadId, + "Context compaction was interrupted. Send this message again to continue.", + ); const thread = yield* resolveThreadShell(event.payload.threadId); if (!thread) { return; @@ -1643,11 +1760,15 @@ const make = Effect.gen(function* () { const wasCompacting = compactingThreadIds.has(thread.id); stoppingThreadIds.add(thread.id); const clearStopping = Effect.sync(() => void stoppingThreadIds.delete(thread.id)); - yield* ( - thread.session && thread.session.status !== "stopped" - ? providerService.stopSession({ threadId: thread.id }) - : Effect.void + yield* cancelTurnsAfterCompaction( + thread.id, + "The session was stopped during context compaction. Send this message again to continue.", ).pipe( + Effect.andThen( + thread.session && thread.session.status !== "stopped" + ? providerService.stopSession({ threadId: thread.id }) + : Effect.void, + ), Effect.matchCauseEffect({ onFailure: (cause) => { if (Cause.hasInterruptsOnly(cause)) { @@ -1761,6 +1882,14 @@ const make = Effect.gen(function* () { const processDomainEventSafely = (event: ProviderIntentEvent) => processDomainEvent(event).pipe( + // A replay that returned before forking its send still holds its entry; settle it so + // the compaction queue moves on. Forked sends drop the entry first and settle it themselves. + Effect.ensuring( + Effect.suspend(() => { + const resumed = event.commandId !== null && resumedTurnStarts.get(event.commandId); + return resumed ? Deferred.succeed(resumed.sent, undefined) : Effect.void; + }), + ), Effect.catchCause((cause) => { if (Cause.hasInterruptsOnly(cause)) { return Effect.interrupt; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 5422a8730f27..c4136156df10 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -422,6 +422,29 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("lets a launch-arg permission flag win over the thread runtime mode", () => { + const harness = makeHarness({ + claudeConfig: { launchArgs: "--dangerously-skip-permissions --verbose" }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "auto-accept-edits", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.permissionMode, "bypassPermissions"); + assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); + // The honored flag is dropped from extraArgs so the CLI sees it once. + assert.deepEqual(createInput?.options.extraArgs, { verbose: null }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("loads Claude filesystem settings sources for SDK sessions", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 4af5a0633654..981e183414f1 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -4608,7 +4608,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) => runPromise(handleResumeDialog(request, callbackOptions)); const claudeBinaryPath = claudeSdkExecutablePath; - const extraArgs = parseCliArgs(claudeSettings.launchArgs).flags; + const { + "permission-mode": launchArgPermissionMode, + "dangerously-skip-permissions": launchArgSkipPermissions, + ...extraArgs + } = parseCliArgs(claudeSettings.launchArgs).flags; const selectedModel = input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection : undefined; const modelSelection = selectedModel @@ -4649,7 +4653,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( auto: "auto", "full-access": "bypassPermissions", }; - const permissionMode = runtimeModeToPermission[input.runtimeMode]; + // A permission launch arg is folded into the mode T3 sends rather than + // passed through: the CLI resolves both inputs together, so argv order + // never let the user's flag win. + const permissionMode = + (launchArgPermissionMode as PermissionMode | null | undefined) ?? + (launchArgSkipPermissions === null || launchArgSkipPermissions === "true" + ? "bypassPermissions" + : runtimeModeToPermission[input.runtimeMode]); const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), diff --git a/apps/web/package.json b/apps/web/package.json index c2ce73b42b11..16579e730f1b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -34,6 +34,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", @@ -59,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", @@ -68,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.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index d21960a28e34..adc3ddb77444 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -110,13 +110,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(() => {}); @@ -156,7 +179,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 192abaf385f7..0dd85e9147dc 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"; @@ -1009,9 +1013,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 ( @@ -1029,6 +1038,7 @@ function SuspenseShikiCodeBlock({ themeName={themeName} cacheKey={cacheKey} isStreaming={isStreaming} + preserveLines={isStreaming || hasStreamed} /> ); } @@ -1039,6 +1049,7 @@ interface UncachedShikiCodeBlockProps { themeName: DiffThemeName; cacheKey: string; isStreaming: boolean; + preserveLines: boolean; } function UncachedShikiCodeBlock({ @@ -1047,11 +1058,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( @@ -1059,22 +1079,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" ? ( +
+ ) : ( +
+ +
); } @@ -3104,12 +3131,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 06fcce22ee3f..973dd5749027 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -59,6 +59,14 @@ import { resolveSendEnvMode, threadShellHasStarted, resolveDraftHeroState, + isPaintOnlyThreadTimeline, + peekHeldThreadTimeline, + peekRememberedThreadTimeline, + rememberReadyThreadTimeline, + resetHeldThreadTimeline, + resolveThreadSwitchTimeline, + threadKeysShareEnvironment, + timelineHasEphemeralPreviewUrls, scheduleEnvironmentReconnectWarning, startNewThreadForProject, codexArtifactTemplatePromptToAppend, @@ -565,6 +573,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 66214df385e8..772a0f3cf2fa 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -18,6 +18,7 @@ import { type ThreadLinkedPullRequest, type TurnId, } from "@t3tools/contracts"; +import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure, @@ -262,6 +263,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; @@ -612,6 +742,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 283e85365667..51b9c5eabc48 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -415,6 +415,12 @@ import { resolveComposerInteractionMode, resolveComposerProviderSelection, resolveDraftHeroState, + isPaintOnlyThreadTimeline, + peekHeldThreadTimeline, + peekRememberedThreadTimeline, + rememberReadyThreadTimeline, + resolveThreadSwitchTimeline, + timelineHasEphemeralPreviewUrls, observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, @@ -1379,6 +1385,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 = (_targetTurnCount: number) => {}; +const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; /** * Drops the send-time anchored end space. That space is what holds a sent @@ -3239,6 +3249,18 @@ export default function ChatView(props: ChatViewProps) { timelineMessages, workLogEntries, ]); + 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; @@ -3337,6 +3359,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 @@ -4910,6 +4950,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; @@ -5881,15 +5936,13 @@ export default function ChatView(props: ChatViewProps) { pendingApprovals.length > 0 || pendingUserInputs.length > 0 || showPlanFollowUpPrompt; - const compactDisabled = compactThreadUnavailable || composerHasUnsentContent; + const compactDisabled = compactThreadUnavailable; const compactDisabledReason = compactDisabled - ? composerHasUnsentContent - ? "Send or clear your draft before compacting" - : !activeProject - ? "Choose a project before compacting" - : !manualCompactionProviderAvailable - ? "Compaction is unavailable for this provider" - : "Compacting is unavailable right now" + ? !activeProject + ? "Choose a project before compacting" + : !manualCompactionProviderAvailable + ? "Compaction is unavailable for this provider" + : "Compacting is unavailable right now" : null; const resumeCompactionBannerItem = useMemo(() => { if ( @@ -6479,6 +6532,78 @@ export default function ChatView(props: ChatViewProps) { ], ); + const onCompactContext = async () => { + if (compactDisabled || !activeThread || !clientSettingsHydrated || sendInFlightRef.current) { + return; + } + const context = composerRef.current?.getSendContext(); + if (!context?.providerAvailable) return; + + // Compaction is a standalone command; the draft and its attachments stay local. + const threadId = activeThread.id; + const messageId = newMessageId(); + const createdAt = new Date().toISOString(); + sendInFlightRef.current = true; + beginLocalDispatch(); + setThreadError(threadId, null); + setOptimisticUserMessages((messages) => [ + ...messages, + { + id: messageId, + role: "user", + text: "/compact", + turnId: null, + createdAt, + updatedAt: createdAt, + streaming: false, + }, + ]); + scrollToEnd(); + try { + const settingsResult = await persistThreadSettingsForNextTurn({ + threadId, + createdAt, + modelSelection: context.selectedModelSelection, + ...(localCheckoutBranchMismatch + ? { branch: localCheckoutBranchMismatch.currentBranch } + : {}), + runtimeMode, + interactionMode: context.interactionMode, + }); + const result = + settingsResult._tag === "Failure" + ? settingsResult + : await startThreadTurn({ + environmentId, + input: { + threadId, + message: { messageId, role: "user", text: "/compact", attachments: [] }, + modelSelection: context.selectedModelSelection, + runtimeMode, + interactionMode: context.interactionMode, + createdAt, + }, + }); + if (result._tag === "Failure") { + setOptimisticUserMessages((messages) => + messages.filter((message) => message.id !== messageId), + ); + resetLocalDispatch(); + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + setThreadError( + threadId, + error instanceof Error ? error.message : "Failed to compact context.", + ); + } + } else { + clearUsageLimitsFor(routeThreadKey); + } + } finally { + sendInFlightRef.current = false; + } + }; + const onSend = async ( e?: { preventDefault: () => void }, submissionIntent: ComposerSubmissionIntent = "foreground", @@ -8365,54 +8490,78 @@ 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 */} @@ -8575,6 +8724,7 @@ export default function ChatView(props: ChatViewProps) { onPageScrollKeyDown={onComposerPageScrollKeyDown} onPageScrollKeyUp={onComposerPageScrollKeyUp} onPageScrollRelease={onComposerPageScrollRelease} + onCompactContext={onCompactContext} onSend={onSend} onInterrupt={onInterrupt} onImplementPlanInNewThread={onImplementPlanInNewThread} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 3a3936623275..81fd6047f13e 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,10 +1,6 @@ import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { GitPullRequestIcon } from "lucide-react"; -import { LinkBranchPullRequestButton } from "./pullRequest/LinkBranchPullRequestButton"; -import { - resolveThreadCurrentPullRequestLink, - visibleThreadPullRequests, -} from "@t3tools/shared/threadPullRequests"; +import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests"; import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, @@ -764,12 +760,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) : null} - {pr && - (supportsMultiplePullRequests - ? visibleThreadPullRequests(thread.pullRequests).length === 0 - : thread.linkedPullRequest == null) ? ( - - ) : null} {threadStatus && } {renamingThreadKey === threadKey ? ( - ) : null} {sortable?.isDragging ? ( dragDestination ) : ( @@ -1925,13 +1913,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { )} {terminalStatusIcon} {prBadge} - {prBadge && - pr && - (supportsMultiplePullRequests - ? visibleThreadPullRequests(thread.pullRequests).length === 0 - : thread.linkedPullRequest == null) ? ( - - ) : null} {diff ? ( +{diff.insertions}{" "} diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index e948aa4091ee..2a69eaeddc9d 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -160,7 +160,7 @@ export function ThreadPullRequestBadgeControl({ ? `Stack of ${badge.layers} pull requests, ${badge.state}` : `${status?.tooltip ?? `PR #${number}, status pending`}${ badge?.kind === "pull-request" && badge.others > 0 - ? `, and ${badge.others} more linked` + ? `, and ${badge.others} more linked; overall ${badge.state}` : "" }`; const className = cn( @@ -170,11 +170,9 @@ export function ThreadPullRequestBadgeControl({ "text-xs tabular-nums", variant === "ghost" && "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]", - linkedCount !== null - ? "text-secondary-label" - : isStack - ? PR_STATE_COLOR_CLASS[badge.state] - : (status?.colorClass ?? "text-muted-foreground"), + badge !== null && (isStack || linkedCount !== null) + ? PR_STATE_COLOR_CLASS[badge.state] + : (status?.colorClass ?? "text-muted-foreground"), ); const content = ( <> @@ -276,10 +274,11 @@ export function ThreadPullRequestsMiniList({ } /** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */ -const PR_STATE_COLOR_CLASS: Record["state"], string> = { +const PR_STATE_COLOR_CLASS: Record = { open: "text-emerald-600 dark:text-emerald-300/90", merged: "text-violet-600 dark:text-violet-300/90", closed: "text-red-600 dark:text-red-300/90", + draft: "text-zinc-500 dark:text-zinc-400/80", }; export function settledPrHoverColorClass( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7291f8cead22..7ecb5e2dc635 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1354,6 +1354,7 @@ export interface ChatComposerProps { onPageScrollRelease: () => void; // Callbacks + onCompactContext: () => void; onSend: (e?: { preventDefault: () => void }, intent?: ComposerSubmissionIntent) => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; @@ -1460,6 +1461,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onPageScrollKeyDown, onPageScrollKeyUp, onPageScrollRelease, + onCompactContext, onSend, onInterrupt, onImplementPlanInNewThread, @@ -1995,7 +1997,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /** * Count of pasted images still being compressed, per thread. Reserved * against the attachment limit so concurrent pastes can't overshoot it, - * and checked before sending or compacting so an image cannot move into + * and checked before sending so an image cannot move into * the next draft. */ const pendingImageCompressionsRef = useRef>(new Map()); @@ -3097,42 +3099,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ) { return; } - // The compact buttons cannot see the compression counter (it lives in - // a ref), so they render enabled during a paste; toast instead of - // silently ignoring the click. - if ((pendingImageCompressionsRef.current.get(attachmentTargetKey) ?? 0) > 0) { - toastManager.add({ - type: "info", - title: "Still compressing a pasted image.", - description: "Compact again once its thumbnail appears.", - }); - return; - } - - promptRef.current = "/compact"; - setComposerDraftPrompt(composerDraftTarget, "/compact"); - submitComposer(); - // A blocked dispatch (busy send ref, provider preflight rejection) - // would leave the injected "/compact" behind as if the user typed it. - // Clearing here is safe even when the send did dispatch: the send - // snapshots its prompt synchronously and clears the draft itself. - if (promptRef.current === "/compact") { - promptRef.current = ""; - setComposerDraftPrompt(composerDraftTarget, ""); - } + onCompactContext(); }, [ activePendingApproval, activeThreadId, compactDisabled, - composerDraftTarget, isConnecting, isSendBusy, noProviderAvailable, + onCompactContext, pendingUserInputs.length, phase, - promptRef, - setComposerDraftPrompt, - submitComposer, ]); const expandMobileComposer = useCallback(() => { if (composerBlurFrameRef.current !== null) { 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 000000000000..66af6dcc5ed0 --- /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 000000000000..f0f971681c10 --- /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/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c47402f4204b..07252d6ab7fb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -348,12 +348,24 @@ describe("MessagesTimeline", () => { }); const toggle = renderer!.root.findByProps({ "aria-expanded": false }); await act(() => toggle.props.onClick()); + const questionToggle = renderer!.root.find( + (node) => + node.props["aria-label"]?.startsWith("Question answer submitted:") && + node.props["aria-expanded"] === false, + ); + expect(questionToggle.props["aria-label"]).toContain( + Object.values(answers)[0] ?? "spec.txt", + ); + expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec"); + await act(() => questionToggle.props.onClick()); const markup = JSON.stringify(renderer!.toJSON()); expect(markup.match(/Provide a spec/g)).toHaveLength(1); - expect(markup.match(/spec\.txt/g)).toHaveLength(1); + expect(markup).toContain("spec.txt"); expect(markup).toContain("Provide a screenshot"); expect(markup).toContain("shot.png"); for (const answer of Object.values(answers)) expect(markup).toContain(answer); + await act(() => questionToggle.props.onClick()); + expect(JSON.stringify(renderer!.toJSON())).not.toContain("Provide a spec"); } finally { await act(() => renderer?.unmount()); } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 3544e87817bd..e2c3b6520de1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,4 +1,14 @@ import { GitPullRequestIcon } from "lucide-react"; +import { + getQuestionAnswerPreview, + getQuestionAnswerText, + hasQuestionAnswer, +} from "@t3tools/client-runtime/work-log/user-input"; +import { + deriveTimelineMinimapItems, + resolveTimelineMinimapPreview, + type TimelineMinimapItem, +} from "./timelineMinimapItems"; import { type AssistantCitation, type EnvironmentId, @@ -321,6 +331,12 @@ interface MessagesTimelineProps { runningTurnId: TurnId | null; turnDiffSummaries: ReadonlyArray; 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; onRevertToTurnCount: (targetTurnCount: number) => void; @@ -379,6 +395,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ runningTurnId, turnDiffSummaries, routeThreadKey, + displayThreadKey, onOpenTurnDiff, supportsConversationRollback, onRevertToTurnCount, @@ -406,17 +423,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()); @@ -508,7 +538,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; @@ -547,34 +576,34 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timelineEntries, latestTurn, runningTurnId, - expandedTurnIds, - expandedWorkGroupIds, + expandedTurnIds: paintedExpandedTurnIds, + expandedWorkGroupIds: paintedExpandedWorkGroupIds, isWorking, activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, }, - 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, supportsConversationRollback, ]); - const rows = useStableRows(rawRows); + const rows = useStableRows(rawRows, listIdentityKey); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); const [timelineViewportElement, setTimelineViewportElement] = useState( null, @@ -812,7 +841,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 (
@@ -839,7 +870,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ref={listRef} data={rows} - extraData={rows.length} + extraData={`${listIdentityKey}:${rows.length}`} keyExtractor={keyExtractor} getItemType={getItemType} renderItem={renderItem} @@ -913,13 +944,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; @@ -928,51 +952,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; @@ -1006,7 +985,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 @@ -2115,7 +2100,7 @@ function LiveActivityRow({ active = false, shimmer = false, }: { - label: string; + label: ReactNode; iconName?: WorkEntryIconName; toolIcon?: ToolActivityIcon | undefined; failed?: boolean; @@ -2155,7 +2140,7 @@ function LiveActivityContent({ active = false, highlighted = false, }: { - label: string; + label: ReactNode; iconName: WorkEntryIconName | undefined; toolIcon?: ToolActivityIcon | undefined; failed?: boolean; @@ -2213,7 +2198,25 @@ function LiveWorkEntryTimelineRow({ row }: { row: Extract ctx.onToggleWorkGroup(row.groupId, row.id)} > + {label} + + {getQuestionAnswerPreview(row.entry.questionAnswer)} + + + ) : ( + label + ) + } iconName={workEntryIconName(row.entry)} toolIcon={row.entry.toolIcon ?? row.entry.toolSource?.icon} failed={failed} @@ -2760,17 +2763,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]); } // --------------------------------------------------------------------------- @@ -3148,6 +3157,7 @@ const toolCallExpandedBodyClassName = function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconName { if ( + workEntry.questionAnswer || workEntry.sourceActivityKind === "user-input.requested" || workEntry.sourceActivityKind === "user-input.resolved" ) { @@ -3325,9 +3335,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { showWarningIndicator || showDestructiveRowStyle ? undefined : (workEntry.toolIcon ?? workEntry.toolSource?.icon); - const previewText = workEntry.questionAnswer - ? "Question answer submitted" - : (displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot)); + const previewText = displayLabel ?? workEntryDisplayLabel(workEntry, workspaceRoot); + const answerPreview = workEntry.questionAnswer + ? getQuestionAnswerPreview(workEntry.questionAnswer) + : null; const viewedImagePath = workEntryViewedImagePath(workEntry); const viewedImage = viewedImagePath && threadRef @@ -3337,6 +3348,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { }) : null; const canExpand = + Boolean(workEntry.questionAnswer) || (showFailedIndicator && previewText.trim().length > 0) || (workEntry.itemType === "mcp_tool_call" && workEntry.toolData !== undefined) || Boolean( @@ -3374,9 +3386,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { : workLogEntryIsToolLike(workEntry) ? "text-secondary-label" : "text-foreground/80"; + const accessiblePreview = [previewText, answerPreview].filter(Boolean).join(": "); const accessibleDisplayText = showFailedIndicator - ? `${previewText}, tool call failed` - : previewText; + ? `${accessiblePreview}, tool call failed` + : accessiblePreview; const rowToggleProps = canExpand ? { role: "button" as const, @@ -3421,7 +3434,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: {

{previewText} + {answerPreview ? ( + + {answerPreview} + + ) : null}

{showFailedIndicator && @@ -3470,10 +3497,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { />
) : null} - {workEntry.questionAnswer ? ( + {expanded && workEntry.questionAnswer ? ( ) : null} - {expanded && canExpand && expandedBody ? ( + {expanded && canExpand && expandedBody && !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/components/chat/timelineMinimapItems.test.ts b/apps/web/src/components/chat/timelineMinimapItems.test.ts new file mode 100644 index 000000000000..f7c40aa71206 --- /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 000000000000..0a37c686e780 --- /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), + }; +} diff --git a/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx b/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx deleted file mode 100644 index bbcb9e144190..000000000000 --- a/apps/web/src/components/pullRequest/LinkBranchPullRequestButton.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { ScopedThreadRef } from "@t3tools/contracts"; -import { Link2 } from "lucide-react"; -import { useState } from "react"; -import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; -import { Button } from "../ui/button"; -import { toastManager } from "../ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; - -/** Adopts a branch discovery as a durable link, even after the thread changes branches. */ -export function LinkBranchPullRequestButton({ - threadRef, - url, -}: { - threadRef: ScopedThreadRef; - url: string; -}) { - const linking = usePullRequestLinking(threadRef.environmentId); - const [pending, setPending] = useState(false); - if (!linking.canLink(url)) return null; - return ( - - event.stopPropagation()} - onClick={async (event) => { - event.preventDefault(); - event.stopPropagation(); - setPending(true); - try { - await linking.changeLink(threadRef, url, true); - } catch (error) { - toastManager.add({ - type: "error", - title: "Could not link pull request", - description: error instanceof Error ? error.message : String(error), - }); - } finally { - setPending(false); - } - }} - > - - - } - /> - Link this PR to keep it with this thread - - ); -} diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 236d7cd86ad9..0284a2e6cdec 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1447,7 +1447,13 @@ export function PullRequestDetailPanel({ onPickerOpenChange={setThreadPickerOpen} /> ) : null} -
+
+
+
+ {(showChecks ? detail.checks : []).map((check, index) => { + const finding = { kind: "check", check } as const; + const failing = check.status === "failure" || check.status === "cancelled"; + return ( +
- - {check.name} - - {pullRequestCheckStatusLabel(check)} - - - {/* Only where there is something to fix. A passing check has no failure to - reproduce, and the button would be an invitation to waste a thread. */} - {onFixFinding && failing ? ( - - ) : null} -
- ); - })} + + {check.name} + + {pullRequestCheckStatusLabel(check)} + + + {/* Only where there is something to fix. A passing check has no failure to + reproduce, and the button would be an invitation to waste a thread. */} + {onFixFinding && failing ? ( + + ) : null} +
+ ); + })} +
)} - +
0 ? ( - - ) : null + } > {activityPending ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx index 26c80326aec0..f17ec882c5a0 100644 --- a/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsUnavailableState.tsx @@ -25,7 +25,7 @@ export function PullRequestsUnavailableState({ gitHubUrl?: string; }) { return ( - + diff --git a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx index c9ef8974ef13..35a449a3da79 100644 --- a/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx +++ b/apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx @@ -29,6 +29,7 @@ import { pullRequestListLines, type PullRequestListLine } from "./pullRequestLis import { PullRequestActorAvatar, PullRequestDiffStat, + PullRequestApprovalGlyph, PullRequestStateGlyph, pullRequestChecksStatePresentation, } from "./pullRequestPresentation"; @@ -111,27 +112,23 @@ function LinkRow({ {snapshot?.title ?? link.repository} - {/* Right-aligned signals, in the order a reviewer scans them: are checks green, - has someone ruled, how big is it. Each is absent rather than neutral when the + {/* Match the full PR list: review verdict, checks, then diff counts. + Each is absent rather than neutral when the host said nothing, so a row without them reads as unknown, not as fine. */} - {snapshot?.checksState ? : null} {snapshot?.state === "open" && (snapshot.reviewDecision === "approved" || snapshot.reviewDecision === "changes-requested") ? ( - - {snapshot.reviewDecision === "approved" ? "Approved" : "Changes requested"} - + snapshot.reviewDecision === "approved" ? ( + + ) : ( + Changes requested + ) ) : null} {snapshot?.state === "open" && snapshot.mergeability === "conflicting" ? ( Conflicts ) : null} + {snapshot?.checksState ? : null} {snapshot?.updatedAt ? ( - · {formatRelativeTimeLabel(snapshot.updatedAt)} + {formatRelativeTimeLabel(snapshot.updatedAt)} ) : null} diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 9cd3340832dd..890efb67cb9b 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -17,6 +17,7 @@ import { GitPullRequestDraftIcon, GitPullRequestIcon, TriangleAlertIcon, + UserCheckIcon, } from "lucide-react"; import { Children, isValidElement, type ReactNode } from "react"; @@ -32,6 +33,21 @@ interface StatePresentation { readonly Icon: typeof GitPullRequestIcon; } +export function PullRequestApprovalGlyph() { + return ( + + }> + + Approved + + Approved + + ); +} + /** * How a pull request's state reads on this page. Open, closed, merged, and draft use the same * ink as the thread badge in `ThreadStatusIndicators`, so one pull request cannot look like two @@ -173,7 +189,7 @@ const CHECKS_STATE_PRESENTATION = { passing: { label: "All checks have passed", Icon: CircleCheckIcon, - toneClassName: "text-emerald-600 dark:text-emerald-300/90", + toneClassName: CHECK_STATUS_PRESENTATION.success.toneClassName, }, failing: { label: "Some checks were not successful", diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index e41843e6d9cc..0743b91f6edf 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -87,6 +87,7 @@ const modelTotals = Object.freeze([ costUsd: 10, totalTokens: 100, records: 1, + unpricedRecords: 0, costShare: 10 / 16, }, { @@ -95,6 +96,7 @@ const modelTotals = Object.freeze([ costUsd: 5, totalTokens: 1_000, records: 1, + unpricedRecords: 0, costShare: 5 / 16, }, { @@ -103,8 +105,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, + }, ]); const environments = [ @@ -190,6 +202,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"; @@ -202,6 +225,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 deb05f266b98..21970c675596 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"; @@ -363,9 +364,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`}
@@ -511,10 +516,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/apps/web/src/lib/incrementalHighlighting.test.ts b/apps/web/src/lib/incrementalHighlighting.test.ts new file mode 100644 index 000000000000..36c27cea14a2 --- /dev/null +++ b/apps/web/src/lib/incrementalHighlighting.test.ts @@ -0,0 +1,95 @@ +import { toHtml } from "hast-util-to-html"; +import { getSharedHighlighter } from "@pierre/diffs"; +import { describe, expect, it } from "vite-plus/test"; + +import { createIncrementalHighlightedDocument } from "./incrementalHighlighting"; + +const samples = { + typescript: "/* multi\nline comment */\nconst x = `template\n${1 + 2}`;\nconst re = /abc/;\n", + python: '#!/usr/bin/python\nx = """multi\nline"""\nprint(x)\n', + bash: "#!/bin/bash\ncat <\nconst x = 1;\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 = createIncrementalHighlightedDocument(highlighter, language, theme); + for (let end = 0; end <= code.length; end++) { + const text = code.slice(0, end); + expect(toHtml(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 = createIncrementalHighlightedDocument( + 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(toHtml(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 = createIncrementalHighlightedDocument(highlighter, language, "pierre-dark"); + for (const text of ["plain\ntext", "\u001b[31mred\ncontinued", "\n"]) { + expect(toHtml(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 = 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(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 new file mode 100644 index 000000000000..fef3598a2152 --- /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 createIncrementalHighlightedDocument( + 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) => { + // 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.codeToHast(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.codeToHast(code, options); + } + cached = { + prefix: code.slice(0, end), + state, + children: [...(cached ? [...cached.children, newline] : []), ...codeChildren(root)], + }; + } + const prefix = cached; + if (!prefix) return highlighter.codeToHast(code, options); + return highlighter.codeToHast(code.slice(prefix.prefix.length), { + ...options, + grammarState: prefix.state, + transformers: [ + { + code: (node) => ({ ...node, children: [...prefix.children, newline, ...node.children] }), + }, + ], + }); + }; +} diff --git a/apps/web/src/markdown-incremental.test.tsx b/apps/web/src/markdown-incremental.test.tsx new file mode 100644 index 000000000000..cf06355f3b49 --- /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", + "