feat: refine reasoning work process display

This commit is contained in:
Sivan committed 2026-07-10 02:43:58 +08:00
1 parent b098332f88
commit 310457a903
21 files changed
+923 -192

No files matched your search

+2 -2
View File
@@ -10,9 +10,9 @@
"check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css", "check:css": "node scripts/check-css-syntax.mjs src/styles.css && node scripts/check-z-index-tokens.mjs src/styles.css",
"test:todo-visibility": "node scripts/test-todo-visibility.mjs", "test:todo-visibility": "node scripts/test-todo-visibility.mjs",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts", "test": "node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx && tsx src/__tests__/memory-compiler-display.test.ts && tsx src/__tests__/memory-citation-visibility.test.ts",
"test:typecheck": "tsc --noEmit -p tsconfig.test.json", "test:typecheck": "tsc --noEmit -p tsconfig.test.json",
"test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx" "test:all": "tsc --noEmit -p tsconfig.test.json && node scripts/test-todo-visibility.mjs && tsx src/__tests__/anchored-popover-scroll.test.tsx && tsx src/__tests__/scroll-manager.test.tsx && tsx src/__tests__/math-golden.test.ts && tsx src/__tests__/mermaid-rendering.test.tsx && tsx src/__tests__/text-size.test.ts && tsx src/__tests__/font-availability.test.ts && tsx src/__tests__/theme-auto-background.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/ask-card-layout.test.ts && tsx src/__tests__/approval-modal-file-reference.test.tsx && tsx src/__tests__/provider-model-refresh.test.ts && tsx src/__tests__/composer-profile.test.ts && tsx src/__tests__/composer-history.test.ts && tsx src/__tests__/session-title-contract.test.ts && tsx src/__tests__/composer-keyboard.test.ts && tsx src/__tests__/composer-goal-toggle.test.tsx && tsx src/__tests__/composer-run-strip.test.tsx && tsx src/__tests__/composer-session-draft.test.tsx && tsx src/__tests__/composer-image-capability.test.tsx && tsx src/__tests__/composer-context-menu-clipboard.test.tsx && tsx src/__tests__/use-controller-meta.test.ts && tsx src/__tests__/use-controller-cancel-reconcile.test.tsx && tsx src/__tests__/new-session-load-race.test.tsx && tsx src/__tests__/tab-switch-hydration.test.tsx && tsx src/__tests__/ready-event-meta-sync.test.tsx && tsx src/__tests__/ready-meta-reconcile.test.tsx && tsx src/__tests__/recovery-banner-privacy.test.ts && tsx src/__tests__/recovery-quiet-notifications.test.ts && tsx src/__tests__/workspace-layout.test.ts && tsx src/__tests__/workspace-changes-errors.test.tsx && tsx src/__tests__/workspace-split.test.ts && tsx src/__tests__/workspace-preview-css.test.ts && tsx src/__tests__/resize-drag.test.ts && tsx src/__tests__/wails-resize-fix.test.tsx && tsx src/__tests__/tool-approval-mode.test.ts && tsx src/__tests__/sound.test.ts && tsx src/__tests__/heartbeat-next-run.test.ts && tsx src/__tests__/keyboard-shortcuts.test.ts && tsx src/__tests__/send-failed.test.ts && tsx src/__tests__/edit-replay.test.ts && tsx src/__tests__/message-pasted-blocks.test.ts && tsx src/__tests__/history-tool-status.test.ts && tsx src/__tests__/history-recovery-copies.test.tsx && tsx src/__tests__/message-selection-copy.test.ts && tsx src/__tests__/turn-action-copy.test.ts && tsx src/__tests__/code-block-copy-position.test.tsx && tsx src/__tests__/message-reasoning-panel.test.tsx && tsx src/__tests__/transcript-process-fold.test.ts && tsx src/__tests__/reasoning-display.test.ts && tsx src/__tests__/attachment-display.test.ts && tsx src/__tests__/crash-reporting.test.ts && tsx src/__tests__/bridge-drag-rejection.test.ts && tsx src/__tests__/startup-settings-contract.test.ts && tsx src/__tests__/bundle-contract.test.ts && tsx src/__tests__/command-palette-css.test.ts && tsx src/__tests__/command-palette-interactions.test.tsx && tsx src/__tests__/context-panel-breakdown.test.ts && tsx src/__tests__/context-window-ring.test.tsx && tsx src/__tests__/statusbar-workspace.test.tsx && tsx src/__tests__/settings-refresh-snapshot.test.tsx && tsx src/__tests__/capabilities-panel-actions.test.ts && tsx src/__tests__/tool-data-archive.test.ts && tsx src/__tests__/tool-subject.test.ts && tsx src/__tests__/diff-rendering.test.ts && tsx src/__tests__/render-optimization.test.ts && tsx src/__tests__/transcript-grouping.test.ts && tsx src/__tests__/project-tree-runtime.test.ts && tsx src/__tests__/app-chrome-tabs.test.ts && tsx src/__tests__/open-topic-coalescing.test.tsx"
}, },
"dependencies": { "dependencies": {
"@gsap/react": "^2.1.2", "@gsap/react": "^2.1.2",
+1
View File
@@ -3548,6 +3548,7 @@ export default function App() {
actionPending={state.messageAction != null} actionPending={state.messageAction != null}
rewindDisabled={Boolean(activeTab?.readOnly) || !controllerReady || hydratePlaceholderActive || rewindState != null || rewindCommitting || state.running || state.messageAction != null || state.approval != null || state.ask != null || clearContextPending} rewindDisabled={Boolean(activeTab?.readOnly) || !controllerReady || hydratePlaceholderActive || rewindState != null || rewindCommitting || state.running || state.messageAction != null || state.approval != null || state.ask != null || clearContextPending}
running={state.running || rewindCommitting} running={state.running || rewindCommitting}
turnStartAt={state.turnStartAt}
welcomeVariant={sidebarCreation ? "creation" : "default"} welcomeVariant={sidebarCreation ? "creation" : "default"}
creationMode={sidebarCreation} creationMode={sidebarCreation}
actionHoverMenus={sidebarCreation && !hydratePlaceholderActive} actionHoverMenus={sidebarCreation && !hydratePlaceholderActive}
@@ -0,0 +1,80 @@
// Run: tsx src/__tests__/message-reasoning-panel.test.tsx
import { JSDOM } from "jsdom";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { LocaleProvider } from "../lib/i18n";
import { AssistantMessage } from "../components/Message";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
console.log("\nmessage reasoning panel");
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
Object.defineProperty(globalThis, "navigator", { configurable: true, value: { ...dom.window.navigator, language: "en-US" } });
globalThis.Node = dom.window.Node;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
globalThis.localStorage = dom.window.localStorage;
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
const rootEl = document.getElementById("root");
if (!rootEl) throw new Error("missing root");
const root = createRoot(rootEl);
await act(async () => {
root.render(
<LocaleProvider>
<AssistantMessage
item={{
kind: "assistant",
id: "a1",
text: "",
reasoning: "line one\nline two",
streaming: false,
reasoningComplete: true,
reasoningDurationMs: 2_600,
}}
/>
</LocaleProvider>,
);
});
const header = document.querySelector<HTMLButtonElement>(".reasoning__head");
ok(Boolean(header), "completed reasoning renders a toggle header");
ok(header?.textContent?.includes("thinking") ?? false, "header keeps the reasoning label");
ok(header?.textContent?.includes("lasted 3s") ?? false, "header shows rounded reasoning duration");
ok(!document.querySelector(".reasoning__body"), "completed reasoning is collapsed by default");
await act(async () => {
header?.dispatchEvent(new dom.window.MouseEvent("click", { bubbles: true }));
});
ok(document.querySelector(".reasoning__body")?.textContent?.includes("line two") ?? false, "clicking the header expands the reasoning body");
await act(async () => {
root.unmount();
});
dom.window.close();
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -0,0 +1,68 @@
// Run: tsx src/__tests__/transcript-process-fold.test.ts
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
if (value) {
process.stdout.write(` PASS ${label}\n`);
passed += 1;
} else {
process.stdout.write(` FAIL ${label}\n`);
failed += 1;
}
}
console.log("\ntranscript process fold");
const here = dirname(fileURLToPath(import.meta.url));
const source = readFileSync(resolve(here, "../components/Transcript.tsx"), "utf8");
ok(
source.includes("preferredKind=\"reasoning\""),
"completed compact process batches render as one reasoning fold",
);
ok(
source.includes("assistantAnswerOnly(assistant)") && source.includes("assistantReasoningOnly(assistant)"),
"final assistant reasoning is folded while the answer renders without a second top-level reasoning panel",
);
ok(
source.includes("processBatch.push(...nonAssistantItems)") && source.includes("processBatch.push(assistantReasoningOnly(assistant))"),
"tools and final reasoning are inserted into the same turn-level process batch",
);
ok(
source.includes("Active step") &&
source.includes("processBatch.push(...nonAssistantItems)") &&
source.includes("reasoningDisplay=\"hide\""),
"live compact process stays inside the reasoning fold while streaming answer text renders outside",
);
ok(
source.includes("InlineAssistantReasoning") &&
source.includes("turn-collapse__reasoning-head") &&
source.includes("workStatusLabel(effectiveDurationMs, hasRunningWork, t)") &&
!source.includes("reasoningDisplay=\"only\"") &&
source.includes("case \"notice\"") &&
source.includes("case \"compaction\""),
"the single work fold renders an inner reasoning phase plus notices and compaction",
);
ok(
source.includes("Standard mode keeps the answer body flat") &&
source.includes("standard-process-${processBatchStart}") &&
source.includes("assistantHasVisibleAnswer(assistant, live)") &&
source.includes("pushProcessItem(assistantReasoningOnly(assistant))"),
"standard mode also folds per-turn process items into the single reasoning entry",
);
ok(
source.includes("turnStartAt={turnStartAt}") &&
source.includes("useTick(hasRunningWork)") &&
source.includes("transcript.workingDuration") &&
source.includes("transcript.workedDuration"),
"the outer fold uses running/completed work labels with live elapsed time",
);
console.log(`\n${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
@@ -304,6 +304,46 @@ console.log("\nuse controller meta");
eq(shouldReconcileStaleTurn({ ...rendered, turnActive: false }, 1_000, 31_000), false, "local pending send before turn_started does not reconcile"); eq(shouldReconcileStaleTurn({ ...rendered, turnActive: false }, 1_000, 31_000), false, "local pending send before turn_started does not reconcile");
} }
{
const originalNow = Date.now;
let now = 1_000;
Date.now = () => now;
try {
let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
now = 1_200;
s = reducer(s, { type: "event", e: { kind: "reasoning", reasoning: "plan" } });
eq(s.live?.reasoningStartedAt, 1_200, "first reasoning delta records a reasoning start time");
now = 3_700;
s = reducer(s, { type: "event", e: { kind: "text", text: "answer" } });
eq(s.live?.reasoningComplete, true, "first answer token marks reasoning complete");
eq(s.live?.reasoningCompletedAt, 3_700, "first answer token records reasoning completion time");
now = 4_200;
s = reducer(s, { type: "event", e: { kind: "turn_done" } });
const assistant = s.items.find((item) => item.kind === "assistant");
eq(assistant?.kind === "assistant" && assistant.reasoningDurationMs, 2_500, "turn_done persists the live reasoning duration");
} finally {
Date.now = originalNow;
}
}
{
const originalNow = Date.now;
let now = 5_000;
Date.now = () => now;
try {
let s = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
now = 5_100;
s = reducer(s, { type: "event", e: { kind: "reasoning", reasoning: "diagnose" } });
now = 6_400;
s = reducer(s, { type: "event", e: { kind: "message", text: "done", reasoning: "diagnose" } });
const assistant = s.items.find((item) => item.kind === "assistant");
eq(assistant?.kind === "assistant" && assistant.reasoningDurationMs, 1_300, "final message records reasoning duration when no text delta arrived");
eq(s.live, undefined, "final message still closes live reasoning state");
} finally {
Date.now = originalNow;
}
}
{ {
const started = reducer(initialState, { type: "event", e: { kind: "turn_started" } }); const started = reducer(initialState, { type: "event", e: { kind: "turn_started" } });
const waiting = reducer(started, { type: "event", e: { kind: "approval_request", approval: { id: "1", tool: "bash", subject: "go test" } } }); const waiting = reducer(started, { type: "event", e: { kind: "approval_request", approval: { id: "1", tool: "bash", subject: "go test" } } });
+65 -32
View File
@@ -770,20 +770,24 @@ export function TurnActions({
); );
} }
export const AssistantMessage = memo(function AssistantMessage({ function reasoningDurationLabel(durationMs: number | undefined, t: ReturnType<typeof useT>): string {
if (typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0) {
return t("msg.thinkingDone");
}
const seconds = Math.max(1, Math.round(durationMs / 1000));
return t("msg.thinkingDuration", { s: seconds });
}
function ReasoningPanel({
item, item,
defaultExpanded = false, defaultExpanded,
expandWhileStreaming = true, expandWhileStreaming,
truncateStreamingReasoning = false, truncateStreamingReasoning,
creationMode = false,
}: { }: {
item: AssistantItem; item: AssistantItem;
defaultExpanded?: boolean; defaultExpanded: boolean;
/** false in compact mode: completed steps fold away, so auto-open + fold reads as flicker. */ expandWhileStreaming: boolean;
expandWhileStreaming?: boolean; truncateStreamingReasoning: boolean;
/** Opt-in for compact mode to keep live DeepSeek reasoning from growing an unbounded DOM. */
truncateStreamingReasoning?: boolean;
creationMode?: boolean;
}) { }) {
const t = useT(); const t = useT();
const reasoningBodyRef = useRef<HTMLDivElement>(null); const reasoningBodyRef = useRef<HTMLDivElement>(null);
@@ -811,7 +815,7 @@ export const AssistantMessage = memo(function AssistantMessage({
if (defaultExpanded) { if (defaultExpanded) {
setReasoningOpen(true); setReasoningOpen(true);
} else if (!userOverridden.current) { } else if (!userOverridden.current) {
setReasoningOpen(expandWhileStreaming); setReasoningOpen(expandWhileStreaming && !nowRC);
} }
} else if (nowRC && !wasRC) { } else if (nowRC && !wasRC) {
// Reasoning just finished — auto-close while we wait for text. // Reasoning just finished — auto-close while we wait for text.
@@ -830,35 +834,64 @@ export const AssistantMessage = memo(function AssistantMessage({
userOverridden.current = true; userOverridden.current = true;
setReasoningOpen((v) => !v); setReasoningOpen((v) => !v);
}; };
const hasText = item.streaming || item.text.trim() !== ""; const isReasoningRunning = item.streaming && !item.reasoningComplete;
const processOnly = Boolean(item.reasoning) && !hasText;
const processWithText = Boolean(item.reasoning) && hasText;
const visibleReasoning = reasoningOpen const visibleReasoning = reasoningOpen
? displayReasoningText(item.reasoning, { ? displayReasoningText(item.reasoning, {
streaming: item.streaming, streaming: item.streaming,
truncateStreaming: truncateStreamingReasoning, truncateStreaming: truncateStreamingReasoning,
}) })
: ""; : "";
const label = isReasoningRunning ? t("msg.thinkingRunning") : t("msg.thinking");
const meta = isReasoningRunning ? "" : reasoningDurationLabel(item.reasoningDurationMs, t);
return (
<div className="reasoning">
<button
type="button"
className="reasoning__head"
data-running={isReasoningRunning ? "" : undefined}
onClick={toggleReasoning}
aria-expanded={reasoningOpen}
>
<ProcessBrainIcon size={12} />
<span data-creation-label={t("creation.reasoningLabel")}>{label}</span>
{meta && <span className="reasoning__meta">{meta}</span>}
<ChevronRight className={`reasoning__chevron${reasoningOpen ? " reasoning__chevron--open" : ""}`} size={12} />
</button>
{reasoningOpen && (
<div ref={reasoningBodyRef} className="reasoning__body">{visibleReasoning}</div>
)}
</div>
);
}
export const AssistantMessage = memo(function AssistantMessage({
item,
defaultExpanded = false,
expandWhileStreaming = true,
truncateStreamingReasoning = false,
creationMode = false,
}: {
item: AssistantItem;
defaultExpanded?: boolean;
/** false in compact mode: completed steps fold away, so auto-open + fold reads as flicker. */
expandWhileStreaming?: boolean;
/** Opt-in for compact mode to keep live DeepSeek reasoning from growing an unbounded DOM. */
truncateStreamingReasoning?: boolean;
creationMode?: boolean;
}) {
const hasText = item.streaming || item.text.trim() !== "";
const processOnly = Boolean(item.reasoning) && !hasText;
const processWithText = Boolean(item.reasoning) && hasText;
return ( return (
<div className={`msg msg--assistant${processOnly ? " msg--process-only" : ""}${processWithText ? " msg--process-with-text" : ""}`} data-history-restore={item.id.startsWith("h") ? "" : undefined} data-entrance={item.id}> <div className={`msg msg--assistant${processOnly ? " msg--process-only" : ""}${processWithText ? " msg--process-with-text" : ""}`} data-history-restore={item.id.startsWith("h") ? "" : undefined} data-entrance={item.id}>
{item.reasoning && ( {item.reasoning && (
<div className="reasoning"> <ReasoningPanel
<button item={item}
type="button" defaultExpanded={defaultExpanded}
className="reasoning__head" expandWhileStreaming={expandWhileStreaming}
data-running={item.streaming && !item.reasoningComplete ? "" : undefined} truncateStreamingReasoning={truncateStreamingReasoning}
onClick={toggleReasoning} />
aria-expanded={reasoningOpen}
>
<ProcessBrainIcon size={12} />
<span data-creation-label={t("creation.reasoningLabel")}>{t("msg.thinking")}</span>
<span className="reasoning__meta">{item.streaming && !item.reasoningComplete ? t("msg.thinkingRunning") : t("msg.thinkingDone")}</span>
<ChevronRight className={`reasoning__chevron${reasoningOpen ? " reasoning__chevron--open" : ""}`} size={12} />
</button>
{reasoningOpen && (
<div ref={reasoningBodyRef} className="reasoning__body">{visibleReasoning}</div>
)}
</div>
)} )}
{hasText && ( {hasText && (
<div className="msg__body"> <div className="msg__body">
+284 -107
View File
@@ -3,7 +3,7 @@ import type { Item, LiveStream } from "../lib/useController";
import type { CheckpointMeta } from "../lib/types"; import type { CheckpointMeta } from "../lib/types";
import { useT } from "../lib/i18n"; import { useT } from "../lib/i18n";
import { AssistantMessage, TurnActions, UserMessage } from "./Message"; import { AssistantMessage, TurnActions, UserMessage } from "./Message";
import { ProcessCompactIcon, ProcessPhaseIcon } from "./ProcessCard"; import { ProcessBrainIcon, ProcessCompactIcon, ProcessPhaseIcon } from "./ProcessCard";
import { ToolCard } from "./ToolCard"; import { ToolCard } from "./ToolCard";
import { ArrowDown, ChevronRight } from "lucide-react"; import { ArrowDown, ChevronRight } from "lucide-react";
import { Welcome } from "./Welcome"; import { Welcome } from "./Welcome";
@@ -16,6 +16,7 @@ import { useEntranceAnimation } from "../lib/useEntranceAnimation";
import { useScrollManager } from "../lib/useScrollManager"; import { useScrollManager } from "../lib/useScrollManager";
import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping"; import { buildStepGroups, buildTurnGroups, compactQuestionText, createWarmLayerState, lastQuestionTurn, questionAnchorId, questionTurnsById, scrollVersion, warmColdPageForTurn, warmLayerWithColdPageAtLeast, warmLayerWithExpandedTurn, warmLayerWithNextColdPage, warmPagination, warmUserPreview, type QuestionAnchor, type TurnGroup, type WarmLayerState } from "../lib/transcriptGrouping";
import { appendTurnActionCopyText } from "../lib/turnActionCopy"; import { appendTurnActionCopyText } from "../lib/turnActionCopy";
import { displayReasoningText } from "../lib/reasoningDisplay";
type ToolItem = Extract<Item, { kind: "tool" }>; type ToolItem = Extract<Item, { kind: "tool" }>;
type AssistantItem = Extract<Item, { kind: "assistant" }>; type AssistantItem = Extract<Item, { kind: "assistant" }>;
@@ -23,6 +24,7 @@ type OpenTurnAction = { turn: number; menu: "summary" | "rewind" };
const QUESTION_NAV_MIN_COUNT = 2; const QUESTION_NAV_MIN_COUNT = 2;
const LiveStreamContext = createContext<LiveStream | undefined>(undefined); const LiveStreamContext = createContext<LiveStream | undefined>(undefined);
type AssistantReasoningDisplay = "normal" | "hide";
const LiveAssistantMessage = memo(function LiveAssistantMessage({ const LiveAssistantMessage = memo(function LiveAssistantMessage({
item, item,
@@ -30,20 +32,38 @@ const LiveAssistantMessage = memo(function LiveAssistantMessage({
expandWhileStreaming = true, expandWhileStreaming = true,
truncateStreamingReasoning = false, truncateStreamingReasoning = false,
creationMode = false, creationMode = false,
reasoningDisplay = "normal",
}: { }: {
item: AssistantItem; item: AssistantItem;
defaultExpanded?: boolean; defaultExpanded?: boolean;
expandWhileStreaming?: boolean; expandWhileStreaming?: boolean;
truncateStreamingReasoning?: boolean; truncateStreamingReasoning?: boolean;
creationMode?: boolean; creationMode?: boolean;
reasoningDisplay?: AssistantReasoningDisplay;
}) { }) {
const live = useContext(LiveStreamContext); const live = useContext(LiveStreamContext);
const shown = useMemo( const shown = useMemo(
() => () => {
live && live.id === item.id const merged =
? { ...item, text: live.text, reasoning: live.reasoning, streaming: true, reasoningComplete: live.reasoningComplete } live && live.id === item.id
: item, ? {
[item, live?.id, live?.text, live?.reasoning, live?.reasoningComplete], ...item,
text: live.text,
reasoning: live.reasoning,
streaming: true,
reasoningComplete: live.reasoningComplete,
reasoningDurationMs:
live.reasoningStartedAt && live.reasoningCompletedAt && live.reasoningCompletedAt >= live.reasoningStartedAt
? live.reasoningCompletedAt - live.reasoningStartedAt
: item.reasoningDurationMs,
}
: item;
if (reasoningDisplay === "hide") {
return { ...merged, reasoning: "", reasoningComplete: true, reasoningDurationMs: undefined };
}
return merged;
},
[item, live?.id, live?.text, live?.reasoning, live?.reasoningComplete, live?.reasoningStartedAt, live?.reasoningCompletedAt, reasoningDisplay],
); );
return ( return (
<AssistantMessage <AssistantMessage
@@ -56,6 +76,35 @@ const LiveAssistantMessage = memo(function LiveAssistantMessage({
); );
}); });
function InlineAssistantReasoning({ item }: { item: AssistantItem }) {
const t = useT();
const live = useContext(LiveStreamContext);
const shown = live && live.id === item.id
? {
reasoning: live.reasoning,
streaming: true,
reasoningComplete: live.reasoningComplete,
}
: item;
const reasoning = shown.reasoning.trim();
if (!reasoning) return null;
const visibleReasoning = displayReasoningText(shown.reasoning, {
streaming: shown.streaming,
truncateStreaming: true,
});
const running = shown.streaming && !shown.reasoningComplete;
return (
<div className="turn-collapse__reasoning-phase">
<div className="turn-collapse__reasoning-head" data-running={running ? "" : undefined}>
<ProcessBrainIcon size={12} />
<span>{running ? t("msg.thinkingRunning") : t("msg.thinking")}</span>
<ChevronRight className="reasoning__chevron reasoning__chevron--open" size={12} />
</div>
<div className="turn-collapse__inline-reasoning">{visibleReasoning}</div>
</div>
);
}
// ── Layer budgets ───────────────────────────────────────────────────────────── // ── Layer budgets ─────────────────────────────────────────────────────────────
// Hot zone: the most recent N user turns are always fully rendered. All data // Hot zone: the most recent N user turns are always fully rendered. All data
// stays in memory (items[]), so expanding a warm turn is instant — no API call. // stays in memory (items[]), so expanding a warm turn is instant — no API call.
@@ -75,6 +124,55 @@ const WARM_PAGE_SIZE = 20; // cold-zone pagination batch
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function processDurationMs(items: Item[]): number {
return items.reduce((ms, it) => {
if (it.kind === "tool") return ms + (it.durationMs ?? 0);
if (it.kind === "assistant") return ms + (it.reasoningDurationMs ?? 0);
return ms;
}, 0);
}
function useTick(on: boolean): number {
const [, setN] = useState(0);
useEffect(() => {
if (!on) return;
const id = window.setInterval(() => setN((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, [on]);
return Date.now();
}
function formatWorkDuration(durationMs: number, t: ReturnType<typeof useT>): string {
if (!Number.isFinite(durationMs) || durationMs <= 0) return "";
const totalSeconds = Math.max(1, Math.round(durationMs / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes <= 0) return t("transcript.durationSeconds", { s: totalSeconds });
if (seconds <= 0) return t("transcript.durationMinutes", { m: minutes });
return t("transcript.durationMinutesSeconds", { m: minutes, s: seconds });
}
function workStatusLabel(durationMs: number, running: boolean, t: ReturnType<typeof useT>): string {
const duration = formatWorkDuration(durationMs, t);
if (running) {
return duration ? t("transcript.workingDuration", { duration }) : t("transcript.working");
}
return duration ? t("transcript.workedDuration", { duration }) : t("transcript.worked");
}
function assistantReasoningOnly(item: AssistantItem): AssistantItem {
return { ...item, text: "" };
}
function assistantAnswerOnly(item: AssistantItem): AssistantItem {
return { ...item, reasoning: "", reasoningComplete: true, reasoningDurationMs: undefined };
}
function assistantHasVisibleAnswer(item: AssistantItem, live?: LiveStream): boolean {
if (item.text.trim() !== "") return true;
return Boolean(live && live.id === item.id && live.text.trim() !== "");
}
// ── Transcript component ────────────────────────────────────────────────────── // ── Transcript component ──────────────────────────────────────────────────────
export function Transcript({ export function Transcript({
@@ -100,6 +198,7 @@ export function Transcript({
olderHistoryCount = 0, olderHistoryCount = 0,
loadingOlderHistory = false, loadingOlderHistory = false,
onLoadOlderHistory, onLoadOlderHistory,
turnStartAt,
}: { }: {
items: Item[]; items: Item[];
live?: LiveStream; live?: LiveStream;
@@ -123,6 +222,7 @@ export function Transcript({
olderHistoryCount?: number; olderHistoryCount?: number;
loadingOlderHistory?: boolean; loadingOlderHistory?: boolean;
onLoadOlderHistory?: () => void; onLoadOlderHistory?: () => void;
turnStartAt?: number;
}) { }) {
const t = useT(); const t = useT();
const { const {
@@ -430,31 +530,33 @@ export function Transcript({
// Standard mode: flat rendering (no step groups) // Standard mode: flat rendering (no step groups)
if (stepGroups) { if (stepGroups) {
// Collect consecutive completed non-final steps into batches // Collect consecutive completed non-final steps into batches
let collapseBatch: Item[] = []; let processBatch: Item[] = [];
let collapseBatchStart: string | null = null; let processBatchStart: string | null = null;
const flushCollapseBatch = () => { const flushProcessBatch = () => {
if (collapseBatch.length === 0) return; if (processBatch.length === 0) return;
const dur = collapseBatch.reduce((ms, it) => ms + (it.kind === "tool" ? it.durationMs ?? 0 : 0), 0); const dur = processDurationMs(processBatch);
out.push( out.push(
<TurnCollapse <TurnCollapse
key={`step-batch-${collapseBatchStart}`} key={`turn-process-${processBatchStart}`}
items={collapseBatch} items={processBatch}
durationMs={dur} durationMs={dur}
mode={displayMode} mode={displayMode}
subcalls={subcallsByParent} subcalls={subcallsByParent}
tabId={tabId} tabId={tabId}
creationMode={creationMode} creationMode={creationMode}
turnStartAt={turnStartAt}
preferredKind="reasoning"
/>, />,
); );
collapseBatch = []; processBatch = [];
collapseBatchStart = null; processBatchStart = null;
}; };
for (const group of stepGroups) { for (const group of stepGroups) {
const first = group.items[0]; const first = group.items[0];
if (first.kind === "user") { if (first.kind === "user") {
flushCollapseBatch(); flushProcessBatch();
pushTurnActions(); pushTurnActions();
const tn = userTurn.get(first.id); const tn = userTurn.get(first.id);
const checkpoint = tn == null ? undefined : checkpointsByTurn.get(tn); const checkpoint = tn == null ? undefined : checkpointsByTurn.get(tn);
@@ -478,41 +580,59 @@ export function Transcript({
// Completed non-final step → batch it // Completed non-final step → batch it
if (group.isComplete && !group.isFinal) { if (group.isComplete && !group.isFinal) {
if (!collapseBatchStart) collapseBatchStart = first.id; if (!processBatchStart) processBatchStart = first.id;
collapseBatch.push(...group.items); processBatch.push(...group.items);
continue; continue;
} }
// Final answer or active step → flush any pending batch then render // Completed final answer → fold every process item for this turn into
flushCollapseBatch(); // one reasoning container, then render only the answer text outside it.
const nonAssistantItems = group.items.filter( const nonAssistantItems = group.items.filter(
(it) => it.kind !== "assistant" || (it.streaming && !it.text.trim()) (it) => it.kind !== "assistant" || (it.streaming && !it.text.trim())
); );
const hasRunning = nonAssistantItems.some((it) => it.kind === "tool" && it.status === "running"); const hasRunning = nonAssistantItems.some((it) => it.kind === "tool" && it.status === "running");
if (nonAssistantItems.length > 0 && !hasRunning) { const finalAssistants = group.items.filter((it): it is AssistantItem => it.kind === "assistant" && !it.streaming && it.text.trim() !== "");
const dur = nonAssistantItems.reduce((ms, it) => ms + (it.kind === "tool" ? ((it as ToolItem).durationMs ?? 0) : 0), 0); if (finalAssistants.length > 0 && !hasRunning) {
out.push( if (nonAssistantItems.length > 0) {
<TurnCollapse if (!processBatchStart) processBatchStart = first.id;
key={`step-${first.id}`} processBatch.push(...nonAssistantItems);
items={nonAssistantItems}
durationMs={dur}
mode={displayMode}
subcalls={subcallsByParent}
tabId={tabId}
creationMode={creationMode}
/>,
);
} else if (nonAssistantItems.length > 0) {
for (const it of nonAssistantItems) {
if (it.kind === "tool") {
if (it.parentId) continue;
if (it.name === "todo_write" || it.name === "exit_plan_mode") continue;
out.push(<ToolCard key={it.id} item={it as ToolItem} subcalls={subcallsByParent.get(it.id)} tabId={tabId} />);
}
if (it.kind === "phase") out.push(<PhaseCard key={it.id} text={it.text} />);
} }
for (const assistant of finalAssistants) {
if (!assistant.reasoning) continue;
if (!processBatchStart) processBatchStart = assistant.id;
processBatch.push(assistantReasoningOnly(assistant));
}
flushProcessBatch();
for (const assistant of finalAssistants) {
out.push(
<LiveAssistantMessage
key={assistant.id}
item={assistantAnswerOnly(assistant)}
defaultExpanded={false}
expandWhileStreaming={false}
truncateStreamingReasoning={true}
creationMode={creationMode}
/>,
);
actionText = appendTurnActionCopyText(actionText, assistant.text);
actionReady = true;
}
continue;
} }
// Render the final assistant message (if any) directly
// Active step → keep the live process in the same turn-level reasoning
// fold. The final answer, if it has started streaming, still renders
// outside the fold with its reasoning hidden.
if (nonAssistantItems.length > 0) {
if (!processBatchStart) processBatchStart = first.id;
processBatch.push(...nonAssistantItems);
}
for (const it of group.items) {
if (it.kind !== "assistant") continue;
if (!processBatchStart) processBatchStart = it.id;
processBatch.push(assistantReasoningOnly(it as AssistantItem));
}
flushProcessBatch();
for (const it of group.items) { for (const it of group.items) {
if (it.kind !== "assistant") continue; if (it.kind !== "assistant") continue;
out.push( out.push(
@@ -523,6 +643,7 @@ export function Transcript({
expandWhileStreaming={false} expandWhileStreaming={false}
truncateStreamingReasoning={true} truncateStreamingReasoning={true}
creationMode={creationMode} creationMode={creationMode}
reasoningDisplay="hide"
/>, />,
); );
if (!it.streaming && it.text.trim() !== "") { if (!it.streaming && it.text.trim() !== "") {
@@ -531,51 +652,40 @@ export function Transcript({
} }
} }
} }
flushCollapseBatch(); flushProcessBatch();
if (!running) pushTurnActions(); if (!running) pushTurnActions();
} else { } else {
// Standard mode: flat rendering // Standard mode keeps the answer body flat, but process material still
const roBatch: ToolItem[] = []; // belongs to one turn-level reasoning fold.
const toolBatch: ToolItem[] = []; let processBatch: Item[] = [];
let toolBatchKind: ToolGroupKind | null = null; let processBatchStart: string | null = null;
const flushRO = () => { const pushProcessItem = (it: Item) => {
if (roBatch.length === 0) return; if (!processBatchStart) processBatchStart = it.id;
out.push(<ReadOnlyBatch key={`rob-${roBatch[0].id}`} items={[...roBatch]} subcalls={subcallsByParent} tabId={tabId} />); processBatch.push(it);
roBatch.length = 0;
}; };
const flushToolBatch = () => { const flushProcessBatch = () => {
if (!toolBatchKind || toolBatch.length === 0) return; if (processBatch.length === 0) return;
out.push(<ToolGroup key={`tg-${toolBatch[0].id}`} kind={toolBatchKind} items={[...toolBatch]} subcalls={subcallsByParent} tabId={tabId} />); out.push(
toolBatch.length = 0; <TurnCollapse
toolBatchKind = null; key={`standard-process-${processBatchStart}`}
items={processBatch}
durationMs={processDurationMs(processBatch)}
mode={displayMode}
subcalls={subcallsByParent}
tabId={tabId}
creationMode={creationMode}
turnStartAt={turnStartAt}
preferredKind="reasoning"
/>,
);
processBatch = [];
processBatchStart = null;
}; };
for (let i = hotStartIdx; i < items.length; i++) { for (let i = hotStartIdx; i < items.length; i++) {
const it = items[i]; const it = items[i];
if (creationMode && it.kind === "tool" && isCreationGroupableTool(it as ToolItem)) {
const kind = toolGroupKind(it as ToolItem);
if (kind) {
if (toolBatchKind && toolBatchKind !== kind) flushToolBatch();
toolBatchKind = kind;
toolBatch.push(it as ToolItem);
continue;
}
}
if (
!creationMode &&
it.kind === "tool" &&
!it.parentId &&
it.status !== "running" &&
it.name !== "todo_write" &&
it.name !== "exit_plan_mode" &&
isReadOnlyTool(it.name)
) {
roBatch.push(it as ToolItem);
continue;
}
flushToolBatch();
flushRO();
switch (it.kind) { switch (it.kind) {
case "user": { case "user": {
flushProcessBatch();
pushTurnActions(); pushTurnActions();
const tn = userTurn.get(it.id); const tn = userTurn.get(it.id);
const checkpoint = tn == null ? undefined : checkpointsByTurn.get(tn); const checkpoint = tn == null ? undefined : checkpointsByTurn.get(tn);
@@ -596,30 +706,47 @@ export function Transcript({
); );
break; break;
} }
case "assistant": case "assistant": {
out.push(<LiveAssistantMessage key={it.id} item={it as AssistantItem} defaultExpanded={false} creationMode={creationMode} />); const assistant = it as AssistantItem;
if (!it.streaming && it.text.trim() !== "") { if (assistant.reasoning || (live?.id === assistant.id && live.reasoning)) {
actionText = appendTurnActionCopyText(actionText, it.text); pushProcessItem(assistantReasoningOnly(assistant));
}
if (assistantHasVisibleAnswer(assistant, live)) {
flushProcessBatch();
out.push(
<LiveAssistantMessage
key={assistant.id}
item={assistantAnswerOnly(assistant)}
defaultExpanded={false}
creationMode={creationMode}
/>,
);
}
if (!assistant.streaming && assistant.text.trim() !== "") {
actionText = appendTurnActionCopyText(actionText, assistant.text);
actionReady = true; actionReady = true;
} }
break; break;
}
case "tool": case "tool":
if (it.parentId) break; pushProcessItem(it);
if (it.name === "todo_write") break; break;
if (it.name === "exit_plan_mode") break; case "phase":
out.push(<ToolCard key={it.id} item={it} subcalls={subcallsByParent.get(it.id)} tabId={tabId} />); pushProcessItem(it);
break;
case "notice":
pushProcessItem(it);
break;
case "compaction":
pushProcessItem(it);
break; break;
case "phase": out.push(<PhaseCard key={it.id} text={it.text} />); break;
case "notice": out.push(<NoticeCard key={it.id} level={it.level} text={it.text} detail={it.detail} />); break;
case "compaction": out.push(<CompactionCard key={it.id} item={it} />); break;
} }
} }
flushToolBatch(); flushProcessBatch();
flushRO();
if (!running) pushTurnActions(); if (!running) pushTurnActions();
} }
return out; return out;
}, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode, lastTurn]); }, [hotStartIdx, items, openAction, actionPending, rewindDisabled, running, onEditPrompt, onRewind, subcallsByParent, userTurn, checkpointsByTurn, displayMode, stepGroups, tabId, actionHoverMenus, creationMode, lastTurn, turnStartAt, live?.id, live?.text, live?.reasoning]);
// ── Assemble rendered output ────────────────────────────────────────────── // ── Assemble rendered output ──────────────────────────────────────────────
// Warm/cold zone is a separate memo'd WarmZone component so streaming tokens // Warm/cold zone is a separate memo'd WarmZone component so streaming tokens
@@ -1053,13 +1180,17 @@ type TurnCollapseProps = {
subcalls: Map<string, ToolItem[]>; subcalls: Map<string, ToolItem[]>;
tabId?: string; tabId?: string;
creationMode?: boolean; creationMode?: boolean;
turnStartAt?: number;
preferredKind?: "tool" | "reasoning" | "process";
}; };
function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode = false }: TurnCollapseProps) { function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode = false, turnStartAt, preferredKind }: TurnCollapseProps) {
const t = useT(); const t = useT();
const live = useContext(LiveStreamContext);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const userOverriddenOpen = useRef(false);
const prevRunningRef = useRef(false);
const bodyRef = useRef<HTMLDivElement>(null); const bodyRef = useRef<HTMLDivElement>(null);
useGSAPCollapse(bodyRef, open);
// Keep only items the body will actually render — an expandable fold over // Keep only items the body will actually render — an expandable fold over
// nothing is worse than no fold. // nothing is worse than no fold.
@@ -1067,29 +1198,65 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode =
return items.filter((it) => { return items.filter((it) => {
if (it.kind === "assistant") { if (it.kind === "assistant") {
if (it.text.trim() !== "") return true; if (it.text.trim() !== "") return true;
return Boolean(it.reasoning); return Boolean(it.reasoning || (live?.id === it.id && live.reasoning));
} }
if (it.kind === "phase") return true; if (it.kind === "phase") return true;
if (it.kind === "notice") return true;
if (it.kind === "compaction") return true;
if (it.kind !== "tool") return false; if (it.kind !== "tool") return false;
if (it.parentId || it.name === "todo_write" || it.name === "exit_plan_mode") return false; if (it.parentId || it.name === "todo_write" || it.name === "exit_plan_mode") return false;
return true; return true;
}); });
}, [items, mode]); }, [items, mode, live?.id, live?.reasoning]);
const seconds = Math.round(durationMs / 1000); const seconds = Math.round(durationMs / 1000);
const label = seconds > 0 ? t("transcript.processedDuration", { s: seconds }) : t("transcript.processed");
const hasRunningProcess = displayItems.some((it) => {
if (it.kind === "tool") return it.status === "running";
if (it.kind !== "assistant") return false;
if (live?.id === it.id) return !live.reasoningComplete;
return it.streaming && !it.reasoningComplete;
});
const hasLiveAssistant = displayItems.some((it) => it.kind === "assistant" && live?.id === it.id);
const hasRunningWork = hasRunningProcess || hasLiveAssistant;
const now = useTick(hasRunningWork);
const runningDurationMs = hasRunningWork
? turnStartAt
? Math.max(0, now - turnStartAt)
: live?.reasoningStartedAt
? Math.max(0, now - live.reasoningStartedAt)
: 0
: 0;
const effectiveDurationMs = hasRunningWork ? Math.max(durationMs, runningDurationMs) : durationMs;
useGSAPCollapse(bodyRef, open);
useEffect(() => {
const wasRunning = prevRunningRef.current;
prevRunningRef.current = hasRunningWork;
if (hasRunningWork) {
if (!wasRunning) userOverriddenOpen.current = false;
if (!userOverriddenOpen.current) setOpen(true);
} else if (wasRunning && !userOverriddenOpen.current) {
setOpen(false);
}
}, [hasRunningWork]);
if (displayItems.length === 0) return null; if (displayItems.length === 0) return null;
const collapseKind = displayItems.some((it) => it.kind === "tool") const collapseKind = preferredKind ?? (displayItems.some((it) => it.kind === "tool")
? "tool" ? "tool"
: displayItems.some((it) => it.kind === "assistant" && Boolean(it.reasoning)) : displayItems.some((it) => it.kind === "assistant" && Boolean(it.reasoning))
? "reasoning" ? "reasoning"
: "process"; : "process");
const label = collapseKind === "reasoning"
? workStatusLabel(effectiveDurationMs, hasRunningWork, t)
: seconds > 0
? t("transcript.processedDuration", { s: seconds })
: t("transcript.processed");
const creationLabel = collapseKind === "tool" const creationLabel = collapseKind === "tool"
? t("creation.toolCallsLabel") ? t("creation.toolCallsLabel")
: collapseKind === "reasoning" : collapseKind === "reasoning"
? t("creation.reasoningLabel") ? label
: label; : label;
// Pre-compute body: group consecutive completed read-only tools into ReadOnlyBatch // Pre-compute body: group consecutive completed read-only tools into ReadOnlyBatch
@@ -1118,12 +1285,18 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode =
continue; continue;
} }
} }
if (it.kind !== "tool") {
flushToolBatch();
flushRO();
}
if (!creationMode && it.kind === "tool" && !it.parentId && it.name !== "todo_write" && it.name !== "exit_plan_mode" && it.status !== "running" && isReadOnlyTool(it.name)) { if (!creationMode && it.kind === "tool" && !it.parentId && it.name !== "todo_write" && it.name !== "exit_plan_mode" && it.status !== "running" && isReadOnlyTool(it.name)) {
roBatch.push(it as ToolItem); roBatch.push(it as ToolItem);
continue; continue;
} }
flushToolBatch(); if (it.kind === "tool") {
flushRO(); flushToolBatch();
flushRO();
}
switch (it.kind) { switch (it.kind) {
case "tool": case "tool":
if (it.parentId) break; if (it.parentId) break;
@@ -1132,9 +1305,10 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode =
body.push(<ToolCard key={it.id} item={it as ToolItem} subcalls={subcalls.get(it.id)} tabId={tabId} />); body.push(<ToolCard key={it.id} item={it as ToolItem} subcalls={subcalls.get(it.id)} tabId={tabId} />);
break; break;
case "phase": body.push(<PhaseCard key={it.id} text={it.text} />); break; case "phase": body.push(<PhaseCard key={it.id} text={it.text} />); break;
case "notice": body.push(<NoticeCard key={it.id} level={it.level} text={it.text} detail={it.detail} />); break;
case "compaction": body.push(<CompactionCard key={it.id} item={it} />); break;
case "assistant": { case "assistant": {
const displayItem = it; body.push(<InlineAssistantReasoning key={it.id} item={it as AssistantItem} />);
body.push(<AssistantMessage key={it.id} item={displayItem as AssistantItem} creationMode={creationMode} />);
break; break;
} }
} }
@@ -1147,11 +1321,14 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode =
<button <button
type="button" type="button"
className="reasoning__head" className="reasoning__head"
onClick={() => setOpen((v) => !v)} onClick={() => {
userOverriddenOpen.current = true;
setOpen((v) => !v);
}}
aria-expanded={open} aria-expanded={open}
> >
<ChevronRight className={`reasoning__chevron${open ? " reasoning__chevron--open" : ""}`} size={12} />
<span className="turn-collapse__label" data-creation-label={creationLabel}>{label}</span> <span className="turn-collapse__label" data-creation-label={creationLabel}>{label}</span>
{!hasRunningWork && <ChevronRight className={`reasoning__chevron${open ? " reasoning__chevron--open" : ""}`} size={12} />}
</button> </button>
<div ref={bodyRef} className="turn-collapse__body">{body}</div> <div ref={bodyRef} className="turn-collapse__body">{body}</div>
</div> </div>
+12
View File
@@ -1996,6 +1996,18 @@ function makeMockApp(): AppBindings {
// dev. Bail if cancelled during the wait — nothing was streamed yet. // dev. Bail if cancelled during the wait — nothing was streamed yet.
await delay(700); await delay(700);
if (cancelled) return; if (cancelled) return;
const reasoningChunks = [
"我先判断这是浏览器预览环境,所以不会调用真实 kernel。\n",
"接着模拟 provider 的 reasoning delta:先展示思考过程,再切到正式回复。\n",
"完成后前端应该把过程区折叠成“已工作 N 秒”。\n",
];
for (const chunk of reasoningChunks) {
if (cancelled) return;
emit({ kind: "reasoning", reasoning: chunk });
await delay(520);
}
if (cancelled) return;
await delay(260);
const reply = const reply =
`You said: **${input}**\n\n` + `You said: **${input}**\n\n` +
"This is the browser dev mock — the real reply comes from the kernel " + "This is the browser dev mock — the real reply comes from the kernel " +
+51 -5
View File
@@ -39,7 +39,14 @@ import type {
export type ToolStatus = "running" | "done" | "error" | "stopped"; export type ToolStatus = "running" | "done" | "error" | "stopped";
export type LiveStream = { id: string; text: string; reasoning: string; reasoningComplete: boolean }; export type LiveStream = {
id: string;
text: string;
reasoning: string;
reasoningComplete: boolean;
reasoningStartedAt?: number;
reasoningCompletedAt?: number;
};
export type MessageActionScope = "fork" | "summ-from" | "summ-upto" | "conversation" | "code" | "both"; export type MessageActionScope = "fork" | "summ-from" | "summ-upto" | "conversation" | "code" | "both";
export type MessageActionState = { turn: number; scope: MessageActionScope }; export type MessageActionState = { turn: number; scope: MessageActionScope };
export type HydrateReason = "switch-tab" | "new-session" | "resume-session" | "open-topic" | "startup"; export type HydrateReason = "switch-tab" | "new-session" | "resume-session" | "open-topic" | "startup";
@@ -51,7 +58,7 @@ const HISTORY_PAGE_TURNS = 60;
export type Item = export type Item =
| { kind: "user"; id: string; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number } | { kind: "user"; id: string; text: string; submitText?: string; failed?: boolean; createdAt?: number; checkpointTurn?: number }
| { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; reasoningComplete?: boolean; memoryCitations?: MemoryCitation[] } | { kind: "assistant"; id: string; text: string; reasoning: string; streaming: boolean; reasoningComplete?: boolean; reasoningDurationMs?: number; memoryCitations?: MemoryCitation[] }
| { kind: "phase"; id: string; text: string } | { kind: "phase"; id: string; text: string }
| { kind: "notice"; id: string; level: "info" | "warn"; text: string; detail?: string } | { kind: "notice"; id: string; level: "info" | "warn"; text: string; detail?: string }
| { | {
@@ -562,6 +569,24 @@ function ensureAssistant(s: State): { items: Item[]; id: string; seq: number } {
return { items: [...s.items, item], id, seq: s.seq + 1 }; return { items: [...s.items, item], id, seq: s.seq + 1 };
} }
function liveReasoningDurationMs(live?: LiveStream): number | undefined {
if (!live?.reasoningStartedAt || !live.reasoning) return undefined;
const completedAt = live.reasoningCompletedAt;
if (!completedAt || completedAt < live.reasoningStartedAt) return undefined;
return completedAt - live.reasoningStartedAt;
}
function completeLiveReasoning(live: LiveStream, now = Date.now()): LiveStream {
if (!live.reasoning || live.reasoningCompletedAt) {
return { ...live, reasoningComplete: live.reasoning !== "" || live.reasoningComplete };
}
return {
...live,
reasoningComplete: true,
reasoningCompletedAt: now,
};
}
function flushPendingUser(s: State): State { function flushPendingUser(s: State): State {
if (s.pendingUser === undefined) return s; if (s.pendingUser === undefined) return s;
const lastItem = s.items[s.items.length - 1]; const lastItem = s.items[s.items.length - 1];
@@ -608,10 +633,17 @@ function applyEvent(s: State, e: WireEvent): State {
const { items, id, seq } = ensureAssistant(s); const { items, id, seq } = ensureAssistant(s);
const delta = e.text ?? e.reasoning ?? ""; const delta = e.text ?? e.reasoning ?? "";
const base = s.live?.id === id ? s.live : { id, text: "", reasoning: "", reasoningComplete: false }; const base = s.live?.id === id ? s.live : { id, text: "", reasoning: "", reasoningComplete: false };
const now = Date.now();
const live = const live =
e.kind === "text" e.kind === "text"
? { ...base, text: base.text + delta, reasoningComplete: base.reasoning !== "" || base.reasoningComplete } ? { ...completeLiveReasoning(base, now), text: base.text + delta }
: { ...base, reasoning: base.reasoning + delta, reasoningComplete: false }; : {
...base,
reasoning: base.reasoning + delta,
reasoningComplete: false,
reasoningStartedAt: base.reasoningStartedAt ?? (delta ? now : undefined),
reasoningCompletedAt: undefined,
};
return { ...s, items, live, currentAssistant: id, seq }; return { ...s, items, live, currentAssistant: id, seq };
} }
case "message": { case "message": {
@@ -629,6 +661,8 @@ function applyEvent(s: State, e: WireEvent): State {
return { ...s, items, live: undefined, currentAssistant: undefined }; return { ...s, items, live: undefined, currentAssistant: undefined };
} }
const { items, id, seq } = ensureAssistant(s); const { items, id, seq } = ensureAssistant(s);
const completedLive = s.live?.id === id ? completeLiveReasoning({ ...s.live, text, reasoning }) : undefined;
const reasoningDurationMs = liveReasoningDurationMs(completedLive);
const next = items.map((it) => const next = items.map((it) =>
it.kind === "assistant" && it.id === id it.kind === "assistant" && it.id === id
? (() => { ? (() => {
@@ -638,6 +672,8 @@ function applyEvent(s: State, e: WireEvent): State {
text, text,
reasoning, reasoning,
streaming: false, streaming: false,
reasoningComplete: reasoning !== "" || it.reasoningComplete,
reasoningDurationMs: reasoningDurationMs ?? it.reasoningDurationMs,
memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined, memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined,
}; };
})() })()
@@ -763,7 +799,17 @@ function applyEvent(s: State, e: WireEvent): State {
case "turn_done": { case "turn_done": {
if (s.pendingUser !== undefined) s = flushPendingUser(s); if (s.pendingUser !== undefined) s = flushPendingUser(s);
const finalized = s.items.map((it) => { const finalized = s.items.map((it) => {
if (it.kind === "assistant" && s.live && it.id === s.live.id) return { ...it, text: s.live.text, reasoning: s.live.reasoning, streaming: false }; if (it.kind === "assistant" && s.live && it.id === s.live.id) {
const completedLive = completeLiveReasoning(s.live);
return {
...it,
text: completedLive.text,
reasoning: completedLive.reasoning,
streaming: false,
reasoningComplete: completedLive.reasoning !== "" || completedLive.reasoningComplete,
reasoningDurationMs: liveReasoningDurationMs(completedLive) ?? it.reasoningDurationMs,
};
}
if (it.kind === "assistant" && it.streaming) return { ...it, streaming: false }; if (it.kind === "assistant" && it.streaming) return { ...it, streaming: false };
if (it.kind === "tool" && it.status === "running") return { ...it, status: "stopped" as const }; if (it.kind === "tool" && it.status === "running") return { ...it, status: "stopped" as const };
return it; return it;
+8
View File
@@ -1868,6 +1868,7 @@ export const en = {
"msg.thinking": "thinking", "msg.thinking": "thinking",
"msg.thinkingRunning": "thinking…", "msg.thinkingRunning": "thinking…",
"msg.thinkingDone": "done", "msg.thinkingDone": "done",
"msg.thinkingDuration": "lasted {s}s",
"msg.memoryCitationsCount": "{n} memory references", "msg.memoryCitationsCount": "{n} memory references",
"msg.memoryCompilerCitationsCount": "{n} Memory v5 compiler references", "msg.memoryCompilerCitationsCount": "{n} Memory v5 compiler references",
"msg.memoryCompilerApplied": "Memory v5 compiler applied to this message", "msg.memoryCompilerApplied": "Memory v5 compiler applied to this message",
@@ -1890,6 +1891,13 @@ export const en = {
"transcript.jumpToBottom": "Jump to bottom", "transcript.jumpToBottom": "Jump to bottom",
"transcript.showEarlierHistory": "Show {n} earlier turns", "transcript.showEarlierHistory": "Show {n} earlier turns",
"transcript.toolCount": "{n} tools", "transcript.toolCount": "{n} tools",
"transcript.working": "Working",
"transcript.workingDuration": "Working {duration}",
"transcript.worked": "Worked",
"transcript.workedDuration": "Worked {duration}",
"transcript.durationSeconds": "{s}s",
"transcript.durationMinutes": "{m}m",
"transcript.durationMinutesSeconds": "{m}m {s}s",
"transcript.processed": "Processed", "transcript.processed": "Processed",
"transcript.processedDuration": "Processed {s}s", "transcript.processedDuration": "Processed {s}s",
"notice.info": "Notice", "notice.info": "Notice",
+8
View File
@@ -1207,6 +1207,7 @@ export const zhTW: Record<DictKey, string> = {
"msg.thinking": "思考過程", "msg.thinking": "思考過程",
"msg.thinkingRunning": "思考中…", "msg.thinkingRunning": "思考中…",
"msg.thinkingDone": "已完成", "msg.thinkingDone": "已完成",
"msg.thinkingDuration": "持續了 {s} 秒",
"msg.memoryCitationsCount": "{n} 條記憶引用", "msg.memoryCitationsCount": "{n} 條記憶引用",
"msg.memoryCompilerCitationsCount": "{n} 條 Memory v5 編譯引用", "msg.memoryCompilerCitationsCount": "{n} 條 Memory v5 編譯引用",
"msg.memoryCompilerApplied": "此訊息已套用 Memory v5 編譯器", "msg.memoryCompilerApplied": "此訊息已套用 Memory v5 編譯器",
@@ -1226,6 +1227,13 @@ export const zhTW: Record<DictKey, string> = {
"transcript.jumpToBottom": "跳到底部", "transcript.jumpToBottom": "跳到底部",
"transcript.showEarlierHistory": "展開前 {n} 輪對話", "transcript.showEarlierHistory": "展開前 {n} 輪對話",
"transcript.toolCount": "{n} 個工具", "transcript.toolCount": "{n} 個工具",
"transcript.working": "工作中",
"transcript.workingDuration": "工作中 {duration}",
"transcript.worked": "已工作",
"transcript.workedDuration": "已工作 {duration}",
"transcript.durationSeconds": "{s} 秒",
"transcript.durationMinutes": "{m} 分",
"transcript.durationMinutesSeconds": "{m} 分 {s} 秒",
"transcript.processed": "已處理", "transcript.processed": "已處理",
"transcript.processedDuration": "已處理 {s}s", "transcript.processedDuration": "已處理 {s}s",
"notice.info": "提示", "notice.info": "提示",
+8
View File
@@ -1870,6 +1870,7 @@ export const zh: Record<DictKey, string> = {
"msg.thinking": "思考过程", "msg.thinking": "思考过程",
"msg.thinkingRunning": "思考中…", "msg.thinkingRunning": "思考中…",
"msg.thinkingDone": "已完成", "msg.thinkingDone": "已完成",
"msg.thinkingDuration": "持续了 {s} 秒",
"msg.memoryCitationsCount": "{n} 条记忆引用", "msg.memoryCitationsCount": "{n} 条记忆引用",
"msg.memoryCompilerCitationsCount": "{n} 条 Memory v5 编译引用", "msg.memoryCompilerCitationsCount": "{n} 条 Memory v5 编译引用",
"msg.memoryCompilerApplied": "此消息已应用 Memory v5 编译器", "msg.memoryCompilerApplied": "此消息已应用 Memory v5 编译器",
@@ -1892,6 +1893,13 @@ export const zh: Record<DictKey, string> = {
"transcript.jumpToBottom": "跳到底部", "transcript.jumpToBottom": "跳到底部",
"transcript.showEarlierHistory": "展开前 {n} 轮对话", "transcript.showEarlierHistory": "展开前 {n} 轮对话",
"transcript.toolCount": "{n} 个工具", "transcript.toolCount": "{n} 个工具",
"transcript.working": "工作中",
"transcript.workingDuration": "工作中 {duration}",
"transcript.worked": "已工作",
"transcript.workedDuration": "已工作 {duration}",
"transcript.durationSeconds": "{s} 秒",
"transcript.durationMinutes": "{m} 分",
"transcript.durationMinutesSeconds": "{m} 分 {s} 秒",
"transcript.processed": "已处理", "transcript.processed": "已处理",
"transcript.processedDuration": "已处理 {s}s", "transcript.processedDuration": "已处理 {s}s",
"notice.info": "提示", "notice.info": "提示",
+53 -11
View File
@@ -4157,30 +4157,66 @@ a[href] {
from { background-position: -40% 0; } from { background-position: -40% 0; }
to { background-position: 140% 0; } to { background-position: 140% 0; }
} }
.reasoning {
margin: 2px 0 4px;
}
.reasoning__head { .reasoning__head {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
padding: 3px 8px; min-height: 24px;
padding: 2px 7px;
border: none; border: none;
background: none; background: none;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
font-weight: 560;
color: var(--fg-faint); color: var(--fg-faint);
border-radius: 4px; border-radius: 6px;
-webkit-app-region: no-drag; -webkit-app-region: no-drag;
} }
.reasoning__head:hover { .reasoning__head:hover {
color: var(--fg); color: var(--fg);
background: color-mix(in srgb, var(--fg) 5%, transparent);
}
.reasoning__head > svg:first-child {
color: color-mix(in srgb, var(--fg-faint) 88%, transparent);
}
.reasoning__meta {
color: color-mix(in srgb, var(--fg-faint) 88%, transparent);
font-weight: 500;
} }
.reasoning__chevron { .reasoning__chevron {
flex: 0 0 auto; flex: 0 0 auto;
color: color-mix(in srgb, var(--fg-faint) 76%, transparent);
transition: transform 0.2s; transition: transform 0.2s;
} }
.reasoning__chevron--open { .reasoning__chevron--open {
transform: rotate(90deg); transform: rotate(90deg);
} }
.turn-collapse__reasoning-phase {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.turn-collapse__reasoning-head {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 24px;
color: var(--fg-faint);
font-size: 13px;
font-weight: 560;
line-height: 1.4;
}
.turn-collapse__reasoning-head > svg:first-child {
color: color-mix(in srgb, var(--fg-faint) 88%, transparent);
}
.msg-memory-citations { .msg-memory-citations {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -4248,7 +4284,8 @@ a[href] {
/* ── Shimmer sweep for running items ──────────────────────────────── */ /* ── Shimmer sweep for running items ──────────────────────────────── */
.tool__head[data-running], .tool__head[data-running],
.reasoning__head[data-running] { .reasoning__head[data-running],
.turn-collapse__reasoning-head[data-running] {
background: linear-gradient( background: linear-gradient(
105deg, 105deg,
var(--fg-faint) 0%, var(--fg-faint) 0%,
@@ -4268,7 +4305,8 @@ a[href] {
.tool__head[data-running] .tool__chevron, .tool__head[data-running] .tool__chevron,
.tool__head[data-running] .tool__duration, .tool__head[data-running] .tool__duration,
.reasoning__head[data-running] svg, .reasoning__head[data-running] svg,
.reasoning__head[data-running] .reasoning__meta { .reasoning__head[data-running] .reasoning__meta,
.turn-collapse__reasoning-head[data-running] svg {
color: var(--fg-faint); color: var(--fg-faint);
} }
.tool__head[data-running]:hover, .tool__head[data-running]:hover,
@@ -4435,15 +4473,17 @@ a[href] {
color: var(--fg-faint); color: var(--fg-faint);
background: var(--fg-faint); background: var(--fg-faint);
} }
.reasoning__body { .reasoning__body,
.turn-collapse__inline-reasoning {
overflow: hidden; overflow: hidden;
border-left: 2px solid var(--border); margin: 2px 0 6px 8px;
border-left: 2px solid color-mix(in srgb, var(--border) 82%, transparent);
padding-left: 12px; padding-left: 12px;
color: var(--fg-dim); color: color-mix(in srgb, var(--fg-dim) 78%, transparent);
font-family: var(--font-content-family); font-family: var(--font-content-family);
font-size: var(--font-control); font-size: var(--font-control);
font-style: italic; font-style: normal;
line-height: 1.7; line-height: 1.72;
white-space: pre-wrap; white-space: pre-wrap;
} }
/* ── markdown prose ──────────────────────────────────────────────────────── */ /* ── markdown prose ──────────────────────────────────────────────────────── */
@@ -28761,7 +28801,9 @@ body > .mermaid-diagram--fullscreen {
} }
.app--creation .reasoning__body, .app--creation .reasoning__body,
:root[data-theme-style] .app--creation .reasoning__body { .app--creation .turn-collapse__inline-reasoning,
:root[data-theme-style] .app--creation .reasoning__body,
:root[data-theme-style] .app--creation .turn-collapse__inline-reasoning {
font-family: "Source Han Sans CN", "Source Han Sans SC", "Noto Sans CJK SC", font-family: "Source Han Sans CN", "Source Han Sans SC", "Noto Sans CJK SC",
"Source Han Sans", "思源黑体", "Source Han Sans HC", "Microsoft YaHei UI", "Source Han Sans", "思源黑体", "Source Han Sans HC", "Microsoft YaHei UI",
"Microsoft YaHei", sans-serif; "Microsoft YaHei", sans-serif;
+7 -7
View File
@@ -1256,14 +1256,14 @@ func (a *Agent) Run(ctx context.Context, input string) (runErr error) {
return &maxStepsPause{steps: a.maxSteps, key: a.maxStepsKey} return &maxStepsPause{steps: a.maxSteps, key: a.maxStepsKey}
} }
// warnMissingToolCallReasoning surfaces a DeepSeek thinking-mode tool_calls // warnMissingToolCallReasoning surfaces a thinking-mode tool_calls turn that
// turn that arrived without reasoning text — usually a gateway renaming or // arrived without reasoning text only when the provider/model is expected to
// dropping the reasoning field. The turn is still saved and the replay still // emit it. The turn is still saved and the replay still succeeds (the wire
// succeeds (the wire layer always emits the reasoning_content key for such // layer can conservatively emit the reasoning_content key), but models that do
// turns), but the model continues without its chain-of-thought context, so the // rely on tool-call reasoning continue without their chain-of-thought context,
// degradation is worth a visible warning. // so that degradation is worth a visible warning.
func (a *Agent) warnMissingToolCallReasoning(calls []provider.ToolCall, reasoning string) { func (a *Agent) warnMissingToolCallReasoning(calls []provider.ToolCall, reasoning string) {
if len(calls) == 0 || !provider.RequiresToolCallReasoning(a.prov) { if len(calls) == 0 || !provider.WarnOnMissingToolCallReasoning(a.prov) {
return return
} }
if strings.TrimSpace(reasoning) != "" { if strings.TrimSpace(reasoning) != "" {
+38
View File
@@ -439,3 +439,41 @@ func TestCuratedProviderPresetCapabilities(t *testing.T) {
t.Fatalf("ollama-cloud effort capability = %+v, want none/max", cap) t.Fatalf("ollama-cloud effort capability = %+v, want none/max", cap)
} }
} }
func TestCuratedProviderPresetDeepSeekReasoningProtocolScope(t *testing.T) {
var cfg Config
for _, preset := range CuratedProviderPresets() {
for _, entry := range preset.Entries {
if err := cfg.UpsertProvider(entry); err != nil {
t.Fatalf("upsert preset %q: %v", preset.ID, err)
}
}
}
tests := []struct {
ref string
want string
}{
{ref: "opencode-go/deepseek-v4-pro", want: ReasoningProtocolDeepSeek},
{ref: "opencode-go/deepseek-v4-flash", want: ReasoningProtocolDeepSeek},
{ref: "ollama-cloud/deepseek-v4-pro", want: ReasoningProtocolDeepSeek},
{ref: "ollama-cloud/deepseek-v4-flash", want: ReasoningProtocolDeepSeek},
{ref: "novita/deepseek/deepseek-v4-pro"},
{ref: "novita/deepseek/deepseek-v4-flash"},
{ref: "gmi/deepseek-ai/DeepSeek-V4-Pro"},
{ref: "gmi/deepseek-ai/DeepSeek-V4-Flash"},
{ref: "nvidia/deepseek-ai/deepseek-v4-pro"},
{ref: "vercel-ai-gateway/deepseek/deepseek-v4-pro"},
}
for _, tc := range tests {
t.Run(tc.ref, func(t *testing.T) {
entry, ok := cfg.ResolveModel(tc.ref)
if !ok {
t.Fatalf("ResolveModel(%q) failed", tc.ref)
}
if got := ReasoningProtocolForEntry(entry); got != tc.want {
t.Fatalf("ReasoningProtocolForEntry(%q) = %q, want %q", tc.ref, got, tc.want)
}
})
}
}
+14
View File
@@ -238,6 +238,20 @@ func (c *client) RequiresToolCallReasoning() bool {
return c != nil && c.deepseek && c.thinkingType != "disabled" return c != nil && c.deepseek && c.thinkingType != "disabled"
} }
func (c *client) WarnOnMissingToolCallReasoning() bool {
return c.RequiresToolCallReasoning() && expectsDeepSeekToolCallReasoning(c.model)
}
func expectsDeepSeekToolCallReasoning(model string) bool {
model = strings.ToLower(strings.TrimSpace(model))
if !strings.Contains(model, "deepseek") || strings.Contains(model, "flash") {
return false
}
return strings.Contains(model, "reasoner") ||
strings.Contains(model, "deepseek-r1") ||
strings.Contains(model, "-pro")
}
func (c *client) sendOpts() provider.SendOptions { func (c *client) sendOpts() provider.SendOptions {
return provider.SendOptions{ return provider.SendOptions{
Provider: c.name, Provider: c.name,
+52
View File
@@ -1088,6 +1088,58 @@ func TestBuildRequestAlwaysSendsReasoningKeyOnDeepSeekToolCalls(t *testing.T) {
} }
} }
func TestWarnOnMissingToolCallReasoningMatchesDeepSeekModelFamily(t *testing.T) {
tests := []struct {
name string
model string
want bool
}{
{name: "exact flash", model: "deepseek-v4-flash", want: false},
{name: "namespaced flash", model: "deepseek/deepseek-v4-flash", want: false},
{name: "exact pro", model: "deepseek-v4-pro", want: true},
{name: "namespaced pro", model: "deepseek/deepseek-v4-pro", want: true},
{name: "mixed case pro", model: "deepseek-ai/DeepSeek-V4-Pro", want: true},
{name: "reasoner", model: "deepseek-reasoner", want: true},
{name: "r1", model: "deepseek-ai/DeepSeek-R1-0528", want: true},
{name: "generic deepseek", model: "deepseek-chat", want: false},
{name: "gateway deepseek v3", model: "deepseek-ai/DeepSeek-V3.2", want: false},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
p, err := New(provider.Config{
Name: "deepseek-proxy",
BaseURL: "https://gateway.example/v1",
Model: tc.model,
APIKey: "k",
Extra: map[string]any{"reasoning_protocol": "deepseek"},
})
if err != nil {
t.Fatalf("New: %v", err)
}
if !provider.RequiresToolCallReasoning(p) {
t.Fatal("DeepSeek protocol should keep conservative reasoning_content replay for tool-call turns")
}
if got := provider.WarnOnMissingToolCallReasoning(p); got != tc.want {
t.Fatalf("WarnOnMissingToolCallReasoning() = %v, want %v", got, tc.want)
}
})
}
p, err := New(provider.Config{
Name: "deepseek-v4-pro-openai-protocol",
BaseURL: "https://gateway.example/v1",
Model: "deepseek-v4-pro",
APIKey: "k",
Extra: map[string]any{"reasoning_protocol": "openai"},
})
if err != nil {
t.Fatalf("New OpenAI protocol: %v", err)
}
if provider.WarnOnMissingToolCallReasoning(p) {
t.Fatal("OpenAI protocol should not warn using DeepSeek reasoning_content policy")
}
}
// TestBuildRequestRoundTripsDeepSeekToolCallReasoning keeps the healthy-path // TestBuildRequestRoundTripsDeepSeekToolCallReasoning keeps the healthy-path
// bytes intact: when the session has the provider-issued reasoning, it is // bytes intact: when the session has the provider-issued reasoning, it is
// replayed verbatim on the tool_calls turn. // replayed verbatim on the tool_calls turn.
+22
View File
@@ -639,6 +639,28 @@ func RequiresToolCallReasoning(p Provider) bool {
return ok && policy.RequiresToolCallReasoning() return ok && policy.RequiresToolCallReasoning()
} }
// MissingToolCallReasoningWarningPolicy is optionally implemented by providers
// whose replay protocol requires reasoning_content, but whose active model may
// not reliably emit it. Request serialization should stay conservative while
// user-visible diagnostics can be quieter for models where missing reasoning is
// expected behavior.
type MissingToolCallReasoningWarningPolicy interface {
WarnOnMissingToolCallReasoning() bool
}
// WarnOnMissingToolCallReasoning reports whether a tool_calls turn with empty
// reasoning_content should surface a visible warning.
func WarnOnMissingToolCallReasoning(p Provider) bool {
if nilutil.IsNil(p) {
return false
}
policy, ok := p.(MissingToolCallReasoningWarningPolicy)
if ok {
return policy.WarnOnMissingToolCallReasoning()
}
return RequiresToolCallReasoning(p)
}
// Config is a resolved provider instance configuration. // Config is a resolved provider instance configuration.
type Config struct { type Config struct {
Name string // instance name, e.g. "deepseek" Name string // instance name, e.g. "deepseek"
Executable
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Fast local production-style desktop package from this checkout.
#
# This is a convenience wrapper around ./prod_test for iteration builds:
# native host architecture, no Wails clean, and no macOS DMG unless explicitly
# overridden.
set -euo pipefail
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
cat <<'EOF'
Usage:
./prod_fast_test [platform] [version] [channel]
Examples:
./prod_fast_test
./prod_fast_test darwin/arm64
./prod_fast_test darwin/arm64 v0.0.0-fast stable
Fast defaults:
platform native host architecture; macOS uses darwin/arm64 or darwin/amd64
clean skipped by default; set DESKTOP_BUILD_CLEAN=1 to force Wails -clean
dmg skipped by default; set DESKTOP_BUILD_SKIP_DMG=0 to create it
This delegates to ./prod_test with PROD_TEST_FAST=1.
EOF
exit 0
fi
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
export PROD_TEST_FAST="${PROD_TEST_FAST:-1}"
exec "$SCRIPT_DIR/prod_test" "$@"
+45 -1
View File
@@ -14,6 +14,7 @@ Examples:
./prod_test ./prod_test
./prod_test darwin/arm64 ./prod_test darwin/arm64
./prod_test darwin/universal v1.5.0-prodtest stable ./prod_test darwin/universal v1.5.0-prodtest stable
./prod_fast_test
Defaults: Defaults:
platform auto-detected from this machine; macOS defaults to darwin/universal platform auto-detected from this machine; macOS defaults to darwin/universal
@@ -24,9 +25,14 @@ Environment overrides:
PROD_TEST_PLATFORM Same as first argument. PROD_TEST_PLATFORM Same as first argument.
PROD_TEST_VERSION Same as second argument. PROD_TEST_VERSION Same as second argument.
PROD_TEST_CHANNEL Same as third argument. PROD_TEST_CHANNEL Same as third argument.
PROD_TEST_FAST 1 enables a faster local iteration build:
native host architecture on macOS, no Wails clean,
and no DMG packaging unless overridden below.
PROD_TEST_INSTALL_TOOLS 1 installs missing Wails/create-dmg/nfpm when possible. PROD_TEST_INSTALL_TOOLS 1 installs missing Wails/create-dmg/nfpm when possible.
Set to 0 to only check and print missing deps. Set to 0 to only check and print missing deps.
PROD_TEST_OPEN_DIST 1 opens dist/ after a successful build when supported. PROD_TEST_OPEN_DIST 1 opens dist/ after a successful build when supported.
DESKTOP_BUILD_CLEAN 0 skips Wails -clean.
DESKTOP_BUILD_SKIP_DMG 1 skips macOS DMG creation.
EOF EOF
} }
@@ -87,6 +93,24 @@ detect_platform() {
fi fi
} }
detect_native_platform() {
local os arch machine
case "$(uname -s)" in
Darwin) os="darwin" ;;
Linux) os="linux" ;;
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
*) fail "unsupported host OS: $(uname -s)" ;;
esac
machine="$(uname -m)"
case "$machine" in
arm64|aarch64) arch="arm64" ;;
x86_64|amd64) arch="amd64" ;;
*) fail "unsupported host architecture: $machine" ;;
esac
printf '%s/%s' "$os" "$arch"
}
default_version() { default_version() {
local ref sha dirty local ref sha dirty
ref="$(git branch --show-current 2>/dev/null || true)" ref="$(git branch --show-current 2>/dev/null || true)"
@@ -138,6 +162,9 @@ install_or_check_tools() {
case "$os" in case "$os" in
darwin) darwin)
if [[ "${DESKTOP_BUILD_SKIP_DMG:-0}" == "1" ]]; then
return
fi
if ! command -v create-dmg >/dev/null 2>&1; then if ! command -v create-dmg >/dev/null 2>&1; then
if [[ "$install_tools" == "1" && -x "$(command -v brew 2>/dev/null || true)" ]]; then if [[ "$install_tools" == "1" && -x "$(command -v brew 2>/dev/null || true)" ]]; then
echo "==> installing create-dmg" echo "==> installing create-dmg"
@@ -163,7 +190,21 @@ install_or_check_tools() {
esac esac
} }
PLATFORM="${1:-${PROD_TEST_PLATFORM:-$(detect_platform)}}" FAST="${PROD_TEST_FAST:-0}"
if [[ "$FAST" == "1" ]]; then
export DESKTOP_BUILD_CLEAN="${DESKTOP_BUILD_CLEAN:-0}"
export DESKTOP_BUILD_SKIP_DMG="${DESKTOP_BUILD_SKIP_DMG:-1}"
fi
if [[ -n "${1:-}" ]]; then
PLATFORM="$1"
elif [[ -n "${PROD_TEST_PLATFORM:-}" ]]; then
PLATFORM="$PROD_TEST_PLATFORM"
elif [[ "$FAST" == "1" ]]; then
PLATFORM="$(detect_native_platform)"
else
PLATFORM="$(detect_platform)"
fi
VERSION="${2:-${PROD_TEST_VERSION:-$(default_version)}}" VERSION="${2:-${PROD_TEST_VERSION:-$(default_version)}}"
CHANNEL="${3:-${PROD_TEST_CHANNEL:-stable}}" CHANNEL="${3:-${PROD_TEST_CHANNEL:-stable}}"
OPEN_DIST="${PROD_TEST_OPEN_DIST:-1}" OPEN_DIST="${PROD_TEST_OPEN_DIST:-1}"
@@ -202,6 +243,9 @@ echo "==> commit: $(git rev-parse --short=8 HEAD 2>/dev/null || echo unknown)"
echo "==> platform: $PLATFORM" echo "==> platform: $PLATFORM"
echo "==> version: $VERSION" echo "==> version: $VERSION"
echo "==> channel: $CHANNEL" echo "==> channel: $CHANNEL"
if [[ "$FAST" == "1" ]]; then
echo "==> fast mode: DESKTOP_BUILD_CLEAN=$DESKTOP_BUILD_CLEAN DESKTOP_BUILD_SKIP_DMG=$DESKTOP_BUILD_SKIP_DMG"
fi
bash scripts/desktop-build.sh "$PLATFORM" "$VERSION" "$CHANNEL" bash scripts/desktop-build.sh "$PLATFORM" "$VERSION" "$CHANNEL"
+34 -27
View File
@@ -47,7 +47,9 @@ if [ "$os" = windows ]; then
GOOS=windows GOARCH="$arch" go build -trimpath -ldflags="-s -w" \ GOOS=windows GOARCH="$arch" go build -trimpath -ldflags="-s -w" \
-o "build/windows/installer/$UPDATE_HELPER" ./cmd/update-helper -o "build/windows/installer/$UPDATE_HELPER" ./cmd/update-helper
fi fi
build_args=(-clean -platform "$PLATFORM" -ldflags "$ldflags") build_args=()
[ "${DESKTOP_BUILD_CLEAN:-1}" != "0" ] && build_args+=(-clean)
build_args+=(-platform "$PLATFORM" -ldflags "$ldflags")
[ "$os" = windows ] && build_args+=(-nsis -webview2 embed) [ "$os" = windows ] && build_args+=(-nsis -webview2 embed)
# Link cgo against WebKitGTK 4.1: 4.0 (libwebkit2gtk-4.0.so.37) is gone on # Link cgo against WebKitGTK 4.1: 4.0 (libwebkit2gtk-4.0.so.37) is gone on
# Ubuntu 24.04+/Fedora 40+, while 4.1 ships from Ubuntu 22.04 onward. # Ubuntu 24.04+/Fedora 40+, while 4.1 ships from Ubuntu 22.04 onward.
@@ -102,33 +104,38 @@ darwin)
else else
ditto -c -k --keepParent "$app" "$ROOT/dist/${APPNAME}-darwin-${arch}.zip" ditto -c -k --keepParent "$app" "$ROOT/dist/${APPNAME}-darwin-${arch}.zip"
fi fi
# A drag-to-Applications .dmg for first-time human download. Named -universal so if [ "${DESKTOP_BUILD_SKIP_DMG:-0}" = "1" ]; then
# cmd/sign's substring match (darwin-arm64/darwin-amd64) skips it: the .zip stays echo "==> skip DMG packaging (DESKTOP_BUILD_SKIP_DMG=1)"
# the updater channel, the .dmg is release-page only. create-dmg can exit nonzero else
# while still writing the image, so gate on the file existing, not the exit code. # A drag-to-Applications .dmg for first-time human download. Named -universal so
dmgsrc=$(mktemp -d) # cmd/sign's substring match (darwin-arm64/darwin-amd64) skips it: the .zip stays
cp -R "$app" "$dmgsrc/${APPNAME}.app" # the updater channel, the .dmg is release-page only. create-dmg can exit nonzero
dmg="$ROOT/dist/${APPNAME}-darwin-universal.dmg" # while still writing the image, so gate on the file existing, not the exit code.
create-dmg \ dmgsrc=$(mktemp -d)
--volname "$APPNAME" \ cp -R "$app" "$dmgsrc/${APPNAME}.app"
--window-size 540 380 \ dmg="$ROOT/dist/${APPNAME}-darwin-universal.dmg"
--icon-size 110 \ create-dmg \
--icon "${APPNAME}.app" 150 190 \ --volname "$APPNAME" \
--app-drop-link 390 190 \ --window-size 540 380 \
--no-internet-enable \ --icon-size 110 \
"$dmg" "$dmgsrc" || true --icon "${APPNAME}.app" 150 190 \
[ -f "$dmg" ] || { echo "create-dmg did not produce $dmg" >&2; exit 1; } --app-drop-link 390 190 \
# The .dmg is a separately-downloaded artifact, so sign + notarize + staple the --no-internet-enable \
# disk image itself too — the stapled .app inside isn't enough for the image. "$dmg" "$dmgsrc" || true
if [ "${HAS_APPLE_CERT:-}" = "true" ]; then [ -f "$dmg" ] || { echo "create-dmg did not produce $dmg" >&2; exit 1; }
codesign --force --timestamp -s "$identity" "$dmg" # The .dmg is a separately-downloaded artifact, so sign + notarize + staple the
echo "==> notarytool submit (dmg)" # disk image itself too — the stapled .app inside isn't enough for the image.
xcrun notarytool submit "$dmg" \ if [ "${HAS_APPLE_CERT:-}" = "true" ]; then
--key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY_ID" \ codesign --force --timestamp -s "$identity" "$dmg"
--issuer "$APPLE_API_ISSUER_ID" --wait echo "==> notarytool submit (dmg)"
xcrun stapler staple "$dmg" xcrun notarytool submit "$dmg" \
--key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY_ID" \
--issuer "$APPLE_API_ISSUER_ID" --wait
xcrun stapler staple "$dmg"
fi
rm -rf "$dmgsrc"
fi fi
rm -rf "$staging" "$dmgsrc" rm -rf "$staging"
;; ;;
windows) windows)
# `wails build -nsis` writes the installer under build/bin; its exact name # `wails build -nsis` writes the installer under build/bin; its exact name