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