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",
"test:todo-visibility": "node scripts/test-todo-visibility.mjs",
"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: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": {
"@gsap/react": "^2.1.2",
+1
View File
@@ -3548,6 +3548,7 @@ export default function App() {
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}
running={state.running || rewindCommitting}
turnStartAt={state.turnStartAt}
welcomeVariant={sidebarCreation ? "creation" : "default"}
creationMode={sidebarCreation}
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");
}
{
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 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,
defaultExpanded = false,
expandWhileStreaming = true,
truncateStreamingReasoning = false,
creationMode = false,
defaultExpanded,
expandWhileStreaming,
truncateStreamingReasoning,
}: {
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;
defaultExpanded: boolean;
expandWhileStreaming: boolean;
truncateStreamingReasoning: boolean;
}) {
const t = useT();
const reasoningBodyRef = useRef<HTMLDivElement>(null);
@@ -811,7 +815,7 @@ export const AssistantMessage = memo(function AssistantMessage({
if (defaultExpanded) {
setReasoningOpen(true);
} else if (!userOverridden.current) {
setReasoningOpen(expandWhileStreaming);
setReasoningOpen(expandWhileStreaming && !nowRC);
}
} else if (nowRC && !wasRC) {
// Reasoning just finished — auto-close while we wait for text.
@@ -830,35 +834,64 @@ export const AssistantMessage = memo(function AssistantMessage({
userOverridden.current = true;
setReasoningOpen((v) => !v);
};
const hasText = item.streaming || item.text.trim() !== "";
const processOnly = Boolean(item.reasoning) && !hasText;
const processWithText = Boolean(item.reasoning) && hasText;
const isReasoningRunning = item.streaming && !item.reasoningComplete;
const visibleReasoning = reasoningOpen
? displayReasoningText(item.reasoning, {
streaming: item.streaming,
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 (
<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 && (
<div className="reasoning">
<button
type="button"
className="reasoning__head"
data-running={item.streaming && !item.reasoningComplete ? "" : undefined}
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>
<ReasoningPanel
item={item}
defaultExpanded={defaultExpanded}
expandWhileStreaming={expandWhileStreaming}
truncateStreamingReasoning={truncateStreamingReasoning}
/>
)}
{hasText && (
<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 { useT } from "../lib/i18n";
import { AssistantMessage, TurnActions, UserMessage } from "./Message";
import { ProcessCompactIcon, ProcessPhaseIcon } from "./ProcessCard";
import { ProcessBrainIcon, ProcessCompactIcon, ProcessPhaseIcon } from "./ProcessCard";
import { ToolCard } from "./ToolCard";
import { ArrowDown, ChevronRight } from "lucide-react";
import { Welcome } from "./Welcome";
@@ -16,6 +16,7 @@ import { useEntranceAnimation } from "../lib/useEntranceAnimation";
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 { appendTurnActionCopyText } from "../lib/turnActionCopy";
import { displayReasoningText } from "../lib/reasoningDisplay";
type ToolItem = Extract<Item, { kind: "tool" }>;
type AssistantItem = Extract<Item, { kind: "assistant" }>;
@@ -23,6 +24,7 @@ type OpenTurnAction = { turn: number; menu: "summary" | "rewind" };
const QUESTION_NAV_MIN_COUNT = 2;
const LiveStreamContext = createContext<LiveStream | undefined>(undefined);
type AssistantReasoningDisplay = "normal" | "hide";
const LiveAssistantMessage = memo(function LiveAssistantMessage({
item,
@@ -30,20 +32,38 @@ const LiveAssistantMessage = memo(function LiveAssistantMessage({
expandWhileStreaming = true,
truncateStreamingReasoning = false,
creationMode = false,
reasoningDisplay = "normal",
}: {
item: AssistantItem;
defaultExpanded?: boolean;
expandWhileStreaming?: boolean;
truncateStreamingReasoning?: boolean;
creationMode?: boolean;
reasoningDisplay?: AssistantReasoningDisplay;
}) {
const live = useContext(LiveStreamContext);
const shown = useMemo(
() =>
live && live.id === item.id
? { ...item, text: live.text, reasoning: live.reasoning, streaming: true, reasoningComplete: live.reasoningComplete }
: item,
[item, live?.id, live?.text, live?.reasoning, live?.reasoningComplete],
() => {
const merged =
live && live.id === item.id
? {
...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 (
<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 ─────────────────────────────────────────────────────────────
// 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.
@@ -75,6 +124,55 @@ const WARM_PAGE_SIZE = 20; // cold-zone pagination batch
// ── 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 ──────────────────────────────────────────────────────
export function Transcript({
@@ -100,6 +198,7 @@ export function Transcript({
olderHistoryCount = 0,
loadingOlderHistory = false,
onLoadOlderHistory,
turnStartAt,
}: {
items: Item[];
live?: LiveStream;
@@ -123,6 +222,7 @@ export function Transcript({
olderHistoryCount?: number;
loadingOlderHistory?: boolean;
onLoadOlderHistory?: () => void;
turnStartAt?: number;
}) {
const t = useT();
const {
@@ -430,31 +530,33 @@ export function Transcript({
// Standard mode: flat rendering (no step groups)
if (stepGroups) {
// Collect consecutive completed non-final steps into batches
let collapseBatch: Item[] = [];
let collapseBatchStart: string | null = null;
const flushCollapseBatch = () => {
if (collapseBatch.length === 0) return;
const dur = collapseBatch.reduce((ms, it) => ms + (it.kind === "tool" ? it.durationMs ?? 0 : 0), 0);
let processBatch: Item[] = [];
let processBatchStart: string | null = null;
const flushProcessBatch = () => {
if (processBatch.length === 0) return;
const dur = processDurationMs(processBatch);
out.push(
<TurnCollapse
key={`step-batch-${collapseBatchStart}`}
items={collapseBatch}
key={`turn-process-${processBatchStart}`}
items={processBatch}
durationMs={dur}
mode={displayMode}
subcalls={subcallsByParent}
tabId={tabId}
creationMode={creationMode}
turnStartAt={turnStartAt}
preferredKind="reasoning"
/>,
);
collapseBatch = [];
collapseBatchStart = null;
processBatch = [];
processBatchStart = null;
};
for (const group of stepGroups) {
const first = group.items[0];
if (first.kind === "user") {
flushCollapseBatch();
flushProcessBatch();
pushTurnActions();
const tn = userTurn.get(first.id);
const checkpoint = tn == null ? undefined : checkpointsByTurn.get(tn);
@@ -478,41 +580,59 @@ export function Transcript({
// Completed non-final step → batch it
if (group.isComplete && !group.isFinal) {
if (!collapseBatchStart) collapseBatchStart = first.id;
collapseBatch.push(...group.items);
if (!processBatchStart) processBatchStart = first.id;
processBatch.push(...group.items);
continue;
}
// Final answer or active step → flush any pending batch then render
flushCollapseBatch();
// Completed final answer → fold every process item for this turn into
// one reasoning container, then render only the answer text outside it.
const nonAssistantItems = group.items.filter(
(it) => it.kind !== "assistant" || (it.streaming && !it.text.trim())
);
const hasRunning = nonAssistantItems.some((it) => it.kind === "tool" && it.status === "running");
if (nonAssistantItems.length > 0 && !hasRunning) {
const dur = nonAssistantItems.reduce((ms, it) => ms + (it.kind === "tool" ? ((it as ToolItem).durationMs ?? 0) : 0), 0);
out.push(
<TurnCollapse
key={`step-${first.id}`}
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} />);
const finalAssistants = group.items.filter((it): it is AssistantItem => it.kind === "assistant" && !it.streaming && it.text.trim() !== "");
if (finalAssistants.length > 0 && !hasRunning) {
if (nonAssistantItems.length > 0) {
if (!processBatchStart) processBatchStart = first.id;
processBatch.push(...nonAssistantItems);
}
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) {
if (it.kind !== "assistant") continue;
out.push(
@@ -523,6 +643,7 @@ export function Transcript({
expandWhileStreaming={false}
truncateStreamingReasoning={true}
creationMode={creationMode}
reasoningDisplay="hide"
/>,
);
if (!it.streaming && it.text.trim() !== "") {
@@ -531,51 +652,40 @@ export function Transcript({
}
}
}
flushCollapseBatch();
flushProcessBatch();
if (!running) pushTurnActions();
} else {
// Standard mode: flat rendering
const roBatch: ToolItem[] = [];
const toolBatch: ToolItem[] = [];
let toolBatchKind: ToolGroupKind | null = null;
const flushRO = () => {
if (roBatch.length === 0) return;
out.push(<ReadOnlyBatch key={`rob-${roBatch[0].id}`} items={[...roBatch]} subcalls={subcallsByParent} tabId={tabId} />);
roBatch.length = 0;
// Standard mode keeps the answer body flat, but process material still
// belongs to one turn-level reasoning fold.
let processBatch: Item[] = [];
let processBatchStart: string | null = null;
const pushProcessItem = (it: Item) => {
if (!processBatchStart) processBatchStart = it.id;
processBatch.push(it);
};
const flushToolBatch = () => {
if (!toolBatchKind || toolBatch.length === 0) return;
out.push(<ToolGroup key={`tg-${toolBatch[0].id}`} kind={toolBatchKind} items={[...toolBatch]} subcalls={subcallsByParent} tabId={tabId} />);
toolBatch.length = 0;
toolBatchKind = null;
const flushProcessBatch = () => {
if (processBatch.length === 0) return;
out.push(
<TurnCollapse
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++) {
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) {
case "user": {
flushProcessBatch();
pushTurnActions();
const tn = userTurn.get(it.id);
const checkpoint = tn == null ? undefined : checkpointsByTurn.get(tn);
@@ -596,30 +706,47 @@ export function Transcript({
);
break;
}
case "assistant":
out.push(<LiveAssistantMessage key={it.id} item={it as AssistantItem} defaultExpanded={false} creationMode={creationMode} />);
if (!it.streaming && it.text.trim() !== "") {
actionText = appendTurnActionCopyText(actionText, it.text);
case "assistant": {
const assistant = it as AssistantItem;
if (assistant.reasoning || (live?.id === assistant.id && live.reasoning)) {
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;
}
break;
}
case "tool":
if (it.parentId) break;
if (it.name === "todo_write") break;
if (it.name === "exit_plan_mode") break;
out.push(<ToolCard key={it.id} item={it} subcalls={subcallsByParent.get(it.id)} tabId={tabId} />);
pushProcessItem(it);
break;
case "phase":
pushProcessItem(it);
break;
case "notice":
pushProcessItem(it);
break;
case "compaction":
pushProcessItem(it);
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();
flushRO();
flushProcessBatch();
if (!running) pushTurnActions();
}
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 ──────────────────────────────────────────────
// Warm/cold zone is a separate memo'd WarmZone component so streaming tokens
@@ -1053,13 +1180,17 @@ type TurnCollapseProps = {
subcalls: Map<string, ToolItem[]>;
tabId?: string;
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 live = useContext(LiveStreamContext);
const [open, setOpen] = useState(false);
const userOverriddenOpen = useRef(false);
const prevRunningRef = useRef(false);
const bodyRef = useRef<HTMLDivElement>(null);
useGSAPCollapse(bodyRef, open);
// Keep only items the body will actually render — an expandable fold over
// nothing is worse than no fold.
@@ -1067,29 +1198,65 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode =
return items.filter((it) => {
if (it.kind === "assistant") {
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 === "notice") return true;
if (it.kind === "compaction") return true;
if (it.kind !== "tool") return false;
if (it.parentId || it.name === "todo_write" || it.name === "exit_plan_mode") return false;
return true;
});
}, [items, mode]);
}, [items, mode, live?.id, live?.reasoning]);
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;
const collapseKind = displayItems.some((it) => it.kind === "tool")
const collapseKind = preferredKind ?? (displayItems.some((it) => it.kind === "tool")
? "tool"
: displayItems.some((it) => it.kind === "assistant" && Boolean(it.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"
? t("creation.toolCallsLabel")
: collapseKind === "reasoning"
? t("creation.reasoningLabel")
? label
: label;
// Pre-compute body: group consecutive completed read-only tools into ReadOnlyBatch
@@ -1118,12 +1285,18 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode =
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)) {
roBatch.push(it as ToolItem);
continue;
}
flushToolBatch();
flushRO();
if (it.kind === "tool") {
flushToolBatch();
flushRO();
}
switch (it.kind) {
case "tool":
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} />);
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": {
const displayItem = it;
body.push(<AssistantMessage key={it.id} item={displayItem as AssistantItem} creationMode={creationMode} />);
body.push(<InlineAssistantReasoning key={it.id} item={it as AssistantItem} />);
break;
}
}
@@ -1147,11 +1321,14 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode =
<button
type="button"
className="reasoning__head"
onClick={() => setOpen((v) => !v)}
onClick={() => {
userOverriddenOpen.current = true;
setOpen((v) => !v);
}}
aria-expanded={open}
>
<ChevronRight className={`reasoning__chevron${open ? " reasoning__chevron--open" : ""}`} size={12} />
<span className="turn-collapse__label" data-creation-label={creationLabel}>{label}</span>
{!hasRunningWork && <ChevronRight className={`reasoning__chevron${open ? " reasoning__chevron--open" : ""}`} size={12} />}
</button>
<div ref={bodyRef} className="turn-collapse__body">{body}</div>
</div>
+12
View File
@@ -1996,6 +1996,18 @@ function makeMockApp(): AppBindings {
// dev. Bail if cancelled during the wait — nothing was streamed yet.
await delay(700);
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 =
`You said: **${input}**\n\n` +
"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 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 MessageActionState = { turn: number; scope: MessageActionScope };
export type HydrateReason = "switch-tab" | "new-session" | "resume-session" | "open-topic" | "startup";
@@ -51,7 +58,7 @@ const HISTORY_PAGE_TURNS = 60;
export type Item =
| { 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: "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 };
}
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 {
if (s.pendingUser === undefined) return s;
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 delta = e.text ?? e.reasoning ?? "";
const base = s.live?.id === id ? s.live : { id, text: "", reasoning: "", reasoningComplete: false };
const now = Date.now();
const live =
e.kind === "text"
? { ...base, text: base.text + delta, reasoningComplete: base.reasoning !== "" || base.reasoningComplete }
: { ...base, reasoning: base.reasoning + delta, reasoningComplete: false };
? { ...completeLiveReasoning(base, now), text: base.text + delta }
: {
...base,
reasoning: base.reasoning + delta,
reasoningComplete: false,
reasoningStartedAt: base.reasoningStartedAt ?? (delta ? now : undefined),
reasoningCompletedAt: undefined,
};
return { ...s, items, live, currentAssistant: id, seq };
}
case "message": {
@@ -629,6 +661,8 @@ function applyEvent(s: State, e: WireEvent): State {
return { ...s, items, live: undefined, currentAssistant: undefined };
}
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) =>
it.kind === "assistant" && it.id === id
? (() => {
@@ -638,6 +672,8 @@ function applyEvent(s: State, e: WireEvent): State {
text,
reasoning,
streaming: false,
reasoningComplete: reasoning !== "" || it.reasoningComplete,
reasoningDurationMs: reasoningDurationMs ?? it.reasoningDurationMs,
memoryCitations: memoryCitations.length > 0 ? memoryCitations : undefined,
};
})()
@@ -763,7 +799,17 @@ function applyEvent(s: State, e: WireEvent): State {
case "turn_done": {
if (s.pendingUser !== undefined) s = flushPendingUser(s);
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 === "tool" && it.status === "running") return { ...it, status: "stopped" as const };
return it;
+8
View File
@@ -1868,6 +1868,7 @@ export const en = {
"msg.thinking": "thinking",
"msg.thinkingRunning": "thinking…",
"msg.thinkingDone": "done",
"msg.thinkingDuration": "lasted {s}s",
"msg.memoryCitationsCount": "{n} memory references",
"msg.memoryCompilerCitationsCount": "{n} Memory v5 compiler references",
"msg.memoryCompilerApplied": "Memory v5 compiler applied to this message",
@@ -1890,6 +1891,13 @@ export const en = {
"transcript.jumpToBottom": "Jump to bottom",
"transcript.showEarlierHistory": "Show {n} earlier turns",
"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.processedDuration": "Processed {s}s",
"notice.info": "Notice",
+8
View File
@@ -1207,6 +1207,7 @@ export const zhTW: Record<DictKey, string> = {
"msg.thinking": "思考過程",
"msg.thinkingRunning": "思考中…",
"msg.thinkingDone": "已完成",
"msg.thinkingDuration": "持續了 {s} 秒",
"msg.memoryCitationsCount": "{n} 條記憶引用",
"msg.memoryCompilerCitationsCount": "{n} 條 Memory v5 編譯引用",
"msg.memoryCompilerApplied": "此訊息已套用 Memory v5 編譯器",
@@ -1226,6 +1227,13 @@ export const zhTW: Record<DictKey, string> = {
"transcript.jumpToBottom": "跳到底部",
"transcript.showEarlierHistory": "展開前 {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.processedDuration": "已處理 {s}s",
"notice.info": "提示",
+8
View File
@@ -1870,6 +1870,7 @@ export const zh: Record<DictKey, string> = {
"msg.thinking": "思考过程",
"msg.thinkingRunning": "思考中…",
"msg.thinkingDone": "已完成",
"msg.thinkingDuration": "持续了 {s} 秒",
"msg.memoryCitationsCount": "{n} 条记忆引用",
"msg.memoryCompilerCitationsCount": "{n} 条 Memory v5 编译引用",
"msg.memoryCompilerApplied": "此消息已应用 Memory v5 编译器",
@@ -1892,6 +1893,13 @@ export const zh: Record<DictKey, string> = {
"transcript.jumpToBottom": "跳到底部",
"transcript.showEarlierHistory": "展开前 {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.processedDuration": "已处理 {s}s",
"notice.info": "提示",
+53 -11
View File
@@ -4157,30 +4157,66 @@ a[href] {
from { background-position: -40% 0; }
to { background-position: 140% 0; }
}
.reasoning {
margin: 2px 0 4px;
}
.reasoning__head {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
gap: 6px;
min-height: 24px;
padding: 2px 7px;
border: none;
background: none;
cursor: pointer;
font-size: 13px;
font-weight: 560;
color: var(--fg-faint);
border-radius: 4px;
border-radius: 6px;
-webkit-app-region: no-drag;
}
.reasoning__head:hover {
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 {
flex: 0 0 auto;
color: color-mix(in srgb, var(--fg-faint) 76%, transparent);
transition: transform 0.2s;
}
.reasoning__chevron--open {
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 {
display: flex;
flex-direction: column;
@@ -4248,7 +4284,8 @@ a[href] {
/* ── Shimmer sweep for running items ──────────────────────────────── */
.tool__head[data-running],
.reasoning__head[data-running] {
.reasoning__head[data-running],
.turn-collapse__reasoning-head[data-running] {
background: linear-gradient(
105deg,
var(--fg-faint) 0%,
@@ -4268,7 +4305,8 @@ a[href] {
.tool__head[data-running] .tool__chevron,
.tool__head[data-running] .tool__duration,
.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);
}
.tool__head[data-running]:hover,
@@ -4435,15 +4473,17 @@ a[href] {
color: var(--fg-faint);
background: var(--fg-faint);
}
.reasoning__body {
.reasoning__body,
.turn-collapse__inline-reasoning {
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;
color: var(--fg-dim);
color: color-mix(in srgb, var(--fg-dim) 78%, transparent);
font-family: var(--font-content-family);
font-size: var(--font-control);
font-style: italic;
line-height: 1.7;
font-style: normal;
line-height: 1.72;
white-space: pre-wrap;
}
/* ── markdown prose ──────────────────────────────────────────────────────── */
@@ -28761,7 +28801,9 @@ body > .mermaid-diagram--fullscreen {
}
.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",
"Source Han Sans", "思源黑体", "Source Han Sans HC", "Microsoft YaHei UI",
"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}
}
// warnMissingToolCallReasoning surfaces a DeepSeek thinking-mode tool_calls
// turn that arrived without reasoning text — usually a gateway renaming or
// dropping the reasoning field. The turn is still saved and the replay still
// succeeds (the wire layer always emits the reasoning_content key for such
// turns), but the model continues without its chain-of-thought context, so the
// degradation is worth a visible warning.
// warnMissingToolCallReasoning surfaces a thinking-mode tool_calls turn that
// arrived without reasoning text only when the provider/model is expected to
// emit it. The turn is still saved and the replay still succeeds (the wire
// layer can conservatively emit the reasoning_content key), but models that do
// rely on tool-call reasoning continue without their chain-of-thought context,
// so that degradation is worth a visible warning.
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
}
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)
}
}
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"
}
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 {
return provider.SendOptions{
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
// bytes intact: when the session has the provider-issued reasoning, it is
// replayed verbatim on the tool_calls turn.
+22
View File
@@ -639,6 +639,28 @@ func RequiresToolCallReasoning(p Provider) bool {
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.
type Config struct {
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 darwin/arm64
./prod_test darwin/universal v1.5.0-prodtest stable
./prod_fast_test
Defaults:
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_VERSION Same as second 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.
Set to 0 to only check and print missing deps.
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
}
@@ -87,6 +93,24 @@ detect_platform() {
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() {
local ref sha dirty
ref="$(git branch --show-current 2>/dev/null || true)"
@@ -138,6 +162,9 @@ install_or_check_tools() {
case "$os" in
darwin)
if [[ "${DESKTOP_BUILD_SKIP_DMG:-0}" == "1" ]]; then
return
fi
if ! command -v create-dmg >/dev/null 2>&1; then
if [[ "$install_tools" == "1" && -x "$(command -v brew 2>/dev/null || true)" ]]; then
echo "==> installing create-dmg"
@@ -163,7 +190,21 @@ install_or_check_tools() {
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)}}"
CHANNEL="${3:-${PROD_TEST_CHANNEL:-stable}}"
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 "==> version: $VERSION"
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"
+34 -27
View File
@@ -47,7 +47,9 @@ if [ "$os" = windows ]; then
GOOS=windows GOARCH="$arch" go build -trimpath -ldflags="-s -w" \
-o "build/windows/installer/$UPDATE_HELPER" ./cmd/update-helper
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)
# 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.
@@ -102,33 +104,38 @@ darwin)
else
ditto -c -k --keepParent "$app" "$ROOT/dist/${APPNAME}-darwin-${arch}.zip"
fi
# A drag-to-Applications .dmg for first-time human download. Named -universal so
# cmd/sign's substring match (darwin-arm64/darwin-amd64) skips it: the .zip stays
# the updater channel, the .dmg is release-page only. create-dmg can exit nonzero
# while still writing the image, so gate on the file existing, not the exit code.
dmgsrc=$(mktemp -d)
cp -R "$app" "$dmgsrc/${APPNAME}.app"
dmg="$ROOT/dist/${APPNAME}-darwin-universal.dmg"
create-dmg \
--volname "$APPNAME" \
--window-size 540 380 \
--icon-size 110 \
--icon "${APPNAME}.app" 150 190 \
--app-drop-link 390 190 \
--no-internet-enable \
"$dmg" "$dmgsrc" || true
[ -f "$dmg" ] || { echo "create-dmg did not produce $dmg" >&2; exit 1; }
# The .dmg is a separately-downloaded artifact, so sign + notarize + staple the
# disk image itself too — the stapled .app inside isn't enough for the image.
if [ "${HAS_APPLE_CERT:-}" = "true" ]; then
codesign --force --timestamp -s "$identity" "$dmg"
echo "==> notarytool submit (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"
if [ "${DESKTOP_BUILD_SKIP_DMG:-0}" = "1" ]; then
echo "==> skip DMG packaging (DESKTOP_BUILD_SKIP_DMG=1)"
else
# A drag-to-Applications .dmg for first-time human download. Named -universal so
# cmd/sign's substring match (darwin-arm64/darwin-amd64) skips it: the .zip stays
# the updater channel, the .dmg is release-page only. create-dmg can exit nonzero
# while still writing the image, so gate on the file existing, not the exit code.
dmgsrc=$(mktemp -d)
cp -R "$app" "$dmgsrc/${APPNAME}.app"
dmg="$ROOT/dist/${APPNAME}-darwin-universal.dmg"
create-dmg \
--volname "$APPNAME" \
--window-size 540 380 \
--icon-size 110 \
--icon "${APPNAME}.app" 150 190 \
--app-drop-link 390 190 \
--no-internet-enable \
"$dmg" "$dmgsrc" || true
[ -f "$dmg" ] || { echo "create-dmg did not produce $dmg" >&2; exit 1; }
# The .dmg is a separately-downloaded artifact, so sign + notarize + staple the
# disk image itself too — the stapled .app inside isn't enough for the image.
if [ "${HAS_APPLE_CERT:-}" = "true" ]; then
codesign --force --timestamp -s "$identity" "$dmg"
echo "==> notarytool submit (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
rm -rf "$staging" "$dmgsrc"
rm -rf "$staging"
;;
windows)
# `wails build -nsis` writes the installer under build/bin; its exact name