Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
43131f6
fix(desktop): show v1 uncommitted-changes banner instead of second de…
saddlepaddle Apr 23, 2026
a8b29fa
fix(desktop): fail closed when adopted host-service has no version (#…
saddlepaddle Apr 23, 2026
be3b5fc
fix(desktop): keep v2 terminals and browsers stable across workspace …
Kitenite Apr 24, 2026
083a518
fix(host-service): place v2 worktrees under ~/.superset/worktrees/<pr…
Kitenite Apr 24, 2026
9038e32
feat(marketing): simplify product menu and show yearly discount (#3691)
saddlepaddle Apr 23, 2026
e306eb2
feat(desktop): v2 AI workspace rename generates title + branch togeth…
saddlepaddle Apr 24, 2026
4163c8d
feat(desktop): show PR state as sidebar workspace icon (#3694)
Kitenite Apr 24, 2026
6788324
fix(desktop): use Alerter for automation detail delete confirm (#3695)
saddlepaddle Apr 24, 2026
b3b70cd
fix(desktop): honor agent selection in new-workspace modal (#3699)
saddlepaddle Apr 24, 2026
2bd46ec
fix(host-service): count untracked file lines in getStatus (#3701)
saddlepaddle Apr 24, 2026
5697c8a
fix(desktop): reap stale notify.sh paths from in-repo dev worktrees (…
Kitenite Apr 24, 2026
847bc2c
fix(desktop): adopt Ghostty keyboard model in v2 terminal (#3700)
Kitenite Apr 24, 2026
556dfad
style: lint 自動修正 (agent-wrappers.test.ts フォーマット)
MocA-Love Apr 24, 2026
b91b1c4
fix(deps): regenerate bun.lock to restore @mastra/core 1.26.0-alpha.3…
MocA-Love Apr 24, 2026
1e6e2a3
fix(deps): align @mastra/core to 1.26.0-alpha.3 across workspace
MocA-Love Apr 24, 2026
eb7ccc4
fix(host-service): skip local rewrite when cloud rename is rejected
MocA-Love Apr 24, 2026
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: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
"@hono/node-server": "^1.14.1",
"@hookform/resolvers": "^5.2.2",
"@lezer/highlight": "^1.2.3",
"@mastra/core": "1.25.0",
"@mastra/core": "1.26.0-alpha.3",
"@parcel/watcher": "^2.5.6",
"@pierre/diffs": "1.1.3",
"@radix-ui/react-dialog": "^1.1.15",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# v2 pane persistence across workspace switch

## Context

Switching v2 workspaces unmounts the entire `<WorkspaceTrpcProvider>` subtree
(`layout.tsx:79` uses `key={`${workspace.id}:${hostUrl}`}`). Every pane React
component for the outgoing workspace is torn down and recreated for the
incoming one. Load-bearing long-lived state (xterm instance + WebSocket,
webview guest process, CodeMirror `EditorView`) must live OUTSIDE the
remounting subtree to survive. This note captures the root cause for each
pane kind and the fix pattern so we don't have to re-derive it.

## Shared root cause

The `key` on `WorkspaceTrpcProvider` is load-bearing — it exists
(commit `57557f806`) to prevent crashes from hook calls bleeding across
trpc clients during transitions. We cannot remove it. Any pane that wants
to survive workspace switches must:

1. Hold its long-lived state in a module-level registry singleton.
2. Own a DOM node (or native handle) parented *outside* the React
workspace subtree (body-level `<div>` is the simplest).
3. Let the React component be a thin placeholder that only drives
position/visibility of the registry-owned node.

Think "VSCode `TerminalInstance` + `setVisible`" or the existing
`browserRuntimeRegistry` root-container pattern.

## Terminal — fixed in PR #3687

Was broken: `registry.attach()` fused DOM attach with WebSocket open and was
gated on `ensureSession`. The wrapper was `wrapper.remove()`'d on every
React unmount, so workspace switch was visible detach + reattach. The
`ensureSession` gate also added tRPC latency on warm returns, and opened
the WS against a nonexistent session on cold mount → "Session not found".

Fixed by:
- Park wrapper in a hidden body-level `#v2-terminal-parking` div on
detach instead of `.remove()`.
- Split `attach` into `mount` (sync DOM) and `connect` (called only after
`ensureSession` resolves).
- Narrow `TerminalPane` effect deps to `[terminalId]`; read `workspaceId`
and `websocketUrl` through refs. `websocketUrl` changes go through a
separate `registry.reconnect` that no-ops on a cold transport.

Refs: `terminal-runtime.ts`, `terminal-runtime-registry.ts`,
`TerminalPane.tsx`.

## Browser — fixed

### Symptom

Switching workspaces destroyed the browser webview (and the guest page
along with it) instead of preserving state across the switch.

### Root cause

Confirmed via instrumentation: `browserRuntimeRegistry.destroy` was
being called on workspace switch with a stack rooted in React commit.
The only caller was `usePaneRegistry.tsx`'s `onRemoved` wiring:

```ts
onRemoved: (pane) => browserRuntimeRegistry.destroy(pane.id),
```

`onRemoved` comes from `packages/panes/.../Workspace.tsx`, which diffs
`previousPanesRef` against `current` in a `useEffect` and calls
`registry[kind].onRemoved` for any id that disappeared. The diff lives
inside a single Workspace component instance. Under ideal conditions —
the v2 layout's `key={`${workspace.id}:${hostUrl}`}` remounts on every
switch — this diff should never observe cross-workspace "removal"
because each workspace has its own Workspace component.

But the remount isn't always prompt: layout.tsx's `useLiveQuery` can
return stale WS-A data for a tick while `page.tsx`'s query has already
flipped to WS-B. During that tick the `key` hasn't changed yet, so the
existing `WorkspaceContent` stays mounted, `useV2WorkspacePaneLayout`
calls `store.replaceState(WS-B panes)` on the *same* store instance,
and the Panes library's diff correctly observes "the browser pane from
WS-A is gone now" → fires `onRemoved` → destroys the webview. By the
time the user returns to WS-A, the entry is gone; `attach()` runs the
`createEntry()` cold path and the webview is recreated with its
`initialUrl`, losing state.

The terminal never hit this because terminal destruction goes through
`useGlobalTerminalLifecycle`, which sweeps against *all* workspaces'
persisted `paneLayout` rows and only destroys ids that are provably
absent everywhere. Cross-workspace "removal" isn't a real removal from
that sweep's perspective.

### Fix

Mirrored the terminal pattern: added `useGlobalBrowserLifecycle` under
`_authenticated/components/GlobalBrowserLifecycle/`, mounted it next to
`<GlobalTerminalLifecycle />` in `_authenticated/layout.tsx`, and
removed the `onRemoved` wiring from `usePaneRegistry.tsx`. The new hook
extracts browser `pane.id`s from every workspace's `paneLayout`, diffs
against the previous set, and schedules `browserRuntimeRegistry.destroy`
on a 500 ms grace delay (same timing as the terminal sweep) so
cross-workspace pane moves don't trigger premature teardown.

Hypothesis #1 (placeholder-rect race) and #3 (webview recycling on
`visibility: hidden`) from the original list did not reproduce once #2
was fixed — the instrumentation showed `updateLayout` applying correct
non-zero rects and the webview surviving detach as long as no `destroy`
call fired. Left in place as known-good; will revisit if a future
regression points at either.

## File / Code editor — lower priority

File-viewer panes use CodeMirror `EditorView` created in a `useEffect([])`
inside `CodeEditor.tsx:153-171`, disposed on unmount. Workspace switch
therefore loses: undo history, cursor position, scroll position, any
unsaved viewport scroll. Not reported yet but predictable; users may
complain after terminal/browser are solid.

Fix pattern is identical: a module-level `codeEditorRegistry` keyed by
`${workspaceId}:${filePath}` (or pane id, if file viewer panes are
per-workspace) that owns the `EditorView` and its host div, with a body-
level root container. `CodeEditor` becomes a placeholder that registers
a rect.

Defer until it's a reported problem — the migration is mechanical but
the value is speculative and CodeMirror re-init is already fast.

## Not in scope

- v1 terminal. Sunset per CLAUDE.md / memory.
- v2 chat pane. Currently a "temporarily disabled" stub.
- Diff / comment / devtools. No long-lived state.
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import { BIN_DIR } from "./paths";
export const WRAPPER_MARKER = "# Superset agent-wrapper v1";
export { SUPERSET_MANAGED_BINARIES };

const SUPERSET_MANAGED_HOOK_PATH_PATTERN = /\/\.superset(?:-[^/'"\s\\]+)?\//;
// Dev setup (.superset/lib/setup/steps.sh) points SUPERSET_HOME_DIR at
// $PWD/superset-dev-data — without a leading dot — so we must recognize that
// variant to reap stale notify.sh paths from deleted worktrees.
const SUPERSET_MANAGED_HOOK_PATH_PATTERN =
/\/(?:\.superset(?:-[^/'"\s\\]+)?|superset-dev-data)\//;

export function writeFileIfChanged(
filePath: string,
Expand Down
63 changes: 63 additions & 0 deletions apps/desktop/src/main/lib/agent-setup/agent-wrappers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1186,6 +1186,69 @@ describe("agent-wrappers codex hooks.json", () => {
).toBe(true);
});

it("reaps stale notify.sh paths from in-repo dev worktrees", () => {
const codexHooksPath = path.join(mockedHomeDir, ".codex", "hooks.json");
// Real-world layout: a dev worktree lives under <repo>/.worktrees/<name>
// and its dev setup writes SUPERSET_HOME_DIR=<worktree>/superset-dev-data.
// There is no /.superset/ segment anywhere in the path.
const staleHookPath =
"/Users/test/code/superset/.worktrees/old-branch/superset-dev-data/hooks/notify.sh";
const currentHookPath = "/tmp/.superset-new/hooks/notify.sh";

mkdirSync(path.dirname(codexHooksPath), { recursive: true });
writeFileSync(
codexHooksPath,
JSON.stringify(
{
hooks: {
SessionStart: [
{ hooks: [{ type: "command", command: staleHookPath }] },
],
UserPromptSubmit: [
{ hooks: [{ type: "command", command: staleHookPath }] },
],
Stop: [{ hooks: [{ type: "command", command: staleHookPath }] }],
},
},
null,
2,
),
);

const content = getCodexGlobalHooksJsonContent(currentHookPath);
expect(content).not.toBeNull();
if (content === null) throw new Error("Expected content");

const parsed = JSON.parse(content) as {
hooks: Record<
string,
Array<{
matcher?: string;
hooks: Array<{ type: string; command: string }>;
}>
>;
};

for (const eventName of [
"SessionStart",
"UserPromptSubmit",
"Stop",
] as const) {
const hooks = parsed.hooks[eventName];
expect(Array.isArray(hooks)).toBe(true);
expect(
hooks.some((def) =>
def.hooks.some((hook) => hook.command === currentHookPath),
),
).toBe(true);
expect(
hooks.some((def) =>
def.hooks.some((hook) => hook.command === staleHookPath),
),
).toBe(false);
}
});

it("skips Codex hooks writes when existing JSON is invalid", () => {
const codexHooksPath = path.join(mockedHomeDir, ".codex", "hooks.json");
const invalidJson = "{not-json";
Expand Down
11 changes: 9 additions & 2 deletions apps/desktop/src/main/lib/host-service-coordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { settings } from "@superset/local-db";
import { getDeviceName, getHashedDeviceId } from "@superset/shared/device-info";
import { app } from "electron";
import { env } from "main/env.main";
import semver from "semver";
import { env as sharedEnv } from "shared/env.shared";
import { getProcessEnvWithShellPath } from "../../lib/trpc/routers/workspaces/utils/shell-env";
import { SUPERSET_HOME_DIR } from "./app-environment";
Expand Down Expand Up @@ -287,9 +288,15 @@ export class HostServiceCoordinator extends EventEmitter {
manifest.endpoint,
manifest.authToken,
);
if (version && version < MIN_HOST_SERVICE_VERSION) {
if (
!version ||
!semver.satisfies(version, `>=${MIN_HOST_SERVICE_VERSION}`)
) {
const reason = version
? `version ${version} < ${MIN_HOST_SERVICE_VERSION}`
: "version unknown";
console.log(
`[host-service:${organizationId}] Adopted service version ${version} < ${MIN_HOST_SERVICE_VERSION}, killing`,
`[host-service:${organizationId}] Adopted service ${reason}, killing`,
);
try {
process.kill(manifest.pid, "SIGTERM");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ interface RegistryEntry {
runtime: TerminalRuntime | null;
transport: TerminalTransport;
linkManager: TerminalLinkManager | null;
/** Stored until linkManager is created (attach called after setLinkHandlers). */
/** Stored until linkManager is created (mount called after setLinkHandlers). */
pendingLinkHandlers: TerminalLinkHandlers | null;
}

Expand All @@ -53,10 +53,21 @@ class TerminalRuntimeRegistryImpl {
return entry;
}

attach(
/**
* Ensure the xterm runtime exists and attach it to `container`.
* Synchronous. DOM-only — the WebSocket transport is untouched.
*
* Matches VSCode's pattern (`TerminalInstance.attachToElement`) and
* Tabby's (`XTermFrontend.attach`): the terminal renders immediately
* with a blank cursor, the backend pipe catches up via `connect()` once
* the caller has confirmed the server session exists. Decoupling the
* DOM from the transport is what lets a terminal survive workspace
* switches without an in-flight WebSocket being opened against a
* nonexistent session.
*/
mount(
terminalId: string,
container: HTMLDivElement,
wsUrl: string,
appearance: TerminalAppearance,
) {
const entry = this.getOrCreateEntry(terminalId);
Expand All @@ -76,7 +87,6 @@ class TerminalRuntimeRegistryImpl {
if (!entry.runtime) {
entry.runtime = createRuntime(terminalId, appearance);
entry.linkManager = new TerminalLinkManager(entry.runtime.terminal);
// Apply pending handlers if setLinkHandlers was called before attach
if (entry.pendingLinkHandlers) {
entry.linkManager.setHandlers(entry.pendingLinkHandlers);
entry.pendingLinkHandlers = null;
Expand All @@ -86,24 +96,54 @@ class TerminalRuntimeRegistryImpl {
}

const { runtime, transport } = entry;

attachToContainer(runtime, container, () => {
sendResize(transport, runtime.terminal.cols, runtime.terminal.rows);
});
}

/**
* Open (or re-use) the WebSocket transport for this terminal.
* Caller is responsible for ensuring the server session exists before
* calling — otherwise the server replies "Session not found".
*
* Idempotent: no-op if already connected/connecting to the same URL.
*/
connect(terminalId: string, wsUrl: string) {
const entry = this.entries.get(terminalId);
if (!entry?.runtime) return;
// Reset backoff only when the connection is in a stable state (open or
// disconnected). If the transport is in "closed" state it may be mid-way
// through a reconnect cycle caused by a server failure; resetting there
// would defeat the exponential backoff protection.
if (transport.connectionState !== "closed") {
resetReconnectBackoff(transport);
if (entry.transport.connectionState !== "closed") {
resetReconnectBackoff(entry.transport);
}
connect(transport, runtime.terminal, wsUrl);
connect(entry.transport, entry.runtime.terminal, wsUrl);
}

/**
* Swap the transport onto a new URL when it's already been brought up
* once. Used by effects watching `websocketUrl` — they fire on initial
* mount when the transport is still `"disconnected"` and ensureSession
* is in-flight, and we must not pre-empt that with a premature connect.
*
* Skipped states: `"disconnected"` (never opened; caller should use
* `connect()` via the ensureSession path). Allowed states: `"connecting"`
* (connect() cleanly aborts the in-flight socket), `"open"` (standard
* swap), and `"closed"` (previously live and mid-auto-reconnect — swap
* the URL so the reconnect targets the new endpoint).
*/
reconnect(terminalId: string, wsUrl: string) {
const entry = this.entries.get(terminalId);
if (!entry?.runtime) return;
if (entry.transport.connectionState === "disconnected") return;
if (entry.transport.currentUrl === wsUrl) return;
connect(entry.transport, entry.runtime.terminal, wsUrl);
}

/**
* Set link handler callbacks for a terminal. Safe to call before or after
* attach(). If the runtime already exists, link providers are re-registered.
* mount(). If the runtime already exists, link providers are re-registered.
*/
setLinkHandlers(terminalId: string, handlers: TerminalLinkHandlers) {
const entry = this.getOrCreateEntry(terminalId);
Expand All @@ -114,6 +154,11 @@ class TerminalRuntimeRegistryImpl {
}
}

/**
* Park the wrapper in the hidden body-level container. Runtime and
* transport stay alive; DOM is moved off the React-controlled tree so
* it survives the parent unmount without re-entering xterm.open().
*/
detach(terminalId: string) {
const entry = this.entries.get(terminalId);
if (!entry?.runtime) return;
Expand Down
Loading
Loading