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