diff --git a/crates/agent-gateway/test/websocket/v2_managed_process_test.go b/crates/agent-gateway/test/websocket/v2_managed_process_test.go new file mode 100644 index 000000000..6e54963c5 --- /dev/null +++ b/crates/agent-gateway/test/websocket/v2_managed_process_test.go @@ -0,0 +1,97 @@ +package websocket_test + +// ManagedProcess 快照链路集成测试:agent 发布 ManagedProcessSnapshot 后,已连接 +// 浏览器应收到 process_state 广播帧,新连接则应收到缓存回放帧。 + +import ( + "net/http" + "testing" + "time" + + "github.com/gorilla/websocket" + + gatewayv2 "github.com/liveagent/agent-gateway/internal/proto/v2" + "github.com/liveagent/agent-gateway/internal/protocol/pbws" + "github.com/liveagent/agent-gateway/internal/session" +) + +func receiveProcessStateFrame(t *testing.T, conn *websocket.Conn) *gatewayv2.WebServerFrame { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + frame := receiveWebFrameRaw(t, conn) + if frame.GetProcessState() != nil { + return frame + } + } + t.Fatalf("timed out waiting for process_state frame") + return nil +} + +func expectProcessState(t *testing.T, conn *websocket.Conn, context string) { + t.Helper() + frame := receiveProcessStateFrame(t, conn) + if frame.GetAgentId() != "desktop-agent" || frame.GetProcessState().GetRevision() != 7 { + t.Fatalf("%s frame = agent %q rev %d, want desktop-agent rev 7", + context, frame.GetAgentId(), frame.GetProcessState().GetRevision()) + } +} + +func TestV2ManagedProcessBroadcastAndReplay(t *testing.T) { + t.Parallel() + + sm := session.NewManager() + store := newAgentTokenStore(t) + agentToken, err := store.Issue("desktop-agent", "") + if err != nil { + t.Fatalf("issue desktop agent token: %v", err) + } + srv := pbws.NewServer(newV2TestConfig(), sm, store) + + mux := http.NewServeMux() + mux.Handle("/ws/v2", srv.BrowserHandler()) + mux.Handle("/ws/v2/agent", srv.AgentHandler()) + + agentConn, agentCleanup := dialV2Path(t, mux, "/ws/v2/agent") + defer agentCleanup() + sendProtoFrame(t, agentConn, &gatewayv2.AgentClientFrame{ + Payload: &gatewayv2.AgentClientFrame_Hello{ + Hello: &gatewayv2.ClientHello{ + ProtocolVersion: pbws.ProtocolVersion, + Role: gatewayv2.ClientRole_CLIENT_ROLE_AGENT, + Token: agentToken, + AgentId: "desktop-agent", + }, + }, + }) + if hello := receiveAgentServerFrame(t, agentConn).GetHello(); hello == nil || !hello.GetOk() { + t.Fatalf("agent hello reply = %#v, want ok", hello) + } + + browserConn, browserCleanup := dialV2Path(t, mux, "/ws/v2") + defer browserCleanup() + helloV2(t, browserConn, "ws-token") + + sendProtoFrame(t, agentConn, &gatewayv2.AgentClientFrame{ + Payload: &gatewayv2.AgentClientFrame_Envelope{ + Envelope: &gatewayv2.AgentEnvelope{ + RequestId: "managed-process-1", + Payload: &gatewayv2.AgentEnvelope_ManagedProcessSnapshot{ + ManagedProcessSnapshot: &gatewayv2.ManagedProcessSnapshot{ + Revision: 7, + Processes: []*gatewayv2.ManagedProcessRecord{ + {Id: "p-1", Command: "sleep 1000", Pid: 4242, Running: true}, + }, + }, + }, + }, + }, + }) + + // 已连接浏览器收到广播;新浏览器连接收到缓存回放。 + expectProcessState(t, browserConn, "broadcast") + browserConn2, browserCleanup2 := dialV2Path(t, mux, "/ws/v2") + defer browserCleanup2() + helloV2(t, browserConn2, "ws-token") + expectProcessState(t, browserConn2, "replay") +} diff --git a/crates/agent-gateway/test/webui/web-settings.test.mjs b/crates/agent-gateway/test/webui/web-settings.test.mjs index 8be7a72aa..d789795b1 100644 --- a/crates/agent-gateway/test/webui/web-settings.test.mjs +++ b/crates/agent-gateway/test/webui/web-settings.test.mjs @@ -360,6 +360,7 @@ test("web settings normalization canonicalizes project keyed maps with Windows p openedAt: 2, }, }, + backgroundTasks: { opened: false, dismissedIds: [] }, openVersion: 0, stateVersion: 0, writerId: "", diff --git a/crates/agent-gateway/web/src/lib/gatewaySocket.ts b/crates/agent-gateway/web/src/lib/gatewaySocket.ts index 3267c8e38..54bb0d8f0 100644 --- a/crates/agent-gateway/web/src/lib/gatewaySocket.ts +++ b/crates/agent-gateway/web/src/lib/gatewaySocket.ts @@ -2568,6 +2568,7 @@ export class GatewayWebSocketClient { this.sftpTransferListeners.size > 0 || this.chatActivityListeners.size > 0 || this.tunnelStateListeners.size > 0 || + this.processStateListeners.size > 0 || this.workspaceActivityListeners.size > 0 || this.conversationStreams.size > 0) ); @@ -3569,6 +3570,11 @@ export type GatewayWebSocketClientLike = { let activeClient: GatewayWebSocketClient | null = null; let activeToken = ""; +// True once any client has existed this page lifetime. reset* nulls +// activeClient without notifying, so "activeClient !== null" alone would +// miss the reset→create sequence and leave module-scoped stores subscribed +// to the disposed instance forever. +let everHadClient = false; const clientReplacedListeners = new Set<() => void>(); /** @@ -3588,10 +3594,11 @@ export function getGatewayWebSocketClient(token: string): GatewayWebSocketClient if (activeClient && activeToken === normalizedToken) { return activeClient; } - const replaced = activeClient !== null; + const replaced = everHadClient; activeClient?.dispose(); activeToken = normalizedToken; activeClient = new GatewayWebSocketClient(normalizedToken); + everHadClient = true; if (replaced) { // The new instance is already installed, so re-entrant // getGatewayWebSocketClient calls from listeners hit the fast path. diff --git a/crates/agent-gateway/web/test/gateway-socket-singleton.test.mjs b/crates/agent-gateway/web/test/gateway-socket-singleton.test.mjs new file mode 100644 index 000000000..7fa15a019 --- /dev/null +++ b/crates/agent-gateway/web/test/gateway-socket-singleton.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +const storage = new Map(); +globalThis.localStorage = { + getItem: (key) => (storage.has(key) ? storage.get(key) : null), + setItem: (key, value) => storage.set(key, String(value)), + removeItem: (key) => storage.delete(key), +}; +globalThis.location = { origin: "http://127.0.0.1:9", href: "http://127.0.0.1:9/" }; + +const loader = createWebModuleLoader({ + rootDir: fileURLToPath(new URL("../", import.meta.url)), +}); +const { getGatewayWebSocketClient, onGatewayWebSocketClientReplaced, resetGatewayWebSocketClient } = + loader.loadModule("src/lib/gatewaySocket.ts"); + +// 凡是新实例顶替过既有实例——包括 reset 置空后再创建(登出→登录)——都必须 +// 触发 replaced,否则模块级 store 会永远挂在已 dispose 的旧实例上收不到事件。 +test("单例 reset→create 也触发 replaced", () => { + let fired = 0; + const detach = onGatewayWebSocketClientReplaced(() => { + fired += 1; + }); + const first = getGatewayWebSocketClient("token-a"); + assert.equal(fired, 0); // 首个创建不算替换 + resetGatewayWebSocketClient(); + assert.equal(fired, 0); // reset 本身不通知(此刻无新实例可接) + const second = getGatewayWebSocketClient("token-a"); + assert.notEqual(first, second); + assert.equal(fired, 1); + detach(); + resetGatewayWebSocketClient(); +}); diff --git a/crates/agent-gateway/web/test/managed-process-store.test.mjs b/crates/agent-gateway/web/test/managed-process-store.test.mjs new file mode 100644 index 000000000..3f7f13052 --- /dev/null +++ b/crates/agent-gateway/web/test/managed-process-store.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { createWebModuleLoader } from "../../test/helpers/load-web-module.mjs"; + +function snapshot(revision, overrides = {}) { + return { + ready: true, + agentOnline: true, + revision, + processes: [{ id: `p-${revision}`, running: true }], + ...overrides, + }; +} + +const listeners = new Set(); +const backend = { + failNextFetch: false, + nextState: snapshot(1), + async fetchState() { + if (backend.failNextFetch) { + backend.failNextFetch = false; + throw new Error("fetch failed"); + } + return backend.nextState; + }, + async stop() { + return null; + }, + async clear() { + return null; + }, + async readLog() { + return { content: "", logPath: "", truncated: false }; + }, + subscribe(onState) { + listeners.add(onState); + return () => listeners.delete(onState); + }, + push(state) { + for (const listener of listeners) listener(state); + }, +}; + +const loader = createWebModuleLoader({ + rootDir: fileURLToPath(new URL("../", import.meta.url)), + mocks: { "@liveagent/app/lib/managed-process/backend": { backend } }, +}); +const store = loader.loadModule("@liveagent/ui/lib/managed-process/store.ts"); + +test("managed-process store 镜像语义与 refresh 自愈", async () => { + // 失败的初始化不留僵尸订阅,refresh 兼作重试补齐。 + backend.failNextFetch = true; + await assert.rejects(store.ensureManagedProcessInit(), /fetch failed/); + assert.equal(store.getManagedProcessState().ready, false); + assert.equal(listeners.size, 0); + + backend.nextState = snapshot(5); + await store.refreshManagedProcessState(); + assert.equal(store.getManagedProcessState().revision, 5); + assert.equal(listeners.size, 1); + + // 后端推送直接喂入镜像。 + backend.push(snapshot(6)); + assert.equal(store.getManagedProcessState().revision, 6); + + // 陈旧修订丢弃列表但采纳 agentOnline;等修订放行(在线位翻转不递增修订)。 + backend.push(snapshot(3, { agentOnline: false, processes: [] })); + const stale = store.getManagedProcessState(); + assert.equal(stale.revision, 6); + assert.equal(stale.processes.length, 1); + assert.equal(stale.agentOnline, false); + backend.push(snapshot(6, { agentOnline: true })); + assert.equal(store.getManagedProcessState().agentOnline, true); + + // refresh 拉取新快照对账。 + backend.nextState = snapshot(9); + await store.refreshManagedProcessState(); + assert.equal(store.getManagedProcessState().revision, 9); +}); diff --git a/crates/agent-gui/test/settings/normalization.test.mjs b/crates/agent-gui/test/settings/normalization.test.mjs index aaeb3104d..1ec837628 100644 --- a/crates/agent-gui/test/settings/normalization.test.mjs +++ b/crates/agent-gui/test/settings/normalization.test.mjs @@ -376,6 +376,7 @@ test("settings normalization canonicalizes project keyed maps with Windows path openedAt: 2, }, }, + backgroundTasks: { opened: false, dismissedIds: [] }, openVersion: 0, stateVersion: 0, writerId: "", @@ -890,6 +891,7 @@ test("gateway settings sync payload redacts provider api keys", () => { openedAt: 2, }, }, + backgroundTasks: { opened: false, dismissedIds: [] }, openVersion: 3, stateVersion: 4, writerId: "", @@ -903,6 +905,7 @@ test("gateway settings sync payload redacts provider api keys", () => { openedAt: 3, }, }, + backgroundTasks: { opened: false, dismissedIds: [] }, openVersion: 2, stateVersion: 2, writerId: "", @@ -1241,6 +1244,7 @@ test("normalizes right dock from current settings", () => { }, }, }, + backgroundTasks: { opened: false, dismissedIds: [] }, openVersion: 6, stateVersion: 7, writerId: "", diff --git a/crates/agent-gui/test/settings/right-dock-model.test.mjs b/crates/agent-gui/test/settings/right-dock-model.test.mjs index 371f9c962..efbc03995 100644 --- a/crates/agent-gui/test/settings/right-dock-model.test.mjs +++ b/crates/agent-gui/test/settings/right-dock-model.test.mjs @@ -704,3 +704,77 @@ describe("file tree model", () => { ); }); }); + +test("background-tasks 开合手势:归一化上限、固定可见与 dismissal 快照", () => { + assert.deepEqual( + settings.normalizeRightDockBackgroundTasksState({ + opened: true, + dismissedIds: ["p-1", "", "p-1", 42, " p-2 ", "x".repeat(200)], + }), + { opened: true, dismissedIds: ["p-1", "p-2"] }, + ); + + const base = settings.normalizeRightDockProjectState({}); + const opened = settings.openRightDockBackgroundTasksTabState(base); + assert.equal(opened.activeTabId, settings.RIGHT_DOCK_BACKGROUND_TASKS_TAB_ID); + assert.ok(opened.tabOrder.includes(settings.RIGHT_DOCK_BACKGROUND_TASKS_TAB_ID)); + assert.deepEqual(opened.backgroundTasks, { opened: true, dismissedIds: [] }); + + // 关是 hide-only:仅写 dismissal 快照,activeTabId/tabOrder 不动;重开清空快照。 + const closed = settings.closeRightDockBackgroundTasksTabState(opened, ["p-1"]); + assert.deepEqual(closed, { + ...opened, + backgroundTasks: { opened: false, dismissedIds: ["p-1"] }, + }); + assert.deepEqual(settings.openRightDockBackgroundTasksTabState(closed).backgroundTasks, { + opened: true, + dismissedIds: [], + }); +}); + +test("background-tasks 意图落盘递增版本并随 (stateVersion, writerId) 归并", () => { + // 仅 backgroundTasks 变更也是内容:落盘并递增 stateVersion。 + const opened = settings.updateRightDockProjectState( + settings.normalizeSettings({}), + "/workspace/app", + (current) => settings.openRightDockBackgroundTasksTabState(current), + ); + const state = settings.getRightDockProjectState(opened.customSettings, "/workspace/app"); + assert.deepEqual(state.backgroundTasks, { opened: true, dismissedIds: [] }); + assert.equal(state.stateVersion, 1); + + // 仅后台意图的桶是活状态:lastUsedAt 久远时若被误判为空桶会走墓碑 TTL 被丢弃。 + assert.ok( + settings.normalizeRightDockSettings({ + projects: { + "/workspace/app": { + tools: {}, + backgroundTasks: { opened: true, dismissedIds: [] }, + stateVersion: 2, + lastUsedAt: 1, + }, + }, + }).projects["/workspace/app"], + ); + + // 更高 stateVersion 的对端意图在归并中胜出。 + const merged = sync.applyGatewaySettingsSyncPayload( + opened, + rightDockSyncPayload({ + "/workspace/app": { + activeTabId: settings.RIGHT_DOCK_BACKGROUND_TASKS_TAB_ID, + tabOrder: [settings.RIGHT_DOCK_BACKGROUND_TASKS_TAB_ID], + tools: {}, + backgroundTasks: { opened: false, dismissedIds: ["p-1"] }, + openVersion: 0, + stateVersion: 2, + writerId: "writer-remote", + lastUsedAt: Date.now(), + }, + }), + ); + assert.deepEqual(merged.customSettings.rightDock.projects["/workspace/app"].backgroundTasks, { + opened: false, + dismissedIds: ["p-1"], + }); +}); diff --git a/crates/agent-ui/src/components/project-tools/BackgroundTasksPanel.tsx b/crates/agent-ui/src/components/project-tools/BackgroundTasksPanel.tsx index 86295186d..d3ed9cc4f 100644 --- a/crates/agent-ui/src/components/project-tools/BackgroundTasksPanel.tsx +++ b/crates/agent-ui/src/components/project-tools/BackgroundTasksPanel.tsx @@ -23,6 +23,7 @@ import { createPortal } from "react-dom"; import { clearManagedProcesses, readManagedProcessLog, + refreshManagedProcessState, stopManagedProcess, useManagedProcesses, } from "../../lib/managed-process/store"; @@ -47,6 +48,10 @@ const LOG_MENU_ITEM_CLASS = const LOG_MENU_WIDTH = 150; const LOG_MENU_HEIGHT = 110; +// Visible-panel reconcile cadence: one snapshot request against the desktop +// registry, cheap on both transports. +const RECONCILE_INTERVAL_MS = 30_000; + type LogContextMenuState = { x: number; y: number; @@ -516,6 +521,24 @@ export const BackgroundTasksPanel = memo(function BackgroundTasksPanel( return () => window.clearInterval(timer); }, [active, hasRunning]); + // Change pushes can be dropped in transit (congested gateway broadcast, + // stalled webview), and a missed one would freeze this mirror forever. + // While the panel is the visible tab, reconcile against the authoritative + // registry: once on activation, then at a slow cadence. + useEffect(() => { + if (!active) return; + const refresh = () => { + if (typeof document !== "undefined" && document.visibilityState === "hidden") return; + refreshManagedProcessState().catch(() => { + // Offline agent: keep the cached list; the offline banner is already + // driven by agentOnline. + }); + }; + refresh(); + const timer = window.setInterval(refresh, RECONCILE_INTERVAL_MS); + return () => window.clearInterval(timer); + }, [active]); + const handleCloseLog = useCallback(() => { setLogProcess(null); }, []); diff --git a/crates/agent-ui/src/components/project-tools/RightDockPanel.tsx b/crates/agent-ui/src/components/project-tools/RightDockPanel.tsx index 675435499..2d72dc4d2 100644 --- a/crates/agent-ui/src/components/project-tools/RightDockPanel.tsx +++ b/crates/agent-ui/src/components/project-tools/RightDockPanel.tsx @@ -1,8 +1,11 @@ -import type { - RightDockFileTreeState, - RightDockFileTreeStatePatch, - RightDockProjectState, - SshHostConfig, +import { + closeRightDockBackgroundTasksTabState, + openRightDockBackgroundTasksTabState, + type RightDockBackgroundTasksState, + type RightDockFileTreeState, + type RightDockFileTreeStatePatch, + type RightDockProjectState, + type SshHostConfig, } from "@liveagent/app/lib/settings"; import { openUrl } from "@liveagent/app/shims/tauriOpener"; import { X } from "@liveagent/ui/components/IconSet"; @@ -40,7 +43,6 @@ import { import { RightDockChooser, RightDockCreateMenu } from "./RightDockLauncher"; import { RightDockTabStrip } from "./RightDockTabStrip"; import { - BACKGROUND_TASKS_TAB_ID, dirname, expandedPathsForFileTreePath, formatTerminalSessionTitle, @@ -471,16 +473,22 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel console.error("managed process init failed", error); }); }, []); - // Session-local visibility: the tab stays derived and never writes - // persisted right-dock settings for existence. Closing is hide-only — it - // snapshots the current task ids and touches no process state; a task id - // outside that snapshot (a newly started one) re-derives the tab. - const [backgroundTasksOpened, setBackgroundTasksOpened] = useState(false); - const [backgroundTasksDismissedIds, setBackgroundTasksDismissedIds] = - useState | null>(null); + // Visibility intent lives in the synced right-dock project state so that + // opening/closing the tab on one client mirrors to the others. Closing is + // hide-only — it snapshots the current task ids and touches no process + // state; a task id outside that snapshot (a newly started one) re-derives + // the tab everywhere. Without a project bucket to persist into (no + // projectPathKey), a session-local fallback keeps the launcher working. + const [localBackgroundTasks, setLocalBackgroundTasks] = useState({ + opened: false, + dismissedIds: [], + }); + const backgroundTasksState = projectPathKey ? projectState.backgroundTasks : localBackgroundTasks; const backgroundTasksVisible = - backgroundTasksOpened || - managedProcessState.processes.some((process) => !backgroundTasksDismissedIds?.has(process.id)); + backgroundTasksState.opened || + managedProcessState.processes.some( + (process) => !backgroundTasksState.dismissedIds.includes(process.id), + ); const backgroundTasksRunning = managedProcessState.processes.filter( (process) => process.running, ).length; @@ -515,18 +523,24 @@ export const RightDockPanel = memo(function RightDockPanel(props: RightDockPanel }, [createTerminal]); const openBackgroundTasks = useCallback(() => { - setBackgroundTasksOpened(true); - setBackgroundTasksDismissedIds(null); - activateTab(BACKGROUND_TASKS_TAB_ID); - }, [activateTab]); + if (projectPathKey) { + onProjectStateChange(openRightDockBackgroundTasksTabState); + return; + } + // No project bucket: persisted writes (including tab activation) are + // no-ops, so only the session-local visibility flips. + setLocalBackgroundTasks({ opened: true, dismissedIds: [] }); + }, [onProjectStateChange, projectPathKey]); const closeBackgroundTasks = useCallback(() => { - // Ephemeral only; the persisted activeTabId falls back at render time. - setBackgroundTasksOpened(false); - setBackgroundTasksDismissedIds( - new Set(managedProcessState.processes.map((process) => process.id)), - ); - }, [managedProcessState.processes]); + // Hide-only; the persisted activeTabId falls back at render time. + const visibleIds = managedProcessState.processes.map((process) => process.id); + if (projectPathKey) { + onProjectStateChange((current) => closeRightDockBackgroundTasksTabState(current, visibleIds)); + return; + } + setLocalBackgroundTasks({ opened: false, dismissedIds: visibleIds }); + }, [managedProcessState.processes, onProjectStateChange, projectPathKey]); const { consumeSuppressedTabClick, diff --git a/crates/agent-ui/src/components/project-tools/rightDockModel.ts b/crates/agent-ui/src/components/project-tools/rightDockModel.ts index d33e6e20d..8d3647ec9 100644 --- a/crates/agent-ui/src/components/project-tools/rightDockModel.ts +++ b/crates/agent-ui/src/components/project-tools/rightDockModel.ts @@ -1,4 +1,5 @@ import { + RIGHT_DOCK_BACKGROUND_TASKS_TAB_ID, RIGHT_DOCK_SINGLETON_TAB_IDS, RIGHT_DOCK_TOOL_KINDS, type RightDockProjectState, @@ -29,9 +30,10 @@ export const FILE_TREE_TAB_ID = RIGHT_DOCK_SINGLETON_TAB_IDS.fileTree; export const GIT_REVIEW_TAB_ID = RIGHT_DOCK_SINGLETON_TAB_IDS.gitReview; export const TUNNEL_TAB_ID = RIGHT_DOCK_SINGLETON_TAB_IDS.tunnel; export const SSH_TUNNEL_TAB_ID = RIGHT_DOCK_SINGLETON_TAB_IDS.sshTunnel; -// Derived tab: exists while the managed-process store has records; never -// persisted into right-dock settings. -export const BACKGROUND_TASKS_TAB_ID = "background-tasks"; +// Derived tab: exists while the managed-process store has undismissed +// records, or while projectState.backgroundTasks pins it open (that intent +// syncs across clients through right-dock settings). +export const BACKGROUND_TASKS_TAB_ID = RIGHT_DOCK_BACKGROUND_TASKS_TAB_ID; export const PROJECT_TOOLS_RESIZE_END_EVENT = "liveagent:project-tools-resize-end"; export type RightDockSingletonTabKind = RightDockToolKind; @@ -297,6 +299,7 @@ export function closeRightDockToolTabState( ...(activeTabId ? { activeTabId } : {}), tabOrder: state.tabOrder.filter((id) => id !== tabId), tools, + backgroundTasks: state.backgroundTasks, openVersion: state.openVersion, stateVersion: state.stateVersion, writerId: state.writerId, diff --git a/crates/agent-ui/src/lib/managed-process/store.ts b/crates/agent-ui/src/lib/managed-process/store.ts index 3ce160f48..27c5f3fd6 100644 --- a/crates/agent-ui/src/lib/managed-process/store.ts +++ b/crates/agent-ui/src/lib/managed-process/store.ts @@ -1,8 +1,15 @@ // Client mirror of the desktop-authoritative ManagedProcess registry. State // only ever changes by feeding authoritative snapshots (initial fetch, -// change events, operation responses); there is no write-back path. The -// background-tasks dock tab derives its existence from this store and never -// touches persisted right-dock settings. +// change events, operation responses, reconcile refreshes); there is no +// write-back path. The background-tasks dock tab derives its existence from +// this store combined with the synced right-dock visibility intent +// (RightDockProjectState.backgroundTasks). +// +// Change pushes are lossy in both transports (gateway drops broadcast frames +// under backpressure; a stalled desktop webview can miss Tauri events), so a +// missed push must never strand the mirror: refreshManagedProcessState pulls +// a fresh snapshot, and the store re-runs it whenever the page becomes +// visible again. import { backend } from "@liveagent/app/lib/managed-process/backend"; import { useSyncExternalStore } from "react"; @@ -55,6 +62,7 @@ export function feedManagedProcessState(next: ManagedProcessState) { /** Idempotent: subscribes to backend change events and loads the initial snapshot. */ export function ensureManagedProcessInit(): Promise { + hookVisibilityRefresh(); if (!initPromise) { initPromise = (async () => { const unsubscribe = backend.subscribe(feedManagedProcessState); @@ -74,6 +82,37 @@ export function ensureManagedProcessInit(): Promise { return initPromise; } +/** + * Pulls a fresh authoritative snapshot into the store. Also retries a failed + * init (initPromise resets on failure), so callers can use it as a blanket + * "make the mirror current" reconcile. + */ +export async function refreshManagedProcessState(): Promise { + await ensureManagedProcessInit(); + feedManagedProcessState(await backend.fetchState()); +} + +let visibilityHooked = false; + +// A hidden webview/tab can miss change pushes (throttled webview, dropped +// broadcast frames); reconcile as soon as the page is visible again so the +// dock tab derives from current data even before the panel is opened. +function hookVisibilityRefresh() { + if (visibilityHooked || typeof document === "undefined") { + return; + } + visibilityHooked = true; + document.addEventListener("visibilitychange", () => { + if (document.visibilityState !== "visible") { + return; + } + refreshManagedProcessState().catch(() => { + // Offline agent or transport gap: keep the cached mirror; the next + // visibility flip or panel reconcile retries. + }); + }); +} + export async function stopManagedProcess(id: string): Promise { const next = await backend.stop(id); if (next) feedManagedProcessState(next); diff --git a/crates/agent-ui/src/lib/settings/index.ts b/crates/agent-ui/src/lib/settings/index.ts index ec8ebe43b..1d631e08c 100644 --- a/crates/agent-ui/src/lib/settings/index.ts +++ b/crates/agent-ui/src/lib/settings/index.ts @@ -119,6 +119,19 @@ export type RightDockToolTab = { uiState?: Record; }; +// Stable id of the derived background-tasks tab (not a RightDockToolKind: +// its existence also derives from the managed-process store at render time). +export const RIGHT_DOCK_BACKGROUND_TASKS_TAB_ID = "background-tasks"; + +// Cross-client visibility intent for the background-tasks tab. `opened` +// keeps the tab visible with no processes; `dismissedIds` snapshots the +// process ids visible at close time — a process id outside the snapshot +// re-derives the tab on every client. +export type RightDockBackgroundTasksState = { + opened: boolean; + dismissedIds: string[]; +}; + // Persisted dock state is user intent only: terminal tab existence is derived // from live sessions at render time, so tabOrder may contain session ids that // are dead or not yet loaded — they are preserved here and lazily collected on @@ -127,6 +140,7 @@ export type RightDockProjectState = { activeTabId?: string; tabOrder: string[]; tools: Partial>; + backgroundTasks: RightDockBackgroundTasksState; openVersion: number; stateVersion: number; writerId: string; @@ -2384,6 +2398,7 @@ export function normalizeRightDockProjectState(input: unknown): RightDockProject ...(activeTabId ? { activeTabId } : {}), tabOrder, tools, + backgroundTasks: normalizeRightDockBackgroundTasksState(obj.backgroundTasks), openVersion: normalizeIntegerInRange(obj.openVersion, 0, Number.MAX_SAFE_INTEGER, 0), stateVersion: normalizeIntegerInRange(obj.stateVersion, 0, Number.MAX_SAFE_INTEGER, 0), writerId: typeof obj.writerId === "string" ? obj.writerId.trim().slice(0, 32) : "", @@ -2391,6 +2406,28 @@ export function normalizeRightDockProjectState(input: unknown): RightDockProject }; } +export function normalizeRightDockBackgroundTasksState( + input: unknown, +): RightDockBackgroundTasksState { + const obj = (input && typeof input === "object" ? input : {}) as Record; + const dismissedIds: string[] = []; + if (Array.isArray(obj.dismissedIds)) { + const seen = new Set(); + for (const item of obj.dismissedIds) { + if (typeof item !== "string") continue; + const id = item.trim(); + if (!id || id.length > 160 || seen.has(id)) continue; + seen.add(id); + dismissedIds.push(id); + if (dismissedIds.length >= 200) break; + } + } + return { + opened: obj.opened === true, + dismissedIds, + }; +} + export function normalizeRightDockSettings(input: unknown): RightDockSettings { const obj = (input && typeof input === "object" ? input : {}) as Record; const rawProjects = ( @@ -2404,7 +2441,13 @@ export function normalizeRightDockSettings(input: unknown): RightDockSettings { const normalizedPathKey = workspaceProjectPathKey(pathKey); if (!normalizedPathKey || projects[normalizedPathKey]) continue; const project = normalizeRightDockProjectState(projectState); - const isEmpty = Object.keys(project.tools).length === 0; + // A bucket holding only background-tasks intent (manually opened tab, or + // dismissal snapshot that must keep suppressing derived tabs) is live + // state, not a tombstone. + const isEmpty = + Object.keys(project.tools).length === 0 && + !project.backgroundTasks.opened && + project.backgroundTasks.dismissedIds.length === 0; if (isEmpty && project.openVersion === 0 && project.stateVersion === 0) continue; if (isEmpty) { // Tombstone: start (or continue) the expiry clock, drop once elapsed. @@ -3009,6 +3052,7 @@ function rightDockProjectContentKey(state: RightDockProjectState): string { activeTabId: state.activeTabId ?? "", tabOrder: state.tabOrder, tools: RIGHT_DOCK_TOOL_KINDS.map((kind) => [kind, state.tools[kind] ?? null]), + backgroundTasks: state.backgroundTasks, openVersion: state.openVersion, }); } @@ -3127,6 +3171,34 @@ export function isRightDockSingletonTabOpen( return Boolean(state.tools[kind]); } +// Open gesture for the background-tasks tab: pin it visible everywhere and +// clear any dismissal snapshot so previously hidden records reappear. +export function openRightDockBackgroundTasksTabState( + current: RightDockProjectState, +): RightDockProjectState { + const tabId = RIGHT_DOCK_BACKGROUND_TASKS_TAB_ID; + return { + ...current, + activeTabId: tabId, + tabOrder: current.tabOrder.includes(tabId) ? current.tabOrder : [...current.tabOrder, tabId], + backgroundTasks: { opened: true, dismissedIds: [] }, + }; +} + +// Close gesture: hide-only. The visible process ids are snapshotted so a +// process outside the snapshot (a newly started one) re-derives the tab on +// every client. The persisted activeTabId stays put — render-time resolution +// falls back while the tab is hidden. +export function closeRightDockBackgroundTasksTabState( + current: RightDockProjectState, + visibleProcessIds: readonly string[], +): RightDockProjectState { + return { + ...current, + backgroundTasks: { opened: false, dismissedIds: [...visibleProcessIds] }, + }; +} + export function removeRightDockProjectState( prev: AppSettings, projectPathKey: string, @@ -3143,7 +3215,10 @@ export function removeRightDockProjectState( ); if (!hasRightDockProject && !hasSshProjectAssociation) return prev; const currentRightDockProject = getRightDockProjectState(prev.customSettings, normalizedPathKey); - const hasRightDockTools = Object.keys(currentRightDockProject.tools).length > 0; + const hasRightDockTools = + Object.keys(currentRightDockProject.tools).length > 0 || + currentRightDockProject.backgroundTasks.opened || + currentRightDockProject.backgroundTasks.dismissedIds.length > 0; if (hasRightDockProject && !hasRightDockTools && !hasSshProjectAssociation) return prev; const projects = hasRightDockProject @@ -3153,6 +3228,7 @@ export function removeRightDockProjectState( projects[normalizedPathKey] = { tabOrder: [], tools: {}, + backgroundTasks: { opened: false, dismissedIds: [] }, openVersion: currentRightDockProject.openVersion + 1, stateVersion: currentRightDockProject.stateVersion + 1, writerId: getRightDockWriterId(),