diff --git a/apps/desktop-tauri/scripts/proof-scenarios.json b/apps/desktop-tauri/scripts/proof-scenarios.json index c576456a44..99ca76f18d 100644 --- a/apps/desktop-tauri/scripts/proof-scenarios.json +++ b/apps/desktop-tauri/scripts/proof-scenarios.json @@ -736,6 +736,16 @@ { "expectText": "%" }, { "screenshot": "floatbar" } ] + }, + { + "name": "tray-agent-sessions-hosts", + "query": "window=main&fixture=agent-sessions-hosts", + "size": [310, 1032], + "steps": [ + { "expectText": ["WORKSTATION-A — 2", "BUILD-BOX — 1", "LAB-HOST — unreachable", "ssh: connection timed out", "example-project"] }, + { "expectNoText": "LAB-HOST — 0" }, + { "screenshot": "tray-agent-sessions-hosts", "clip": "panel" } + ] } ] } diff --git a/apps/desktop-tauri/src/components/AgentSessions.test.tsx b/apps/desktop-tauri/src/components/AgentSessions.test.tsx index 9d4940bc5e..4db581081b 100644 --- a/apps/desktop-tauri/src/components/AgentSessions.test.tsx +++ b/apps/desktop-tauri/src/components/AgentSessions.test.tsx @@ -105,4 +105,66 @@ describe("AgentSessions", () => { expect(await screen.findByText("AgentSessionsProviderPi")).toBeTruthy(); }); + + it("groups sessions under one header per host with its session count", async () => { + api.listAgentSessions.mockResolvedValue({ + status: "hosts", + hosts: [ + { + host: "DESKTOP", + sessions: [ + session({ id: "1", host: "DESKTOP", workspace: { cwd: null, projectName: "alpha" } }), + session({ id: "2", host: "DESKTOP", workspace: { cwd: null, projectName: "beta" } }), + ], + error: null, + }, + { + host: "LAPTOP", + sessions: [session({ id: "3", host: "LAPTOP", workspace: { cwd: null, projectName: "gamma" } })], + error: null, + }, + ], + }); + render(); + + const desktopHeader = await screen.findByText("DESKTOP — 2"); + const laptopHeader = await screen.findByText("LAPTOP — 1"); + const alpha = screen.getByText("alpha"); + const gamma = screen.getByText("gamma"); + // Each host's rows follow its own header, and the hosts stay in order. + expect(desktopHeader.compareDocumentPosition(alpha) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(alpha.compareDocumentPosition(laptopHeader) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + expect(laptopHeader.compareDocumentPosition(gamma) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("shows an unreachable host as its own row with the error", async () => { + api.listAgentSessions.mockResolvedValue({ + status: "hosts", + hosts: [ + { + host: "DESKTOP", + sessions: [session({ id: "1", workspace: { cwd: null, projectName: "alpha" } })], + error: null, + }, + { host: "BUILD-BOX", sessions: [], error: "ssh: connect timed out" }, + ], + }); + render(); + + expect(await screen.findByText("BUILD-BOX — AgentSessionsHostUnreachable")).toBeTruthy(); + expect(screen.getByText("ssh: connect timed out").className).toContain("agent-sessions__error"); + expect(screen.queryByText("BUILD-BOX — 0")).toBeNull(); + expect(screen.getByText("DESKTOP — 1")).toBeTruthy(); + }); + + it("keeps a reachable host with no sessions visible as a zero-count header", async () => { + api.listAgentSessions.mockResolvedValue({ + status: "hosts", + hosts: [{ host: "IDLE-BOX", sessions: [], error: null }], + }); + render(); + + expect(await screen.findByText("IDLE-BOX — 0")).toBeTruthy(); + expect(screen.getByText("AgentSessionsEmpty")).toBeTruthy(); + }); }); diff --git a/apps/desktop-tauri/src/components/AgentSessions.tsx b/apps/desktop-tauri/src/components/AgentSessions.tsx index 73f8b336ca..d6ef9761c1 100644 --- a/apps/desktop-tauri/src/components/AgentSessions.tsx +++ b/apps/desktop-tauri/src/components/AgentSessions.tsx @@ -33,9 +33,6 @@ export default function AgentSessions() { if (result?.status === "disabled") return null; const hosts = result?.status === "hosts" ? result.hosts : []; const sessions = hosts.flatMap((host) => host.sessions); - const hostErrors = hosts.flatMap((host) => - host.error ? [`${host.host}: ${host.error}`] : [], - ); const focus = (session: AgentSession) => { void focusAgentSession(session).then((focusResult) => { @@ -53,26 +50,41 @@ export default function AgentSessions() { {result && sessions.length === 0 && (

{t("AgentSessionsEmpty")}

)} - {sessions.map((session) => ( - - ))} - {[...hostErrors, ...(error ? [error] : [])].map((message) => ( -

{message}

+ {host.error + ? `${host.host} — ${t("AgentSessionsHostUnreachable")}` + : `${host.host} — ${host.sessions.length}`} + + {host.error &&

{host.error}

} + {host.sessions.map((session) => ( + + ))} + ))} + {error &&

{error}

} ); } diff --git a/apps/desktop-tauri/src/i18n/keyGroups/core.ts b/apps/desktop-tauri/src/i18n/keyGroups/core.ts index 860e6b7169..842187c7e5 100644 --- a/apps/desktop-tauri/src/i18n/keyGroups/core.ts +++ b/apps/desktop-tauri/src/i18n/keyGroups/core.ts @@ -744,6 +744,7 @@ export const CORE_LOCALE_KEYS = [ "AgentSessionsSshHostsHelper", "AgentSessionsLoading", "AgentSessionsEmpty", + "AgentSessionsHostUnreachable", "UpdatesTitle", "AboutBuilt", "SectionLinks", diff --git a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts index 99aaa49558..0c5dc71977 100644 --- a/apps/desktop-tauri/src/proof/fixtures/scenarios.ts +++ b/apps/desktop-tauri/src/proof/fixtures/scenarios.ts @@ -1,5 +1,7 @@ import type { AccountUsageEntryBridge, + AgentSession, + AgentSessionDiscoveryResult, ClaudeAccount, ClaudeSwapAccount, ClaudeSwapAccountsState, @@ -54,6 +56,7 @@ export const PROOF_SCENARIO_NAMES = [ "vercel", "vercel-team-id-invalid", "kimi-bonus-credits", + "agent-sessions-hosts", ] as const; export type ProofScenarioName = (typeof PROOF_SCENARIO_NAMES)[number]; @@ -92,6 +95,8 @@ export interface ProofScenario { workspaceIds?: Record; /** Error a workspace-value save rejects with, by provider id; absent means saves succeed. */ workspaceIdSaveErrors?: Record; + /** Agent-session discovery served by `list_agent_sessions`; absent means disabled. */ + agentSessions?: AgentSessionDiscoveryResult; } export interface LocalSpendFixture { @@ -1147,6 +1152,55 @@ function kimiBonusCreditsScenario(now: Date): ProofScenario { }; } +function proofAgentSession( + id: string, + host: string, + provider: AgentSession["provider"], + projectName: string, +): AgentSession { + return { + id, + provider, + source: "cli", + state: "active", + pid: null, + transcriptPath: null, + host, + workspace: { cwd: null, projectName }, + activity: { startedAt: null, lastActivityAt: null }, + focusTarget: { kind: "none" }, + }; +} + +/** Two reachable hosts with sessions and one unreachable host; all names are synthetic. */ +function agentSessionsHostsScenario(now: Date): ProofScenario { + const base = defaultScenario(now); + return { + ...base, + name: "agent-sessions-hosts", + settings: { ...base.settings, agentSessionsEnabled: true, agentSessionsLocalOnly: false }, + agentSessions: { + status: "hosts", + hosts: [ + { + host: "WORKSTATION-A", + sessions: [ + proofAgentSession("proof-1", "WORKSTATION-A", "codex", "example-project"), + proofAgentSession("proof-2", "WORKSTATION-A", "claude", "example-notes"), + ], + error: null, + }, + { + host: "BUILD-BOX", + sessions: [proofAgentSession("proof-3", "BUILD-BOX", "claude", "example-ci")], + error: null, + }, + { host: "LAB-HOST", sessions: [], error: "ssh: connection timed out" }, + ], + }, + }; +} + const BUILDERS: Record ProofScenario> = { default: defaultScenario, "two-codex-accounts": twoCodexAccountsScenario, @@ -1175,6 +1229,7 @@ const BUILDERS: Record ProofScenario> = { vercel: vercelScenario, "vercel-team-id-invalid": vercelTeamIdInvalidScenario, "kimi-bonus-credits": kimiBonusCreditsScenario, + "agent-sessions-hosts": agentSessionsHostsScenario, }; export function isProofScenarioName(value: string): value is ProofScenarioName { diff --git a/apps/desktop-tauri/src/proof/router.ts b/apps/desktop-tauri/src/proof/router.ts index 910a75b996..591a3d9eeb 100644 --- a/apps/desktop-tauri/src/proof/router.ts +++ b/apps/desktop-tauri/src/proof/router.ts @@ -5,6 +5,7 @@ import type { CurrentSurfaceState, HttpServerConfig, HttpServerSnapshot, + SessionFocusResult, SettingsSnapshot, SettingsUpdate, TokenAccountBridge, @@ -59,7 +60,6 @@ export const INERT_ACTIONS = [ "codex_account_switch", "download_update", "export_preferences", - "focus_agent_session", "get_openai_live_usage", "get_openai_org_rate_limits", "grok_account_add", @@ -424,7 +424,8 @@ export function createProofRouter( }, grok_accounts_list: () => [], get_hooks_config: proofHooksConfig, - list_agent_sessions: () => ({ status: "disabled" }), + list_agent_sessions: () => scenario.agentSessions ?? { status: "disabled" }, + focus_agent_session: (): SessionFocusResult => ({ status: "focused" }), get_gemini_cli_signed_in: () => ({ signedIn: true, credentialsPath: null }), get_vertexai_status: () => ({ hasCredentials: false, credentialsPath: null }), list_jetbrains_detected_ides: () => [], diff --git a/apps/desktop-tauri/src/styles/parity-overrides.css b/apps/desktop-tauri/src/styles/parity-overrides.css index 82d63f6e1f..9eceb824f7 100644 --- a/apps/desktop-tauri/src/styles/parity-overrides.css +++ b/apps/desktop-tauri/src/styles/parity-overrides.css @@ -321,6 +321,20 @@ color: var(--provider-status-error); } +.agent-sessions__host { + display: grid; + gap: 6px; +} + +.agent-sessions__host-header { + font-weight: 600; + opacity: 0.8; +} + +.agent-sessions__host-header--unreachable { + color: var(--provider-status-error); +} + /* ── Per-provider accent color override (#2972) ─────────────────── */ .provider-detail-accent-color { margin-top: 16px; diff --git a/rust/src/locale.rs b/rust/src/locale.rs index 549b474e91..c3d5a06e50 100644 --- a/rust/src/locale.rs +++ b/rust/src/locale.rs @@ -999,6 +999,7 @@ locale_keys! { AgentSessionsSshHostsHelper, AgentSessionsLoading, AgentSessionsEmpty, + AgentSessionsHostUnreachable, UpdatesTitle, AboutBuilt, SectionLinks, diff --git a/rust/src/locale/en-US.ftl b/rust/src/locale/en-US.ftl index d1c724b93a..170e2e376f 100644 --- a/rust/src/locale/en-US.ftl +++ b/rust/src/locale/en-US.ftl @@ -565,6 +565,7 @@ AgentSessionsSshHostsLabel = Additional SSH hosts AgentSessionsSshHostsHelper = Comma-separated SSH targets. Connections use non-interactive mode and short timeouts. AgentSessionsLoading = Looking for agent sessions... AgentSessionsEmpty = No active agent sessions. +AgentSessionsHostUnreachable = unreachable UpdatesTitle = Updates AboutBuilt = Built { "{}" } SectionLinks = Links diff --git a/rust/src/locale/es-MX.ftl b/rust/src/locale/es-MX.ftl index 67b3e9daa4..884e3392be 100644 --- a/rust/src/locale/es-MX.ftl +++ b/rust/src/locale/es-MX.ftl @@ -533,6 +533,7 @@ AgentSessionsSshHostsLabel = Hosts SSH AgentSessionsSshHostsHelper = Destinos SSH separados por comas. Las conexiones usan modo no interactivo y tiempos de espera cortos. AgentSessionsLoading = Buscando sesiones de agentes... AgentSessionsEmpty = No hay sesiones de agentes activas. +AgentSessionsHostUnreachable = inaccesible UpdatesTitle = Actualizaciones AboutBuilt = Compilado el { "{}" } SectionLinks = Enlaces diff --git a/rust/src/locale/ja-JP.ftl b/rust/src/locale/ja-JP.ftl index dac8b77dfb..b3b60a6db1 100644 --- a/rust/src/locale/ja-JP.ftl +++ b/rust/src/locale/ja-JP.ftl @@ -533,6 +533,7 @@ AgentSessionsSshHostsLabel = SSH ホスト AgentSessionsSshHostsHelper = カンマ区切りの SSH ターゲット。接続は非対話モード・短いタイムアウトを使用。 AgentSessionsLoading = エージェントセッションを検索中… AgentSessionsEmpty = アクティブなエージェントセッションはありません。 +AgentSessionsHostUnreachable = 接続できません UpdatesTitle = アップデート AboutBuilt = ビルド日 { "{}" } SectionLinks = リンク diff --git a/rust/src/locale/ko-KR.ftl b/rust/src/locale/ko-KR.ftl index 144ab686a4..3be7e11dad 100644 --- a/rust/src/locale/ko-KR.ftl +++ b/rust/src/locale/ko-KR.ftl @@ -533,6 +533,7 @@ AgentSessionsSshHostsLabel = SSH 호스트 AgentSessionsSshHostsHelper = 쉼표로 구분된 SSH 대상입니다. 연결은 비대화형 모드와 짧은 제한 시간을 사용합니다. AgentSessionsLoading = 에이전트 세션을 찾는 중... AgentSessionsEmpty = 활성 에이전트 세션이 없습니다. +AgentSessionsHostUnreachable = 연결할 수 없음 UpdatesTitle = 업데이트 AboutBuilt = 빌드 날짜 { "{}" } SectionLinks = 링크 diff --git a/rust/src/locale/pt-BR.ftl b/rust/src/locale/pt-BR.ftl index 8d98f53549..67789ac1d1 100644 --- a/rust/src/locale/pt-BR.ftl +++ b/rust/src/locale/pt-BR.ftl @@ -552,6 +552,7 @@ AgentSessionsSshHostsLabel = Hosts SSH AgentSessionsSshHostsHelper = Destinos SSH separados por vírgula. As conexões usam modo não interativo e tempos limites curtos. AgentSessionsLoading = Procurando sessões de agente... AgentSessionsEmpty = Nenhuma sessão de agente ativa. +AgentSessionsHostUnreachable = inacessível UpdatesTitle = Atualizações AboutBuilt = Compilado em { "{}" } SectionLinks = Links diff --git a/rust/src/locale/ru-RU.ftl b/rust/src/locale/ru-RU.ftl index aca02c77ab..fa030fbd50 100644 --- a/rust/src/locale/ru-RU.ftl +++ b/rust/src/locale/ru-RU.ftl @@ -565,6 +565,7 @@ AgentSessionsSshHostsLabel = SSH-хосты AgentSessionsSshHostsHelper = Цели SSH, разделенные запятыми. Соединения используют неинтерактивный режим и короткие таймауты. AgentSessionsLoading = Ищу агентские сессии... AgentSessionsEmpty = Нет сеансов активных агентов. +AgentSessionsHostUnreachable = недоступен UpdatesTitle = Обновления AboutBuilt = Сборка от { "{}" } SectionLinks = Ссылки diff --git a/rust/src/locale/tr-TR.ftl b/rust/src/locale/tr-TR.ftl index 872146dc7c..28db3139e0 100644 --- a/rust/src/locale/tr-TR.ftl +++ b/rust/src/locale/tr-TR.ftl @@ -565,6 +565,7 @@ AgentSessionsSshHostsLabel = SSH sunucuları AgentSessionsSshHostsHelper = Virgülle ayrılmış SSH hedefleri. Bağlantılar etkileşimsiz modu ve kısa zaman aşımlarını kullanır. AgentSessionsLoading = Aracı oturumları aranıyor... AgentSessionsEmpty = Etkin aracı oturumu yok. +AgentSessionsHostUnreachable = ulaşılamıyor UpdatesTitle = Güncellemeler AboutBuilt = Derleme tarihi { "{}" } SectionLinks = Bağlantılar diff --git a/rust/src/locale/uk-UA.ftl b/rust/src/locale/uk-UA.ftl index 32ec3505db..b344d6261d 100644 --- a/rust/src/locale/uk-UA.ftl +++ b/rust/src/locale/uk-UA.ftl @@ -565,6 +565,7 @@ AgentSessionsSshHostsLabel = SSH-хости AgentSessionsSshHostsHelper = SSH-адреси через кому. З’єднання працюють у неінтерактивному режимі з короткими тайм-аутами. AgentSessionsLoading = Пошук сеансів агентів... AgentSessionsEmpty = Немає активних сеансів агентів. +AgentSessionsHostUnreachable = недоступний UpdatesTitle = Оновлення AboutBuilt = Збірка від { "{}" } SectionLinks = Посилання diff --git a/rust/src/locale/zh-CN.ftl b/rust/src/locale/zh-CN.ftl index 7cc45acb64..fae6c91936 100644 --- a/rust/src/locale/zh-CN.ftl +++ b/rust/src/locale/zh-CN.ftl @@ -533,6 +533,7 @@ AgentSessionsSshHostsLabel = SSH 主机 AgentSessionsSshHostsHelper = 以逗号分隔的 SSH 目标。连接使用非交互模式和短超时。 AgentSessionsLoading = 正在查找 Agent 会话… AgentSessionsEmpty = 没有活跃的 Agent 会话。 +AgentSessionsHostUnreachable = 无法连接 UpdatesTitle = 更新 AboutBuilt = 构建于 { "{}" } SectionLinks = 链接 diff --git a/rust/src/locale/zh-TW.ftl b/rust/src/locale/zh-TW.ftl index 97caa0fdab..497f9f5686 100644 --- a/rust/src/locale/zh-TW.ftl +++ b/rust/src/locale/zh-TW.ftl @@ -533,6 +533,7 @@ AgentSessionsSshHostsLabel = SSH 主機 AgentSessionsSshHostsHelper = 以逗號分隔的 SSH 目標。連線使用非互動模式和短逾時。 AgentSessionsLoading = 正在查找 Agent 工作階段… AgentSessionsEmpty = 沒有活躍的 Agent 工作階段。 +AgentSessionsHostUnreachable = 無法連線 UpdatesTitle = 更新 AboutBuilt = 建置於 { "{}" } SectionLinks = 連結