From 8371ea0c7d3717cdc1bbfd931b1eb7cbef91c0d9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 5 Mar 2026 14:21:16 +0700 Subject: [PATCH] refactor: unify workspace tab management and add runtime metrics --- packages/app/e2e/create-agent.spec.ts | 36 +--- packages/app/e2e/helpers/app.ts | 17 +- packages/app/e2e/helpers/workspace-tabs.ts | 52 +++++ packages/app/e2e/helpers/workspace-ui.ts | 70 ++++++- packages/app/e2e/host-selection.spec.ts | 9 +- .../app/e2e/pairing-offer-parsing.spec.ts | 4 +- .../app/e2e/relay-fallback-connect.spec.ts | 4 +- packages/app/e2e/relay-stability.spec.ts | 9 +- packages/app/e2e/terminal-pane.spec.ts | 3 - .../terminal-responsiveness-benchmark.spec.ts | 62 +++--- .../e2e/workspace-draft-tab-retarget.spec.ts | 125 ++++++++++++ .../e2e/workspace-header-tabs-restore.spec.ts | 37 ++-- .../workspace-sidebar-content-sync.spec.ts | 77 ++------ .../app/src/components/agent-input-area.tsx | 9 +- .../app/src/components/explorer-sidebar.tsx | 4 +- packages/app/src/components/git-diff-pane.tsx | 8 +- packages/app/src/components/left-sidebar.tsx | 4 +- packages/app/src/components/message-input.tsx | 20 +- .../src/components/sidebar-workspace-list.tsx | 2 +- .../src/screens/agent/agent-ready-screen.tsx | 21 -- .../workspace/use-workspace-tab-layout.ts | 6 +- .../workspace/workspace-desktop-tabs-row.tsx | 16 +- .../workspace/workspace-draft-agent-tab.tsx | 12 +- .../screens/workspace/workspace-screen.tsx | 118 ++++++++++- .../workspace/workspace-tab-model.test.ts | 103 ++++++---- .../screens/workspace/workspace-tab-model.ts | 105 ++++++---- .../src/stores/workspace-tabs-store.test.ts | 132 ++++++++----- .../app/src/stores/workspace-tabs-store.ts | 184 +++++++++--------- packages/app/src/styles/theme.ts | 2 + .../server/session.create-agent-title.test.ts | 35 ++++ packages/server/src/server/session.ts | 62 +++++- .../server/src/server/websocket-server.ts | 177 ++++++++++++++++- 32 files changed, 1064 insertions(+), 461 deletions(-) create mode 100644 packages/app/e2e/helpers/workspace-tabs.ts create mode 100644 packages/app/e2e/workspace-draft-tab-retarget.spec.ts create mode 100644 packages/server/src/server/session.create-agent-title.test.ts diff --git a/packages/app/e2e/create-agent.spec.ts b/packages/app/e2e/create-agent.spec.ts index 15eaacfee..c96d793b1 100644 --- a/packages/app/e2e/create-agent.spec.ts +++ b/packages/app/e2e/create-agent.spec.ts @@ -1,44 +1,26 @@ import { test, expect } from './fixtures'; -import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'; +import { createAgentInRepo } from './helpers/app'; import { createTempGitRepo } from './helpers/workspace'; -function parseAgentUrl(url: string): { serverId: string; agentId: string } { - const parsed = new URL(url); - const match = parsed.pathname.match(/\/h\/([^/]+)\/agent\/([^/?#]+)/); - if (!match) { - throw new Error(`Expected /h/:serverId/agent/:agentId URL, got ${url}`); - } - return { - serverId: decodeURIComponent(match[1]), - agentId: decodeURIComponent(match[2]), - }; -} - test('create agent in a temp repo', async ({ page }) => { const repo = await createTempGitRepo(); const prompt = "Respond with exactly: Hello"; try { - await gotoHome(page); - await setWorkingDirectory(page, repo.path); - await ensureHostSelected(page); - await createAgent(page, prompt); + await createAgentInRepo(page, { directory: repo.path, prompt }); // Verify user message is shown in the stream await expect(page.getByText(prompt, { exact: true })).toBeVisible(); // Verify we used the seeded fast model (do not fall back to other defaults). - await page.getByTestId('agent-overflow-menu').click(); - await expect(page.getByText('Model', { exact: true })).toBeVisible(); - await expect( - page.getByTestId('agent-overflow-content').getByText(/gpt-5\.1-codex-mini/i) - ).toBeVisible(); + const modelPicker = page.getByRole("button", { name: /select agent model/i }).first(); + await expect(modelPicker).toBeVisible({ timeout: 30000 }); + await expect(modelPicker).toContainText(/gpt-5\.1-codex-mini/i); - // Verify the created agent's title reflects the response. - const { serverId, agentId } = parseAgentUrl(page.url()); - const agentRow = page.getByTestId(`agent-row-${serverId}-${agentId}`).first(); - await expect(agentRow).not.toContainText(/new agent/i, { timeout: 30000 }); - await expect(agentRow).toContainText(/hello|greet|response/i, { timeout: 30000 }); + // Verify the assistant response is rendered. + await expect(page.getByText("Hello", { exact: true }).first()).toBeVisible({ + timeout: 30000, + }); } finally { await repo.cleanup(); } diff --git a/packages/app/e2e/helpers/app.ts b/packages/app/e2e/helpers/app.ts index e3f7efea9..bb4c77547 100644 --- a/packages/app/e2e/helpers/app.ts +++ b/packages/app/e2e/helpers/app.ts @@ -158,7 +158,12 @@ export const gotoHome = async (page: Page) => { await page.goto('/'); await ensureE2EStorageSeeded(page); await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible(); - await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible(); + const composer = page.getByRole('textbox', { name: 'Message agent...' }); + if (!(await composer.first().isVisible().catch(() => false))) { + const newAgentButton = page.getByText('New agent', { exact: true }).first(); + await newAgentButton.click(); + } + await expect(composer.first()).toBeVisible({ timeout: 30000 }); }; export const openSettings = async (page: Page) => { @@ -585,6 +590,16 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) => await createAgent(page, config.prompt); }; +export const createAgentInRepo = async ( + page: Page, + config: Pick +) => { + await gotoHome(page); + await ensureHostSelected(page); + await setWorkingDirectory(page, config.directory); + await createAgent(page, config.prompt); +}; + export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => { const promptText = page.getByTestId('permission-request-question').first(); await expect(promptText).toBeVisible({ timeout }); diff --git a/packages/app/e2e/helpers/workspace-tabs.ts b/packages/app/e2e/helpers/workspace-tabs.ts new file mode 100644 index 000000000..37718f12b --- /dev/null +++ b/packages/app/e2e/helpers/workspace-tabs.ts @@ -0,0 +1,52 @@ +import { expect, type Page } from "@playwright/test"; + +export async function getWorkspaceTabTestIds(page: Page): Promise { + const tabs = page.locator('[data-testid^="workspace-tab-"]'); + const count = await tabs.count(); + const ids: string[] = []; + for (let index = 0; index < count; index += 1) { + const testId = await tabs.nth(index).getAttribute("data-testid"); + if (testId && !ids.includes(testId)) { + ids.push(testId); + } + } + return ids; +} + +export async function waitForWorkspaceTabsVisible(page: Page): Promise { + await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({ + timeout: 30_000, + }); + await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({ + timeout: 30_000, + }); +} + +export async function ensureWorkspaceAgentPaneVisible(page: Page): Promise { + const toggle = page.getByTestId("workspace-explorer-toggle").first(); + if (!(await toggle.isVisible().catch(() => false))) { + return; + } + const isExpanded = (await toggle.getAttribute("aria-expanded")) === "true"; + if (isExpanded) { + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "false", { + timeout: 10_000, + }); + } +} + +export async function sampleWorkspaceTabIds( + page: Page, + options: { durationMs?: number; intervalMs?: number } = {} +): Promise { + const durationMs = options.durationMs ?? 2_500; + const intervalMs = options.intervalMs ?? 50; + const snapshots: string[][] = []; + const start = Date.now(); + while (Date.now() - start <= durationMs) { + snapshots.push(await getWorkspaceTabTestIds(page)); + await page.waitForTimeout(intervalMs); + } + return snapshots; +} diff --git a/packages/app/e2e/helpers/workspace-ui.ts b/packages/app/e2e/helpers/workspace-ui.ts index 6b939e977..e11b50147 100644 --- a/packages/app/e2e/helpers/workspace-ui.ts +++ b/packages/app/e2e/helpers/workspace-ui.ts @@ -1,24 +1,74 @@ -import { expect, type Page } from "@playwright/test"; +import { expect, type Page } from '@playwright/test'; +import { buildHostWorkspaceRoute } from '@/utils/host-routes'; +import { gotoHome } from './app'; export async function openNewAgentComposer(page: Page): Promise { - await page.goto("/"); + await gotoHome(page); +} - const sidebarNewAgent = page.getByTestId("sidebar-new-agent").first(); - if (await sidebarNewAgent.isVisible().catch(() => false)) { - await sidebarNewAgent.click(); - } else { - await page.getByText("New agent", { exact: true }).first().click(); +export function workspaceLabelFromPath(value: string): string { + const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); + const parts = normalized.split('/').filter(Boolean); + return parts[parts.length - 1] ?? normalized; +} + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function candidateWorkspaceIds(inputPath: string): string[] { + const trimmed = inputPath.replace(/\/+$/, ''); + const candidates = new Set([trimmed]); + if (trimmed.startsWith('/var/')) { + candidates.add(`/private${trimmed}`); } + if (trimmed.startsWith('/private/var/')) { + candidates.add(trimmed.replace(/^\/private/, '')); + } + return Array.from(candidates); +} - await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({ +function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) { + const ids = candidateWorkspaceIds(workspacePath).map( + (id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]` + ); + return page.locator(ids.join(',')).first(); +} + +export async function switchWorkspaceViaSidebar(input: { + page: Page; + serverId: string; + targetWorkspacePath: string; +}): Promise { + const row = workspaceRowLocator(input.page, input.serverId, input.targetWorkspacePath); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.click(); + + const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.targetWorkspacePath); + await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), { + timeout: 30_000, + }); +} + +export async function expectWorkspaceHeader( + page: Page, + input: { title: string; subtitle: string } +): Promise { + const titleLocator = page.getByTestId('workspace-header-title'); + const subtitleLocator = page.getByTestId('workspace-header-subtitle'); + + await expect(titleLocator.first()).toHaveText(input.title, { + timeout: 30_000, + }); + await expect(subtitleLocator.first()).toHaveText(input.subtitle, { timeout: 30_000, }); } export async function seedWorkspaceActivity(page: Page, marker: string): Promise { - const input = page.getByRole("textbox", { name: "Message agent..." }); + const input = page.getByRole('textbox', { name: 'Message agent...' }); await expect(input).toBeEditable({ timeout: 30_000 }); await input.fill(marker); - await input.press("Enter"); + await input.press('Enter'); await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); } diff --git a/packages/app/e2e/host-selection.spec.ts b/packages/app/e2e/host-selection.spec.ts index 737cca021..efc225ea7 100644 --- a/packages/app/e2e/host-selection.spec.ts +++ b/packages/app/e2e/host-selection.spec.ts @@ -65,6 +65,13 @@ test('new agent respects serverId in the URL', async ({ page }) => { await page.goto(`/?serverId=${encodeURIComponent(serverId)}`); await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible(); + const newAgentButton = page.getByTestId('sidebar-new-agent').first(); + if (await newAgentButton.isVisible().catch(() => false)) { + await newAgentButton.click(); + } else { + await page.getByText('New agent', { exact: true }).first().click(); + } + const input = page.getByRole('textbox', { name: 'Message agent...' }); await expect(input).toBeEditable({ timeout: 30000 }); }); @@ -95,7 +102,7 @@ test('new agent auto-selects first online host when no preference is stored', as updatedAt: nowIso, }; - await page.goto('/'); + await gotoHome(page); await page.evaluate( ({ daemon }) => { const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; diff --git a/packages/app/e2e/pairing-offer-parsing.spec.ts b/packages/app/e2e/pairing-offer-parsing.spec.ts index de3e6084d..acede9a6f 100644 --- a/packages/app/e2e/pairing-offer-parsing.spec.ts +++ b/packages/app/e2e/pairing-offer-parsing.spec.ts @@ -1,5 +1,6 @@ import { test, expect } from './fixtures'; import { Buffer } from 'node:buffer'; +import { gotoHome, openSettings } from './helpers/app'; function encodeBase64Url(input: string): string { return Buffer.from(input, 'utf8') @@ -20,7 +21,8 @@ test('pairing flow accepts #offer=ConnectionOfferV2 and stores relay-only host', } // Override the default fixture seeding for this test. - await page.goto('/settings'); + await gotoHome(page); + await openSettings(page); await page.evaluate(() => { const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); diff --git a/packages/app/e2e/relay-fallback-connect.spec.ts b/packages/app/e2e/relay-fallback-connect.spec.ts index 0ca38dc25..3e166e0f6 100644 --- a/packages/app/e2e/relay-fallback-connect.spec.ts +++ b/packages/app/e2e/relay-fallback-connect.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from './fixtures'; +import { gotoHome, openSettings } from './helpers/app'; test('connects via relay when direct endpoints fail', async ({ page }) => { const relayPort = process.env.E2E_RELAY_PORT; @@ -26,7 +27,8 @@ test('connects via relay when direct endpoints fail', async ({ page }) => { }; // Override the default fixture seeding for this test. - await page.goto('/settings'); + await gotoHome(page); + await openSettings(page); await page.evaluate((daemon) => { const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); diff --git a/packages/app/e2e/relay-stability.spec.ts b/packages/app/e2e/relay-stability.spec.ts index 79b029796..0ada78006 100644 --- a/packages/app/e2e/relay-stability.spec.ts +++ b/packages/app/e2e/relay-stability.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from './fixtures'; +import { gotoHome, openSettings } from './helpers/app'; test('relay connection stays stable across multiple tabs', async ({ page }) => { const relayPort = process.env.E2E_RELAY_PORT; @@ -26,7 +27,8 @@ test('relay connection stays stable across multiple tabs', async ({ page }) => { }; // Use relay by making the direct endpoint intentionally fail. - await page.goto('/settings'); + await gotoHome(page); + await openSettings(page); await page.evaluate((daemon) => { const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); @@ -45,7 +47,10 @@ test('relay connection stays stable across multiple tabs', async ({ page }) => { await page2.routeWebSocket(/:(6767)\b/, async (ws) => { await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' }); }); - await page2.goto('/settings'); + await page2.goto('/'); + const settingsButton2 = page2.locator('[data-testid="sidebar-settings"]:visible').first(); + await expect(settingsButton2).toBeVisible({ timeout: 20000 }); + await settingsButton2.click(); const card2 = page2.getByTestId(`daemon-card-${serverId}`); await expect(card2.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 }); await expect(card2.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 }); diff --git a/packages/app/e2e/terminal-pane.spec.ts b/packages/app/e2e/terminal-pane.spec.ts index 34597e6dc..71254dfcd 100644 --- a/packages/app/e2e/terminal-pane.spec.ts +++ b/packages/app/e2e/terminal-pane.spec.ts @@ -75,7 +75,6 @@ async function openNewAgentDraft(page: Page): Promise { const newAgentButton = page.getByTestId("sidebar-new-agent").first(); await expect(newAgentButton).toBeVisible({ timeout: 30000 }); await newAgentButton.click(); - await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 }); await expect( page.locator('[data-testid="working-directory-select"]:visible').first() ).toBeVisible({ @@ -385,9 +384,7 @@ test("mobile terminal tab switch keeps command input routed to the selected tab" await setWorkingDirectory(page, repo.path); await ensureHostSelected(page); await createAgent(page, "Reply with exactly: terminal routing"); - const createdAgentUrl = page.url(); await page.setViewportSize({ width: 390, height: 844 }); - await page.goto(createdAgentUrl); await ensureExplorerTabsVisible(page); const terminalsTab = visibleTestId(page, "explorer-tab-terminals"); diff --git a/packages/app/e2e/terminal-responsiveness-benchmark.spec.ts b/packages/app/e2e/terminal-responsiveness-benchmark.spec.ts index 65aa4f5f1..25329cdf6 100644 --- a/packages/app/e2e/terminal-responsiveness-benchmark.spec.ts +++ b/packages/app/e2e/terminal-responsiveness-benchmark.spec.ts @@ -1,27 +1,13 @@ -import { test, expect } from "./fixtures"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { test, expect, type Page } from "./fixtures"; import { setWorkingDirectory } from "./helpers/app"; import { createTempGitRepo } from "./helpers/workspace"; -import { openNewAgentComposer, seedWorkspaceActivity } from "./helpers/workspace-ui"; -import { buildHostWorkspaceRoute } from "@/utils/host-routes"; - -function buildWorkspaceRoute(serverId: string, workspacePath: string): string { - return buildHostWorkspaceRoute(serverId, workspacePath); -} - -async function openWorkspace(page: Page, workspacePath: string): Promise { - const serverId = process.env.E2E_SERVER_ID; - if (!serverId) { - throw new Error("E2E_SERVER_ID is not set."); - } - - await page.goto(buildWorkspaceRoute(serverId, workspacePath)); - await expect(page).toHaveURL(new RegExp(`/h/${encodeURIComponent(serverId)}/workspace/`), { - timeout: 30_000, - }); - await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({ - timeout: 30_000, - }); -} +import { + openNewAgentComposer, + seedWorkspaceActivity, + switchWorkspaceViaSidebar, +} from "./helpers/workspace-ui"; function percentile(values: number[], p: number): number { if (values.length === 0) { @@ -58,6 +44,7 @@ function summarize(values: number[]) { function buildStressCommand(doneMarker: string): string { // Deterministic synthetic "TUI-like" redraw loop: alternate screen + cursor-home repaint. + const markerFile = ".paseo-terminal-benchmark-marker"; return [ "i=1", "printf '\\033[?1049h\\033[2J'", @@ -72,9 +59,19 @@ function buildStressCommand(doneMarker: string): string { "i=$((i+1))", "done", `printf '\\033[?1049l\\n${doneMarker}\\n'`, + `printf '${doneMarker}\\n' > '${markerFile}'`, ].join("; "); } +async function markerFileContains(filePath: string, marker: string): Promise { + try { + const text = await readFile(filePath, "utf8"); + return text.includes(marker); + } catch { + return false; + } +} + async function toggleExplorerAndMeasureLatency(page: Page): Promise { const toggle = page.getByTestId("workspace-explorer-toggle").first(); await expect(toggle).toBeVisible({ timeout: 30_000 }); @@ -92,13 +89,22 @@ test("workspace terminal responsiveness benchmark (report-only, single stress pr }, testInfo) => { test.setTimeout(180_000); const repo = await createTempGitRepo("paseo-e2e-terminal-benchmark-"); + const markerFilePath = path.join(repo.path, ".paseo-terminal-benchmark-marker"); try { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + await openNewAgentComposer(page); await setWorkingDirectory(page, repo.path); await seedWorkspaceActivity(page, `terminal benchmark seed ${Date.now()}`); - await openWorkspace(page, repo.path); + await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path }); + await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({ + timeout: 30_000, + }); const newTerminalButton = page.getByTestId("workspace-new-terminal-tab").first(); await expect(newTerminalButton).toBeVisible({ timeout: 30_000 }); @@ -163,11 +169,11 @@ test("workspace terminal responsiveness benchmark (report-only, single stress pr }); await surface.click({ force: true }); - await page.keyboard.type(`echo ${postMarker}`, { delay: 0 }); + await page.keyboard.type(`echo ${postMarker} >> .paseo-terminal-benchmark-marker`, { delay: 0 }); await page.keyboard.press("Enter"); - await expect(page.getByText(postMarker).first()).toBeVisible({ + await expect.poll(async () => await markerFileContains(markerFilePath, postMarker), { timeout: 120_000, - }); + }).toBe(true); const diagnostics = await page.evaluate(async () => { const debug = ( @@ -205,9 +211,7 @@ test("workspace terminal responsiveness benchmark (report-only, single stress pr frameSleepMs: 10, doneMarker, postMarker, - doneMarkerObserved: - (await page.getByText(doneMarker).first().isVisible().catch(() => false)) || - (await page.getByText(doneMarker).count()) > 0, + doneMarkerObserved: await markerFileContains(markerFilePath, doneMarker), }, frameGapMs: { ...summarize(frameGapsMs), diff --git a/packages/app/e2e/workspace-draft-tab-retarget.spec.ts b/packages/app/e2e/workspace-draft-tab-retarget.spec.ts new file mode 100644 index 000000000..3f0ff1adb --- /dev/null +++ b/packages/app/e2e/workspace-draft-tab-retarget.spec.ts @@ -0,0 +1,125 @@ +import { test, expect } from "./fixtures"; +import type { Page } from "@playwright/test"; +import { createAgentInRepo } from "./helpers/app"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + ensureWorkspaceAgentPaneVisible, + getWorkspaceTabTestIds, + sampleWorkspaceTabIds, + waitForWorkspaceTabsVisible, +} from "./helpers/workspace-tabs"; +import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui"; + +async function expectComposerFocused(page: Page) { + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await expect(composer).toBeEditable({ timeout: 30_000 }); + await expect + .poll(async () => { + return await composer.evaluate( + (element) => document.activeElement === element + ); + }) + .toBe(true); +} + +test("workspace draft submit retargets tab in place without transient extra tabs", async ({ page }) => { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + + const repo = await createTempGitRepo("paseo-e2e-draft-retarget-"); + const seedPrompt = `seed prompt ${Date.now()}`; + const createPrompt = `retarget prompt ${Date.now()}`; + + try { + await createAgentInRepo(page, { directory: repo.path, prompt: seedPrompt }); + + await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path }); + await waitForWorkspaceTabsVisible(page); + await ensureWorkspaceAgentPaneVisible(page); + + const beforeDraftIds = await getWorkspaceTabTestIds(page); + await page.getByTestId("workspace-new-agent-tab").first().click(); + await ensureWorkspaceAgentPaneVisible(page); + await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeEditable(); + + const withDraftIds = await getWorkspaceTabTestIds(page); + expect(withDraftIds.length).toBe(beforeDraftIds.length + 1); + const draftTabTestId = withDraftIds.find((id) => !beforeDraftIds.includes(id)); + expect(draftTabTestId).toBeTruthy(); + + const draftId = draftTabTestId!.replace("workspace-tab-", ""); + const draftCloseButton = page.getByTestId(`workspace-draft-close-${draftId}`).first(); + await expect(draftCloseButton).toBeVisible({ timeout: 30_000 }); + + const samplingPromise = sampleWorkspaceTabIds(page, { durationMs: 3_000, intervalMs: 40 }); + const input = page.getByRole("textbox", { name: "Message agent..." }); + await input.fill(createPrompt); + await input.press("Enter"); + await expect(page.getByText(createPrompt, { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + + const snapshots = await samplingPromise; + const maxObservedCount = snapshots.reduce((max, ids) => Math.max(max, ids.length), 0); + expect(maxObservedCount).toBe(withDraftIds.length); + + const finalIds = await getWorkspaceTabTestIds(page); + expect(finalIds.length).toBe(withDraftIds.length); + expect(finalIds).toContain(draftTabTestId!); + await expect(draftCloseButton).not.toBeVisible({ timeout: 30_000 }); + } finally { + await repo.cleanup(); + } +}); + +test("workspace agent tab switch focuses composer on desktop web", async ({ page }) => { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + + const repo = await createTempGitRepo("paseo-e2e-tab-focus-"); + const firstPrompt = `first tab prompt ${Date.now()}`; + const secondPrompt = `second tab prompt ${Date.now()}`; + + try { + await createAgentInRepo(page, { directory: repo.path, prompt: firstPrompt }); + await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repo.path }); + await waitForWorkspaceTabsVisible(page); + await ensureWorkspaceAgentPaneVisible(page); + + const beforeSecondAgentIds = await getWorkspaceTabTestIds(page); + await page.getByTestId("workspace-new-agent-tab").first().click(); + await expectComposerFocused(page); + + const composer = page.getByRole("textbox", { name: "Message agent..." }).first(); + await composer.fill(secondPrompt); + await composer.press("Enter"); + await expect(page.getByText(secondPrompt, { exact: true }).first()).toBeVisible({ + timeout: 30_000, + }); + + const withSecondAgentIds = await getWorkspaceTabTestIds(page); + const secondAgentTabTestId = withSecondAgentIds.find( + (id) => !beforeSecondAgentIds.includes(id) + ); + if (!secondAgentTabTestId) { + throw new Error("Expected second agent tab to be created."); + } + + const firstAgentTabTestId = beforeSecondAgentIds[0]; + if (!firstAgentTabTestId) { + throw new Error("Expected first agent tab to exist."); + } + + await page.getByTestId(firstAgentTabTestId).first().click(); + await expectComposerFocused(page); + + await page.getByTestId(secondAgentTabTestId).first().click(); + await expectComposerFocused(page); + } finally { + await repo.cleanup(); + } +}); diff --git a/packages/app/e2e/workspace-header-tabs-restore.spec.ts b/packages/app/e2e/workspace-header-tabs-restore.spec.ts index a54a3288b..349671359 100644 --- a/packages/app/e2e/workspace-header-tabs-restore.spec.ts +++ b/packages/app/e2e/workspace-header-tabs-restore.spec.ts @@ -1,16 +1,7 @@ import { test, expect, type Page } from "./fixtures"; -import { - createAgent, - ensureHostSelected, - gotoHome, - setWorkingDirectory, -} from "./helpers/app"; +import { createAgentInRepo } from "./helpers/app"; import { createTempGitRepo } from "./helpers/workspace"; -import { buildHostWorkspaceRoute } from "@/utils/host-routes"; - -function buildWorkspaceRoute(serverId: string, workspacePath: string): string { - return buildHostWorkspaceRoute(serverId, workspacePath); -} +import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui"; async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise { const serverId = process.env.E2E_SERVER_ID; @@ -18,15 +9,11 @@ async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promis throw new Error("E2E_SERVER_ID is not set."); } - await gotoHome(page); - await ensureHostSelected(page); - await setWorkingDirectory(page, workspacePath); - await createAgent(page, `workspace header restore ${Date.now()}`); - - await page.goto(buildWorkspaceRoute(serverId, workspacePath)); - await expect(page).toHaveURL(new RegExp(`/h/${encodeURIComponent(serverId)}/workspace/`), { - timeout: 30000, + await createAgentInRepo(page, { + directory: workspacePath, + prompt: `workspace header restore ${Date.now()}`, }); + await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspacePath }); await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({ timeout: 30000, }); @@ -49,15 +36,17 @@ test("workspace new-tab buttons stay on-screen during horizontal scroll", async await expect(tabsScroll).toBeVisible({ timeout: 30000 }); // Create enough terminal tabs to ensure the tabs row has overflow to scroll. - const terminalTabs = page.locator('[data-testid^="workspace-tab-terminal:"]'); - const initialTerminalCount = await terminalTabs.count(); - const targetTerminalCount = initialTerminalCount + 8; + const workspaceTabs = page.locator( + '[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])' + ); + const initialTabCount = await workspaceTabs.count(); + const targetTabCount = initialTabCount + 8; - for (let attempt = initialTerminalCount; attempt < targetTerminalCount; attempt += 1) { + for (let attempt = initialTabCount; attempt < targetTabCount; attempt += 1) { await expect(terminalButton).toBeEnabled({ timeout: 30000 }); await terminalButton.click(); await expect - .poll(async () => await terminalTabs.count(), { timeout: 30000 }) + .poll(async () => await workspaceTabs.count(), { timeout: 30000 }) .toBeGreaterThanOrEqual(attempt + 1); } diff --git a/packages/app/e2e/workspace-sidebar-content-sync.spec.ts b/packages/app/e2e/workspace-sidebar-content-sync.spec.ts index 4a88cf3ac..d6e2c0489 100644 --- a/packages/app/e2e/workspace-sidebar-content-sync.spec.ts +++ b/packages/app/e2e/workspace-sidebar-content-sync.spec.ts @@ -1,68 +1,14 @@ import { execSync } from 'node:child_process'; -import { test, expect, type Page } from './fixtures'; +import { test } from './fixtures'; import { setWorkingDirectory } from './helpers/app'; import { createTempGitRepo } from './helpers/workspace'; -import { openNewAgentComposer, seedWorkspaceActivity } from './helpers/workspace-ui'; -import { buildHostWorkspaceRoute } from '@/utils/host-routes'; - -function escapeRegex(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -function workspaceLabelFromPath(value: string): string { - const normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); - const parts = normalized.split('/').filter(Boolean); - return parts[parts.length - 1] ?? normalized; -} - -function candidateWorkspaceIds(inputPath: string): string[] { - const trimmed = inputPath.replace(/\/+$/, ''); - const candidates = new Set([trimmed]); - if (trimmed.startsWith('/var/')) { - candidates.add(`/private${trimmed}`); - } - if (trimmed.startsWith('/private/var/')) { - candidates.add(trimmed.replace(/^\/private/, '')); - } - return Array.from(candidates); -} - -function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) { - const ids = candidateWorkspaceIds(workspacePath).map( - (id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]` - ); - return page.locator(ids.join(',')).first(); -} - -async function switchViaSidebar(input: { - page: Page; - serverId: string; - targetWorkspacePath: string; -}) { - const row = workspaceRowLocator(input.page, input.serverId, input.targetWorkspacePath); - await expect(row).toBeVisible({ timeout: 30000 }); - await row.click(); - - const targetWorkspaceRoute = buildHostWorkspaceRoute(input.serverId, input.targetWorkspacePath); - await expect(input.page).toHaveURL(new RegExp(escapeRegex(targetWorkspaceRoute)), { - timeout: 30000, - }); -} - -async function expectWorkspaceHeader( - page: Page, - input: { title: string; subtitle: string } -): Promise { - const titleLocator = page.getByTestId('workspace-header-title'); - const subtitleLocator = page.getByTestId('workspace-header-subtitle'); - - await expect(titleLocator.first()).toHaveText(input.title, { - timeout: 30000, - }); - await expect(subtitleLocator.first()).toHaveText(input.subtitle, { - timeout: 30000, - }); -} +import { + expectWorkspaceHeader, + openNewAgentComposer, + seedWorkspaceActivity, + switchWorkspaceViaSidebar, + workspaceLabelFromPath, +} from './helpers/workspace-ui'; test('sidebar workspace switch keeps visible content in sync with selected workspace', async ({ page }) => { const serverId = process.env.E2E_SERVER_ID; @@ -88,20 +34,19 @@ test('sidebar workspace switch keeps visible content in sync with selected works await setWorkingDirectory(page, repoB.path); await seedWorkspaceActivity(page, tokenB); - await page.goto(buildHostWorkspaceRoute(serverId, repoA.path)); - await expect(page).toHaveURL(new RegExp('/workspace/'), { timeout: 30000 }); + await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path }); await expectWorkspaceHeader(page, { title: 'sync-a-branch', subtitle: workspaceLabelFromPath(repoA.path), }); - await switchViaSidebar({ page, serverId, targetWorkspacePath: repoB.path }); + await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoB.path }); await expectWorkspaceHeader(page, { title: 'sync-b-branch', subtitle: workspaceLabelFromPath(repoB.path), }); - await switchViaSidebar({ page, serverId, targetWorkspacePath: repoA.path }); + await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path }); await expectWorkspaceHeader(page, { title: 'sync-a-branch', subtitle: workspaceLabelFromPath(repoA.path), diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 7225592c9..ef170df30 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -1,6 +1,6 @@ import { View, Pressable, Text, ActivityIndicator, Platform } from 'react-native' import { useState, useEffect, useRef, useCallback } from 'react' -import { StyleSheet, useUnistyles } from 'react-native-unistyles' +import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { ArrowUp, Square, Pencil, AudioLines } from 'lucide-react-native' import Animated from 'react-native-reanimated' import { useSafeAreaInsets } from 'react-native-safe-area-context' @@ -119,6 +119,10 @@ export function AgentInputArea({ const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead) const [internalInput, setInternalInput] = useState('') + const isDesktopWebBreakpoint = + Platform.OS === 'web' && + UnistylesRuntime.breakpoint !== 'xs' && + UnistylesRuntime.breakpoint !== 'sm' const userInput = value ?? internalInput const setUserInput = onChangeText ?? setInternalInput const [cursorIndex, setCursorIndex] = useState(0) @@ -775,7 +779,8 @@ export function AgentInputArea({ client={client} isReadyForDictation={isDictationReady} placeholder="Message agent..." - autoFocus={autoFocus} + autoFocus={autoFocus && isDesktopWebBreakpoint} + autoFocusKey={`${serverId}:${agentId}`} disabled={isSubmitLoading} isScreenFocused={isScreenFocused} leftContent={leftContent} diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index b4a3e578f..7944294a5 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -440,14 +440,14 @@ const styles = StyleSheet.create((theme) => ({ top: 0, right: 0, bottom: 0, - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surfaceSidebar, overflow: "hidden", }, desktopSidebar: { position: "relative", borderLeftWidth: 1, borderLeftColor: theme.colors.border, - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surfaceSidebar, }, resizeHandle: { position: "absolute", diff --git a/packages/app/src/components/git-diff-pane.tsx b/packages/app/src/components/git-diff-pane.tsx index 68db8c033..d03a7f8b8 100644 --- a/packages/app/src/components/git-diff-pane.tsx +++ b/packages/app/src/components/git-diff-pane.tsx @@ -948,7 +948,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) { if (isStatusLoading) { bodyContent = ( - + Checking repository... ); @@ -967,8 +967,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) { } else if (isDiffLoading) { bodyContent = ( - - Loading changes... + ); } else if (diffErrorMessage) { @@ -1234,9 +1233,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) { {branchLabel} - {isStatusFetching && ( - - )} {isGit ? ( diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 0b3f171b9..ed34ba61f 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -560,7 +560,7 @@ const styles = StyleSheet.create((theme) => ({ top: 0, left: 0, bottom: 0, - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surfaceSidebar, overflow: 'hidden', }, sidebarContent: { @@ -571,7 +571,7 @@ const styles = StyleSheet.create((theme) => ({ desktopSidebar: { borderRightWidth: 1, borderRightColor: theme.colors.border, - backgroundColor: theme.colors.surface0, + backgroundColor: theme.colors.surfaceSidebar, }, sidebarHeader: { height: { diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index bb2ba9077..661bb7f1c 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -30,6 +30,7 @@ import { } from '@/utils/image-attachments-from-files' import type { AttachmentMetadata } from '@/attachments/types' import { useAttachmentPreviewUrl } from '@/attachments/use-attachment-preview-url' +import { focusWithRetries } from '@/utils/web-focus' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Shortcut } from '@/components/ui/shortcut' import type { MessageInputKeyboardActionKind } from '@/keyboard/actions' @@ -62,6 +63,7 @@ export interface MessageInputProps { isReadyForDictation?: boolean placeholder?: string autoFocus?: boolean + autoFocusKey?: string disabled?: boolean /** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */ isScreenFocused?: boolean @@ -137,6 +139,7 @@ export const MessageInput = forwardRef(funct isReadyForDictation, placeholder = 'Message...', autoFocus = false, + autoFocusKey, disabled = false, isScreenFocused = true, leftContent, @@ -231,15 +234,20 @@ export const MessageInput = forwardRef(funct valueRef.current = value }, [value]) - // Autofocus on web when autoFocus prop is true + // Autofocus on web when autoFocus is true, and re-run when focus key changes. useEffect(() => { if (!IS_WEB || !autoFocus) return - // Use requestAnimationFrame to ensure DOM is ready - const rafId = requestAnimationFrame(() => { - textInputRef.current?.focus() + return focusWithRetries({ + focus: () => textInputRef.current?.focus(), + isFocused: () => { + const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null + const native = typeof current?.getNativeRef === 'function' ? current.getNativeRef() : current + const element = native instanceof HTMLElement ? native : null + const active = typeof document !== 'undefined' ? document.activeElement : null + return Boolean(element) && active === element + }, }) - return () => cancelAnimationFrame(rafId) - }, [autoFocus]) + }, [autoFocus, autoFocusKey]) const handleDictationTranscript = useCallback( (text: string, _meta: { requestId: string }) => { diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 0cff12155..76589183b 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -1181,7 +1181,7 @@ const styles = StyleSheet.create((theme) => ({ marginBottom: theme.spacing[1], }, workspaceListContainer: { - marginLeft: theme.spacing[4], + marginLeft: theme.spacing[2], }, emptyText: { color: theme.colors.foregroundMuted, diff --git a/packages/app/src/screens/agent/agent-ready-screen.tsx b/packages/app/src/screens/agent/agent-ready-screen.tsx index 066676457..c4742b2ca 100644 --- a/packages/app/src/screens/agent/agent-ready-screen.tsx +++ b/packages/app/src/screens/agent/agent-ready-screen.tsx @@ -6,7 +6,6 @@ import { Platform, BackHandler, } from "react-native"; -import { useRouter, type Href } from "expo-router"; import { useFocusEffect } from "@react-navigation/native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useQueryClient } from "@tanstack/react-query"; @@ -60,7 +59,6 @@ import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention"; import type { DaemonClient } from "@server/client/daemon-client"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import type { ExplorerCheckoutContext } from "@/stores/panel-store"; -import { buildHostRootRoute } from "@/utils/host-routes"; const EMPTY_STREAM_ITEMS: StreamItem[] = []; const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); @@ -187,7 +185,6 @@ function AgentScreenContent({ const { theme } = useUnistyles(); const toast = useToast(); const insets = useSafeAreaInsets(); - const router = useRouter(); const queryClient = useQueryClient(); const resolvedAgentId = agentId; const { isArchivingAgent } = useArchiveAgent(); @@ -514,24 +511,6 @@ function AgentScreenContent({ resolvedAgentId && isArchivingAgent({ serverId, agentId: resolvedAgentId }) ); - const hasRedirectedArchivedAgentRef = useRef(false); - - useEffect(() => { - if (!resolvedAgentId) { - hasRedirectedArchivedAgentRef.current = false; - return; - } - if (!agent?.archivedAt) { - hasRedirectedArchivedAgentRef.current = false; - return; - } - if (hasRedirectedArchivedAgentRef.current) { - return; - } - hasRedirectedArchivedAgentRef.current = true; - const route: Href = buildHostRootRoute(serverId) as Href; - router.replace(route); - }, [agent?.archivedAt, resolvedAgentId, router, serverId]); useEffect(() => { if (!resolvedAgentId) { diff --git a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts index e76c7a2c0..7ac34cacf 100644 --- a/packages/app/src/screens/workspace/use-workspace-tab-layout.ts +++ b/packages/app/src/screens/workspace/use-workspace-tab-layout.ts @@ -6,7 +6,7 @@ import { } from "@/screens/workspace/workspace-tab-layout"; type UseWorkspaceTabLayoutInput = { - tabLabels: string[]; + tabLabelLengths: number[]; viewportWidthOverride?: number | null; metrics: { rowHorizontalInset: number; @@ -36,10 +36,10 @@ export function useWorkspaceTabLayout(input: UseWorkspaceTabLayoutInput): UseWor () => computeWorkspaceTabLayout({ viewportWidth: resolvedViewportWidth, - tabLabelLengths: input.tabLabels.map((label) => label.length), + tabLabelLengths: input.tabLabelLengths, metrics: input.metrics, }), - [input.metrics, input.tabLabels, resolvedViewportWidth] + [input.metrics, input.tabLabelLengths, resolvedViewportWidth] ); return { diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 9ac032610..f46c98ebb 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -21,6 +21,7 @@ import { encodeFilePathForPathSegment } from "@/utils/host-routes"; import type { Agent } from "@/stores/session-store"; const DROPDOWN_WIDTH = 220; +const LOADING_TAB_LABEL_SKELETON_WIDTH = 80; type NewTabOptionId = "__new_tab_agent__" | "__new_tab_terminal__"; type WorkspaceDesktopTabsRowProps = { @@ -101,8 +102,19 @@ export function WorkspaceDesktopTabsRow({ [tabsActionsWidth, theme.spacing] ); + const tabLabelLengths = useMemo( + () => + tabs.map((tab) => { + if (tab.kind === "agent" && tab.titleState === "loading") { + return Math.max(1, Math.ceil(LOADING_TAB_LABEL_SKELETON_WIDTH / layoutMetrics.estimatedCharWidth)); + } + return tab.label.length; + }), + [layoutMetrics.estimatedCharWidth, tabs] + ); + const { layout } = useWorkspaceTabLayout({ - tabLabels: tabs.map((tab) => tab.label), + tabLabelLengths, viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null, metrics: layoutMetrics, }); @@ -478,7 +490,7 @@ const styles = StyleSheet.create((theme) => ({ opacity: 0.9, }, tabLabelSkeletonWithCloseButton: { - width: 80, + width: LOADING_TAB_LABEL_SKELETON_WIDTH, }, tabLabelWithCloseButton: { paddingRight: 0, diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 80587063c..b93aa2797 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -10,12 +10,11 @@ import { MAX_CONTENT_WIDTH } from "@/constants/layout"; import { useAgentFormState } from "@/hooks/use-agent-form-state"; import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { useSessionStore, type Agent } from "@/stores/session-store"; +import type { Agent } from "@/stores/session-store"; import { generateMessageId, type StreamItem, type UserMessageImageAttachment } from "@/types/stream"; import { encodeImages } from "@/utils/encode-images"; import type { AgentCapabilityFlags, AgentSessionConfig } from "@server/server/agent/agent-sdk-types"; import type { AgentSnapshotPayload } from "@server/shared/messages"; -import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_STREAM_ITEMS: StreamItem[] = []; @@ -268,13 +267,6 @@ export function WorkspaceDraftAgentTab({ const agentId = result.id; updatePendingAgentId({ draftId, agentId }); - const normalized = normalizeAgentSnapshot(result, serverId); - useSessionStore.getState().setAgents(serverId, (prev) => { - const next = new Map(prev); - next.set(agentId, normalized); - return next; - }); - onCreated(result); return; } catch (error) { @@ -403,6 +395,7 @@ const styles = StyleSheet.create((theme) => ({ width: "100%", alignSelf: "center", maxWidth: MAX_CONTENT_WIDTH, + backgroundColor: theme.colors.surface0, }, contentContainer: { flex: 1, @@ -423,6 +416,7 @@ const styles = StyleSheet.create((theme) => ({ }, inputAreaWrapper: { width: "100%", + backgroundColor: theme.colors.surface0, }, errorContainer: { marginTop: theme.spacing[2], diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 5cf7fea6c..dfc09152b 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -5,6 +5,7 @@ import { Platform, Pressable, Text, + useColorScheme, View, } from "react-native"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -49,6 +50,7 @@ import { useWorkspaceTabsStore, } from "@/stores/workspace-tabs-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; import { buildWorkspaceOpenIntentParam, type WorkspaceOpenIntent, @@ -69,6 +71,7 @@ import { getStatusDotColor } from "@/utils/status-dot-color"; import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab"; import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; @@ -133,6 +136,8 @@ function WorkspaceScreenContent({ openIntent, }: WorkspaceScreenProps) { const { theme } = useUnistyles(); + const isDarkMode = useColorScheme() === "dark"; + const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0; const toast = useToast(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; @@ -404,11 +409,13 @@ function WorkspaceScreenContent({ persistenceKey ? state.focusedTabIdByWorkspace[persistenceKey] ?? "" : "" ); const openDraftTab = useWorkspaceTabsStore((state) => state.openDraftTab); + const ensureTab = useWorkspaceTabsStore((state) => state.ensureTab); const openOrFocusTab = useWorkspaceTabsStore((state) => state.openOrFocusTab); const focusTab = useWorkspaceTabsStore((state) => state.focusTab); const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab); - const promoteDraftToAgent = useWorkspaceTabsStore((state) => state.promoteDraftToAgent); + const retargetWorkspaceTab = useWorkspaceTabsStore((state) => state.retargetTab); const reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs); + const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId); const workspaceTabActionRequest = useKeyboardShortcutsStore( (state) => state.workspaceTabActionRequest ); @@ -483,12 +490,90 @@ function WorkspaceScreenContent({ normalizedWorkspaceId, ]); + useEffect(() => { + if (!normalizedServerId || !normalizedWorkspaceId) { + return; + } + + const agentIds = new Set(workspaceAgents.map((agent) => agent.id)); + const terminalIds = new Set(terminals.map((terminal) => terminal.id)); + const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => { + if (tab.target.kind !== "draft") { + return false; + } + const pending = pendingByDraftId[tab.target.draftId]; + return pending?.serverId === normalizedServerId && pending.lifecycle === "active"; + }); + + for (const agent of workspaceAgents) { + const representedByTarget = uiTabs.some( + (tab) => tab.target.kind === "agent" && tab.target.agentId === agent.id + ); + const representedByDeterministicTabId = uiTabs.some( + (tab) => tab.tabId === `agent_${agent.id}` + ); + if ( + hasActivePendingDraftCreateInWorkspace && + !representedByTarget && + !representedByDeterministicTabId + ) { + continue; + } + ensureTab({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + target: { kind: "agent", agentId: agent.id }, + }); + } + for (const terminal of terminals) { + ensureTab({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + target: { kind: "terminal", terminalId: terminal.id }, + }); + } + + const canPruneAgentTabs = hasHydratedAgents; + const canPruneTerminalTabs = terminalsQuery.isSuccess; + for (const tab of uiTabs) { + if (canPruneAgentTabs && tab.target.kind === "agent" && !agentIds.has(tab.target.agentId)) { + closeWorkspaceTab({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + tabId: tab.tabId, + }); + } + if ( + canPruneTerminalTabs && + tab.target.kind === "terminal" && + !terminalIds.has(tab.target.terminalId) + ) { + closeWorkspaceTab({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + tabId: tab.tabId, + }); + } + } + }, [ + closeWorkspaceTab, + ensureTab, + hasHydratedAgents, + normalizedServerId, + normalizedWorkspaceId, + pendingByDraftId, + terminals, + terminalsQuery.isSuccess, + uiTabs, + workspaceAgents, + ]); + const tabModel = useMemo( () => deriveWorkspaceTabModel({ workspaceAgents, terminals, - uiTabs, + tabs: uiTabs, tabOrder, focusedTabId, }), @@ -540,6 +625,10 @@ function WorkspaceScreenContent({ if (!persistenceKey) { return; } + if (workspaceAgents.length > 0 || terminals.length > 0) { + emptyWorkspaceSeedRef.current = null; + return; + } if (tabs.length > 0) { emptyWorkspaceSeedRef.current = null; return; @@ -564,7 +653,9 @@ function WorkspaceScreenContent({ normalizedWorkspaceId, openDraftTab, persistenceKey, + terminals.length, tabs.length, + workspaceAgents.length, ]); const handleOpenFileFromExplorer = useCallback( @@ -1047,15 +1138,25 @@ function WorkspaceScreenContent({ draftId={target.draftId} onCreated={(agentSnapshot) => { const tabId = activeTabId ?? target.draftId; - const nextAgentTabId = promoteDraftToAgent({ + const normalized = normalizeAgentSnapshot(agentSnapshot, normalizedServerId); + const nextTabId = retargetWorkspaceTab({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, - draftTabId: tabId, - agentId: agentSnapshot.id, + tabId, + target: { kind: "agent", agentId: agentSnapshot.id }, }); - if (nextAgentTabId) { - navigateToTabId(nextAgentTabId); + if (nextTabId) { + focusTab({ + serverId: normalizedServerId, + workspaceId: normalizedWorkspaceId, + tabId: nextTabId, + }); } + useSessionStore.getState().setAgents(normalizedServerId, (prev) => { + const next = new Map(prev); + next.set(agentSnapshot.id, normalized); + return next; + }); }} /> ); @@ -1107,7 +1208,7 @@ function WorkspaceScreenContent({ }; return ( - + ({ content: { flex: 1, minHeight: 0, + backgroundColor: theme.colors.surface0, }, emptyState: { flex: 1, diff --git a/packages/app/src/screens/workspace/workspace-tab-model.test.ts b/packages/app/src/screens/workspace/workspace-tab-model.test.ts index 01224ee8c..6502522bc 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.test.ts @@ -59,7 +59,11 @@ describe("deriveWorkspaceTabModel", () => { makeAgent({ id: "agent-b", title: "" }), ], terminals: [{ id: "term-1", name: "shell" }], - uiTabs: [], + tabs: [ + { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, + { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, + { tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 }, + ], tabOrder: [], }); @@ -92,7 +96,10 @@ describe("deriveWorkspaceTabModel", () => { const model = deriveWorkspaceTabModel({ workspaceAgents: [makeAgent({ id: "agent-a", title: "A" })], terminals: [], - uiTabs, + tabs: [ + ...uiTabs, + { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 3 }, + ], tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"], }); @@ -103,7 +110,11 @@ describe("deriveWorkspaceTabModel", () => { const model = deriveWorkspaceTabModel({ workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })], terminals: [{ id: "term-1", name: "zsh" }], - uiTabs: [], + tabs: [ + { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, + { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, + { tabId: "terminal_term-1", target: { kind: "terminal", terminalId: "term-1" }, createdAt: 3 }, + ], tabOrder: ["terminal_term-1", "agent_agent-b"], }); @@ -115,10 +126,13 @@ describe("deriveWorkspaceTabModel", () => { }); it("uses focused tab when present, otherwise falls back to first tab", () => { - const base = { + const base: Parameters[0] = { workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })], terminals: [], - uiTabs: [], + tabs: [ + { tabId: "agent_agent-a", target: { kind: "agent", agentId: "agent-a" }, createdAt: 1 }, + { tabId: "agent_agent-b", target: { kind: "agent", agentId: "agent-b" }, createdAt: 2 }, + ], tabOrder: ["agent_agent-a", "agent_agent-b"], }; @@ -141,7 +155,13 @@ describe("deriveWorkspaceTabModel", () => { const model = deriveWorkspaceTabModel({ workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })], terminals: [], - uiTabs: [], + tabs: [ + { + tabId: "agent_workspace-b-agent", + target: { kind: "agent", agentId: "workspace-b-agent" }, + createdAt: 1, + }, + ], tabOrder: ["agent_workspace-b-agent"], focusedTabId: "agent_workspace-a-agent", }); @@ -153,7 +173,7 @@ describe("deriveWorkspaceTabModel", () => { }); }); - it("covers regression: non-archived attention agent remains visible even if UI tabs omitted it", () => { + it("does not materialize tabs absent from workspace tab membership", () => { const offending = makeAgent({ id: "offender", title: "Needs permission", @@ -164,7 +184,7 @@ describe("deriveWorkspaceTabModel", () => { const model = deriveWorkspaceTabModel({ workspaceAgents: [offending], terminals: [], - uiTabs: [ + tabs: [ { tabId: "draft_123", target: { kind: "draft", draftId: "draft_123" }, @@ -174,42 +194,47 @@ describe("deriveWorkspaceTabModel", () => { tabOrder: ["draft_123"], }); - expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_offender")).toBe(true); + expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_offender")).toBe(false); }); - it("includes older attention and failure agents in workspace tabs when session data is complete", () => { - const olderPermissionAgent = makeAgent({ - id: "agent-permission-old", - title: "Need permission", - createdAt: new Date("2026-01-15T00:00:00.000Z"), - requiresAttention: true, - attentionReason: "permission", - }); - const olderFailedAgent = makeAgent({ - id: "agent-failed-old", - title: "Failed run", - createdAt: new Date("2026-01-10T00:00:00.000Z"), - requiresAttention: true, - attentionReason: "error", - }); - const newerAgent = makeAgent({ - id: "agent-recent", - title: "Recent work", - createdAt: new Date("2026-03-04T00:00:00.000Z"), - }); - + it("keeps retargeted agent tab id stable while upgrading descriptor data", () => { const model = deriveWorkspaceTabModel({ - workspaceAgents: [newerAgent, olderPermissionAgent, olderFailedAgent], + workspaceAgents: [], terminals: [], - uiTabs: [], - tabOrder: [], + tabs: [ + { + tabId: "draft_abc", + target: { kind: "agent", agentId: "agent-1" }, + createdAt: 1, + }, + ], + tabOrder: ["draft_abc"], }); + const initial = model.tabs[0]?.descriptor; + expect(initial?.tabId).toBe("draft_abc"); + expect(initial?.kind).toBe("agent"); + if (initial?.kind === "agent") { + expect(initial.titleState).toBe("loading"); + expect(initial.agentId).toBe("agent-1"); + } - expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_agent-permission-old")).toBe( - true - ); - expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_agent-failed-old")).toBe( - true - ); + const upgraded = deriveWorkspaceTabModel({ + workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })], + terminals: [], + tabs: [ + { + tabId: "draft_abc", + target: { kind: "agent", agentId: "agent-1" }, + createdAt: 1, + }, + ], + tabOrder: ["draft_abc"], + }); + const upgradedDescriptor = upgraded.tabs[0]?.descriptor; + expect(upgradedDescriptor?.tabId).toBe("draft_abc"); + if (upgradedDescriptor?.kind === "agent") { + expect(upgradedDescriptor.titleState).toBe("ready"); + expect(upgradedDescriptor.label).toBe("Ready title"); + } }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-model.ts b/packages/app/src/screens/workspace/workspace-tab-model.ts index c3f9a7edf..0299a8085 100644 --- a/packages/app/src/screens/workspace/workspace-tab-model.ts +++ b/packages/app/src/screens/workspace/workspace-tab-model.ts @@ -53,7 +53,7 @@ function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string return normalized; } -function normalizeUiTab(tab: WorkspaceTab): WorkspaceTab | null { +function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null { if (!tab || typeof tab !== "object") { return null; } @@ -75,6 +75,28 @@ function normalizeUiTab(tab: WorkspaceTab): WorkspaceTab | null { createdAt: tab.createdAt, }; } + if (tab.target.kind === "agent") { + const agentId = trimNonEmpty(tab.target.agentId); + if (!agentId) { + return null; + } + return { + tabId, + target: { kind: "agent", agentId }, + createdAt: tab.createdAt, + }; + } + if (tab.target.kind === "terminal") { + const terminalId = trimNonEmpty(tab.target.terminalId); + if (!terminalId) { + return null; + } + return { + tabId, + target: { kind: "terminal", terminalId }, + createdAt: tab.createdAt, + }; + } if (tab.target.kind === "file") { const path = trimNonEmpty(tab.target.path); if (!path) { @@ -105,53 +127,20 @@ export function buildWorkspaceTabId(target: WorkspaceTabTarget): string { export function deriveWorkspaceTabModel(input: { workspaceAgents: Agent[]; terminals: TerminalLike[]; - uiTabs: WorkspaceTab[]; + tabs: WorkspaceTab[]; tabOrder: string[]; focusedTabId?: string | null; }): WorkspaceTabModel { const tabsById = new Map(); + const agentsById = new Map(input.workspaceAgents.map((agent) => [agent.id, agent])); + const terminalsById = new Map(input.terminals.map((terminal) => [terminal.id, terminal])); - for (const agent of input.workspaceAgents) { - const target: WorkspaceTabTarget = { kind: "agent", agentId: agent.id }; - const tabId = buildWorkspaceTabId(target); - const label = resolveWorkspaceAgentTabLabel(agent.title); - tabsById.set(tabId, { - target, - descriptor: { - key: tabId, - tabId, - kind: "agent", - agentId: agent.id, - provider: agent.provider, - label: label ?? "", - subtitle: `${formatProviderLabel(agent.provider)} agent`, - titleState: label ? "ready" : "loading", - }, - }); - } - - for (const terminal of input.terminals) { - const target: WorkspaceTabTarget = { kind: "terminal", terminalId: terminal.id }; - const tabId = buildWorkspaceTabId(target); - tabsById.set(tabId, { - target, - descriptor: { - key: tabId, - tabId, - kind: "terminal", - terminalId: terminal.id, - label: trimNonEmpty(terminal.name) ?? "Terminal", - subtitle: "Terminal", - }, - }); - } - - const normalizedUiTabs = input.uiTabs - .map((tab) => normalizeUiTab(tab)) + const normalizedTabs = input.tabs + .map((tab) => normalizeWorkspaceTab(tab)) .filter((tab): tab is WorkspaceTab => tab !== null) .sort((left, right) => left.createdAt - right.createdAt); - for (const tab of normalizedUiTabs) { + for (const tab of normalizedTabs) { if (tab.target.kind === "draft") { tabsById.set(tab.tabId, { target: tab.target, @@ -167,6 +156,42 @@ export function deriveWorkspaceTabModel(input: { continue; } + if (tab.target.kind === "agent") { + const agent = agentsById.get(tab.target.agentId) ?? null; + const label = resolveWorkspaceAgentTabLabel(agent?.title); + const provider = agent?.provider ?? "codex"; + tabsById.set(tab.tabId, { + target: tab.target, + descriptor: { + key: tab.tabId, + tabId: tab.tabId, + kind: "agent", + agentId: tab.target.agentId, + provider, + label: label ?? "", + subtitle: `${formatProviderLabel(provider)} agent`, + titleState: label ? "ready" : "loading", + }, + }); + continue; + } + + if (tab.target.kind === "terminal") { + const terminal = terminalsById.get(tab.target.terminalId) ?? null; + tabsById.set(tab.tabId, { + target: tab.target, + descriptor: { + key: tab.tabId, + tabId: tab.tabId, + kind: "terminal", + terminalId: tab.target.terminalId, + label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal", + subtitle: "Terminal", + }, + }); + continue; + } + if (tab.target.kind === "file") { const filePath = tab.target.path; const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath; diff --git a/packages/app/src/stores/workspace-tabs-store.test.ts b/packages/app/src/stores/workspace-tabs-store.test.ts index e2212dc62..559dc9483 100644 --- a/packages/app/src/stores/workspace-tabs-store.test.ts +++ b/packages/app/src/stores/workspace-tabs-store.test.ts @@ -20,7 +20,7 @@ import { buildWorkspaceTabPersistenceKey, useWorkspaceTabsStore } from "@/stores const SERVER_ID = "server-1"; const WORKSPACE_ID = "/repo/worktree"; -describe("workspace-tabs-store promoteDraftToAgent", () => { +describe("workspace-tabs-store retargetTab", () => { beforeEach(() => { useWorkspaceTabsStore.setState({ uiTabsByWorkspace: {}, @@ -29,55 +29,26 @@ describe("workspace-tabs-store promoteDraftToAgent", () => { }); }); - it("replaces draft tab id in order with agent tab id, preserves tab count, and removes draft UI tab", () => { + it("keeps a promoted draft tab in-place by mutating target without changing tab id", () => { const draftTabId = "draft_123"; - const agentId = "agent-1"; - const expectedAgentTabId = `agent_${agentId}`; const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); expect(key).toBeTruthy(); const workspaceKey = key as string; + useWorkspaceTabsStore.getState().ensureTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "left" }, + }); useWorkspaceTabsStore.getState().openDraftTab({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID, draftId: draftTabId, }); - - const beforeOrder = useWorkspaceTabsStore.getState().tabOrderByWorkspace[workspaceKey] ?? []; - const promoted = useWorkspaceTabsStore.getState().promoteDraftToAgent({ + useWorkspaceTabsStore.getState().ensureTab({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID, - draftTabId, - agentId, - }); - - const state = useWorkspaceTabsStore.getState(); - const afterOrder = state.tabOrderByWorkspace[workspaceKey] ?? []; - - expect(promoted).toBe(expectedAgentTabId); - expect(afterOrder).toEqual([expectedAgentTabId]); - expect(afterOrder).toHaveLength(beforeOrder.length); - expect(state.uiTabsByWorkspace[workspaceKey]).toBeUndefined(); - expect(state.focusedTabIdByWorkspace[workspaceKey]).toBe(expectedAgentTabId); - }); - - it("does not create duplicate agent tab ids when agent tab already exists", () => { - const draftTabId = "draft_456"; - const agentId = "agent-dup"; - const expectedAgentTabId = `agent_${agentId}`; - const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); - expect(key).toBeTruthy(); - const workspaceKey = key as string; - - useWorkspaceTabsStore.getState().openDraftTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - draftId: draftTabId, - }); - useWorkspaceTabsStore.getState().openOrFocusTab({ - serverId: SERVER_ID, - workspaceId: WORKSPACE_ID, - target: { kind: "agent", agentId }, + target: { kind: "agent", agentId: "right" }, }); useWorkspaceTabsStore.getState().focusTab({ serverId: SERVER_ID, @@ -85,23 +56,84 @@ describe("workspace-tabs-store promoteDraftToAgent", () => { tabId: draftTabId, }); - const beforeOrder = useWorkspaceTabsStore.getState().tabOrderByWorkspace[workspaceKey] ?? []; - const promoted = useWorkspaceTabsStore.getState().promoteDraftToAgent({ + const before = useWorkspaceTabsStore.getState(); + const beforeOrder = before.tabOrderByWorkspace[workspaceKey] ?? []; + + const retargeted = useWorkspaceTabsStore.getState().retargetTab({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID, - draftTabId, - agentId, + tabId: draftTabId, + target: { kind: "agent", agentId: "created" }, + }); + + const after = useWorkspaceTabsStore.getState(); + const afterOrder = after.tabOrderByWorkspace[workspaceKey] ?? []; + const tabs = after.uiTabsByWorkspace[workspaceKey] ?? []; + const retargetedTab = tabs.find((tab) => tab.tabId === draftTabId) ?? null; + + expect(retargeted).toBe(draftTabId); + expect(afterOrder).toEqual(beforeOrder); + expect(after.focusedTabIdByWorkspace[workspaceKey]).toBe(draftTabId); + expect(retargetedTab?.target).toEqual({ kind: "agent", agentId: "created" }); + }); + + it("ensureTab adds non-focused membership while openOrFocusTab focuses", () => { + const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); + expect(key).toBeTruthy(); + const workspaceKey = key as string; + + const terminalTabId = useWorkspaceTabsStore.getState().ensureTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "terminal", terminalId: "term-1" }, + }); + expect(terminalTabId).toBe("terminal_term-1"); + expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBeUndefined(); + + const focusedTabId = useWorkspaceTabsStore.getState().openOrFocusTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "terminal", terminalId: "term-1" }, + }); + expect(focusedTabId).toBe("terminal_term-1"); + expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe( + "terminal_term-1" + ); + }); + + it("ensureTab deduplicates by target when a retargeted tab already exists", () => { + const draftTabId = "draft_x"; + const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); + expect(key).toBeTruthy(); + const workspaceKey = key as string; + + useWorkspaceTabsStore.getState().openDraftTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + draftId: draftTabId, + }); + useWorkspaceTabsStore.getState().retargetTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId: draftTabId, + target: { kind: "agent", agentId: "created-agent" }, + }); + + const ensured = useWorkspaceTabsStore.getState().ensureTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "created-agent" }, }); const state = useWorkspaceTabsStore.getState(); - const afterOrder = state.tabOrderByWorkspace[workspaceKey] ?? []; - const duplicateCount = afterOrder.filter((tabId) => tabId === expectedAgentTabId).length; + const tabs = state.uiTabsByWorkspace[workspaceKey] ?? []; + const order = state.tabOrderByWorkspace[workspaceKey] ?? []; + const matchingTabs = tabs.filter( + (tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent" + ); - expect(promoted).toBe(expectedAgentTabId); - expect(duplicateCount).toBe(1); - expect(afterOrder).toEqual([expectedAgentTabId]); - expect(afterOrder.length).toBeLessThanOrEqual(beforeOrder.length); - expect(state.focusedTabIdByWorkspace[workspaceKey]).toBe(expectedAgentTabId); - expect(state.uiTabsByWorkspace[workspaceKey]).toBeUndefined(); + expect(ensured).toBe(draftTabId); + expect(matchingTabs).toHaveLength(1); + expect(order).toEqual([draftTabId]); }); }); diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index 8493706ae..bb4c4c037 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -61,8 +61,23 @@ function normalizeTabTarget(value: WorkspaceTabTarget | null | undefined): Works return null; } -function isUiTarget(target: WorkspaceTabTarget): target is Extract { - return target.kind === "draft" || target.kind === "file"; +function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean { + if (left.kind !== right.kind) { + return false; + } + if (left.kind === "draft" && right.kind === "draft") { + return left.draftId === right.draftId; + } + if (left.kind === "agent" && right.kind === "agent") { + return left.agentId === right.agentId; + } + if (left.kind === "terminal" && right.kind === "terminal") { + return left.terminalId === right.terminalId; + } + if (left.kind === "file" && right.kind === "file") { + return left.path === right.path; + } + return false; } function buildDeterministicTabId(target: WorkspaceTabTarget): string { @@ -107,6 +122,11 @@ type WorkspaceTabsState = { tabOrderByWorkspace: Record; focusedTabIdByWorkspace: Record; openDraftTab: (input: { serverId: string; workspaceId: string; draftId: string }) => string | null; + ensureTab: (input: { + serverId: string; + workspaceId: string; + target: WorkspaceTabTarget; + }) => string | null; openOrFocusTab: (input: { serverId: string; workspaceId: string; @@ -114,11 +134,11 @@ type WorkspaceTabsState = { }) => string | null; focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; - promoteDraftToAgent: (input: { + retargetTab: (input: { serverId: string; workspaceId: string; - draftTabId: string; - agentId: string; + tabId: string; + target: WorkspaceTabTarget; }) => string | null; reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void; getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[]; @@ -141,39 +161,39 @@ export const useWorkspaceTabsStore = create()( target: { kind: "draft", draftId: normalizedDraftId }, }); }, - openOrFocusTab: ({ serverId, workspaceId, target }) => { + ensureTab: ({ serverId, workspaceId, target }) => { const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const normalizedTarget = normalizeTabTarget(target); if (!key || !normalizedTarget) { return null; } - const tabId = buildDeterministicTabId(normalizedTarget); + const deterministicTabId = buildDeterministicTabId(normalizedTarget); + let resolvedTabId = deterministicTabId; const now = Date.now(); set((state) => { - const currentOrder = state.tabOrderByWorkspace[key] ?? []; - const nextOrder = ensureInOrder({ current: currentOrder, tabId }); - - if (!isUiTarget(normalizedTarget)) { - return { - tabOrderByWorkspace: - nextOrder === currentOrder - ? state.tabOrderByWorkspace - : { ...state.tabOrderByWorkspace, [key]: nextOrder }, - focusedTabIdByWorkspace: { - ...state.focusedTabIdByWorkspace, - [key]: tabId, - }, - }; - } - const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const existingIndex = currentTabs.findIndex((tab) => tab.tabId === tabId); - const nextTabs = - existingIndex >= 0 - ? currentTabs - : [...currentTabs, { tabId, target: normalizedTarget, createdAt: now }]; + const tabWithSameTarget = + currentTabs.find((tab) => tabTargetsEqual(tab.target, normalizedTarget)) ?? null; + const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId; + resolvedTabId = effectiveTabId; + + const currentOrder = state.tabOrderByWorkspace[key] ?? []; + const nextOrder = ensureInOrder({ current: currentOrder, tabId: effectiveTabId }); + const existingIndex = currentTabs.findIndex((tab) => tab.tabId === effectiveTabId); + const nextTabs = (() => { + if (existingIndex < 0) { + return [...currentTabs, { tabId: effectiveTabId, target: normalizedTarget, createdAt: now }]; + } + const existing = currentTabs[existingIndex]; + if (existing && tabTargetsEqual(existing.target, normalizedTarget)) { + return currentTabs; + } + return currentTabs.map((tab, index) => + index === existingIndex ? { ...tab, target: normalizedTarget } : tab + ); + })(); return { uiTabsByWorkspace: @@ -184,13 +204,17 @@ export const useWorkspaceTabsStore = create()( nextOrder === currentOrder ? state.tabOrderByWorkspace : { ...state.tabOrderByWorkspace, [key]: nextOrder }, - focusedTabIdByWorkspace: { - ...state.focusedTabIdByWorkspace, - [key]: tabId, - }, }; }); + return resolvedTabId; + }, + openOrFocusTab: ({ serverId, workspaceId, target }) => { + const tabId = get().ensureTab({ serverId, workspaceId, target }); + if (!tabId) { + return null; + } + get().focusTab({ serverId, workspaceId, tabId }); return tabId; }, focusTab: ({ serverId, workspaceId, tabId }) => { @@ -272,81 +296,40 @@ export const useWorkspaceTabsStore = create()( }; }); }, - promoteDraftToAgent: ({ serverId, workspaceId, draftTabId, agentId }) => { + retargetTab: ({ serverId, workspaceId, tabId, target }) => { const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); - const normalizedDraftTabId = trimNonEmpty(draftTabId); - const normalizedAgentId = trimNonEmpty(agentId); - if (!key || !normalizedDraftTabId || !normalizedAgentId) { + const normalizedTabId = trimNonEmpty(tabId); + const normalizedTarget = normalizeTabTarget(target); + if (!key || !normalizedTabId || !normalizedTarget) { return null; } - const nextTabId = `agent_${normalizedAgentId}`; - let promotedTabId: string | null = null; + let retargetedTabId: string | null = null; set((state) => { - const currentOrder = state.tabOrderByWorkspace[key] ?? []; const currentTabs = state.uiTabsByWorkspace[key] ?? []; - const hasDraftInOrder = currentOrder.includes(normalizedDraftTabId); - const hasDraftInUiTabs = currentTabs.some((tab) => tab.tabId === normalizedDraftTabId); - if (!hasDraftInOrder && !hasDraftInUiTabs) { + const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId); + if (index < 0) { return state; } - const hasAgentTabInOrder = currentOrder.includes(nextTabId); - const nextOrder = hasAgentTabInOrder - ? currentOrder.filter((tabId) => tabId !== normalizedDraftTabId) - : currentOrder - .map((tabId) => (tabId === normalizedDraftTabId ? nextTabId : tabId)) - .filter((tabId, index, arr) => arr.indexOf(tabId) === index); - - const nextTabs = currentTabs.filter((tab) => tab.tabId !== normalizedDraftTabId); - const nextUiTabsByWorkspace = - nextTabs.length === 0 - ? (() => { - const { [key]: _removed, ...rest } = state.uiTabsByWorkspace; - return rest; - })() - : nextTabs.length === currentTabs.length - ? state.uiTabsByWorkspace - : { ...state.uiTabsByWorkspace, [key]: nextTabs }; - - const nextTabOrderByWorkspace = - nextOrder.length === 0 - ? (() => { - const { [key]: _removed, ...rest } = state.tabOrderByWorkspace; - return rest; - })() - : nextOrder.length === currentOrder.length && - nextOrder.every((tabId, index) => tabId === currentOrder[index]) - ? state.tabOrderByWorkspace - : { ...state.tabOrderByWorkspace, [key]: nextOrder }; - - const currentFocused = trimNonEmpty(state.focusedTabIdByWorkspace[key]); - const nextFocused = !currentFocused || currentFocused === normalizedDraftTabId ? nextTabId : currentFocused; - const nextFocusedByWorkspace = - nextFocused === currentFocused - ? state.focusedTabIdByWorkspace - : { ...state.focusedTabIdByWorkspace, [key]: nextFocused }; - - const tabsChanged = nextTabs.length !== currentTabs.length; - const orderChanged = - nextOrder.length !== currentOrder.length || - nextOrder.some((tabId, index) => tabId !== currentOrder[index]); - const focusChanged = nextFocused !== currentFocused; - - if (!tabsChanged && !orderChanged && !focusChanged) { + const currentTarget = currentTabs[index]?.target; + if (currentTarget && tabTargetsEqual(currentTarget, normalizedTarget)) { return state; } - promotedTabId = nextTabId; + const nextTabs = currentTabs.map((tab, tabIndex) => + tabIndex === index ? { ...tab, target: normalizedTarget } : tab + ); + retargetedTabId = normalizedTabId; return { - uiTabsByWorkspace: nextUiTabsByWorkspace, - tabOrderByWorkspace: nextTabOrderByWorkspace, - focusedTabIdByWorkspace: nextFocusedByWorkspace, + uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs }, + tabOrderByWorkspace: state.tabOrderByWorkspace, + focusedTabIdByWorkspace: state.focusedTabIdByWorkspace, }; }); - return promotedTabId; + return retargetedTabId; }, reorderTabs: ({ serverId, workspaceId, tabIds }) => { const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); @@ -389,12 +372,25 @@ export const useWorkspaceTabsStore = create()( }), { name: "workspace-tabs-state", - version: 4, + version: 5, storage: createJSONStorage(() => AsyncStorage), partialize: (state) => { const nextUiTabsByWorkspace: Record = {}; for (const key in state.uiTabsByWorkspace) { - const tabs = (state.uiTabsByWorkspace[key] ?? []).filter((tab) => isUiTarget(tab.target)); + const tabs = (state.uiTabsByWorkspace[key] ?? []) + .map((tab) => { + const normalizedTarget = normalizeTabTarget(tab.target); + const normalizedTabId = trimNonEmpty(tab.tabId); + if (!normalizedTarget || !normalizedTabId) { + return null; + } + return { + tabId: normalizedTabId, + target: normalizedTarget, + createdAt: typeof tab.createdAt === "number" ? tab.createdAt : Date.now(), + } satisfies WorkspaceTab; + }) + .filter((tab): tab is WorkspaceTab => tab !== null); if (tabs.length > 0) { nextUiTabsByWorkspace[key] = tabs; } @@ -473,10 +469,6 @@ export const useWorkspaceTabsStore = create()( orderFromTabs.push(tabId); } - if (!isUiTarget(normalizedTarget)) { - continue; - } - nextUiTabs.push({ tabId, target: normalizedTarget, diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 0f0663198..c6888e9be 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -110,6 +110,7 @@ const lightSemanticColors = { surface1: "#fafafa", // Subtle hover (was zinc-100, now zinc-50) surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100) surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200) + surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main) // Text foreground: "#09090b", @@ -179,6 +180,7 @@ const darkSemanticColors = { surface1: "#1f1f23", // Subtle hover surface2: "#27272a", // Elevated: badges, inputs, sheets surface3: "#3f3f46", // Highest elevation + surfaceSidebar: "#121216", // Sidebar background (darker than main) // Text foreground: "#fafafa", diff --git a/packages/server/src/server/session.create-agent-title.test.ts b/packages/server/src/server/session.create-agent-title.test.ts new file mode 100644 index 000000000..9a6b30ce6 --- /dev/null +++ b/packages/server/src/server/session.create-agent-title.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'vitest' + +import { resolveCreateAgentTitles } from './session.js' + +describe('resolveCreateAgentTitles', () => { + test('derives a provisional title from prompt when explicit title is absent', () => { + const resolved = resolveCreateAgentTitles({ + configTitle: undefined, + initialPrompt: 'Implement auth retries with backoff\n\ninclude tests', + }) + + expect(resolved.explicitTitle).toBeNull() + expect(resolved.provisionalTitle).toBe('Implement auth retries with backoff') + }) + + test('preserves explicit title and does not treat it as provisional', () => { + const resolved = resolveCreateAgentTitles({ + configTitle: ' Keep This Title ', + initialPrompt: 'Ignored prompt title', + }) + + expect(resolved.explicitTitle).toBe('Keep This Title') + expect(resolved.provisionalTitle).toBe('Keep This Title') + }) + + test('returns null values when prompt and title are empty', () => { + const resolved = resolveCreateAgentTitles({ + configTitle: ' ', + initialPrompt: ' ', + }) + + expect(resolved.explicitTitle).toBeNull() + expect(resolved.provisionalTitle).toBeNull() + }) +}) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2ea76c5b8..2bd4de14c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -177,6 +177,23 @@ function deriveInitialAgentTitle(prompt: string): string | null { return clamped.length > 0 ? clamped : null } +export function resolveCreateAgentTitles(options: { + configTitle?: string | null + initialPrompt?: string | null +}): { explicitTitle: string | null; provisionalTitle: string | null } { + const explicitTitle = + typeof options.configTitle === 'string' && options.configTitle.trim().length > 0 + ? options.configTitle.trim() + : null + const trimmedPrompt = options.initialPrompt?.trim() + const provisionalTitle = explicitTitle ?? (trimmedPrompt ? deriveInitialAgentTitle(trimmedPrompt) : null) + + return { + explicitTitle, + provisionalTitle, + } +} + function deriveRemoteProjectKey(remoteUrl: string | null): string | null { if (!remoteUrl) { return null @@ -299,6 +316,16 @@ type TerminalStreamPendingChunk = { replay: boolean } +export type SessionRuntimeMetrics = { + checkoutDiffTargetCount: number + checkoutDiffSubscriptionCount: number + checkoutDiffWatcherCount: number + checkoutDiffFallbackRefreshTargetCount: number + terminalDirectorySubscriptionCount: number + terminalSubscriptionCount: number + terminalStreamCount: number +} + type FetchAgentsRequestMessage = Extract type FetchAgentsRequestFilter = NonNullable type FetchAgentsRequestSort = NonNullable[number] @@ -747,6 +774,27 @@ export class Session { return this.clientActivity } + public getRuntimeMetrics(): SessionRuntimeMetrics { + let checkoutDiffWatcherCount = 0 + let checkoutDiffFallbackRefreshTargetCount = 0 + for (const target of this.checkoutDiffTargets.values()) { + checkoutDiffWatcherCount += target.watchers.length + if (target.fallbackRefreshInterval) { + checkoutDiffFallbackRefreshTargetCount += 1 + } + } + + return { + checkoutDiffTargetCount: this.checkoutDiffTargets.size, + checkoutDiffSubscriptionCount: this.checkoutDiffSubscriptions.size, + checkoutDiffWatcherCount, + checkoutDiffFallbackRefreshTargetCount, + terminalDirectorySubscriptionCount: this.subscribedTerminalDirectories.size, + terminalSubscriptionCount: this.terminalSubscriptions.size, + terminalStreamCount: this.terminalStreams.size, + } + } + /** * Send initial state to client after connection */ @@ -2563,15 +2611,13 @@ export class Session { try { const trimmedPrompt = initialPrompt?.trim() - const derivedTitle = - typeof config.title === 'string' && config.title.trim().length > 0 - ? config.title.trim() - : trimmedPrompt - ? deriveInitialAgentTitle(trimmedPrompt) - : null + const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({ + configTitle: config.title, + initialPrompt: trimmedPrompt, + }) const resolvedConfig: AgentSessionConfig = { ...config, - ...(derivedTitle ? { title: derivedTitle } : {}), + ...(provisionalTitle ? { title: provisionalTitle } : {}), } const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig( @@ -2605,7 +2651,7 @@ export class Session { agentId: snapshot.id, cwd: snapshot.cwd, initialPrompt: trimmedPrompt, - explicitTitle: snapshot.config.title, + explicitTitle, paseoHome: this.paseoHome, logger: this.sessionLogger, }) diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 81bef7464..c25e8da96 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -25,7 +25,11 @@ import { } from "../shared/binary-mux.js"; import type { AllowedHostsConfig } from "./allowed-hosts.js"; import { isHostAllowed } from "./allowed-hosts.js"; -import { Session, type SessionLifecycleIntent } from "./session.js"; +import { + Session, + type SessionLifecycleIntent, + type SessionRuntimeMetrics, +} from "./session.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import { PushTokenStore } from "./push/token-store.js"; @@ -162,12 +166,31 @@ type SessionConnection = { externalDisconnectCleanupTimeout: ReturnType | null; }; +type WebSocketRuntimeCounters = { + connectedAwaitingHello: number; + helloResumed: number; + helloNew: number; + pendingDisconnected: number; + sessionDisconnectedWaitingReconnect: number; + sessionSocketDisconnectedAttached: number; + sessionCleanup: number; + validationFailed: number; + binaryBeforeHelloRejected: number; + pendingMessageRejectedBeforeHello: number; + missingConnectionForMessage: number; + unexpectedHelloOnActiveConnection: number; + relayExternalSocketAttached: number; + originRejected: number; + hostRejected: number; +}; + const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000; const HELLO_TIMEOUT_MS = 15_000; const WS_CLOSE_HELLO_TIMEOUT = 4001; const WS_CLOSE_INVALID_HELLO = 4002; const WS_CLOSE_INCOMPATIBLE_PROTOCOL = 4003; const WS_PROTOCOL_VERSION = 1; +const WS_RUNTIME_METRICS_FLUSH_MS = 30_000; export class MissingDaemonVersionError extends Error { constructor() { @@ -219,6 +242,27 @@ export class VoiceAssistantWebSocketServer { private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private serverCapabilities: ServerCapabilities | undefined; + private runtimeWindowStartedAt = Date.now(); + private readonly runtimeCounters: WebSocketRuntimeCounters = { + connectedAwaitingHello: 0, + helloResumed: 0, + helloNew: 0, + pendingDisconnected: 0, + sessionDisconnectedWaitingReconnect: 0, + sessionSocketDisconnectedAttached: 0, + sessionCleanup: 0, + validationFailed: 0, + binaryBeforeHelloRejected: 0, + pendingMessageRejectedBeforeHello: 0, + missingConnectionForMessage: 0, + unexpectedHelloOnActiveConnection: 0, + relayExternalSocketAttached: 0, + originRejected: 0, + hostRejected: 0, + }; + private readonly inboundMessageCounts = new Map(); + private readonly inboundSessionRequestCounts = new Map(); + private runtimeMetricsInterval: ReturnType | null = null; constructor( server: HTTPServer, @@ -295,6 +339,7 @@ export class VoiceAssistantWebSocketServer { const origin = requestMetadata.origin; const requestHost = requestMetadata.host ?? null; if (requestHost && !isHostAllowed(requestHost, allowedHosts)) { + this.incrementRuntimeCounter("hostRejected"); this.logger.warn( { ...requestMetadata, host: requestHost }, "Rejected connection from disallowed host" @@ -310,6 +355,7 @@ export class VoiceAssistantWebSocketServer { if (!origin || allowedOrigins.has(origin) || sameOrigin) { callback(true); } else { + this.incrementRuntimeCounter("originRejected"); this.logger.warn( { ...requestMetadata, origin }, "Rejected connection from origin" @@ -323,6 +369,12 @@ export class VoiceAssistantWebSocketServer { void this.attachSocket(ws, request); }); + const runtimeMetricsInterval = setInterval(() => { + this.flushRuntimeMetrics(); + }, WS_RUNTIME_METRICS_FLUSH_MS); + this.runtimeMetricsInterval = runtimeMetricsInterval; + (runtimeMetricsInterval as unknown as { unref?: () => void }).unref?.(); + this.logger.info("WebSocket server initialized on /ws"); } @@ -355,10 +407,19 @@ export class VoiceAssistantWebSocketServer { ws: WebSocketLike, metadata?: ExternalSocketMetadata ): Promise { + if (metadata?.transport === "relay") { + this.incrementRuntimeCounter("relayExternalSocketAttached"); + } await this.attachSocket(ws, undefined, metadata); } public async close(): Promise { + if (this.runtimeMetricsInterval) { + clearInterval(this.runtimeMetricsInterval); + this.runtimeMetricsInterval = null; + } + this.flushRuntimeMetrics({ final: true }); + const uniqueConnections = new Set([ ...this.sessions.values(), ...this.externalSessionsByKey.values(), @@ -494,6 +555,7 @@ export class VoiceAssistantWebSocketServer { (timeout as unknown as { unref?: () => void }).unref?.(); this.pendingConnections.set(ws, pending); + this.incrementRuntimeCounter("connectedAwaitingHello"); this.bindSocketHandlers(ws); pending.connectionLogger.trace( @@ -633,6 +695,7 @@ export class VoiceAssistantWebSocketServer { this.clearPendingConnection(ws); const existing = this.externalSessionsByKey.get(clientId); if (existing) { + this.incrementRuntimeCounter("helloResumed"); if (existing.externalDisconnectCleanupTimeout) { clearTimeout(existing.externalDisconnectCleanupTimeout); existing.externalDisconnectCleanupTimeout = null; @@ -652,6 +715,7 @@ export class VoiceAssistantWebSocketServer { } const connectionLogger = pending.connectionLogger.child({ clientId }); + this.incrementRuntimeCounter("helloNew"); const connection = this.createSessionConnection({ ws, clientId, @@ -733,6 +797,7 @@ export class VoiceAssistantWebSocketServer { ): Promise { const pending = this.clearPendingConnection(ws); if (pending) { + this.incrementRuntimeCounter("pendingDisconnected"); pending.connectionLogger.trace( { code: details.code, @@ -752,6 +817,7 @@ export class VoiceAssistantWebSocketServer { connection.sockets.delete(ws); if (connection.sockets.size === 0) { + this.incrementRuntimeCounter("sessionDisconnectedWaitingReconnect"); if (connection.externalDisconnectCleanupTimeout) { clearTimeout(connection.externalDisconnectCleanupTimeout); } @@ -777,6 +843,7 @@ export class VoiceAssistantWebSocketServer { } if (connection.sockets.size > 0) { + this.incrementRuntimeCounter("sessionSocketDisconnectedAttached"); connection.connectionLogger.trace( { clientId: connection.clientId, @@ -796,6 +863,7 @@ export class VoiceAssistantWebSocketServer { connection: SessionConnection, logMessage: string ): Promise { + this.incrementRuntimeCounter("sessionCleanup"); if (connection.externalDisconnectCleanupTimeout) { clearTimeout(connection.externalDisconnectCleanupTimeout); connection.externalDisconnectCleanupTimeout = null; @@ -832,6 +900,7 @@ export class VoiceAssistantWebSocketServer { const frame = decodeBinaryMuxFrame(asBytes); if (frame) { if (!activeConnection) { + this.incrementRuntimeCounter("binaryBeforeHelloRejected"); log.warn("Rejected binary frame before hello"); this.clearPendingConnection(ws); try { @@ -848,6 +917,7 @@ export class VoiceAssistantWebSocketServer { const parsed = JSON.parse(buffer.toString()); const parsedMessage = WSInboundMessageSchema.safeParse(parsed); if (!parsedMessage.success) { + this.incrementRuntimeCounter("validationFailed"); if (pendingConnection) { pendingConnection.connectionLogger.warn( { @@ -913,6 +983,7 @@ export class VoiceAssistantWebSocketServer { } const message = parsedMessage.data; + this.recordInboundMessageType(message.type); if (message.type === "ping") { this.sendToClient(ws, { type: "pong" }); @@ -939,6 +1010,7 @@ export class VoiceAssistantWebSocketServer { }, "Rejected pending message before hello" ); + this.incrementRuntimeCounter("pendingMessageRejectedBeforeHello"); this.clearPendingConnection(ws); try { ws.close(WS_CLOSE_INVALID_HELLO, "Session message before hello"); @@ -949,11 +1021,13 @@ export class VoiceAssistantWebSocketServer { } if (!activeConnection) { + this.incrementRuntimeCounter("missingConnectionForMessage"); this.logger.error("No connection found for websocket"); return; } if (message.type === "hello") { + this.incrementRuntimeCounter("unexpectedHelloOnActiveConnection"); activeConnection.connectionLogger.warn("Received hello on active connection"); try { ws.close(WS_CLOSE_INVALID_HELLO, "Unexpected hello"); @@ -964,6 +1038,7 @@ export class VoiceAssistantWebSocketServer { } if (message.type === "session") { + this.recordInboundSessionRequestType(message.message.type); await activeConnection.session.handleMessage(message.message); } } catch (error) { @@ -1039,6 +1114,106 @@ export class VoiceAssistantWebSocketServer { private readonly ACTIVITY_THRESHOLD_MS = 120_000; + private incrementRuntimeCounter(counter: keyof WebSocketRuntimeCounters): void { + this.runtimeCounters[counter] += 1; + } + + private incrementCount(map: Map, key: string): void { + map.set(key, (map.get(key) ?? 0) + 1); + } + + private recordInboundMessageType(type: string): void { + this.incrementCount(this.inboundMessageCounts, type); + } + + private recordInboundSessionRequestType(type: string): void { + this.incrementCount(this.inboundSessionRequestCounts, type); + } + + private getTopCounts(map: Map, limit: number): Array<[string, number]> { + return [...map.entries()].sort((a, b) => b[1] - a[1]).slice(0, limit); + } + + private collectSessionRuntimeMetrics(): SessionRuntimeMetrics { + const uniqueConnections = new Set(this.externalSessionsByKey.values()); + let checkoutDiffTargetCount = 0; + let checkoutDiffSubscriptionCount = 0; + let checkoutDiffWatcherCount = 0; + let checkoutDiffFallbackRefreshTargetCount = 0; + let terminalDirectorySubscriptionCount = 0; + let terminalSubscriptionCount = 0; + let terminalStreamCount = 0; + + for (const connection of uniqueConnections) { + const sessionMetrics = connection.session.getRuntimeMetrics(); + checkoutDiffTargetCount += sessionMetrics.checkoutDiffTargetCount; + checkoutDiffSubscriptionCount += sessionMetrics.checkoutDiffSubscriptionCount; + checkoutDiffWatcherCount += sessionMetrics.checkoutDiffWatcherCount; + checkoutDiffFallbackRefreshTargetCount += + sessionMetrics.checkoutDiffFallbackRefreshTargetCount; + terminalDirectorySubscriptionCount += sessionMetrics.terminalDirectorySubscriptionCount; + terminalSubscriptionCount += sessionMetrics.terminalSubscriptionCount; + terminalStreamCount += sessionMetrics.terminalStreamCount; + } + + return { + checkoutDiffTargetCount, + checkoutDiffSubscriptionCount, + checkoutDiffWatcherCount, + checkoutDiffFallbackRefreshTargetCount, + terminalDirectorySubscriptionCount, + terminalSubscriptionCount, + terminalStreamCount, + }; + } + + private flushRuntimeMetrics(options?: { final?: boolean }): void { + const now = Date.now(); + const windowMs = Math.max(0, now - this.runtimeWindowStartedAt); + const activeConnections = new Set(this.sessions.values()).size; + const activeSockets = this.sessions.size; + const pendingConnections = this.pendingConnections.size; + const reconnectGraceSessions = [...this.externalSessionsByKey.values()].filter( + (connection) => + connection.sockets.size === 0 && + connection.externalDisconnectCleanupTimeout !== null + ).length; + const sessionMetrics = this.collectSessionRuntimeMetrics(); + + this.logger.info( + { + windowMs, + final: Boolean(options?.final), + sessions: { + activeConnections, + externalSessionKeys: this.externalSessionsByKey.size, + reconnectGraceSessions, + }, + sockets: { + activeSockets, + pendingConnections, + }, + counters: { ...this.runtimeCounters }, + inboundMessageTypesTop: this.getTopCounts(this.inboundMessageCounts, 12), + inboundSessionRequestTypesTop: this.getTopCounts( + this.inboundSessionRequestCounts, + 20 + ), + runtime: sessionMetrics, + }, + "ws_runtime_metrics" + ); + + for (const counter of Object.keys(this.runtimeCounters) as Array< + keyof WebSocketRuntimeCounters + >) { + this.runtimeCounters[counter] = 0; + } + this.inboundMessageCounts.clear(); + this.inboundSessionRequestCounts.clear(); + this.runtimeWindowStartedAt = now; + } + private getClientActivityState(session: Session): ClientAttentionState { const activity = session.getClientActivity(); if (!activity) {