Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Reasonix Desktop (Wails shell)

Model/provider setup: [English guide](../docs/MODEL_SETTINGS.md) · [中文指南](../docs/MODEL_SETTINGS.zh-CN.md).

A native desktop window around the Reasonix Go kernel. The same
transport-agnostic `control.Controller` that backs the chat TUI and the HTTP/SSE
server is bound **directly** to a React webview — Go methods in, typed events
Expand Down
10 changes: 6 additions & 4 deletions desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -9254,10 +9254,12 @@ func removeServerOrder(order []string, name string) []string {
// ModelInfo is one (provider, model) the bottom switcher can pick. Ref ("provider/
// model") is what SetModel takes; Provider/Model are for display.
type ModelInfo struct {
Ref string `json:"ref"`
Provider string `json:"provider"`
Model string `json:"model"`
Current bool `json:"current"`
Ref string `json:"ref"`
Provider string `json:"provider"`
Model string `json:"model"`
Current bool `json:"current"`
ContextWindow int `json:"contextWindow,omitempty"`
Vision bool `json:"vision,omitempty"`
}

type EffortInfo struct {
Expand Down
14 changes: 6 additions & 8 deletions desktop/frontend/scripts/check-bundle-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,10 @@ console.log("\nbundle budgets");
// The subagent outcome envelope, partial-state card, and history hydration add
// 0.5 KiB gzip on the initial path. The model-capability resolver and its
// read-only provider badges add a measured 0.2 KiB including gzip/toolchain
// rounding. Direct topic-bar actions replace the overflow trigger; together
// with per-model image guidance, shared model matching, Automation's page
// projection, and foreground/hourly updater refresh, retain the measured
// merged-path ceiling: 464.8 KiB gzip. Export and provider editors stay lazy.
const initialJSBudgetKiB = 464.8;
// rounding. The integrated management shell, image capability controls, and
// upstream updater refresh measure 465.4 KiB gzip (base: 464.7 KiB).
// Keep the next decimal ceiling and leave feature editors lazy.
const initialJSBudgetKiB = 465.5;
assertBudget("initial JavaScript gzip", initialJSGzip, initialJSBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk gzip", largestInitialJS, 280 * 1024);
// Render-blocking CSS is intentionally absent: styles.css loads deferred via
Expand Down Expand Up @@ -270,7 +269,7 @@ for (const path of localeChunks) {
// dropping the unknown-state explanation.
// Image input mode, provenance and unknown-state guidance measure 60.724 KiB
// zh and 61.570 KiB zh-TW. Keep the next decimal ceiling per locale.
const budget = name.startsWith("zh-TW-") ? 61.6 * 1024 : 60.8 * 1024;
const budget = name.startsWith("zh-TW-") ? 61.7 * 1024 : 60.8 * 1024;
assertBudget(`${name} gzip`, gzipBytes(path), budget);
}

Expand Down Expand Up @@ -366,8 +365,7 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
// On the current main-v2 base, the combined measured path is 2474.6 KiB;
// the model-capability helper and localized status copy add 0.9 KiB; retain
// the smallest bounded cross-platform ceiling.
// Automation's shell projection and the updater refresh owner both remain
// on the merged initial path: 2481.7 KiB raw at the next decimal ceiling.
// Retain the upstream updater ceiling and independent chunk gates.
const rawInitialBudgetKiB = 2_481.7;
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
2 changes: 1 addition & 1 deletion desktop/frontend/scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ for (const [name, owner] of OWNED_ELSEWHERE) {

// Suites that statically import CSS (e.g. HeartbeatPanel's heartbeat.css) need
// the css-stub loader hook so tsx resolves the import under node.
const CSS_STUB_SUITES = new Set(["heartbeat-editor.test.tsx", "heartbeat-next-run.test.ts"]);
const CSS_STUB_SUITES = new Set(["provider-image-input.test.tsx", "heartbeat-editor.test.tsx", "heartbeat-next-run.test.ts", "settings-page-navigation.test.tsx", "automation-management.test.tsx", "trash-management.test.tsx", "capabilities-panel-actions.test.ts", "provider-access-card.test.tsx", "provider-editor-model-picker.test.tsx", "provider-name-readonly.test.tsx", "settings-refresh-snapshot.test.tsx", "shell-support-install.test.tsx", "shortcuts-recorder-focus.test.tsx"]);

const suites = files.filter((name) => !OWNED_ELSEWHERE.has(name));
console.log(`run-tests: ${suites.length} discovered suites (${OWNED_ELSEWHERE.size} owned by dedicated scripts)`);
Expand Down
299 changes: 136 additions & 163 deletions desktop/frontend/src/App.tsx

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions desktop/frontend/src/__tests__/app-chrome-tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,7 @@ ok(

ok(
/topicShortcutIndexFromEvent\(event, desktopPlatform\)/.test(appSource) &&
/useTopicShortcuts\(!sidebarCollapsed, desktopPlatform\)/.test(appSource),
/useTopicShortcuts\(!sidebarCollapsed && !managementActive, desktopPlatform\)/.test(appSource),
"topic shortcuts use the resolved desktop platform",
);

Expand Down Expand Up @@ -566,7 +566,7 @@ ok(
);

ok(
/const enterChatViewForTabNavigation = useCallback\(\(\) => \{\s*setMainView\("chat"\);/.test(appSource) &&
/const enterChatViewForTabNavigation = useCallback\(\(\) => \{\s*enterConversation\(\);/.test(appSource) &&
/const enqueueTabSwitch = useCallback\([\s\S]*?enterChatViewForTabNavigation\(\);[\s\S]*?enqueueNavigationRequest/.test(appSource) &&
/const revealBackgroundRuntime = useCallback[\s\S]*?enterChatViewForTabNavigation\(\);[\s\S]*?RevealBackgroundRuntime/.test(appSource) &&
/const revealWorkspaceWriter = useCallback[\s\S]*?enterChatViewForTabNavigation\(\);[\s\S]*?RevealWorkspaceWriterForTab/.test(appSource),
Expand All @@ -579,7 +579,7 @@ ok(
);

ok(
/<HeartbeatView[\s\S]*onOpenTopic=\{\(scope, workspaceRoot, topicId\) => \{[\s\S]*void handleOpenTopic\(scope, workspaceRoot, topicId\);[\s\S]*\}\}/.test(appSource),
/onOpenTopic: openAutomationTopic/.test(appSource) && /const openAutomationTopic = useCallback[\s\S]*enqueueNavigationWithIntent\(\{ kind: "topic", scope, workspaceRoot, topicId \}, intent\)/.test(appSource),
"heartbeat topic navigation uses the guarded open-topic path",
);

Expand Down
44 changes: 44 additions & 0 deletions desktop/frontend/src/__tests__/automation-drafts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import { useAutomationDraftStore as store, automationDraftDirty } from "../store/automationDrafts";
import type { HeartbeatTask } from "../custom/features/heartbeat/heartbeat.types";
const task: HeartbeatTask = { id: "a", title: "Original", prompt: "Saved prompt", interval: "30m", enabled: true, createdAt: 1 };
store.setState({ entries: {} });
store.getState().ensure(task);
assert.equal(automationDraftDirty(store.getState().entries.a), false);
store.getState().edit("a", (draft) => ({ ...draft, title: "Local" }));
store.getState().ui("a", { frequency: "cron", tab: "history" });
store.getState().reconcile([{ ...task, prompt: "External", topicId: "run", lastRunAt: 10 }]);
let entry = store.getState().entries.a;
assert.equal(entry.draft.title, "Local");
assert.equal(entry.draft.prompt, "External");
assert.equal(entry.draft.topicId, "run");
assert.equal(entry.frequency, "cron");
assert.equal(entry.tab, "history");
assert.deepEqual(entry.conflicts, []);
store.getState().reconcile([{ ...task, title: "Remote", prompt: "External", topicId: "run" }]);
assert.deepEqual(store.getState().entries.a.conflicts, ["title"]);
store.getState().reconcile([{ ...task, title: "Remote", prompt: "External", topicId: "new-run" }]);
assert.deepEqual(store.getState().entries.a.conflicts, ["title"], "repeated refresh must not erase unresolved conflicts");
store.getState().discard("a");
assert.equal(store.getState().entries.a.draft.title, "Remote");
assert.deepEqual(store.getState().entries.a.conflicts, []);
store.getState().edit("a", (draft) => ({ ...draft, prompt: "Keep this" }));
const version = store.getState().begin("a")!;
assert.equal(store.getState().begin("a"), null);
store.getState().edit("a", (draft) => ({ ...draft, prompt: "Wrong" }));
assert.equal(store.getState().entries.a.draft.prompt, "Keep this");
store.getState().finish("a", version - 1, task);
assert.equal(store.getState().entries.a.busy, true);
store.getState().finish("a", version);
assert.equal(store.getState().entries.a.error, true);
assert.equal(store.getState().entries.a.draft.prompt, "Keep this");
store.getState().reconcile([]);
assert.equal(store.getState().entries.a.missing, true);
assert.equal(store.getState().entries.a.draft.prompt, "Keep this");
store.getState().ensure({ ...task, id: "new", createdAt: undefined }, true);
store.getState().reconcile([]);
assert.equal(store.getState().entries.new.missing, false);
assert.equal(store.getState().entries.new.baseline, null);
store.getState().discard("new");
assert.equal(store.getState().entries.new, undefined);
console.log("PASS automation draft merge, conflict, engine state, locking, stale completion and deletion contracts");
58 changes: 58 additions & 0 deletions desktop/frontend/src/__tests__/automation-management.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { managementDom } from "../test-support/managementDom";
import type { HeartbeatTask } from "../custom/features/heartbeat/heartbeat.types";
const dom = managementDom();
const { default: React, act } = await import("react");
const { createRoot } = await import("react-dom/client");
const { LocaleProvider } = await import("../lib/i18n");
const { HeartbeatView } = await import("../custom/features/heartbeat/HeartbeatPanel");
const { useAutomationDraftStore: store } = await import("../store/automationDrafts");
let tasks: HeartbeatTask[] = [
{ id: "a", title: "Task A", prompt: "Saved", interval: "1h", enabled: true, createdAt: 1 },
{ id: "b", title: "Task B", prompt: "Saved B", interval: "2h", enabled: false, createdAt: 2 },
];
let release: (() => void) | undefined;
let failSave = false;
Object.assign(window, { go: { main: { App: {
async HeartbeatReloadConfig() { return { revision: 1, etag: "a", tasks }; },
async HeartbeatSaveConfig(value: { tasks: HeartbeatTask[] }) {
if (failSave) throw new Error("failed");
await new Promise<void>((resolve) => { release = resolve; });
tasks = value.tasks; return { revision: 2, etag: "b", tasks };
},
async ListWorkspaces() { return []; }, async HeartbeatGenerateID() { return "draft-new"; },
} } } });
const root = createRoot(document.getElementById("root")!);
const render = (active = true) => <LocaleProvider><HeartbeatView active={active} /></LocaleProvider>;
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find((node) => node.textContent?.trim() === text)!;
await act(async () => root.render(render()));
await act(async () => button("Task A").click());
await act(async () => store.getState().edit("a", (draft) => ({ ...draft, title: "Edited A", prompt: "Keep this draft" })));
await act(async () => button("Task B").click());
await act(async () => button("Task A · Unsaved").click());
assert.equal(document.querySelector<HTMLInputElement>('[aria-label="Title"]')?.value, "Edited A");
await act(async () => root.render(render(false)));
await act(async () => root.render(render()));
assert.equal(document.querySelector<HTMLInputElement>('[aria-label="Title"]')?.value, "Edited A");
await act(async () => button("Paused").click());
assert.equal(document.querySelector<HTMLInputElement>('[aria-label="Title"]')?.value, "Edited A", "filters do not discard the active editor");
await act(async () => button("All").click());
await act(async () => button("Save").click());
assert.equal(store.getState().entries.a.busy, true);
await act(async () => button("Task B").click());
await act(async () => release?.());
assert.equal(document.querySelector<HTMLInputElement>('[aria-label="Title"]')?.value, "Task B", "save completion must not steal selection");
assert.equal(store.getState().entries.a.draft.title, "Edited A");
assert.equal(store.getState().entries.a.busy, false);
await act(async () => button("Edited A").click());
assert.equal(button("Save").disabled, true, "saved baseline immediately clears dirty footer state");
assert.ok(document.querySelector(".automation-save-status")?.textContent?.includes("Saved"));
await act(async () => store.getState().edit("a", (draft) => ({ ...draft, prompt: "Survives failed save" })));
failSave = true;
await act(async () => button("Save").click());
assert.equal(store.getState().entries.a.draft.prompt, "Survives failed save");
assert.equal(store.getState().entries.a.error, true);
await act(async () => root.unmount());
assert.equal(store.getState().entries.a.draft.prompt, "Survives failed save", "component unmount does not own draft lifetime");
dom.window.close();
console.log("PASS automation page/filter retention, save-switch race, failure and unmount draft retention");
65 changes: 35 additions & 30 deletions desktop/frontend/src/__tests__/automation-surface-layout.test.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,40 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { JSDOM } from "jsdom";

const appSource = readFileSync(new URL("../App.tsx", import.meta.url), "utf8");
const stylesSource = readFileSync(new URL("../styles.css", import.meta.url), "utf8");
const heartbeatStyles = readFileSync(new URL("../custom/features/heartbeat/heartbeat.css", import.meta.url), "utf8");
const terminalWarmthSource = readFileSync(new URL("../lib/useWarmTerminalPanel.ts", import.meta.url), "utf8");
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf8");
const app = read("../App.tsx");
const isolation = read("../lib/useManagementWorkspace.ts");
const shell = read("../components/ManagementPageShell.tsx");
const css = read("../components/ManagementPageShell.css");
const heartbeat = read("../custom/features/heartbeat/HeartbeatPanel.tsx");
const warmth = read("../lib/useWarmTerminalPanel.ts");

assert.match(appSource, /const chatSurfaceVisible = !automationView;/, "one page projection gates every chat-owned surface");
assert.match(appSource, /surfaceWorkspacePanelMaximized = chatSurfaceVisible && workspacePanelOpen && workspacePanelMaximized/, "stored workspace maximization is presentation-only on automation");
assert.match(appSource, /terminalSurfaceOpen = chatSurfaceVisible && terminalPanelOpen && !remoteSurfaceActive/, "stored terminal state remains intact while its surface is hidden");
// The shared full-window shell replaces the old chat-pane projection. Background
// geometry and component identity survive while all workspace input is inert.
assert.match(app, /useManagementWorkspace\(layoutRef, managementActive\)/);
assert.match(isolation, /workspace\.inert = true/);
assert.match(isolation, /workspace\.inert = false/);
assert.doesNotMatch(app, /mainView === "automation"/);
assert.match(app, /inert=\{managementActive\}/);
assert.match(css, /\.management-screen \{[^}]*position: fixed;[^}]*inset: 0;/);
assert.match(shell, /hidden=\{!active\} inert=\{!active\}/);
assert.match(app, /if \(managementActive\) returnToWorkspace\(\)/);
assert.match(heartbeat, /<ManagementPageShell active=\{active\}/);
assert.match(css, /management-titlebar-height: 48px/);
assert.match(app, /useWarmTerminalPanel\(terminalPanelOpen, terminalResizing, !managementActive\)/);
assert.match(warmth, /if \(open\) setMounted\(true\)/);
assert.match(warmth, /if \(!open \|\| !visible\) \{\s*setFitEnabled\(false\)/s);

assert.match(appSource, /automationView \? "app--automation" : ""/, "root marks automation before lazy Heartbeat mounts");
assert.match(appSource, /!automationView \? "layout--terminal-drawer-open" : ""/, "automation removes the terminal grid row");
assert.match(appSource, /\{!automationView && \(\s*<button\s+className="terminal-drawer-resizer"/s, "automation does not render the terminal resize handle");
assert.match(appSource, /Boolean\(activeTabId\) && !automationView/, "automation disables the hidden-chat close shortcut");
assert.match(heartbeatStyles, /\.app--automation \.skip-to-composer\s*\{\s*display:\s*none;/s, "automation removes the hidden composer focus target");
assert.match(appSource, /if \(automationView\) \{\s*setMainView\("chat"\);\s*setTerminalPanelOpen\(true\)/s, "terminal toggle leaves automation and opens the terminal");
assert.match(appSource, /<Tooltip label=\{t\("heartbeat\.scheduler"\)\} fill side="top">[\s\S]{0,300}<AlarmClock size=\{16\}/, "Workbench keeps its existing footer automation entry");
assert.doesNotMatch(appSource, /sidebar__quick-action--active/, "Workbench does not add a second automation entry beside New session");

assert.match(stylesSource, /\.layout--automation \.terminal-drawer\s*\{\s*display:\s*none;/s, "hidden terminal cannot occupy or receive input");
assert.match(heartbeatStyles, /\.automation-surface > \.heartbeat-page\s*\{[^}]*flex:\s*1;[^}]*width:\s*100%;/s, "shared automation page fills its shell");
assert.match(heartbeatStyles, /\.layout--sidebar-collapsed \.automation-surface\s*\{[^}]*padding-top:\s*44px/s, "collapsed automation reserves one shared titlebar safe area");
assert.match(heartbeatStyles, /\.app--darwin \.automation-sidebar-toggle\s*\{[^}]*left:\s*96px/s, "macOS sidebar recovery stays clear of traffic lights");
assert.match(stylesSource, /\.layout--automation:not\(\.layout--sidebar-collapsed\)[^}]*grid-template-columns:\s*var\(--sidebar-expanded-width\)/s, "minimum-width automation can restore its sidebar");

assert.match(heartbeatStyles, /container:\s*heartbeat-page \/ inline-size/, "Heartbeat responds to its real content width");
assert.match(heartbeatStyles, /@container heartbeat-page \(max-width:\s*559px\)/, "narrow editor mode starts below 560px");
assert.match(heartbeatStyles, /\.heartbeat-split--detail-open \.heartbeat-split__left,[\s\S]*\.heartbeat-split--detail-open \.heartbeat-split__divider\s*\{\s*display:\s*none;/s, "narrow editor hides the list and divider without remounting");

assert.match(terminalWarmthSource, /if \(open\) setMounted\(true\)/, "terminal content remains mounted while presentation is hidden");
assert.match(terminalWarmthSource, /if \(!open \|\| !visible\) \{\s*setFitEnabled\(false\)/s, "terminal fitting pauses while automation hides it");

console.log("automation surface layout: 20 passed");
// Exercise the selector actually used by App against the header actually emitted
// by the shared shell. A class rename must not silently break native double-click.
const selector = app.match(/const onChromeSurface = target\?\.closest\("([^"]+)"\)/)![1];
const headerClass = shell.match(/<header className="([^"]+)"/)![1];
const dom = new JSDOM(`<section><header class="${headerClass}"></header><main><button>Back</button></main></section>`);
assert(dom.window.document.querySelector("header")!.closest(selector));
assert.equal(dom.window.document.querySelector("button")!.closest(selector), null);
assert.match(app, /desktopPlatform === "darwin"/);
assert.match(app, /windowsFramelessChrome \|\| desktopPlatform/);
assert.match(app, /target\?\.closest\("button, input, textarea, select, a,/);
dom.window.close();
console.log("PASS shared management geometry, input isolation, terminal retention and native titlebar dispatch");
Original file line number Diff line number Diff line change
Expand Up @@ -134,9 +134,9 @@ console.log("\nhistory recovery data visibility");
session({ path: "/t/normal.jsonl", title: "deleted session", deletedAt: now }),
session({ path: "/t/covered.jsonl", title: "system copy", deletedAt: now, recovered: true, recoveryCopy: true }),
],
onRestore: (path: string) => restored.push(path),
onPurge: (path: string) => purged.push(path),
onPurgeAll: (paths: string[]) => emptied.push(paths),
onRestore: async (path: string) => { restored.push(path); },
onPurge: async (path: string) => { purged.push(path); },
onPurgeAll: async (paths: string[]) => { emptied.push(paths); },
});

ok(document.body.textContent?.includes("system copy") === false, "trash collapses system recovery data by default");
Expand Down
Loading
Loading