refactor: unify workspace tab management and add runtime metrics

This commit is contained in:
Mohamed Boudra
2026-03-05 14:21:16 +07:00
parent 6dc49ccbc7
commit 8371ea0c7d
32 changed files with 1064 additions and 461 deletions

View File

@@ -1,44 +1,26 @@
import { test, expect } from './fixtures'; import { test, expect } from './fixtures';
import { createAgent, ensureHostSelected, gotoHome, setWorkingDirectory } from './helpers/app'; import { createAgentInRepo } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace'; 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 }) => { test('create agent in a temp repo', async ({ page }) => {
const repo = await createTempGitRepo(); const repo = await createTempGitRepo();
const prompt = "Respond with exactly: Hello"; const prompt = "Respond with exactly: Hello";
try { try {
await gotoHome(page); await createAgentInRepo(page, { directory: repo.path, prompt });
await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page);
await createAgent(page, prompt);
// Verify user message is shown in the stream // Verify user message is shown in the stream
await expect(page.getByText(prompt, { exact: true })).toBeVisible(); await expect(page.getByText(prompt, { exact: true })).toBeVisible();
// Verify we used the seeded fast model (do not fall back to other defaults). // Verify we used the seeded fast model (do not fall back to other defaults).
await page.getByTestId('agent-overflow-menu').click(); const modelPicker = page.getByRole("button", { name: /select agent model/i }).first();
await expect(page.getByText('Model', { exact: true })).toBeVisible(); await expect(modelPicker).toBeVisible({ timeout: 30000 });
await expect( await expect(modelPicker).toContainText(/gpt-5\.1-codex-mini/i);
page.getByTestId('agent-overflow-content').getByText(/gpt-5\.1-codex-mini/i)
).toBeVisible();
// Verify the created agent's title reflects the response. // Verify the assistant response is rendered.
const { serverId, agentId } = parseAgentUrl(page.url()); await expect(page.getByText("Hello", { exact: true }).first()).toBeVisible({
const agentRow = page.getByTestId(`agent-row-${serverId}-${agentId}`).first(); timeout: 30000,
await expect(agentRow).not.toContainText(/new agent/i, { timeout: 30000 }); });
await expect(agentRow).toContainText(/hello|greet|response/i, { timeout: 30000 });
} finally { } finally {
await repo.cleanup(); await repo.cleanup();
} }

View File

@@ -158,7 +158,12 @@ export const gotoHome = async (page: Page) => {
await page.goto('/'); await page.goto('/');
await ensureE2EStorageSeeded(page); await ensureE2EStorageSeeded(page);
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible(); 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) => { export const openSettings = async (page: Page) => {
@@ -585,6 +590,16 @@ export const createAgentWithConfig = async (page: Page, config: AgentConfig) =>
await createAgent(page, config.prompt); await createAgent(page, config.prompt);
}; };
export const createAgentInRepo = async (
page: Page,
config: Pick<AgentConfig, 'directory' | 'prompt'>
) => {
await gotoHome(page);
await ensureHostSelected(page);
await setWorkingDirectory(page, config.directory);
await createAgent(page, config.prompt);
};
export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => { export const waitForPermissionPrompt = async (page: Page, timeout = 30000) => {
const promptText = page.getByTestId('permission-request-question').first(); const promptText = page.getByTestId('permission-request-question').first();
await expect(promptText).toBeVisible({ timeout }); await expect(promptText).toBeVisible({ timeout });

View File

@@ -0,0 +1,52 @@
import { expect, type Page } from "@playwright/test";
export async function getWorkspaceTabTestIds(page: Page): Promise<string[]> {
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<void> {
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<void> {
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<string[][]> {
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;
}

View File

@@ -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<void> { export async function openNewAgentComposer(page: Page): Promise<void> {
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();
} }
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({ 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<string>([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();
}
export async function switchWorkspaceViaSidebar(input: {
page: Page;
serverId: string;
targetWorkspacePath: string;
}): Promise<void> {
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<void> {
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, timeout: 30_000,
}); });
} }
export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> { export async function seedWorkspaceActivity(page: Page, marker: string): Promise<void> {
const input = page.getByRole("textbox", { name: "Message agent..." }); const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30_000 }); await expect(input).toBeEditable({ timeout: 30_000 });
await input.fill(marker); await input.fill(marker);
await input.press("Enter"); await input.press('Enter');
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 }); await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
} }

View File

@@ -65,6 +65,13 @@ test('new agent respects serverId in the URL', async ({ page }) => {
await page.goto(`/?serverId=${encodeURIComponent(serverId)}`); await page.goto(`/?serverId=${encodeURIComponent(serverId)}`);
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible(); 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...' }); const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30000 }); 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, updatedAt: nowIso,
}; };
await page.goto('/'); await gotoHome(page);
await page.evaluate( await page.evaluate(
({ daemon }) => { ({ daemon }) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';

View File

@@ -1,5 +1,6 @@
import { test, expect } from './fixtures'; import { test, expect } from './fixtures';
import { Buffer } from 'node:buffer'; import { Buffer } from 'node:buffer';
import { gotoHome, openSettings } from './helpers/app';
function encodeBase64Url(input: string): string { function encodeBase64Url(input: string): string {
return Buffer.from(input, 'utf8') 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. // Override the default fixture seeding for this test.
await page.goto('/settings'); await gotoHome(page);
await openSettings(page);
await page.evaluate(() => { await page.evaluate(() => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);

View File

@@ -1,4 +1,5 @@
import { test, expect } from './fixtures'; import { test, expect } from './fixtures';
import { gotoHome, openSettings } from './helpers/app';
test('connects via relay when direct endpoints fail', async ({ page }) => { test('connects via relay when direct endpoints fail', async ({ page }) => {
const relayPort = process.env.E2E_RELAY_PORT; 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. // Override the default fixture seeding for this test.
await page.goto('/settings'); await gotoHome(page);
await openSettings(page);
await page.evaluate((daemon) => { await page.evaluate((daemon) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);

View File

@@ -1,4 +1,5 @@
import { test, expect } from './fixtures'; import { test, expect } from './fixtures';
import { gotoHome, openSettings } from './helpers/app';
test('relay connection stays stable across multiple tabs', async ({ page }) => { test('relay connection stays stable across multiple tabs', async ({ page }) => {
const relayPort = process.env.E2E_RELAY_PORT; 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. // Use relay by making the direct endpoint intentionally fail.
await page.goto('/settings'); await gotoHome(page);
await openSettings(page);
await page.evaluate((daemon) => { await page.evaluate((daemon) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1'; const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce); 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 page2.routeWebSocket(/:(6767)\b/, async (ws) => {
await ws.close({ code: 1008, reason: 'Blocked connection to localhost:6767 during e2e.' }); 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}`); const card2 = page2.getByTestId(`daemon-card-${serverId}`);
await expect(card2.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 }); await expect(card2.getByText('Relay', { exact: true })).toBeVisible({ timeout: 20000 });
await expect(card2.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 }); await expect(card2.getByText('Online', { exact: true })).toBeVisible({ timeout: 20000 });

View File

@@ -75,7 +75,6 @@ async function openNewAgentDraft(page: Page): Promise<void> {
const newAgentButton = page.getByTestId("sidebar-new-agent").first(); const newAgentButton = page.getByTestId("sidebar-new-agent").first();
await expect(newAgentButton).toBeVisible({ timeout: 30000 }); await expect(newAgentButton).toBeVisible({ timeout: 30000 });
await newAgentButton.click(); await newAgentButton.click();
await expect(page).toHaveURL(/\/h\/[^/]+\/agent(\?|$)/, { timeout: 30000 });
await expect( await expect(
page.locator('[data-testid="working-directory-select"]:visible').first() page.locator('[data-testid="working-directory-select"]:visible').first()
).toBeVisible({ ).toBeVisible({
@@ -385,9 +384,7 @@ test("mobile terminal tab switch keeps command input routed to the selected tab"
await setWorkingDirectory(page, repo.path); await setWorkingDirectory(page, repo.path);
await ensureHostSelected(page); await ensureHostSelected(page);
await createAgent(page, "Reply with exactly: terminal routing"); await createAgent(page, "Reply with exactly: terminal routing");
const createdAgentUrl = page.url();
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
await page.goto(createdAgentUrl);
await ensureExplorerTabsVisible(page); await ensureExplorerTabsVisible(page);
const terminalsTab = visibleTestId(page, "explorer-tab-terminals"); const terminalsTab = visibleTestId(page, "explorer-tab-terminals");

View File

@@ -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 { setWorkingDirectory } from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace"; import { createTempGitRepo } from "./helpers/workspace";
import { openNewAgentComposer, seedWorkspaceActivity } from "./helpers/workspace-ui"; import {
import { buildHostWorkspaceRoute } from "@/utils/host-routes"; openNewAgentComposer,
seedWorkspaceActivity,
function buildWorkspaceRoute(serverId: string, workspacePath: string): string { switchWorkspaceViaSidebar,
return buildHostWorkspaceRoute(serverId, workspacePath); } from "./helpers/workspace-ui";
}
async function openWorkspace(page: Page, workspacePath: string): Promise<void> {
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,
});
}
function percentile(values: number[], p: number): number { function percentile(values: number[], p: number): number {
if (values.length === 0) { if (values.length === 0) {
@@ -58,6 +44,7 @@ function summarize(values: number[]) {
function buildStressCommand(doneMarker: string): string { function buildStressCommand(doneMarker: string): string {
// Deterministic synthetic "TUI-like" redraw loop: alternate screen + cursor-home repaint. // Deterministic synthetic "TUI-like" redraw loop: alternate screen + cursor-home repaint.
const markerFile = ".paseo-terminal-benchmark-marker";
return [ return [
"i=1", "i=1",
"printf '\\033[?1049h\\033[2J'", "printf '\\033[?1049h\\033[2J'",
@@ -72,9 +59,19 @@ function buildStressCommand(doneMarker: string): string {
"i=$((i+1))", "i=$((i+1))",
"done", "done",
`printf '\\033[?1049l\\n${doneMarker}\\n'`, `printf '\\033[?1049l\\n${doneMarker}\\n'`,
`printf '${doneMarker}\\n' > '${markerFile}'`,
].join("; "); ].join("; ");
} }
async function markerFileContains(filePath: string, marker: string): Promise<boolean> {
try {
const text = await readFile(filePath, "utf8");
return text.includes(marker);
} catch {
return false;
}
}
async function toggleExplorerAndMeasureLatency(page: Page): Promise<number> { async function toggleExplorerAndMeasureLatency(page: Page): Promise<number> {
const toggle = page.getByTestId("workspace-explorer-toggle").first(); const toggle = page.getByTestId("workspace-explorer-toggle").first();
await expect(toggle).toBeVisible({ timeout: 30_000 }); await expect(toggle).toBeVisible({ timeout: 30_000 });
@@ -92,13 +89,22 @@ test("workspace terminal responsiveness benchmark (report-only, single stress pr
}, testInfo) => { }, testInfo) => {
test.setTimeout(180_000); test.setTimeout(180_000);
const repo = await createTempGitRepo("paseo-e2e-terminal-benchmark-"); const repo = await createTempGitRepo("paseo-e2e-terminal-benchmark-");
const markerFilePath = path.join(repo.path, ".paseo-terminal-benchmark-marker");
try { try {
const serverId = process.env.E2E_SERVER_ID;
if (!serverId) {
throw new Error("E2E_SERVER_ID is not set.");
}
await openNewAgentComposer(page); await openNewAgentComposer(page);
await setWorkingDirectory(page, repo.path); await setWorkingDirectory(page, repo.path);
await seedWorkspaceActivity(page, `terminal benchmark seed ${Date.now()}`); 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(); const newTerminalButton = page.getByTestId("workspace-new-terminal-tab").first();
await expect(newTerminalButton).toBeVisible({ timeout: 30_000 }); 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 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 page.keyboard.press("Enter");
await expect(page.getByText(postMarker).first()).toBeVisible({ await expect.poll(async () => await markerFileContains(markerFilePath, postMarker), {
timeout: 120_000, timeout: 120_000,
}); }).toBe(true);
const diagnostics = await page.evaluate(async () => { const diagnostics = await page.evaluate(async () => {
const debug = ( const debug = (
@@ -205,9 +211,7 @@ test("workspace terminal responsiveness benchmark (report-only, single stress pr
frameSleepMs: 10, frameSleepMs: 10,
doneMarker, doneMarker,
postMarker, postMarker,
doneMarkerObserved: doneMarkerObserved: await markerFileContains(markerFilePath, doneMarker),
(await page.getByText(doneMarker).first().isVisible().catch(() => false)) ||
(await page.getByText(doneMarker).count()) > 0,
}, },
frameGapMs: { frameGapMs: {
...summarize(frameGapsMs), ...summarize(frameGapsMs),

View File

@@ -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();
}
});

View File

@@ -1,16 +1,7 @@
import { test, expect, type Page } from "./fixtures"; import { test, expect, type Page } from "./fixtures";
import { import { createAgentInRepo } from "./helpers/app";
createAgent,
ensureHostSelected,
gotoHome,
setWorkingDirectory,
} from "./helpers/app";
import { createTempGitRepo } from "./helpers/workspace"; import { createTempGitRepo } from "./helpers/workspace";
import { buildHostWorkspaceRoute } from "@/utils/host-routes"; import { switchWorkspaceViaSidebar } from "./helpers/workspace-ui";
function buildWorkspaceRoute(serverId: string, workspacePath: string): string {
return buildHostWorkspaceRoute(serverId, workspacePath);
}
async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise<void> { async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promise<void> {
const serverId = process.env.E2E_SERVER_ID; 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."); throw new Error("E2E_SERVER_ID is not set.");
} }
await gotoHome(page); await createAgentInRepo(page, {
await ensureHostSelected(page); directory: workspacePath,
await setWorkingDirectory(page, workspacePath); prompt: `workspace header restore ${Date.now()}`,
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 switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: workspacePath });
await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({ await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
timeout: 30000, timeout: 30000,
}); });
@@ -49,15 +36,17 @@ test("workspace new-tab buttons stay on-screen during horizontal scroll", async
await expect(tabsScroll).toBeVisible({ timeout: 30000 }); await expect(tabsScroll).toBeVisible({ timeout: 30000 });
// Create enough terminal tabs to ensure the tabs row has overflow to scroll. // Create enough terminal tabs to ensure the tabs row has overflow to scroll.
const terminalTabs = page.locator('[data-testid^="workspace-tab-terminal:"]'); const workspaceTabs = page.locator(
const initialTerminalCount = await terminalTabs.count(); '[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])'
const targetTerminalCount = initialTerminalCount + 8; );
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 expect(terminalButton).toBeEnabled({ timeout: 30000 });
await terminalButton.click(); await terminalButton.click();
await expect await expect
.poll(async () => await terminalTabs.count(), { timeout: 30000 }) .poll(async () => await workspaceTabs.count(), { timeout: 30000 })
.toBeGreaterThanOrEqual(attempt + 1); .toBeGreaterThanOrEqual(attempt + 1);
} }

View File

@@ -1,68 +1,14 @@
import { execSync } from 'node:child_process'; import { execSync } from 'node:child_process';
import { test, expect, type Page } from './fixtures'; import { test } from './fixtures';
import { setWorkingDirectory } from './helpers/app'; import { setWorkingDirectory } from './helpers/app';
import { createTempGitRepo } from './helpers/workspace'; import { createTempGitRepo } from './helpers/workspace';
import { openNewAgentComposer, seedWorkspaceActivity } from './helpers/workspace-ui'; import {
import { buildHostWorkspaceRoute } from '@/utils/host-routes'; expectWorkspaceHeader,
openNewAgentComposer,
function escapeRegex(value: string): string { seedWorkspaceActivity,
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); switchWorkspaceViaSidebar,
} workspaceLabelFromPath,
} from './helpers/workspace-ui';
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<string>([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<void> {
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,
});
}
test('sidebar workspace switch keeps visible content in sync with selected workspace', async ({ page }) => { test('sidebar workspace switch keeps visible content in sync with selected workspace', async ({ page }) => {
const serverId = process.env.E2E_SERVER_ID; 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 setWorkingDirectory(page, repoB.path);
await seedWorkspaceActivity(page, tokenB); await seedWorkspaceActivity(page, tokenB);
await page.goto(buildHostWorkspaceRoute(serverId, repoA.path)); await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
await expect(page).toHaveURL(new RegExp('/workspace/'), { timeout: 30000 });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: 'sync-a-branch', title: 'sync-a-branch',
subtitle: workspaceLabelFromPath(repoA.path), subtitle: workspaceLabelFromPath(repoA.path),
}); });
await switchViaSidebar({ page, serverId, targetWorkspacePath: repoB.path }); await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoB.path });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: 'sync-b-branch', title: 'sync-b-branch',
subtitle: workspaceLabelFromPath(repoB.path), subtitle: workspaceLabelFromPath(repoB.path),
}); });
await switchViaSidebar({ page, serverId, targetWorkspacePath: repoA.path }); await switchWorkspaceViaSidebar({ page, serverId, targetWorkspacePath: repoA.path });
await expectWorkspaceHeader(page, { await expectWorkspaceHeader(page, {
title: 'sync-a-branch', title: 'sync-a-branch',
subtitle: workspaceLabelFromPath(repoA.path), subtitle: workspaceLabelFromPath(repoA.path),

View File

@@ -1,6 +1,6 @@
import { View, Pressable, Text, ActivityIndicator, Platform } from 'react-native' import { View, Pressable, Text, ActivityIndicator, Platform } from 'react-native'
import { useState, useEffect, useRef, useCallback } from 'react' 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 { ArrowUp, Square, Pencil, AudioLines } from 'lucide-react-native'
import Animated from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -119,6 +119,10 @@ export function AgentInputArea({
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead) const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead)
const [internalInput, setInternalInput] = useState('') const [internalInput, setInternalInput] = useState('')
const isDesktopWebBreakpoint =
Platform.OS === 'web' &&
UnistylesRuntime.breakpoint !== 'xs' &&
UnistylesRuntime.breakpoint !== 'sm'
const userInput = value ?? internalInput const userInput = value ?? internalInput
const setUserInput = onChangeText ?? setInternalInput const setUserInput = onChangeText ?? setInternalInput
const [cursorIndex, setCursorIndex] = useState(0) const [cursorIndex, setCursorIndex] = useState(0)
@@ -775,7 +779,8 @@ export function AgentInputArea({
client={client} client={client}
isReadyForDictation={isDictationReady} isReadyForDictation={isDictationReady}
placeholder="Message agent..." placeholder="Message agent..."
autoFocus={autoFocus} autoFocus={autoFocus && isDesktopWebBreakpoint}
autoFocusKey={`${serverId}:${agentId}`}
disabled={isSubmitLoading} disabled={isSubmitLoading}
isScreenFocused={isScreenFocused} isScreenFocused={isScreenFocused}
leftContent={leftContent} leftContent={leftContent}

View File

@@ -440,14 +440,14 @@ const styles = StyleSheet.create((theme) => ({
top: 0, top: 0,
right: 0, right: 0,
bottom: 0, bottom: 0,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surfaceSidebar,
overflow: "hidden", overflow: "hidden",
}, },
desktopSidebar: { desktopSidebar: {
position: "relative", position: "relative",
borderLeftWidth: 1, borderLeftWidth: 1,
borderLeftColor: theme.colors.border, borderLeftColor: theme.colors.border,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surfaceSidebar,
}, },
resizeHandle: { resizeHandle: {
position: "absolute", position: "absolute",

View File

@@ -948,7 +948,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) {
if (isStatusLoading) { if (isStatusLoading) {
bodyContent = ( bodyContent = (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" /> <ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.loadingText}>Checking repository...</Text> <Text style={styles.loadingText}>Checking repository...</Text>
</View> </View>
); );
@@ -967,8 +967,7 @@ export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) {
} else if (isDiffLoading) { } else if (isDiffLoading) {
bodyContent = ( bodyContent = (
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" /> <ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
<Text style={styles.loadingText}>Loading changes...</Text>
</View> </View>
); );
} else if (diffErrorMessage) { } else if (diffErrorMessage) {
@@ -1234,9 +1233,6 @@ export function GitDiffPane({ serverId, workspaceId, cwd }: GitDiffPaneProps) {
<Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}> <Text style={styles.branchLabel} testID="changes-branch" numberOfLines={1}>
{branchLabel} {branchLabel}
</Text> </Text>
{isStatusFetching && (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
)}
</View> </View>
{isGit ? ( {isGit ? (
<View style={styles.headerRight}> <View style={styles.headerRight}>

View File

@@ -560,7 +560,7 @@ const styles = StyleSheet.create((theme) => ({
top: 0, top: 0,
left: 0, left: 0,
bottom: 0, bottom: 0,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surfaceSidebar,
overflow: 'hidden', overflow: 'hidden',
}, },
sidebarContent: { sidebarContent: {
@@ -571,7 +571,7 @@ const styles = StyleSheet.create((theme) => ({
desktopSidebar: { desktopSidebar: {
borderRightWidth: 1, borderRightWidth: 1,
borderRightColor: theme.colors.border, borderRightColor: theme.colors.border,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surfaceSidebar,
}, },
sidebarHeader: { sidebarHeader: {
height: { height: {

View File

@@ -30,6 +30,7 @@ import {
} from '@/utils/image-attachments-from-files' } from '@/utils/image-attachments-from-files'
import type { AttachmentMetadata } from '@/attachments/types' import type { AttachmentMetadata } from '@/attachments/types'
import { useAttachmentPreviewUrl } from '@/attachments/use-attachment-preview-url' import { useAttachmentPreviewUrl } from '@/attachments/use-attachment-preview-url'
import { focusWithRetries } from '@/utils/web-focus'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Shortcut } from '@/components/ui/shortcut' import { Shortcut } from '@/components/ui/shortcut'
import type { MessageInputKeyboardActionKind } from '@/keyboard/actions' import type { MessageInputKeyboardActionKind } from '@/keyboard/actions'
@@ -62,6 +63,7 @@ export interface MessageInputProps {
isReadyForDictation?: boolean isReadyForDictation?: boolean
placeholder?: string placeholder?: string
autoFocus?: boolean autoFocus?: boolean
autoFocusKey?: string
disabled?: boolean disabled?: boolean
/** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */ /** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */
isScreenFocused?: boolean isScreenFocused?: boolean
@@ -137,6 +139,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
isReadyForDictation, isReadyForDictation,
placeholder = 'Message...', placeholder = 'Message...',
autoFocus = false, autoFocus = false,
autoFocusKey,
disabled = false, disabled = false,
isScreenFocused = true, isScreenFocused = true,
leftContent, leftContent,
@@ -231,15 +234,20 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
valueRef.current = value valueRef.current = value
}, [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(() => { useEffect(() => {
if (!IS_WEB || !autoFocus) return if (!IS_WEB || !autoFocus) return
// Use requestAnimationFrame to ensure DOM is ready return focusWithRetries({
const rafId = requestAnimationFrame(() => { focus: () => textInputRef.current?.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, autoFocusKey])
}, [autoFocus])
const handleDictationTranscript = useCallback( const handleDictationTranscript = useCallback(
(text: string, _meta: { requestId: string }) => { (text: string, _meta: { requestId: string }) => {

View File

@@ -1181,7 +1181,7 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
}, },
workspaceListContainer: { workspaceListContainer: {
marginLeft: theme.spacing[4], marginLeft: theme.spacing[2],
}, },
emptyText: { emptyText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,

View File

@@ -6,7 +6,6 @@ import {
Platform, Platform,
BackHandler, BackHandler,
} from "react-native"; } from "react-native";
import { useRouter, type Href } from "expo-router";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useQueryClient } from "@tanstack/react-query"; 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 type { DaemonClient } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
import type { ExplorerCheckoutContext } from "@/stores/panel-store"; import type { ExplorerCheckoutContext } from "@/stores/panel-store";
import { buildHostRootRoute } from "@/utils/host-routes";
const EMPTY_STREAM_ITEMS: StreamItem[] = []; const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
@@ -187,7 +185,6 @@ function AgentScreenContent({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const toast = useToast(); const toast = useToast();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const resolvedAgentId = agentId; const resolvedAgentId = agentId;
const { isArchivingAgent } = useArchiveAgent(); const { isArchivingAgent } = useArchiveAgent();
@@ -514,24 +511,6 @@ function AgentScreenContent({
resolvedAgentId && resolvedAgentId &&
isArchivingAgent({ serverId, agentId: 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(() => { useEffect(() => {
if (!resolvedAgentId) { if (!resolvedAgentId) {

View File

@@ -6,7 +6,7 @@ import {
} from "@/screens/workspace/workspace-tab-layout"; } from "@/screens/workspace/workspace-tab-layout";
type UseWorkspaceTabLayoutInput = { type UseWorkspaceTabLayoutInput = {
tabLabels: string[]; tabLabelLengths: number[];
viewportWidthOverride?: number | null; viewportWidthOverride?: number | null;
metrics: { metrics: {
rowHorizontalInset: number; rowHorizontalInset: number;
@@ -36,10 +36,10 @@ export function useWorkspaceTabLayout(input: UseWorkspaceTabLayoutInput): UseWor
() => () =>
computeWorkspaceTabLayout({ computeWorkspaceTabLayout({
viewportWidth: resolvedViewportWidth, viewportWidth: resolvedViewportWidth,
tabLabelLengths: input.tabLabels.map((label) => label.length), tabLabelLengths: input.tabLabelLengths,
metrics: input.metrics, metrics: input.metrics,
}), }),
[input.metrics, input.tabLabels, resolvedViewportWidth] [input.metrics, input.tabLabelLengths, resolvedViewportWidth]
); );
return { return {

View File

@@ -21,6 +21,7 @@ import { encodeFilePathForPathSegment } from "@/utils/host-routes";
import type { Agent } from "@/stores/session-store"; import type { Agent } from "@/stores/session-store";
const DROPDOWN_WIDTH = 220; const DROPDOWN_WIDTH = 220;
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
type NewTabOptionId = "__new_tab_agent__" | "__new_tab_terminal__"; type NewTabOptionId = "__new_tab_agent__" | "__new_tab_terminal__";
type WorkspaceDesktopTabsRowProps = { type WorkspaceDesktopTabsRowProps = {
@@ -101,8 +102,19 @@ export function WorkspaceDesktopTabsRow({
[tabsActionsWidth, theme.spacing] [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({ const { layout } = useWorkspaceTabLayout({
tabLabels: tabs.map((tab) => tab.label), tabLabelLengths,
viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null, viewportWidthOverride: tabsContainerWidth > 0 ? tabsContainerWidth : null,
metrics: layoutMetrics, metrics: layoutMetrics,
}); });
@@ -478,7 +490,7 @@ const styles = StyleSheet.create((theme) => ({
opacity: 0.9, opacity: 0.9,
}, },
tabLabelSkeletonWithCloseButton: { tabLabelSkeletonWithCloseButton: {
width: 80, width: LOADING_TAB_LABEL_SKELETON_WIDTH,
}, },
tabLabelWithCloseButton: { tabLabelWithCloseButton: {
paddingRight: 0, paddingRight: 0,

View File

@@ -10,12 +10,11 @@ import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { useAgentFormState } from "@/hooks/use-agent-form-state"; import { useAgentFormState } from "@/hooks/use-agent-form-state";
import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useCreateFlowStore } from "@/stores/create-flow-store"; 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 { generateMessageId, type StreamItem, type UserMessageImageAttachment } from "@/types/stream";
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import type { AgentCapabilityFlags, AgentSessionConfig } from "@server/server/agent/agent-sdk-types"; import type { AgentCapabilityFlags, AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages"; import type { AgentSnapshotPayload } from "@server/shared/messages";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSIONS = new Map();
const EMPTY_STREAM_ITEMS: StreamItem[] = []; const EMPTY_STREAM_ITEMS: StreamItem[] = [];
@@ -268,13 +267,6 @@ export function WorkspaceDraftAgentTab({
const agentId = result.id; const agentId = result.id;
updatePendingAgentId({ draftId, agentId }); 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); onCreated(result);
return; return;
} catch (error) { } catch (error) {
@@ -403,6 +395,7 @@ const styles = StyleSheet.create((theme) => ({
width: "100%", width: "100%",
alignSelf: "center", alignSelf: "center",
maxWidth: MAX_CONTENT_WIDTH, maxWidth: MAX_CONTENT_WIDTH,
backgroundColor: theme.colors.surface0,
}, },
contentContainer: { contentContainer: {
flex: 1, flex: 1,
@@ -423,6 +416,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
inputAreaWrapper: { inputAreaWrapper: {
width: "100%", width: "100%",
backgroundColor: theme.colors.surface0,
}, },
errorContainer: { errorContainer: {
marginTop: theme.spacing[2], marginTop: theme.spacing[2],

View File

@@ -5,6 +5,7 @@ import {
Platform, Platform,
Pressable, Pressable,
Text, Text,
useColorScheme,
View, View,
} from "react-native"; } from "react-native";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -49,6 +50,7 @@ import {
useWorkspaceTabsStore, useWorkspaceTabsStore,
} from "@/stores/workspace-tabs-store"; } from "@/stores/workspace-tabs-store";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { import {
buildWorkspaceOpenIntentParam, buildWorkspaceOpenIntentParam,
type WorkspaceOpenIntent, type WorkspaceOpenIntent,
@@ -69,6 +71,7 @@ import { getStatusDotColor } from "@/utils/status-dot-color";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { buildProviderCommand } from "@/utils/provider-command-templates"; import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys"; import { generateDraftId } from "@/stores/draft-keys";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab"; import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row"; import { WorkspaceDesktopTabsRow } from "@/screens/workspace/workspace-desktop-tabs-row";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
@@ -133,6 +136,8 @@ function WorkspaceScreenContent({
openIntent, openIntent,
}: WorkspaceScreenProps) { }: WorkspaceScreenProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isDarkMode = useColorScheme() === "dark";
const mainBackgroundColor = isDarkMode ? theme.colors.surface1 : theme.colors.surface0;
const toast = useToast(); const toast = useToast();
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -404,11 +409,13 @@ function WorkspaceScreenContent({
persistenceKey ? state.focusedTabIdByWorkspace[persistenceKey] ?? "" : "" persistenceKey ? state.focusedTabIdByWorkspace[persistenceKey] ?? "" : ""
); );
const openDraftTab = useWorkspaceTabsStore((state) => state.openDraftTab); const openDraftTab = useWorkspaceTabsStore((state) => state.openDraftTab);
const ensureTab = useWorkspaceTabsStore((state) => state.ensureTab);
const openOrFocusTab = useWorkspaceTabsStore((state) => state.openOrFocusTab); const openOrFocusTab = useWorkspaceTabsStore((state) => state.openOrFocusTab);
const focusTab = useWorkspaceTabsStore((state) => state.focusTab); const focusTab = useWorkspaceTabsStore((state) => state.focusTab);
const closeWorkspaceTab = useWorkspaceTabsStore((state) => state.closeTab); 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 reorderWorkspaceTabs = useWorkspaceTabsStore((state) => state.reorderTabs);
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
const workspaceTabActionRequest = useKeyboardShortcutsStore( const workspaceTabActionRequest = useKeyboardShortcutsStore(
(state) => state.workspaceTabActionRequest (state) => state.workspaceTabActionRequest
); );
@@ -483,12 +490,90 @@ function WorkspaceScreenContent({
normalizedWorkspaceId, 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( const tabModel = useMemo(
() => () =>
deriveWorkspaceTabModel({ deriveWorkspaceTabModel({
workspaceAgents, workspaceAgents,
terminals, terminals,
uiTabs, tabs: uiTabs,
tabOrder, tabOrder,
focusedTabId, focusedTabId,
}), }),
@@ -540,6 +625,10 @@ function WorkspaceScreenContent({
if (!persistenceKey) { if (!persistenceKey) {
return; return;
} }
if (workspaceAgents.length > 0 || terminals.length > 0) {
emptyWorkspaceSeedRef.current = null;
return;
}
if (tabs.length > 0) { if (tabs.length > 0) {
emptyWorkspaceSeedRef.current = null; emptyWorkspaceSeedRef.current = null;
return; return;
@@ -564,7 +653,9 @@ function WorkspaceScreenContent({
normalizedWorkspaceId, normalizedWorkspaceId,
openDraftTab, openDraftTab,
persistenceKey, persistenceKey,
terminals.length,
tabs.length, tabs.length,
workspaceAgents.length,
]); ]);
const handleOpenFileFromExplorer = useCallback( const handleOpenFileFromExplorer = useCallback(
@@ -1047,15 +1138,25 @@ function WorkspaceScreenContent({
draftId={target.draftId} draftId={target.draftId}
onCreated={(agentSnapshot) => { onCreated={(agentSnapshot) => {
const tabId = activeTabId ?? target.draftId; const tabId = activeTabId ?? target.draftId;
const nextAgentTabId = promoteDraftToAgent({ const normalized = normalizeAgentSnapshot(agentSnapshot, normalizedServerId);
const nextTabId = retargetWorkspaceTab({
serverId: normalizedServerId, serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId, workspaceId: normalizedWorkspaceId,
draftTabId: tabId, tabId,
agentId: agentSnapshot.id, target: { kind: "agent", agentId: agentSnapshot.id },
});
if (nextTabId) {
focusTab({
serverId: normalizedServerId,
workspaceId: normalizedWorkspaceId,
tabId: nextTabId,
}); });
if (nextAgentTabId) {
navigateToTabId(nextAgentTabId);
} }
useSessionStore.getState().setAgents(normalizedServerId, (prev) => {
const next = new Map(prev);
next.set(agentSnapshot.id, normalized);
return next;
});
}} }}
/> />
); );
@@ -1107,7 +1208,7 @@ function WorkspaceScreenContent({
}; };
return ( return (
<View style={styles.container}> <View style={[styles.container, { backgroundColor: mainBackgroundColor }]}>
<View style={styles.threePaneRow}> <View style={styles.threePaneRow}>
<View style={styles.centerColumn}> <View style={styles.centerColumn}>
<ScreenHeader <ScreenHeader
@@ -1654,6 +1755,7 @@ const styles = StyleSheet.create((theme) => ({
content: { content: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
backgroundColor: theme.colors.surface0,
}, },
emptyState: { emptyState: {
flex: 1, flex: 1,

View File

@@ -59,7 +59,11 @@ describe("deriveWorkspaceTabModel", () => {
makeAgent({ id: "agent-b", title: "" }), makeAgent({ id: "agent-b", title: "" }),
], ],
terminals: [{ id: "term-1", name: "shell" }], 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: [], tabOrder: [],
}); });
@@ -92,7 +96,10 @@ describe("deriveWorkspaceTabModel", () => {
const model = deriveWorkspaceTabModel({ const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-a", title: "A" })], workspaceAgents: [makeAgent({ id: "agent-a", title: "A" })],
terminals: [], 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"], tabOrder: ["draft_123", "agent_agent-a", "file_/repo/worktree/README.md"],
}); });
@@ -103,7 +110,11 @@ describe("deriveWorkspaceTabModel", () => {
const model = deriveWorkspaceTabModel({ const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })], workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [{ id: "term-1", name: "zsh" }], 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"], 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", () => { it("uses focused tab when present, otherwise falls back to first tab", () => {
const base = { const base: Parameters<typeof deriveWorkspaceTabModel>[0] = {
workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })], workspaceAgents: [makeAgent({ id: "agent-a" }), makeAgent({ id: "agent-b" })],
terminals: [], 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"], tabOrder: ["agent_agent-a", "agent_agent-b"],
}; };
@@ -141,7 +155,13 @@ describe("deriveWorkspaceTabModel", () => {
const model = deriveWorkspaceTabModel({ const model = deriveWorkspaceTabModel({
workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })], workspaceAgents: [makeAgent({ id: "workspace-b-agent", title: "B" })],
terminals: [], terminals: [],
uiTabs: [], tabs: [
{
tabId: "agent_workspace-b-agent",
target: { kind: "agent", agentId: "workspace-b-agent" },
createdAt: 1,
},
],
tabOrder: ["agent_workspace-b-agent"], tabOrder: ["agent_workspace-b-agent"],
focusedTabId: "agent_workspace-a-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({ const offending = makeAgent({
id: "offender", id: "offender",
title: "Needs permission", title: "Needs permission",
@@ -164,7 +184,7 @@ describe("deriveWorkspaceTabModel", () => {
const model = deriveWorkspaceTabModel({ const model = deriveWorkspaceTabModel({
workspaceAgents: [offending], workspaceAgents: [offending],
terminals: [], terminals: [],
uiTabs: [ tabs: [
{ {
tabId: "draft_123", tabId: "draft_123",
target: { kind: "draft", draftId: "draft_123" }, target: { kind: "draft", draftId: "draft_123" },
@@ -174,42 +194,47 @@ describe("deriveWorkspaceTabModel", () => {
tabOrder: ["draft_123"], 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({ const model = deriveWorkspaceTabModel({
workspaceAgents: [newerAgent, olderPermissionAgent, olderFailedAgent], workspaceAgents: [],
terminals: [], terminals: [],
uiTabs: [], tabs: [
tabOrder: [], {
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( const upgraded = deriveWorkspaceTabModel({
true workspaceAgents: [makeAgent({ id: "agent-1", title: "Ready title" })],
); terminals: [],
expect(model.tabs.some((tab) => tab.descriptor.tabId === "agent_agent-failed-old")).toBe( tabs: [
true {
); 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");
}
}); });
}); });

View File

@@ -53,7 +53,7 @@ function resolveWorkspaceAgentTabLabel(title: string | null | undefined): string
return normalized; return normalized;
} }
function normalizeUiTab(tab: WorkspaceTab): WorkspaceTab | null { function normalizeWorkspaceTab(tab: WorkspaceTab): WorkspaceTab | null {
if (!tab || typeof tab !== "object") { if (!tab || typeof tab !== "object") {
return null; return null;
} }
@@ -75,6 +75,28 @@ function normalizeUiTab(tab: WorkspaceTab): WorkspaceTab | null {
createdAt: tab.createdAt, 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") { if (tab.target.kind === "file") {
const path = trimNonEmpty(tab.target.path); const path = trimNonEmpty(tab.target.path);
if (!path) { if (!path) {
@@ -105,53 +127,20 @@ export function buildWorkspaceTabId(target: WorkspaceTabTarget): string {
export function deriveWorkspaceTabModel(input: { export function deriveWorkspaceTabModel(input: {
workspaceAgents: Agent[]; workspaceAgents: Agent[];
terminals: TerminalLike[]; terminals: TerminalLike[];
uiTabs: WorkspaceTab[]; tabs: WorkspaceTab[];
tabOrder: string[]; tabOrder: string[];
focusedTabId?: string | null; focusedTabId?: string | null;
}): WorkspaceTabModel { }): WorkspaceTabModel {
const tabsById = new Map<string, WorkspaceDerivedTab>(); const tabsById = new Map<string, WorkspaceDerivedTab>();
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 normalizedTabs = input.tabs
const target: WorkspaceTabTarget = { kind: "agent", agentId: agent.id }; .map((tab) => normalizeWorkspaceTab(tab))
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))
.filter((tab): tab is WorkspaceTab => tab !== null) .filter((tab): tab is WorkspaceTab => tab !== null)
.sort((left, right) => left.createdAt - right.createdAt); .sort((left, right) => left.createdAt - right.createdAt);
for (const tab of normalizedUiTabs) { for (const tab of normalizedTabs) {
if (tab.target.kind === "draft") { if (tab.target.kind === "draft") {
tabsById.set(tab.tabId, { tabsById.set(tab.tabId, {
target: tab.target, target: tab.target,
@@ -167,6 +156,42 @@ export function deriveWorkspaceTabModel(input: {
continue; 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") { if (tab.target.kind === "file") {
const filePath = tab.target.path; const filePath = tab.target.path;
const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath; const fileName = filePath.split("/").filter(Boolean).pop() ?? filePath;

View File

@@ -20,7 +20,7 @@ import { buildWorkspaceTabPersistenceKey, useWorkspaceTabsStore } from "@/stores
const SERVER_ID = "server-1"; const SERVER_ID = "server-1";
const WORKSPACE_ID = "/repo/worktree"; const WORKSPACE_ID = "/repo/worktree";
describe("workspace-tabs-store promoteDraftToAgent", () => { describe("workspace-tabs-store retargetTab", () => {
beforeEach(() => { beforeEach(() => {
useWorkspaceTabsStore.setState({ useWorkspaceTabsStore.setState({
uiTabsByWorkspace: {}, 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 draftTabId = "draft_123";
const agentId = "agent-1";
const expectedAgentTabId = `agent_${agentId}`;
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy(); expect(key).toBeTruthy();
const workspaceKey = key as string; const workspaceKey = key as string;
useWorkspaceTabsStore.getState().ensureTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "agent", agentId: "left" },
});
useWorkspaceTabsStore.getState().openDraftTab({ useWorkspaceTabsStore.getState().openDraftTab({
serverId: SERVER_ID, serverId: SERVER_ID,
workspaceId: WORKSPACE_ID, workspaceId: WORKSPACE_ID,
draftId: draftTabId, draftId: draftTabId,
}); });
useWorkspaceTabsStore.getState().ensureTab({
const beforeOrder = useWorkspaceTabsStore.getState().tabOrderByWorkspace[workspaceKey] ?? [];
const promoted = useWorkspaceTabsStore.getState().promoteDraftToAgent({
serverId: SERVER_ID, serverId: SERVER_ID,
workspaceId: WORKSPACE_ID, workspaceId: WORKSPACE_ID,
draftTabId, target: { kind: "agent", agentId: "right" },
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 },
}); });
useWorkspaceTabsStore.getState().focusTab({ useWorkspaceTabsStore.getState().focusTab({
serverId: SERVER_ID, serverId: SERVER_ID,
@@ -85,23 +56,84 @@ describe("workspace-tabs-store promoteDraftToAgent", () => {
tabId: draftTabId, tabId: draftTabId,
}); });
const beforeOrder = useWorkspaceTabsStore.getState().tabOrderByWorkspace[workspaceKey] ?? []; const before = useWorkspaceTabsStore.getState();
const promoted = useWorkspaceTabsStore.getState().promoteDraftToAgent({ const beforeOrder = before.tabOrderByWorkspace[workspaceKey] ?? [];
const retargeted = useWorkspaceTabsStore.getState().retargetTab({
serverId: SERVER_ID, serverId: SERVER_ID,
workspaceId: WORKSPACE_ID, workspaceId: WORKSPACE_ID,
draftTabId, tabId: draftTabId,
agentId, 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 state = useWorkspaceTabsStore.getState();
const afterOrder = state.tabOrderByWorkspace[workspaceKey] ?? []; const tabs = state.uiTabsByWorkspace[workspaceKey] ?? [];
const duplicateCount = afterOrder.filter((tabId) => tabId === expectedAgentTabId).length; const order = state.tabOrderByWorkspace[workspaceKey] ?? [];
const matchingTabs = tabs.filter(
(tab) => tab.target.kind === "agent" && tab.target.agentId === "created-agent"
);
expect(promoted).toBe(expectedAgentTabId); expect(ensured).toBe(draftTabId);
expect(duplicateCount).toBe(1); expect(matchingTabs).toHaveLength(1);
expect(afterOrder).toEqual([expectedAgentTabId]); expect(order).toEqual([draftTabId]);
expect(afterOrder.length).toBeLessThanOrEqual(beforeOrder.length);
expect(state.focusedTabIdByWorkspace[workspaceKey]).toBe(expectedAgentTabId);
expect(state.uiTabsByWorkspace[workspaceKey]).toBeUndefined();
}); });
}); });

View File

@@ -61,8 +61,23 @@ function normalizeTabTarget(value: WorkspaceTabTarget | null | undefined): Works
return null; return null;
} }
function isUiTarget(target: WorkspaceTabTarget): target is Extract<WorkspaceTabTarget, { kind: "draft" | "file" }> { function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
return target.kind === "draft" || target.kind === "file"; 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 { function buildDeterministicTabId(target: WorkspaceTabTarget): string {
@@ -107,6 +122,11 @@ type WorkspaceTabsState = {
tabOrderByWorkspace: Record<string, string[]>; tabOrderByWorkspace: Record<string, string[]>;
focusedTabIdByWorkspace: Record<string, string>; focusedTabIdByWorkspace: Record<string, string>;
openDraftTab: (input: { serverId: string; workspaceId: string; draftId: string }) => string | null; openDraftTab: (input: { serverId: string; workspaceId: string; draftId: string }) => string | null;
ensureTab: (input: {
serverId: string;
workspaceId: string;
target: WorkspaceTabTarget;
}) => string | null;
openOrFocusTab: (input: { openOrFocusTab: (input: {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
@@ -114,11 +134,11 @@ type WorkspaceTabsState = {
}) => string | null; }) => string | null;
focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; focusTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void; closeTab: (input: { serverId: string; workspaceId: string; tabId: string }) => void;
promoteDraftToAgent: (input: { retargetTab: (input: {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
draftTabId: string; tabId: string;
agentId: string; target: WorkspaceTabTarget;
}) => string | null; }) => string | null;
reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void; reorderTabs: (input: { serverId: string; workspaceId: string; tabIds: string[] }) => void;
getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[]; getWorkspaceTabs: (input: { serverId: string; workspaceId: string }) => WorkspaceTab[];
@@ -141,39 +161,39 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
target: { kind: "draft", draftId: normalizedDraftId }, target: { kind: "draft", draftId: normalizedDraftId },
}); });
}, },
openOrFocusTab: ({ serverId, workspaceId, target }) => { ensureTab: ({ serverId, workspaceId, target }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const normalizedTarget = normalizeTabTarget(target); const normalizedTarget = normalizeTabTarget(target);
if (!key || !normalizedTarget) { if (!key || !normalizedTarget) {
return null; return null;
} }
const tabId = buildDeterministicTabId(normalizedTarget); const deterministicTabId = buildDeterministicTabId(normalizedTarget);
let resolvedTabId = deterministicTabId;
const now = Date.now(); const now = Date.now();
set((state) => { 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 currentTabs = state.uiTabsByWorkspace[key] ?? [];
const existingIndex = currentTabs.findIndex((tab) => tab.tabId === tabId); const tabWithSameTarget =
const nextTabs = currentTabs.find((tab) => tabTargetsEqual(tab.target, normalizedTarget)) ?? null;
existingIndex >= 0 const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId;
? currentTabs resolvedTabId = effectiveTabId;
: [...currentTabs, { tabId, target: normalizedTarget, createdAt: now }];
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 { return {
uiTabsByWorkspace: uiTabsByWorkspace:
@@ -184,13 +204,17 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
nextOrder === currentOrder nextOrder === currentOrder
? state.tabOrderByWorkspace ? state.tabOrderByWorkspace
: { ...state.tabOrderByWorkspace, [key]: nextOrder }, : { ...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; return tabId;
}, },
focusTab: ({ serverId, workspaceId, tabId }) => { focusTab: ({ serverId, workspaceId, tabId }) => {
@@ -272,81 +296,40 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
}; };
}); });
}, },
promoteDraftToAgent: ({ serverId, workspaceId, draftTabId, agentId }) => { retargetTab: ({ serverId, workspaceId, tabId, target }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
const normalizedDraftTabId = trimNonEmpty(draftTabId); const normalizedTabId = trimNonEmpty(tabId);
const normalizedAgentId = trimNonEmpty(agentId); const normalizedTarget = normalizeTabTarget(target);
if (!key || !normalizedDraftTabId || !normalizedAgentId) { if (!key || !normalizedTabId || !normalizedTarget) {
return null; return null;
} }
const nextTabId = `agent_${normalizedAgentId}`; let retargetedTabId: string | null = null;
let promotedTabId: string | null = null;
set((state) => { set((state) => {
const currentOrder = state.tabOrderByWorkspace[key] ?? [];
const currentTabs = state.uiTabsByWorkspace[key] ?? []; const currentTabs = state.uiTabsByWorkspace[key] ?? [];
const hasDraftInOrder = currentOrder.includes(normalizedDraftTabId); const index = currentTabs.findIndex((tab) => tab.tabId === normalizedTabId);
const hasDraftInUiTabs = currentTabs.some((tab) => tab.tabId === normalizedDraftTabId); if (index < 0) {
if (!hasDraftInOrder && !hasDraftInUiTabs) {
return state; return state;
} }
const hasAgentTabInOrder = currentOrder.includes(nextTabId); const currentTarget = currentTabs[index]?.target;
const nextOrder = hasAgentTabInOrder if (currentTarget && tabTargetsEqual(currentTarget, normalizedTarget)) {
? 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) {
return state; return state;
} }
promotedTabId = nextTabId; const nextTabs = currentTabs.map((tab, tabIndex) =>
tabIndex === index ? { ...tab, target: normalizedTarget } : tab
);
retargetedTabId = normalizedTabId;
return { return {
uiTabsByWorkspace: nextUiTabsByWorkspace, uiTabsByWorkspace: { ...state.uiTabsByWorkspace, [key]: nextTabs },
tabOrderByWorkspace: nextTabOrderByWorkspace, tabOrderByWorkspace: state.tabOrderByWorkspace,
focusedTabIdByWorkspace: nextFocusedByWorkspace, focusedTabIdByWorkspace: state.focusedTabIdByWorkspace,
}; };
}); });
return promotedTabId; return retargetedTabId;
}, },
reorderTabs: ({ serverId, workspaceId, tabIds }) => { reorderTabs: ({ serverId, workspaceId, tabIds }) => {
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
@@ -389,12 +372,25 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
}), }),
{ {
name: "workspace-tabs-state", name: "workspace-tabs-state",
version: 4, version: 5,
storage: createJSONStorage(() => AsyncStorage), storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => { partialize: (state) => {
const nextUiTabsByWorkspace: Record<string, WorkspaceTab[]> = {}; const nextUiTabsByWorkspace: Record<string, WorkspaceTab[]> = {};
for (const key in state.uiTabsByWorkspace) { 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) { if (tabs.length > 0) {
nextUiTabsByWorkspace[key] = tabs; nextUiTabsByWorkspace[key] = tabs;
} }
@@ -473,10 +469,6 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
orderFromTabs.push(tabId); orderFromTabs.push(tabId);
} }
if (!isUiTarget(normalizedTarget)) {
continue;
}
nextUiTabs.push({ nextUiTabs.push({
tabId, tabId,
target: normalizedTarget, target: normalizedTarget,

View File

@@ -110,6 +110,7 @@ const lightSemanticColors = {
surface1: "#fafafa", // Subtle hover (was zinc-100, now zinc-50) surface1: "#fafafa", // Subtle hover (was zinc-100, now zinc-50)
surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100) surface2: "#f4f4f5", // Elevated: badges, inputs, sheets (was zinc-200, now zinc-100)
surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200) surface3: "#e4e4e7", // Highest elevation (was zinc-300, now zinc-200)
surfaceSidebar: "#f4f4f5", // Sidebar background (darker than main)
// Text // Text
foreground: "#09090b", foreground: "#09090b",
@@ -179,6 +180,7 @@ const darkSemanticColors = {
surface1: "#1f1f23", // Subtle hover surface1: "#1f1f23", // Subtle hover
surface2: "#27272a", // Elevated: badges, inputs, sheets surface2: "#27272a", // Elevated: badges, inputs, sheets
surface3: "#3f3f46", // Highest elevation surface3: "#3f3f46", // Highest elevation
surfaceSidebar: "#121216", // Sidebar background (darker than main)
// Text // Text
foreground: "#fafafa", foreground: "#fafafa",

View File

@@ -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()
})
})

View File

@@ -177,6 +177,23 @@ function deriveInitialAgentTitle(prompt: string): string | null {
return clamped.length > 0 ? clamped : 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 { function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
if (!remoteUrl) { if (!remoteUrl) {
return null return null
@@ -299,6 +316,16 @@ type TerminalStreamPendingChunk = {
replay: boolean replay: boolean
} }
export type SessionRuntimeMetrics = {
checkoutDiffTargetCount: number
checkoutDiffSubscriptionCount: number
checkoutDiffWatcherCount: number
checkoutDiffFallbackRefreshTargetCount: number
terminalDirectorySubscriptionCount: number
terminalSubscriptionCount: number
terminalStreamCount: number
}
type FetchAgentsRequestMessage = Extract<SessionInboundMessage, { type: 'fetch_agents_request' }> type FetchAgentsRequestMessage = Extract<SessionInboundMessage, { type: 'fetch_agents_request' }>
type FetchAgentsRequestFilter = NonNullable<FetchAgentsRequestMessage['filter']> type FetchAgentsRequestFilter = NonNullable<FetchAgentsRequestMessage['filter']>
type FetchAgentsRequestSort = NonNullable<FetchAgentsRequestMessage['sort']>[number] type FetchAgentsRequestSort = NonNullable<FetchAgentsRequestMessage['sort']>[number]
@@ -747,6 +774,27 @@ export class Session {
return this.clientActivity 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 * Send initial state to client after connection
*/ */
@@ -2563,15 +2611,13 @@ export class Session {
try { try {
const trimmedPrompt = initialPrompt?.trim() const trimmedPrompt = initialPrompt?.trim()
const derivedTitle = const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
typeof config.title === 'string' && config.title.trim().length > 0 configTitle: config.title,
? config.title.trim() initialPrompt: trimmedPrompt,
: trimmedPrompt })
? deriveInitialAgentTitle(trimmedPrompt)
: null
const resolvedConfig: AgentSessionConfig = { const resolvedConfig: AgentSessionConfig = {
...config, ...config,
...(derivedTitle ? { title: derivedTitle } : {}), ...(provisionalTitle ? { title: provisionalTitle } : {}),
} }
const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig( const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig(
@@ -2605,7 +2651,7 @@ export class Session {
agentId: snapshot.id, agentId: snapshot.id,
cwd: snapshot.cwd, cwd: snapshot.cwd,
initialPrompt: trimmedPrompt, initialPrompt: trimmedPrompt,
explicitTitle: snapshot.config.title, explicitTitle,
paseoHome: this.paseoHome, paseoHome: this.paseoHome,
logger: this.sessionLogger, logger: this.sessionLogger,
}) })

View File

@@ -25,7 +25,11 @@ import {
} from "../shared/binary-mux.js"; } from "../shared/binary-mux.js";
import type { AllowedHostsConfig } from "./allowed-hosts.js"; import type { AllowedHostsConfig } from "./allowed-hosts.js";
import { isHostAllowed } 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 { AgentProvider } from "./agent/agent-sdk-types.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js"; import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { PushTokenStore } from "./push/token-store.js"; import { PushTokenStore } from "./push/token-store.js";
@@ -162,12 +166,31 @@ type SessionConnection = {
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null; externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | 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 EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000;
const HELLO_TIMEOUT_MS = 15_000; const HELLO_TIMEOUT_MS = 15_000;
const WS_CLOSE_HELLO_TIMEOUT = 4001; const WS_CLOSE_HELLO_TIMEOUT = 4001;
const WS_CLOSE_INVALID_HELLO = 4002; const WS_CLOSE_INVALID_HELLO = 4002;
const WS_CLOSE_INCOMPATIBLE_PROTOCOL = 4003; const WS_CLOSE_INCOMPATIBLE_PROTOCOL = 4003;
const WS_PROTOCOL_VERSION = 1; const WS_PROTOCOL_VERSION = 1;
const WS_RUNTIME_METRICS_FLUSH_MS = 30_000;
export class MissingDaemonVersionError extends Error { export class MissingDaemonVersionError extends Error {
constructor() { constructor() {
@@ -219,6 +242,27 @@ export class VoiceAssistantWebSocketServer {
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null;
private serverCapabilities: ServerCapabilities | undefined; 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<string, number>();
private readonly inboundSessionRequestCounts = new Map<string, number>();
private runtimeMetricsInterval: ReturnType<typeof setInterval> | null = null;
constructor( constructor(
server: HTTPServer, server: HTTPServer,
@@ -295,6 +339,7 @@ export class VoiceAssistantWebSocketServer {
const origin = requestMetadata.origin; const origin = requestMetadata.origin;
const requestHost = requestMetadata.host ?? null; const requestHost = requestMetadata.host ?? null;
if (requestHost && !isHostAllowed(requestHost, allowedHosts)) { if (requestHost && !isHostAllowed(requestHost, allowedHosts)) {
this.incrementRuntimeCounter("hostRejected");
this.logger.warn( this.logger.warn(
{ ...requestMetadata, host: requestHost }, { ...requestMetadata, host: requestHost },
"Rejected connection from disallowed host" "Rejected connection from disallowed host"
@@ -310,6 +355,7 @@ export class VoiceAssistantWebSocketServer {
if (!origin || allowedOrigins.has(origin) || sameOrigin) { if (!origin || allowedOrigins.has(origin) || sameOrigin) {
callback(true); callback(true);
} else { } else {
this.incrementRuntimeCounter("originRejected");
this.logger.warn( this.logger.warn(
{ ...requestMetadata, origin }, { ...requestMetadata, origin },
"Rejected connection from origin" "Rejected connection from origin"
@@ -323,6 +369,12 @@ export class VoiceAssistantWebSocketServer {
void this.attachSocket(ws, request); 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"); this.logger.info("WebSocket server initialized on /ws");
} }
@@ -355,10 +407,19 @@ export class VoiceAssistantWebSocketServer {
ws: WebSocketLike, ws: WebSocketLike,
metadata?: ExternalSocketMetadata metadata?: ExternalSocketMetadata
): Promise<void> { ): Promise<void> {
if (metadata?.transport === "relay") {
this.incrementRuntimeCounter("relayExternalSocketAttached");
}
await this.attachSocket(ws, undefined, metadata); await this.attachSocket(ws, undefined, metadata);
} }
public async close(): Promise<void> { public async close(): Promise<void> {
if (this.runtimeMetricsInterval) {
clearInterval(this.runtimeMetricsInterval);
this.runtimeMetricsInterval = null;
}
this.flushRuntimeMetrics({ final: true });
const uniqueConnections = new Set<SessionConnection>([ const uniqueConnections = new Set<SessionConnection>([
...this.sessions.values(), ...this.sessions.values(),
...this.externalSessionsByKey.values(), ...this.externalSessionsByKey.values(),
@@ -494,6 +555,7 @@ export class VoiceAssistantWebSocketServer {
(timeout as unknown as { unref?: () => void }).unref?.(); (timeout as unknown as { unref?: () => void }).unref?.();
this.pendingConnections.set(ws, pending); this.pendingConnections.set(ws, pending);
this.incrementRuntimeCounter("connectedAwaitingHello");
this.bindSocketHandlers(ws); this.bindSocketHandlers(ws);
pending.connectionLogger.trace( pending.connectionLogger.trace(
@@ -633,6 +695,7 @@ export class VoiceAssistantWebSocketServer {
this.clearPendingConnection(ws); this.clearPendingConnection(ws);
const existing = this.externalSessionsByKey.get(clientId); const existing = this.externalSessionsByKey.get(clientId);
if (existing) { if (existing) {
this.incrementRuntimeCounter("helloResumed");
if (existing.externalDisconnectCleanupTimeout) { if (existing.externalDisconnectCleanupTimeout) {
clearTimeout(existing.externalDisconnectCleanupTimeout); clearTimeout(existing.externalDisconnectCleanupTimeout);
existing.externalDisconnectCleanupTimeout = null; existing.externalDisconnectCleanupTimeout = null;
@@ -652,6 +715,7 @@ export class VoiceAssistantWebSocketServer {
} }
const connectionLogger = pending.connectionLogger.child({ clientId }); const connectionLogger = pending.connectionLogger.child({ clientId });
this.incrementRuntimeCounter("helloNew");
const connection = this.createSessionConnection({ const connection = this.createSessionConnection({
ws, ws,
clientId, clientId,
@@ -733,6 +797,7 @@ export class VoiceAssistantWebSocketServer {
): Promise<void> { ): Promise<void> {
const pending = this.clearPendingConnection(ws); const pending = this.clearPendingConnection(ws);
if (pending) { if (pending) {
this.incrementRuntimeCounter("pendingDisconnected");
pending.connectionLogger.trace( pending.connectionLogger.trace(
{ {
code: details.code, code: details.code,
@@ -752,6 +817,7 @@ export class VoiceAssistantWebSocketServer {
connection.sockets.delete(ws); connection.sockets.delete(ws);
if (connection.sockets.size === 0) { if (connection.sockets.size === 0) {
this.incrementRuntimeCounter("sessionDisconnectedWaitingReconnect");
if (connection.externalDisconnectCleanupTimeout) { if (connection.externalDisconnectCleanupTimeout) {
clearTimeout(connection.externalDisconnectCleanupTimeout); clearTimeout(connection.externalDisconnectCleanupTimeout);
} }
@@ -777,6 +843,7 @@ export class VoiceAssistantWebSocketServer {
} }
if (connection.sockets.size > 0) { if (connection.sockets.size > 0) {
this.incrementRuntimeCounter("sessionSocketDisconnectedAttached");
connection.connectionLogger.trace( connection.connectionLogger.trace(
{ {
clientId: connection.clientId, clientId: connection.clientId,
@@ -796,6 +863,7 @@ export class VoiceAssistantWebSocketServer {
connection: SessionConnection, connection: SessionConnection,
logMessage: string logMessage: string
): Promise<void> { ): Promise<void> {
this.incrementRuntimeCounter("sessionCleanup");
if (connection.externalDisconnectCleanupTimeout) { if (connection.externalDisconnectCleanupTimeout) {
clearTimeout(connection.externalDisconnectCleanupTimeout); clearTimeout(connection.externalDisconnectCleanupTimeout);
connection.externalDisconnectCleanupTimeout = null; connection.externalDisconnectCleanupTimeout = null;
@@ -832,6 +900,7 @@ export class VoiceAssistantWebSocketServer {
const frame = decodeBinaryMuxFrame(asBytes); const frame = decodeBinaryMuxFrame(asBytes);
if (frame) { if (frame) {
if (!activeConnection) { if (!activeConnection) {
this.incrementRuntimeCounter("binaryBeforeHelloRejected");
log.warn("Rejected binary frame before hello"); log.warn("Rejected binary frame before hello");
this.clearPendingConnection(ws); this.clearPendingConnection(ws);
try { try {
@@ -848,6 +917,7 @@ export class VoiceAssistantWebSocketServer {
const parsed = JSON.parse(buffer.toString()); const parsed = JSON.parse(buffer.toString());
const parsedMessage = WSInboundMessageSchema.safeParse(parsed); const parsedMessage = WSInboundMessageSchema.safeParse(parsed);
if (!parsedMessage.success) { if (!parsedMessage.success) {
this.incrementRuntimeCounter("validationFailed");
if (pendingConnection) { if (pendingConnection) {
pendingConnection.connectionLogger.warn( pendingConnection.connectionLogger.warn(
{ {
@@ -913,6 +983,7 @@ export class VoiceAssistantWebSocketServer {
} }
const message = parsedMessage.data; const message = parsedMessage.data;
this.recordInboundMessageType(message.type);
if (message.type === "ping") { if (message.type === "ping") {
this.sendToClient(ws, { type: "pong" }); this.sendToClient(ws, { type: "pong" });
@@ -939,6 +1010,7 @@ export class VoiceAssistantWebSocketServer {
}, },
"Rejected pending message before hello" "Rejected pending message before hello"
); );
this.incrementRuntimeCounter("pendingMessageRejectedBeforeHello");
this.clearPendingConnection(ws); this.clearPendingConnection(ws);
try { try {
ws.close(WS_CLOSE_INVALID_HELLO, "Session message before hello"); ws.close(WS_CLOSE_INVALID_HELLO, "Session message before hello");
@@ -949,11 +1021,13 @@ export class VoiceAssistantWebSocketServer {
} }
if (!activeConnection) { if (!activeConnection) {
this.incrementRuntimeCounter("missingConnectionForMessage");
this.logger.error("No connection found for websocket"); this.logger.error("No connection found for websocket");
return; return;
} }
if (message.type === "hello") { if (message.type === "hello") {
this.incrementRuntimeCounter("unexpectedHelloOnActiveConnection");
activeConnection.connectionLogger.warn("Received hello on active connection"); activeConnection.connectionLogger.warn("Received hello on active connection");
try { try {
ws.close(WS_CLOSE_INVALID_HELLO, "Unexpected hello"); ws.close(WS_CLOSE_INVALID_HELLO, "Unexpected hello");
@@ -964,6 +1038,7 @@ export class VoiceAssistantWebSocketServer {
} }
if (message.type === "session") { if (message.type === "session") {
this.recordInboundSessionRequestType(message.message.type);
await activeConnection.session.handleMessage(message.message); await activeConnection.session.handleMessage(message.message);
} }
} catch (error) { } catch (error) {
@@ -1039,6 +1114,106 @@ export class VoiceAssistantWebSocketServer {
private readonly ACTIVITY_THRESHOLD_MS = 120_000; private readonly ACTIVITY_THRESHOLD_MS = 120_000;
private incrementRuntimeCounter(counter: keyof WebSocketRuntimeCounters): void {
this.runtimeCounters[counter] += 1;
}
private incrementCount(map: Map<string, number>, 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<string, number>, 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<SessionConnection>(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<SessionConnection>(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 { private getClientActivityState(session: Session): ClientAttentionState {
const activity = session.getClientActivity(); const activity = session.getClientActivity();
if (!activity) { if (!activity) {