diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json index df8f3e32df..30c6daca1d 100644 --- a/desktop/frontend/package.json +++ b/desktop/frontend/package.json @@ -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", diff --git a/desktop/frontend/src/App.tsx b/desktop/frontend/src/App.tsx index 86f3a44677..8d114718e7 100644 --- a/desktop/frontend/src/App.tsx +++ b/desktop/frontend/src/App.tsx @@ -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} diff --git a/desktop/frontend/src/__tests__/message-reasoning-panel.test.tsx b/desktop/frontend/src/__tests__/message-reasoning-panel.test.tsx new file mode 100644 index 0000000000..0be153b2d8 --- /dev/null +++ b/desktop/frontend/src/__tests__/message-reasoning-panel.test.tsx @@ -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("
", { + 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( + + + , + ); +}); + +const header = document.querySelector(".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); diff --git a/desktop/frontend/src/__tests__/transcript-process-fold.test.ts b/desktop/frontend/src/__tests__/transcript-process-fold.test.ts new file mode 100644 index 0000000000..c1ad7e4c5c --- /dev/null +++ b/desktop/frontend/src/__tests__/transcript-process-fold.test.ts @@ -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); diff --git a/desktop/frontend/src/__tests__/use-controller-meta.test.ts b/desktop/frontend/src/__tests__/use-controller-meta.test.ts index 613a5c82f8..308ed1d03c 100644 --- a/desktop/frontend/src/__tests__/use-controller-meta.test.ts +++ b/desktop/frontend/src/__tests__/use-controller-meta.test.ts @@ -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" } } }); diff --git a/desktop/frontend/src/components/Message.tsx b/desktop/frontend/src/components/Message.tsx index a00c2fd460..f13477d78c 100644 --- a/desktop/frontend/src/components/Message.tsx +++ b/desktop/frontend/src/components/Message.tsx @@ -770,20 +770,24 @@ export function TurnActions({ ); } -export const AssistantMessage = memo(function AssistantMessage({ +function reasoningDurationLabel(durationMs: number | undefined, t: ReturnType): 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(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 ( +
+ + {reasoningOpen && ( +
{visibleReasoning}
+ )} +
+ ); +} + +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 (
{item.reasoning && ( -
- - {reasoningOpen && ( -
{visibleReasoning}
- )} -
+ )} {hasText && (
diff --git a/desktop/frontend/src/components/Transcript.tsx b/desktop/frontend/src/components/Transcript.tsx index 43dd73875d..306cd03f1c 100644 --- a/desktop/frontend/src/components/Transcript.tsx +++ b/desktop/frontend/src/components/Transcript.tsx @@ -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; type AssistantItem = Extract; @@ -23,6 +24,7 @@ type OpenTurnAction = { turn: number; menu: "summary" | "rewind" }; const QUESTION_NAV_MIN_COUNT = 2; const LiveStreamContext = createContext(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 ( +
+ + {running ? t("msg.thinkingRunning") : t("msg.thinking")} + +
+
{visibleReasoning}
+
+ ); +} + // ── 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): 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): 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( , ); - 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( - , - ); - } 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(); - } - if (it.kind === "phase") out.push(); + 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( + , + ); + 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(); - 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(); - toolBatch.length = 0; - toolBatchKind = null; + const flushProcessBatch = () => { + if (processBatch.length === 0) return; + out.push( + , + ); + 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(); - 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( + , + ); + } + 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(); + pushProcessItem(it); + break; + case "phase": + pushProcessItem(it); + break; + case "notice": + pushProcessItem(it); + break; + case "compaction": + pushProcessItem(it); break; - case "phase": out.push(); break; - case "notice": out.push(); break; - case "compaction": out.push(); 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; 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(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(); break; case "phase": body.push(); break; + case "notice": body.push(); break; + case "compaction": body.push(); break; case "assistant": { - const displayItem = it; - body.push(); + body.push(); break; } } @@ -1147,11 +1321,14 @@ function TurnCollapse({ items, durationMs, mode, subcalls, tabId, creationMode =
{body}
diff --git a/desktop/frontend/src/lib/bridge.ts b/desktop/frontend/src/lib/bridge.ts index 0636e4416f..d7e517925d 100644 --- a/desktop/frontend/src/lib/bridge.ts +++ b/desktop/frontend/src/lib/bridge.ts @@ -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 " + diff --git a/desktop/frontend/src/lib/useController.ts b/desktop/frontend/src/lib/useController.ts index 4df358c427..dc02667a6d 100644 --- a/desktop/frontend/src/lib/useController.ts +++ b/desktop/frontend/src/lib/useController.ts @@ -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; diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index da9d1fc712..a0a22ec0eb 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -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", diff --git a/desktop/frontend/src/locales/zh-TW.ts b/desktop/frontend/src/locales/zh-TW.ts index 582288e43a..a3ff95f8f8 100644 --- a/desktop/frontend/src/locales/zh-TW.ts +++ b/desktop/frontend/src/locales/zh-TW.ts @@ -1207,6 +1207,7 @@ export const zhTW: Record = { "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 = { "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": "提示", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index f002d3312f..a1170e368b 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -1870,6 +1870,7 @@ export const zh: Record = { "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 = { "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": "提示", diff --git a/desktop/frontend/src/styles.css b/desktop/frontend/src/styles.css index 6d91b7107b..48e18d0f9f 100644 --- a/desktop/frontend/src/styles.css +++ b/desktop/frontend/src/styles.css @@ -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; diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 5bc1736c12..7da9829b54 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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) != "" { diff --git a/internal/config/provider_presets_test.go b/internal/config/provider_presets_test.go index 3430ba95d3..d4c7625b39 100644 --- a/internal/config/provider_presets_test.go +++ b/internal/config/provider_presets_test.go @@ -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) + } + }) + } +} diff --git a/internal/provider/openai/openai.go b/internal/provider/openai/openai.go index aa11506f68..8397032a88 100644 --- a/internal/provider/openai/openai.go +++ b/internal/provider/openai/openai.go @@ -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, diff --git a/internal/provider/openai/openai_test.go b/internal/provider/openai/openai_test.go index 07b8d6ab4d..5b7fbdc9dd 100644 --- a/internal/provider/openai/openai_test.go +++ b/internal/provider/openai/openai_test.go @@ -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. diff --git a/internal/provider/provider.go b/internal/provider/provider.go index ea6aaf2aba..9df73146c6 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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" diff --git a/prod_fast_test b/prod_fast_test new file mode 100755 index 0000000000..04122572de --- /dev/null +++ b/prod_fast_test @@ -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" "$@" diff --git a/prod_test b/prod_test index da93023168..36ba5b5e05 100755 --- a/prod_test +++ b/prod_test @@ -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" diff --git a/scripts/desktop-build.sh b/scripts/desktop-build.sh index dd817e3e48..f010dd4858 100755 --- a/scripts/desktop-build.sh +++ b/scripts/desktop-build.sh @@ -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