Files
Netcatty/application/AppHandlers.trayJump.test.ts
T
陈大猫 f99efa8ac1 fix(ui): open AI silent sessions in an attach terminal popup (#2380)
* fix(ui): open AI silent sessions in an attach terminal popup

Tray jumps for hidden MCP sessions attach the existing terminal popup
to the live PTY instead of activating a tab-less main-window surface.
Output is rebound to the popup and restored when the popup closes.

* fix(terminal): register rebindOutput handlers under worker mode

Worker-enabled builds returned early from terminalBridge.registerHandlers
without registering rebind/restore IPC, so the first AI silent-session
attach popup always failed with "No handler registered". Register those
handlers in both paths and rebind display routing via terminalWorkerManager.

* fix(terminal): harden AI attach popup for worker mode lifecycle

Address codex review P1s: resume flow control before restoring output,
route exit/events to the rebound renderer, and replay home scrollback
via a snapshot request before rebinding display output.

* fix(terminal): restore attach routing on popup close and dual-notify exit

Hardening for AI observe popups: restore output from BrowserWindow closed
lifecycle (not only React cleanup), keep attach home for exit events so
main/tray lifecycle updates, and resume paused backend flow on attach.

* fix(terminal): resume flow on attach restore and close snapshot gaps

Resume backend output when attach popups are force-closed, fan out
lifecycle exit events to home+display in non-worker mode, and pause
during snapshot→rebind so live bytes are not dropped from the popup.

* fix(terminal): harden attach restore and worker lifecycle edges

Prefer live display for ZMODEM dialogs after rebind, dual-notify worker
crash exits to attach-home, and avoid clearing attach-home when restore
targets a dead renderer.

* fix(terminal): sync observe-popup state home and fan out SSH exits

Push popup serializations home before restore, fall back to hibernate
snapshots, settle briefly after pause for worker handoff, and dual-notify
SSH exits to attach-home owners in non-worker mode.

* fix(terminal): fan out exits for mosh/telnet/et attach owners

Use shared fanout for in-process protocol exits so attach-home and
display renderers both receive netcatty:exit under non-worker mode.

* fix(terminal): restore attach only onto main app windows

Avoid routing silent-session output to settings/prewarm windows when the
original home renderer is gone; fall back only to registered main windows.

* fix(terminal): harden silent session popup handoff

* fix(terminal): close observed popup with session

* fix(terminal): keep observe popup from reconnecting

* fix(terminal): preserve cleared observe snapshots

* fix(terminal): route transfer prompts to observer

* fix(terminal): keep context after observe restore
2026-07-22 08:58:58 +08:00

131 lines
3.8 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import type { TerminalSession } from "../domain/models";
import {
buildAiSilentSessionPopupPayload,
handleTrayJumpToSessionImpl,
} from "./app/AppHandlers";
const session = (overrides: Partial<TerminalSession> = {}): TerminalSession => ({
id: "session-1",
hostId: "host-1",
hostLabel: "AI Box",
hostname: "10.0.0.1",
username: "root",
status: "connected",
protocol: "ssh",
port: 22,
...overrides,
});
test("buildAiSilentSessionPopupPayload attaches the same live session PTY", () => {
const payload = buildAiSilentSessionPopupPayload(
session({ hiddenFromTabs: true }),
);
assert.equal(payload.parentSessionId, "session-1");
assert.equal(payload.attachSessionId, "session-1");
assert.equal(payload.title, "AI Box");
assert.equal(payload.startupCommand, "");
assert.equal(payload.sourceSession.hiddenFromTabs, undefined);
assert.equal(payload.sourceSession.reuseConnectionFromSessionId, undefined);
});
test("handleTrayJumpToSessionImpl opens a terminal popup for AI silent sessions", async () => {
const opened: unknown[] = [];
let activeTabId = "session-1";
await handleTrayJumpToSessionImpl(
() => ({
sessions: [session({ hiddenFromTabs: true })],
setActiveTabId: (id: string) => {
activeTabId = id;
},
getActiveTabId: () => activeTabId,
setWorkspaceFocusedSession: () => {
throw new Error("should not focus workspace for silent sessions");
},
netcattyBridge: {
get: () => ({
openTerminalPopup: async (payload: unknown) => {
opened.push(payload);
return { success: true, popupId: "popup-1" };
},
}),
},
}),
"session-1",
);
assert.equal(opened.length, 1);
assert.equal(activeTabId, "vault");
assert.deepEqual(opened[0], buildAiSilentSessionPopupPayload(session({ hiddenFromTabs: true })));
});
test("handleTrayJumpToSessionImpl still activates normal solo sessions in the main window", async () => {
let activeTabId = "vault";
let openedMain = 0;
await handleTrayJumpToSessionImpl(
() => ({
sessions: [session()],
setActiveTabId: (id: string) => {
activeTabId = id;
},
setWorkspaceFocusedSession: () => {
throw new Error("solo sessions should not use workspace focus");
},
netcattyBridge: {
get: () => ({
openMainWindow: async () => {
openedMain += 1;
return { success: true };
},
openTerminalPopup: async () => {
throw new Error("should not open popup for visible sessions");
},
}),
},
}),
"session-1",
);
assert.equal(activeTabId, "session-1");
assert.equal(openedMain, 1);
});
test("handleTrayJumpToSessionImpl focuses workspace sessions without opening a popup", async () => {
let activeTabId = "vault";
let focused: { workspaceId: string; sessionId: string } | null = null;
let openedMain = 0;
await handleTrayJumpToSessionImpl(
() => ({
sessions: [session({ workspaceId: "ws-1" })],
setActiveTabId: (id: string) => {
activeTabId = id;
},
setWorkspaceFocusedSession: (workspaceId: string, sessionId: string) => {
focused = { workspaceId, sessionId };
},
netcattyBridge: {
get: () => ({
openMainWindow: async () => {
openedMain += 1;
return { success: true };
},
openTerminalPopup: async () => {
throw new Error("should not open popup for workspace sessions");
},
}),
},
}),
"session-1",
);
assert.equal(activeTabId, "ws-1");
assert.equal(openedMain, 1);
assert.deepEqual(focused, { workspaceId: "ws-1", sessionId: "session-1" });
});