mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-15 04:42:45 +00:00
refactor: unify workspace tab management and add runtime metrics
This commit is contained in:
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
52
packages/app/e2e/helpers/workspace-tabs.ts
Normal file
52
packages/app/e2e/helpers/workspace-tabs.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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();
|
export function workspaceLabelFromPath(value: string): string {
|
||||||
if (await sidebarNewAgent.isVisible().catch(() => false)) {
|
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||||
await sidebarNewAgent.click();
|
const parts = normalized.split('/').filter(Boolean);
|
||||||
} else {
|
return parts[parts.length - 1] ?? normalized;
|
||||||
await page.getByText("New agent", { exact: true }).first().click();
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible({
|
function workspaceRowLocator(page: Page, serverId: string, workspacePath: string) {
|
||||||
|
const ids = candidateWorkspaceIds(workspacePath).map(
|
||||||
|
(id) => `[data-testid="sidebar-workspace-row-${serverId}:${id}"]`
|
||||||
|
);
|
||||||
|
return page.locator(ids.join(',')).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function switchWorkspaceViaSidebar(input: {
|
||||||
|
page: Page;
|
||||||
|
serverId: string;
|
||||||
|
targetWorkspacePath: string;
|
||||||
|
}): Promise<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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
125
packages/app/e2e/workspace-draft-tab-retarget.spec.ts
Normal file
125
packages/app/e2e/workspace-draft-tab-retarget.spec.ts
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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 (nextAgentTabId) {
|
if (nextTabId) {
|
||||||
navigateToTabId(nextAgentTabId);
|
focusTab({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
workspaceId: normalizedWorkspaceId,
|
||||||
|
tabId: nextTabId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
useSessionStore.getState().setAgents(normalizedServerId, (prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(agentSnapshot.id, normalized);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1107,7 +1208,7 @@ function WorkspaceScreenContent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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,
|
||||||
|
|||||||
@@ -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", () => {
|
it("keeps retargeted agent tab id stable while upgrading descriptor data", () => {
|
||||||
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"),
|
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user