feat: add sqlite storage and terminal ui
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
import { createTempGitRepo } from "./workspace";
|
||||
|
||||
// ─── Navigation ────────────────────────────────────────────────────────────
|
||||
|
||||
function getServerId(): string {
|
||||
const serverId = process.env.E2E_SERVER_ID;
|
||||
if (!serverId) {
|
||||
throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup).");
|
||||
}
|
||||
return serverId;
|
||||
}
|
||||
|
||||
/** Navigate to a workspace and wait for the tab bar to appear. */
|
||||
export async function gotoWorkspace(page: Page, cwd: string): Promise<void> {
|
||||
const route = buildHostWorkspaceRoute(getServerId(), cwd);
|
||||
await page.goto(route);
|
||||
await waitForTabBar(page);
|
||||
}
|
||||
|
||||
// ─── Tab bar queries ───────────────────────────────────────────────────────
|
||||
|
||||
/** Wait for the workspace tab bar to be visible. */
|
||||
export async function waitForTabBar(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-tabs-row").first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Return all tab test IDs currently in the tab bar. */
|
||||
export async function getTabTestIds(page: Page): Promise<string[]> {
|
||||
const tabs = page.locator('[data-testid^="workspace-tab-"]');
|
||||
const count = await tabs.count();
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const testId = await tabs.nth(i).getAttribute("data-testid");
|
||||
if (testId) ids.push(testId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** Return the number of tabs matching a kind prefix (e.g. "launcher", "draft", "terminal", "agent"). */
|
||||
export async function countTabsOfKind(page: Page, kind: string): Promise<number> {
|
||||
const ids = await getTabTestIds(page);
|
||||
return ids.filter((id) => id.includes(kind)).length;
|
||||
}
|
||||
|
||||
/** Return the currently active tab's test ID (the one with aria-selected or focus styling). */
|
||||
export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
|
||||
const activeTab = page.locator('[data-testid^="workspace-tab-"][aria-selected="true"]').first();
|
||||
if (await activeTab.isVisible().catch(() => false)) {
|
||||
return activeTab.getAttribute("data-testid");
|
||||
}
|
||||
// Fallback: the tab with focused styling
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Tab actions ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Click the '+' button in the tab bar to open a new launcher tab. */
|
||||
export async function clickNewTabButton(page: Page): Promise<void> {
|
||||
const button = page.getByTestId("workspace-new-tab");
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Press Cmd+T (macOS) to open a new tab. */
|
||||
export async function pressNewTabShortcut(page: Page): Promise<void> {
|
||||
await page.keyboard.press("Meta+t");
|
||||
}
|
||||
|
||||
// ─── Launcher panel assertions ─────────────────────────────────────────────
|
||||
|
||||
/** Wait for the launcher panel to render with its primary tiles. */
|
||||
export async function waitForLauncherPanel(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Assert that the launcher panel shows provider tiles under "Terminal Agents". */
|
||||
export async function assertProviderTilesVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Terminal Agents", { exact: true }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Assert the launcher panel has a "New Chat" tile. */
|
||||
export async function assertNewChatTileVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "New Chat" }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
/** Assert the launcher panel has a "Terminal" tile. */
|
||||
export async function assertTerminalTileVisible(page: Page): Promise<void> {
|
||||
await expect(page.getByRole("button", { name: "Terminal" }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
// ─── Launcher tile clicks ──────────────────────────────────────────────────
|
||||
|
||||
/** Click the "New Chat" tile on the launcher panel. */
|
||||
export async function clickNewChat(page: Page): Promise<void> {
|
||||
const button = page.getByRole("button", { name: "New Chat" }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click the "Terminal" tile on the launcher panel. */
|
||||
export async function clickTerminal(page: Page): Promise<void> {
|
||||
const button = page.getByRole("button", { name: "Terminal" }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
|
||||
/** Click a provider tile by label (e.g. "Claude Code", "Codex"). */
|
||||
export async function clickProviderTile(page: Page, providerLabel: string): Promise<void> {
|
||||
const tile = page.getByRole("button", { name: providerLabel }).first();
|
||||
await expect(tile).toBeVisible({ timeout: 10_000 });
|
||||
await tile.click();
|
||||
}
|
||||
|
||||
// ─── Tab title assertions ──────────────────────────────────────────────────
|
||||
|
||||
/** Wait for any tab in the bar to display the given title text. */
|
||||
export async function waitForTabWithTitle(
|
||||
page: Page,
|
||||
title: string | RegExp,
|
||||
timeout = 30_000,
|
||||
): Promise<void> {
|
||||
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
|
||||
await expect(page.locator('[data-testid^="workspace-tab-"]').filter({ hasText: matcher }).first())
|
||||
.toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert the new-tab '+' button is visible and there is only one. */
|
||||
export async function assertSingleNewTabButton(page: Page): Promise<void> {
|
||||
const buttons = page.getByTestId("workspace-new-tab");
|
||||
// There might be multiple panes, each with a "+" button
|
||||
// But within a single pane there should only be one
|
||||
const count = await buttons.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// ─── No-flash measurement ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Measure the time between clicking a launcher tile and the replacement panel becoming visible.
|
||||
* Returns elapsed milliseconds.
|
||||
*/
|
||||
export async function measureTileTransition(
|
||||
page: Page,
|
||||
clickAction: () => Promise<void>,
|
||||
successLocator: ReturnType<Page["locator"]>,
|
||||
timeout = 5_000,
|
||||
): Promise<number> {
|
||||
const start = Date.now();
|
||||
await clickAction();
|
||||
await expect(successLocator).toBeVisible({ timeout });
|
||||
return Date.now() - start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample tab IDs at high frequency across a transition to detect blank/intermediate states.
|
||||
* Returns all unique snapshots observed.
|
||||
*/
|
||||
export async function sampleTabsDuringTransition(
|
||||
page: Page,
|
||||
action: () => Promise<void>,
|
||||
durationMs = 2_000,
|
||||
intervalMs = 30,
|
||||
): Promise<string[][]> {
|
||||
const snapshots: string[][] = [];
|
||||
const startSampling = async () => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < durationMs) {
|
||||
snapshots.push(await getTabTestIds(page));
|
||||
await page.waitForTimeout(intervalMs);
|
||||
}
|
||||
};
|
||||
|
||||
const samplingPromise = startSampling();
|
||||
await action();
|
||||
await samplingPromise;
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
// ─── Workspace setup ───────────────────────────────────────────────────────
|
||||
|
||||
/** Create a temp git repo and return its path with a cleanup function. */
|
||||
export async function createWorkspace(prefix = "launcher-e2e-"): ReturnType<typeof createTempGitRepo> {
|
||||
return createTempGitRepo(prefix);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
gotoWorkspace,
|
||||
waitForLauncherPanel,
|
||||
assertProviderTilesVisible,
|
||||
assertNewChatTileVisible,
|
||||
assertTerminalTileVisible,
|
||||
assertSingleNewTabButton,
|
||||
clickNewTabButton,
|
||||
pressNewTabShortcut,
|
||||
clickNewChat,
|
||||
clickTerminal,
|
||||
clickProviderTile,
|
||||
countTabsOfKind,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
measureTileTransition,
|
||||
sampleTabsDuringTransition,
|
||||
} from "./helpers/launcher";
|
||||
import {
|
||||
connectTerminalClient,
|
||||
waitForTerminalContent,
|
||||
setupDeterministicPrompt,
|
||||
type TerminalPerfDaemonClient,
|
||||
} from "./helpers/terminal-perf";
|
||||
|
||||
// ─── Shared state ──────────────────────────────────────────────────────────
|
||||
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("launcher-e2e-");
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (tempRepo) await tempRepo.cleanup();
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Launcher Tab Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Launcher tab", () => {
|
||||
test("Cmd+T opens launcher panel with New Chat, Terminal, and provider tiles", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
|
||||
await waitForLauncherPanel(page);
|
||||
await assertNewChatTileVisible(page);
|
||||
await assertTerminalTileVisible(page);
|
||||
await assertProviderTilesVisible(page);
|
||||
});
|
||||
|
||||
test("opening two new tabs creates two launcher tabs", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await waitForLauncherPanel(page);
|
||||
const countAfterFirst = await countTabsOfKind(page, "launcher");
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await waitForLauncherPanel(page);
|
||||
const countAfterSecond = await countTabsOfKind(page, "launcher");
|
||||
|
||||
expect(countAfterSecond).toBe(countAfterFirst + 1);
|
||||
});
|
||||
|
||||
test("clicking New Chat replaces launcher in-place with draft tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const tabsBefore = await getTabTestIds(page);
|
||||
const launcherCountBefore = tabsBefore.filter((id) => id.includes("launcher")).length;
|
||||
|
||||
await clickNewChat(page);
|
||||
|
||||
// Draft composer should appear (the agent message input)
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." });
|
||||
await expect(composer.first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Launcher tab should have been replaced (not added alongside)
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
const launcherCountAfter = tabsAfter.filter((id) => id.includes("launcher")).length;
|
||||
const draftCountAfter = tabsAfter.filter((id) => id.includes("draft")).length;
|
||||
|
||||
expect(launcherCountAfter).toBe(launcherCountBefore - 1);
|
||||
expect(draftCountAfter).toBeGreaterThanOrEqual(1);
|
||||
// Total tab count should stay the same (replaced, not added)
|
||||
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||
});
|
||||
|
||||
test("clicking Terminal replaces launcher with standalone terminal", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const tabsBefore = await getTabTestIds(page);
|
||||
|
||||
await clickTerminal(page);
|
||||
|
||||
// Terminal surface should appear
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Tab count stays the same (in-place replacement)
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||
|
||||
// The launcher tab is gone, a terminal tab exists
|
||||
const terminalTabs = tabsAfter.filter((id) => id.includes("terminal"));
|
||||
expect(terminalTabs.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("clicking a provider tile replaces launcher with terminal agent tab", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const tabsBefore = await getTabTestIds(page);
|
||||
|
||||
// Click the first visible provider tile under "Terminal Agents"
|
||||
const providerTiles = page.locator('[role="button"]').filter({
|
||||
has: page.locator("text=Terminal Agents").locator("..").locator(".."),
|
||||
});
|
||||
|
||||
// Try clicking any provider tile — find the first one after the "Terminal Agents" label
|
||||
const terminalAgentsLabel = page.getByText("Terminal Agents", { exact: true }).first();
|
||||
await expect(terminalAgentsLabel).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The provider grid follows the label. Click the first provider tile.
|
||||
const providerGrid = terminalAgentsLabel.locator("~ *").first();
|
||||
const firstProvider = providerGrid.getByRole("button").first();
|
||||
if (await firstProvider.isVisible().catch(() => false)) {
|
||||
await firstProvider.click();
|
||||
} else {
|
||||
// Fallback: look for any provider button after the section label
|
||||
const allButtons = page.getByRole("button");
|
||||
const count = await allButtons.count();
|
||||
let clicked = false;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const btn = allButtons.nth(i);
|
||||
const text = await btn.innerText().catch(() => "");
|
||||
// Skip known non-provider buttons
|
||||
if (["New Chat", "Terminal", "More", "+"].includes(text.trim())) continue;
|
||||
if (!text.trim()) continue;
|
||||
await btn.click();
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
if (!clicked) {
|
||||
test.skip(true, "No provider tiles available");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Should see an agent panel (terminal surface or agent stream)
|
||||
const agentOrTerminal = page.locator(
|
||||
'[data-testid="terminal-surface"], [data-testid^="agent-"]',
|
||||
);
|
||||
await expect(agentOrTerminal.first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Tab count stays the same (replaced, not added)
|
||||
const tabsAfter = await getTabTestIds(page);
|
||||
expect(tabsAfter.length).toBe(tabsBefore.length);
|
||||
});
|
||||
|
||||
test("tab bar shows a single + button per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await assertSingleNewTabButton(page);
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Terminal Title Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Terminal title propagation", () => {
|
||||
let client: TerminalPerfDaemonClient;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
client = await connectTerminalClient();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (client) await client.close();
|
||||
});
|
||||
|
||||
test("terminal tab title updates from OSC title escape sequence", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "title-test");
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
// Navigate to workspace and open the terminal
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await clickTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Send OSC 0 (set window title) escape sequence
|
||||
const testTitle = `E2E-Title-${Date.now()}`;
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;${testTitle}\\007'\n`, { delay: 0 });
|
||||
|
||||
// Wait for the tab to reflect the new title
|
||||
await waitForTabWithTitle(page, testTitle, 15_000);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
test("title debouncing coalesces rapid changes", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const result = await client.createTerminal(tempRepo.path, "debounce-test");
|
||||
if (!result.terminal) throw new Error(`Failed to create terminal: ${result.error}`);
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await clickTerminal(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
await expect(terminal.first()).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.first().click();
|
||||
|
||||
await setupDeterministicPrompt(page);
|
||||
|
||||
// Fire many rapid title changes — only the last should stick
|
||||
const finalTitle = `Final-${Date.now()}`;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;Rapid-${i}\\007'\n`, { delay: 0 });
|
||||
}
|
||||
await terminal
|
||||
.first()
|
||||
.pressSequentially(`printf '\\033]0;${finalTitle}\\007'\n`, { delay: 0 });
|
||||
|
||||
// The tab should eventually settle on the final title
|
||||
await waitForTabWithTitle(page, finalTitle, 15_000);
|
||||
} finally {
|
||||
await client.killTerminal(terminalId).catch(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// No-Flash Transition Tests
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
test.describe("Launcher transitions (no flash)", () => {
|
||||
test("New Chat transition has no blank intermediate tab state", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
// Sample tabs at high frequency across the transition
|
||||
const snapshots = await sampleTabsDuringTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
2_000,
|
||||
30,
|
||||
);
|
||||
|
||||
// Every snapshot should have at least one tab — no blank/zero-tab frames
|
||||
for (const snapshot of snapshots) {
|
||||
expect(snapshot.length).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
|
||||
// Tab count should never increase (no duplicate flash from add-then-remove)
|
||||
const counts = snapshots.map((s) => s.length);
|
||||
const maxCount = Math.max(...counts);
|
||||
const initialCount = counts[0] ?? 0;
|
||||
|
||||
// Allow at most +1 transient tab (tolerance for React render batching)
|
||||
expect(maxCount).toBeLessThanOrEqual(initialCount + 1);
|
||||
});
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const terminal = page.locator('[data-testid="terminal-surface"]');
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickTerminal(page),
|
||||
terminal.first(),
|
||||
20_000,
|
||||
);
|
||||
|
||||
// Terminal surface should appear within a reasonable budget.
|
||||
// Note: terminal creation involves a server round-trip, so we allow more time
|
||||
// than a pure in-memory transition, but it should still be well under 5 seconds.
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
});
|
||||
|
||||
test("New Chat click → composer appears without launcher flash", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
|
||||
const elapsed = await measureTileTransition(
|
||||
page,
|
||||
() => clickNewChat(page),
|
||||
composer,
|
||||
10_000,
|
||||
);
|
||||
|
||||
// Draft replacement is fully in-memory — should be fast
|
||||
// We use a generous budget here because CI can be slow, but the key assertion
|
||||
// is that no blank/flash frame appears (tested above).
|
||||
expect(elapsed).toBeLessThan(3_000);
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,7 @@ import { getIsDesktop } from "@/constants/layout";
|
||||
import { CommandCenter } from "@/components/command-center";
|
||||
import { ProjectPickerModal } from "@/components/project-picker-modal";
|
||||
import { KeyboardShortcutsDialog } from "@/components/keyboard-shortcuts-dialog";
|
||||
import { WorkspaceSetupDialog } from "@/components/workspace-setup-dialog";
|
||||
import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
@@ -371,6 +372,7 @@ function AppContainer({
|
||||
<UpdateBanner />
|
||||
<CommandCenter />
|
||||
<ProjectPickerModal />
|
||||
<WorkspaceSetupDialog />
|
||||
<KeyboardShortcutsDialog />
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
@@ -68,7 +69,9 @@ export default function HostIndexRoute() {
|
||||
|
||||
const primaryWorkspace = visibleWorkspaces[0];
|
||||
if (primaryWorkspace?.id?.trim()) {
|
||||
router.replace(buildHostWorkspaceRoute(serverId, primaryWorkspace.id.trim()) as any);
|
||||
router.replace(
|
||||
buildHostWorkspaceOpenRoute(serverId, primaryWorkspace.id.trim(), "draft:new") as any,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import { formatTimeAgo } from "@/utils/time";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive } from "lucide-react-native";
|
||||
import { Archive, SquareTerminal } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
interface AgentListProps {
|
||||
@@ -130,6 +131,7 @@ function SessionRow({
|
||||
const isSelected = selectedAgentId === agentKey;
|
||||
const statusLabel = formatStatusLabel(agent.status);
|
||||
const projectPath = shortenPath(agent.cwd);
|
||||
const ProviderIcon = getProviderIcon(agent.provider);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -145,12 +147,21 @@ function SessionRow({
|
||||
>
|
||||
<View style={styles.rowContent}>
|
||||
<View style={styles.rowTitleRow}>
|
||||
<View style={styles.providerIconWrap}>
|
||||
<ProviderIcon size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<Text
|
||||
style={[styles.sessionTitle, isSelected && styles.sessionTitleHighlighted]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{agent.title || "New session"}
|
||||
</Text>
|
||||
{agent.terminal ? (
|
||||
<SessionBadge
|
||||
label="Terminal"
|
||||
icon={<SquareTerminal size={theme.fontSize.xs} color={theme.colors.foregroundMuted} />}
|
||||
/>
|
||||
) : null}
|
||||
{agent.archivedAt ? (
|
||||
<SessionBadge
|
||||
label="Archived"
|
||||
@@ -239,6 +250,7 @@ export function AgentList({
|
||||
workspaceId: agent.cwd,
|
||||
target: { kind: "agent", agentId },
|
||||
pin: Boolean(agent.archivedAt),
|
||||
requestReopen: agent.terminal && agent.status === "closed",
|
||||
});
|
||||
router.navigate(route as any);
|
||||
},
|
||||
@@ -433,6 +445,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
providerIconWrap: {
|
||||
width: theme.iconSize.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
rowMetaRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveStatusControlMode } from "./agent-input-area.status-controls";
|
||||
import { resolveStatusControlMode } from "./composer.status-controls";
|
||||
|
||||
describe("resolveStatusControlMode", () => {
|
||||
it("uses ready mode when no controlled status controls are provided", () => {
|
||||
+9
-6
@@ -41,7 +41,7 @@ import {
|
||||
persistAttachmentFromBlob,
|
||||
persistAttachmentFromFileUri,
|
||||
} from "@/attachments/service";
|
||||
import { resolveStatusControlMode } from "@/components/agent-input-area.status-controls";
|
||||
import { resolveStatusControlMode } from "@/components/composer.status-controls";
|
||||
import { markScrollInvestigationRender } from "@/utils/scroll-jank-investigation";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
@@ -56,11 +56,12 @@ type QueuedMessage = {
|
||||
|
||||
type ImageListUpdater = ImageAttachment[] | ((prev: ImageAttachment[]) => ImageAttachment[]);
|
||||
|
||||
interface AgentInputAreaProps {
|
||||
interface ComposerProps {
|
||||
agentId: string;
|
||||
serverId: string;
|
||||
isInputActive: boolean;
|
||||
onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
|
||||
allowEmptySubmit?: boolean;
|
||||
/** Externally controlled loading state. When true, disables the submit button. */
|
||||
isSubmitLoading?: boolean;
|
||||
/** When true, blurs the input immediately when submitting. */
|
||||
@@ -89,11 +90,12 @@ const EMPTY_ARRAY: readonly QueuedMessage[] = [];
|
||||
const DESKTOP_MESSAGE_PLACEHOLDER = "Message the agent, tag @files, or use /commands and /skills";
|
||||
const MOBILE_MESSAGE_PLACEHOLDER = "Message, @files, /commands";
|
||||
|
||||
export function AgentInputArea({
|
||||
export function Composer({
|
||||
agentId,
|
||||
serverId,
|
||||
isInputActive,
|
||||
onSubmitMessage,
|
||||
allowEmptySubmit = false,
|
||||
isSubmitLoading = false,
|
||||
blurOnSubmit = false,
|
||||
value,
|
||||
@@ -109,8 +111,8 @@ export function AgentInputArea({
|
||||
onAttentionInputFocus,
|
||||
onAttentionPromptSend,
|
||||
statusControls,
|
||||
}: AgentInputAreaProps) {
|
||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`);
|
||||
}: ComposerProps) {
|
||||
markScrollInvestigationRender(`Composer:${serverId}:${agentId}`);
|
||||
const { theme } = useUnistyles();
|
||||
const buttonIconSize = Platform.OS === "web" ? theme.iconSize.md : theme.iconSize.lg;
|
||||
const insets = useSafeAreaInsets();
|
||||
@@ -480,7 +482,7 @@ export function AgentInputArea({
|
||||
return;
|
||||
}
|
||||
void voice.startVoice(serverId, agentId).catch((error) => {
|
||||
console.error("[AgentInputArea] Failed to start voice mode", error);
|
||||
console.error("[Composer] Failed to start voice mode", error);
|
||||
const message =
|
||||
error instanceof Error ? error.message : typeof error === "string" ? error : null;
|
||||
if (message && message.trim().length > 0) {
|
||||
@@ -677,6 +679,7 @@ export function AgentInputArea({
|
||||
value={userInput}
|
||||
onChangeText={setUserInput}
|
||||
onSubmit={handleSubmit}
|
||||
allowEmptySubmit={allowEmptySubmit}
|
||||
isSubmitDisabled={isProcessing || isSubmitLoading}
|
||||
isSubmitLoading={isProcessing || isSubmitLoading}
|
||||
images={selectedImages}
|
||||
@@ -0,0 +1,16 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface AiderIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function AiderIcon({ size = 16, color = "currentColor" }: AiderIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill={color}>
|
||||
<Path
|
||||
d="M9 3.75h5.25V4.5H9V3.75Zm-.75.75h6v2.25h-6V4.5Zm6 3h2.25v3h-2.25v-3Zm-6 3h8.25v3h-8.25v-3Zm-1.5 3h1.5v3h-1.5v-3Zm7.5 0h2.25v3h-2.25v-3Zm-6 3h6v3h-6v-3Zm8.25 0H18v3h-1.5v-3Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface AmpIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function AmpIcon({ size = 16, color = "currentColor" }: AmpIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
fill={color}
|
||||
d="m9.686 6.949 2.907.775-1.98-7.404-3.353.903 1.194 4.49a1.94 1.94 0 0 0 1.232 1.236Z"
|
||||
/>
|
||||
<Path
|
||||
fill={color}
|
||||
d="m4.771 22 6.34-6.327 2.307 8.62 3.352-.903L13.432 10.87.912 7.533 0 10.906l8.61 2.3-6.31 6.317L4.77 22Z"
|
||||
/>
|
||||
<Path
|
||||
fill={color}
|
||||
d="m13.254 11.707.778 2.917a1.937 1.937 0 0 0 1.23 1.234l4.511 1.199.89-3.37-7.409-1.98Z"
|
||||
/>
|
||||
<Path
|
||||
fill={color}
|
||||
d="m15.916 2.484-2.883 2.88a2.063 2.063 0 0 0-.512 1.193l-.046 1.825 1.69.06-.022-.001c.463 0 .898-.181 1.225-.507L18.35 4.95l-2.434-2.467Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface GeminiIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function GeminiIcon({ size = 16, color = "currentColor" }: GeminiIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none">
|
||||
<Path
|
||||
fill={color}
|
||||
d="M20.616 10.835a14.147 14.147 0 0 1-4.45-3.001 14.111 14.111 0 0 1-3.678-6.452.503.503 0 0 0-.975 0 14.134 14.134 0 0 1-3.679 6.452 14.155 14.155 0 0 1-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 0 0 0 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 0 1 4.45 3.001 14.112 14.112 0 0 1 3.679 6.453.502.502 0 0 0 .975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 0 1 3.001-4.45 14.113 14.113 0 0 1 6.453-3.678.503.503 0 0 0 0-.975 13.245 13.245 0 0 1-2.003-.678Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
interface OpenCodeIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function OpenCodeIcon({ size = 16, color = "currentColor" }: OpenCodeIconProps) {
|
||||
return (
|
||||
<Svg width={size} height={size} viewBox="96 64 288 384" fill={color}>
|
||||
<Path d="M320 224V352H192V224H320Z" opacity="0.4" />
|
||||
<Path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export interface MessageInputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmit: (payload: MessagePayload) => void;
|
||||
allowEmptySubmit?: boolean;
|
||||
isSubmitDisabled?: boolean;
|
||||
isSubmitLoading?: boolean;
|
||||
images?: ImageAttachment[];
|
||||
@@ -178,6 +179,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
value,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
allowEmptySubmit = false,
|
||||
isSubmitDisabled = false,
|
||||
isSubmitLoading = false,
|
||||
images = [],
|
||||
@@ -851,7 +853,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
|
||||
}
|
||||
|
||||
const hasImages = images.length > 0;
|
||||
const hasSendableContent = value.trim().length > 0 || hasImages;
|
||||
const hasSendableContent = value.trim().length > 0 || hasImages || allowEmptySubmit;
|
||||
const shouldShowSendButton = hasSendableContent || isSubmitLoading;
|
||||
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === "function";
|
||||
const isSendButtonDisabled =
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { Bot } from "lucide-react-native";
|
||||
import { AiderIcon } from "@/components/icons/aider-icon";
|
||||
import { AmpIcon } from "@/components/icons/amp-icon";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { GeminiIcon } from "@/components/icons/gemini-icon";
|
||||
import { OpenCodeIcon } from "@/components/icons/opencode-icon";
|
||||
|
||||
const PROVIDER_ICONS: Record<string, typeof Bot> = {
|
||||
claude: ClaudeIcon as unknown as typeof Bot,
|
||||
codex: CodexIcon as unknown as typeof Bot,
|
||||
gemini: GeminiIcon as unknown as typeof Bot,
|
||||
amp: AmpIcon as unknown as typeof Bot,
|
||||
aider: AiderIcon as unknown as typeof Bot,
|
||||
opencode: OpenCodeIcon as unknown as typeof Bot,
|
||||
};
|
||||
|
||||
export function getProviderIcon(provider: string): typeof Bot {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type GestureResponderEvent,
|
||||
} from "react-native";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { useMutation, useQueries } from "@tanstack/react-query";
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
@@ -45,7 +45,6 @@ import { getHostRuntimeStore, isHostRuntimeConnected } from "@/runtime/host-runt
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { projectIconQueryKey } from "@/hooks/use-project-icon-query";
|
||||
import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
@@ -83,8 +82,8 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query";
|
||||
import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model";
|
||||
import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
|
||||
@@ -239,7 +238,7 @@ function WorkspaceStatusIndicator({
|
||||
}
|
||||
|
||||
const KindIcon =
|
||||
workspaceKind === "local_checkout"
|
||||
workspaceKind === "checkout"
|
||||
? Monitor
|
||||
: workspaceKind === "worktree"
|
||||
? FolderGit2
|
||||
@@ -654,7 +653,6 @@ function ProjectHeaderRow({
|
||||
canCreateWorktree,
|
||||
isProjectActive = false,
|
||||
onWorkspacePress,
|
||||
onWorktreeCreated,
|
||||
shortcutNumber = null,
|
||||
showShortcutBadge = false,
|
||||
drag,
|
||||
@@ -666,50 +664,30 @@ function ProjectHeaderRow({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const isMobileBreakpoint =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const toast = useToast();
|
||||
const beginWorkspaceSetup = useWorkspaceSetupStore((state) => state.beginWorkspaceSetup);
|
||||
|
||||
const handleBeginWorkspaceSetup = useCallback(() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
onWorkspacePress?.();
|
||||
beginWorkspaceSetup({
|
||||
serverId,
|
||||
projectPath: project.iconWorkingDir,
|
||||
projectName: displayName,
|
||||
creationMethod: "create_worktree",
|
||||
navigationMethod: "navigate",
|
||||
});
|
||||
}, [beginWorkspaceSetup, displayName, onWorkspacePress, project.iconWorkingDir, serverId]);
|
||||
|
||||
const createWorktreeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!serverId) {
|
||||
throw new Error("No server");
|
||||
}
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
const payload = await client.createPaseoWorktree({
|
||||
cwd: project.iconWorkingDir,
|
||||
worktreeSlug: createNameId(),
|
||||
});
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error ?? "Failed to create worktree");
|
||||
}
|
||||
return payload.workspace;
|
||||
},
|
||||
onSuccess: (workspace) => {
|
||||
mergeWorkspaces(serverId!, [normalizeWorkspaceDescriptor(workspace)]);
|
||||
onWorktreeCreated?.(workspace.id);
|
||||
onWorkspacePress?.();
|
||||
router.navigate(
|
||||
prepareWorkspaceTab({
|
||||
serverId: serverId!,
|
||||
workspaceId: workspace.id,
|
||||
target: { kind: "draft", draftId: "new" },
|
||||
}) as any,
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
},
|
||||
});
|
||||
useKeyboardActionHandler({
|
||||
handlerId: `worktree-new-${project.projectKey}`,
|
||||
actions: ["worktree.new"],
|
||||
enabled: isProjectActive && canCreateWorktree && !createWorktreeMutation.isPending,
|
||||
enabled: isProjectActive && canCreateWorktree,
|
||||
priority: 0,
|
||||
handle: () => {
|
||||
createWorktreeMutation.mutate();
|
||||
handleBeginWorkspaceSetup();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
@@ -753,9 +731,9 @@ function ProjectHeaderRow({
|
||||
{canCreateWorktree ? (
|
||||
<NewWorktreeButton
|
||||
displayName={displayName}
|
||||
onPress={() => createWorktreeMutation.mutate()}
|
||||
onPress={handleBeginWorkspaceSetup}
|
||||
visible={isHovered || isMobileBreakpoint}
|
||||
loading={createWorktreeMutation.isPending}
|
||||
loading={false}
|
||||
showShortcutHint={isProjectActive}
|
||||
testID={`sidebar-project-new-worktree-${project.projectKey}`}
|
||||
/>
|
||||
@@ -840,7 +818,7 @@ function WorkspaceRowInner({
|
||||
const prHint = useWorkspacePrHint({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.workspaceId,
|
||||
enabled: workspace.workspaceKind !== "directory",
|
||||
enabled: workspace.projectKind === "git",
|
||||
});
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
@@ -1096,7 +1074,7 @@ function WorkspaceRowWithMenu({
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
||||
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
@@ -1241,7 +1219,7 @@ function NonGitProjectRowWithMenuContent({
|
||||
|
||||
setIsArchivingWorkspace(true);
|
||||
try {
|
||||
const payload = await client.archiveWorkspace(workspace.workspaceId);
|
||||
const payload = await client.archiveWorkspace(Number(workspace.workspaceId));
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
@@ -1352,7 +1330,7 @@ function FlattenedProjectRow({
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
isProjectActive?: boolean;
|
||||
}) {
|
||||
if (project.projectKind === "non_git") {
|
||||
if (project.projectKind === "directory") {
|
||||
return (
|
||||
<NonGitProjectRowWithMenu
|
||||
project={project}
|
||||
|
||||
@@ -86,12 +86,7 @@ interface SplitContainerProps {
|
||||
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
|
||||
onSelectNewTabOption: (selection: {
|
||||
optionId: "__new_tab_agent__" | "__new_tab_terminal__";
|
||||
paneId?: string;
|
||||
}) => void;
|
||||
onNewTerminalTab: (input: { paneId?: string }) => void;
|
||||
newTabAgentOptionId?: "__new_tab_agent__" | "__new_tab_terminal__";
|
||||
onCreateLauncherTab: (input: { paneId?: string }) => void;
|
||||
buildPaneContentModel: (input: {
|
||||
paneId: string;
|
||||
isPaneFocused: boolean;
|
||||
@@ -267,9 +262,7 @@ export function SplitContainer({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId = "__new_tab_agent__",
|
||||
onCreateLauncherTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
@@ -536,9 +529,7 @@ export function SplitContainer({
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
onCreateLauncherTab={onCreateLauncherTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -658,9 +649,7 @@ function SplitNodeView({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId,
|
||||
onCreateLauncherTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
@@ -693,9 +682,7 @@ function SplitNodeView({
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
onCreateLauncherTab={onCreateLauncherTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -743,9 +730,7 @@ function SplitNodeView({
|
||||
onCloseTabsToLeft={onCloseTabsToLeft}
|
||||
onCloseTabsToRight={onCloseTabsToRight}
|
||||
onCloseOtherTabs={onCloseOtherTabs}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId}
|
||||
onCreateLauncherTab={onCreateLauncherTab}
|
||||
buildPaneContentModel={buildPaneContentModel}
|
||||
onFocusPane={onFocusPane}
|
||||
onSplitPane={onSplitPane}
|
||||
@@ -792,9 +777,7 @@ function SplitPaneView({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
onNewTerminalTab,
|
||||
newTabAgentOptionId,
|
||||
onCreateLauncherTab,
|
||||
buildPaneContentModel,
|
||||
onFocusPane,
|
||||
onSplitPane,
|
||||
@@ -907,9 +890,7 @@ function SplitPaneView({
|
||||
onCloseTabsToLeft={(tabId) => onCloseTabsToLeft(tabId, paneTabs)}
|
||||
onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)}
|
||||
onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)}
|
||||
onSelectNewTabOption={onSelectNewTabOption}
|
||||
onNewTerminalTab={onNewTerminalTab}
|
||||
newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"}
|
||||
onCreateLauncherTab={onCreateLauncherTab}
|
||||
onReorderTabs={(nextTabs) => {
|
||||
onReorderTabsInPane(
|
||||
pane.id,
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";
|
||||
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
||||
import { Bot, ChevronLeft, MessagesSquare, SquareTerminal } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { createNameId } from "mnemonic-id";
|
||||
import { AdaptiveModalSheet, AdaptiveTextInput } from "@/components/adaptive-modal-sheet";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useProviderRecency } from "@/stores/provider-recency-store";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import type { MessagePayload } from "./message-input";
|
||||
|
||||
type SetupStep = "choose" | "chat" | "terminal-agent";
|
||||
|
||||
export function WorkspaceSetupDialog() {
|
||||
const { theme } = useUnistyles();
|
||||
const toast = useToast();
|
||||
const pendingWorkspaceSetup = useWorkspaceSetupStore((state) => state.pendingWorkspaceSetup);
|
||||
const clearWorkspaceSetup = useWorkspaceSetupStore((state) => state.clearWorkspaceSetup);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const [step, setStep] = useState<SetupStep>("choose");
|
||||
const [terminalPrompt, setTerminalPrompt] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [createdWorkspace, setCreatedWorkspace] = useState<ReturnType<
|
||||
typeof normalizeWorkspaceDescriptor
|
||||
> | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<"chat" | "terminal-agent" | "terminal" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const serverId = pendingWorkspaceSetup?.serverId ?? "";
|
||||
const projectPath = pendingWorkspaceSetup?.projectPath ?? "";
|
||||
const projectName = pendingWorkspaceSetup?.projectName?.trim() ?? "";
|
||||
const workspace = createdWorkspace;
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const chatDraft = useAgentInputDraft({
|
||||
draftKey: `workspace-setup:${serverId}:${projectPath}`,
|
||||
composer: {
|
||||
initialServerId: serverId || null,
|
||||
initialValues: projectPath ? { workingDir: projectPath } : undefined,
|
||||
isVisible: pendingWorkspaceSetup !== null,
|
||||
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||
lockedWorkingDir: workspace?.id ?? projectPath,
|
||||
},
|
||||
});
|
||||
const composerState = chatDraft.composerState;
|
||||
if (!composerState && pendingWorkspaceSetup) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
}
|
||||
const { providers: sortedProviders, recordUsage } = useProviderRecency(
|
||||
composerState?.providerDefinitions ?? [],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setStep("choose");
|
||||
setTerminalPrompt("");
|
||||
setErrorMessage(null);
|
||||
setCreatedWorkspace(null);
|
||||
setPendingAction(null);
|
||||
}, [pendingWorkspaceSetup?.creationMethod, projectPath, serverId]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
clearWorkspaceSetup();
|
||||
}, [clearWorkspaceSetup]);
|
||||
|
||||
const navigateAfterCreation = useCallback(
|
||||
(
|
||||
workspaceId: string,
|
||||
target: { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string },
|
||||
) => {
|
||||
if (!pendingWorkspaceSetup) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearWorkspaceSetup();
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: pendingWorkspaceSetup.serverId,
|
||||
workspaceId,
|
||||
target,
|
||||
navigationMethod: pendingWorkspaceSetup.navigationMethod,
|
||||
});
|
||||
},
|
||||
[clearWorkspaceSetup, pendingWorkspaceSetup],
|
||||
);
|
||||
|
||||
const withConnectedClient = useCallback(() => {
|
||||
if (!client || !isConnected) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return client;
|
||||
}, [client, isConnected]);
|
||||
|
||||
const ensureWorkspace = useCallback(async () => {
|
||||
if (!pendingWorkspaceSetup) {
|
||||
throw new Error("No workspace setup is pending");
|
||||
}
|
||||
|
||||
if (createdWorkspace) {
|
||||
return createdWorkspace;
|
||||
}
|
||||
|
||||
const connectedClient = withConnectedClient();
|
||||
const payload =
|
||||
pendingWorkspaceSetup.creationMethod === "create_worktree"
|
||||
? await connectedClient.createPaseoWorktree({
|
||||
cwd: pendingWorkspaceSetup.projectPath,
|
||||
worktreeSlug: createNameId(),
|
||||
})
|
||||
: await connectedClient.openProject(pendingWorkspaceSetup.projectPath);
|
||||
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(
|
||||
payload.error ??
|
||||
(pendingWorkspaceSetup.creationMethod === "create_worktree"
|
||||
? "Failed to create worktree"
|
||||
: "Failed to open project"),
|
||||
);
|
||||
}
|
||||
|
||||
const normalizedWorkspace = normalizeWorkspaceDescriptor(payload.workspace);
|
||||
mergeWorkspaces(pendingWorkspaceSetup.serverId, [normalizedWorkspace]);
|
||||
if (pendingWorkspaceSetup.creationMethod === "open_project") {
|
||||
setHasHydratedWorkspaces(pendingWorkspaceSetup.serverId, true);
|
||||
}
|
||||
setCreatedWorkspace(normalizedWorkspace);
|
||||
return normalizedWorkspace;
|
||||
}, [
|
||||
createdWorkspace,
|
||||
mergeWorkspaces,
|
||||
pendingWorkspaceSetup,
|
||||
setHasHydratedWorkspaces,
|
||||
withConnectedClient,
|
||||
]);
|
||||
|
||||
const getIsStillActive = useCallback(() => {
|
||||
const current = useWorkspaceSetupStore.getState().pendingWorkspaceSetup;
|
||||
return (
|
||||
current?.serverId === pendingWorkspaceSetup?.serverId &&
|
||||
current?.projectPath === pendingWorkspaceSetup?.projectPath &&
|
||||
current?.creationMethod === pendingWorkspaceSetup?.creationMethod
|
||||
);
|
||||
}, [
|
||||
pendingWorkspaceSetup?.creationMethod,
|
||||
pendingWorkspaceSetup?.projectPath,
|
||||
pendingWorkspaceSetup?.serverId,
|
||||
]);
|
||||
|
||||
const handleCreateChatAgent = useCallback(
|
||||
async ({ text, images }: MessagePayload) => {
|
||||
try {
|
||||
setPendingAction("chat");
|
||||
setErrorMessage(null);
|
||||
const workspace = await ensureWorkspace();
|
||||
const connectedClient = withConnectedClient();
|
||||
if (!composerState) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
}
|
||||
|
||||
const encodedImages = await encodeImages(images);
|
||||
const agent = await connectedClient.createAgent({
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: workspace.id,
|
||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? { modeId: composerState.selectedMode }
|
||||
: {}),
|
||||
...(composerState.effectiveModelId ? { model: composerState.effectiveModelId } : {}),
|
||||
...(composerState.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: composerState.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
...(text.trim() ? { initialPrompt: text.trim() } : {}),
|
||||
...(encodedImages && encodedImages.length > 0 ? { images: encodedImages } : {}),
|
||||
});
|
||||
|
||||
if (!getIsStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
|
||||
return next;
|
||||
});
|
||||
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
if (getIsStillActive()) {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
composerState,
|
||||
getIsStillActive,
|
||||
navigateAfterCreation,
|
||||
serverId,
|
||||
setAgents,
|
||||
ensureWorkspace,
|
||||
toast,
|
||||
withConnectedClient,
|
||||
],
|
||||
);
|
||||
|
||||
const handleCreateTerminalAgent = useCallback(async () => {
|
||||
try {
|
||||
setPendingAction("terminal-agent");
|
||||
setErrorMessage(null);
|
||||
const workspace = await ensureWorkspace();
|
||||
const connectedClient = withConnectedClient();
|
||||
if (!composerState) {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
}
|
||||
|
||||
const agent = await connectedClient.createAgent({
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: workspace.id,
|
||||
terminal: true,
|
||||
...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}),
|
||||
});
|
||||
|
||||
if (!getIsStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordUsage(composerState.selectedProvider);
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
|
||||
return next;
|
||||
});
|
||||
navigateAfterCreation(workspace.id, { kind: "agent", agentId: agent.id });
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
if (getIsStillActive()) {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
composerState,
|
||||
getIsStillActive,
|
||||
navigateAfterCreation,
|
||||
recordUsage,
|
||||
serverId,
|
||||
setAgents,
|
||||
ensureWorkspace,
|
||||
terminalPrompt,
|
||||
toast,
|
||||
withConnectedClient,
|
||||
]);
|
||||
|
||||
const handleCreateTerminal = useCallback(async () => {
|
||||
try {
|
||||
setPendingAction("terminal");
|
||||
setErrorMessage(null);
|
||||
const workspace = await ensureWorkspace();
|
||||
const connectedClient = withConnectedClient();
|
||||
|
||||
const payload = await connectedClient.createTerminal(workspace.id);
|
||||
if (payload.error || !payload.terminal) {
|
||||
throw new Error(payload.error ?? "Failed to open terminal");
|
||||
}
|
||||
|
||||
if (!getIsStillActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigateAfterCreation(workspace.id, { kind: "terminal", terminalId: payload.terminal.id });
|
||||
} catch (error) {
|
||||
const message = toErrorMessage(error);
|
||||
setErrorMessage(message);
|
||||
toast.error(message);
|
||||
} finally {
|
||||
if (getIsStillActive()) {
|
||||
setPendingAction(null);
|
||||
}
|
||||
}
|
||||
}, [ensureWorkspace, getIsStillActive, navigateAfterCreation, toast, withConnectedClient]);
|
||||
|
||||
const workspaceTitle =
|
||||
workspace?.name ||
|
||||
workspace?.projectDisplayName ||
|
||||
projectName ||
|
||||
projectPath.split(/[\\/]/).filter(Boolean).pop() ||
|
||||
projectPath;
|
||||
const workspacePath = workspace?.projectRootPath || projectPath;
|
||||
|
||||
if (!pendingWorkspaceSetup || !projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdaptiveModalSheet
|
||||
title="Set up workspace"
|
||||
visible={true}
|
||||
onClose={handleClose}
|
||||
snapPoints={["82%", "94%"]}
|
||||
testID="workspace-setup-dialog"
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.workspaceTitle}>{workspaceTitle}</Text>
|
||||
<Text style={styles.workspacePath}>{workspacePath}</Text>
|
||||
</View>
|
||||
|
||||
{step === "choose" ? (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>What do you want to open?</Text>
|
||||
<View style={styles.choiceGrid}>
|
||||
<ChoiceCard
|
||||
title="Chat Agent"
|
||||
description="Open this workspace with a prompt-first chat agent."
|
||||
Icon={MessagesSquare}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={() => {
|
||||
setErrorMessage(null);
|
||||
setStep("chat");
|
||||
}}
|
||||
/>
|
||||
<ChoiceCard
|
||||
title="Terminal Agent"
|
||||
description="Launch an agent-backed terminal in an agent tab."
|
||||
Icon={Bot}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={() => {
|
||||
setErrorMessage(null);
|
||||
setStep("terminal-agent");
|
||||
}}
|
||||
/>
|
||||
<ChoiceCard
|
||||
title="Terminal"
|
||||
description="Create the workspace, then open a standalone terminal tab."
|
||||
Icon={SquareTerminal}
|
||||
disabled={pendingAction !== null}
|
||||
pending={pendingAction === "terminal"}
|
||||
onPress={() => {
|
||||
void handleCreateTerminal();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{step === "chat" ? (
|
||||
<View style={styles.section}>
|
||||
<StepHeader
|
||||
title="Chat Agent"
|
||||
onBack={() => {
|
||||
setErrorMessage(null);
|
||||
setStep("choose");
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.helper}>
|
||||
Start with a prompt and optional images. The workspace is created first, then the agent launches, then navigation happens.
|
||||
</Text>
|
||||
<View style={styles.composerCard}>
|
||||
<Composer
|
||||
agentId={`workspace-setup:${serverId}:${projectPath}`}
|
||||
serverId={serverId}
|
||||
isInputActive={true}
|
||||
onSubmitMessage={handleCreateChatAgent}
|
||||
isSubmitLoading={pendingAction === "chat"}
|
||||
blurOnSubmit={true}
|
||||
value={chatDraft.text}
|
||||
onChangeText={chatDraft.setText}
|
||||
images={chatDraft.images}
|
||||
onChangeImages={chatDraft.setImages}
|
||||
clearDraft={chatDraft.clear}
|
||||
autoFocus
|
||||
commandDraftConfig={composerState?.commandDraftConfig}
|
||||
statusControls={
|
||||
composerState
|
||||
? {
|
||||
...composerState.statusControls,
|
||||
disabled: pendingAction !== null,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{step === "terminal-agent" ? (
|
||||
<View style={styles.section}>
|
||||
<StepHeader
|
||||
title="Terminal Agent"
|
||||
onBack={() => {
|
||||
setErrorMessage(null);
|
||||
setStep("choose");
|
||||
}}
|
||||
/>
|
||||
<Text style={styles.helper}>
|
||||
Choose a provider and optionally send an initial prompt. The workspace is created before the terminal agent launches.
|
||||
</Text>
|
||||
|
||||
<View style={styles.providerGrid}>
|
||||
{sortedProviders.map((provider) => (
|
||||
<ProviderOption
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
selected={provider.id === composerState?.selectedProvider}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={() => composerState?.setProviderFromUser(provider.id)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.fieldLabel}>Initial prompt</Text>
|
||||
<AdaptiveTextInput
|
||||
value={terminalPrompt}
|
||||
onChangeText={setTerminalPrompt}
|
||||
placeholder="Optional"
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
style={styles.input}
|
||||
multiline
|
||||
autoCapitalize="sentences"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
style={styles.actionButton}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={handleClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
style={styles.actionButton}
|
||||
disabled={pendingAction !== null}
|
||||
onPress={() => {
|
||||
void handleCreateTerminalAgent();
|
||||
}}
|
||||
>
|
||||
{pendingAction === "terminal-agent" ? "Launching..." : "Launch"}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</AdaptiveModalSheet>
|
||||
);
|
||||
}
|
||||
|
||||
function StepHeader({ title, onBack }: { title: string; onBack: () => void }) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<View style={styles.stepHeader}>
|
||||
<Pressable accessibilityRole="button" onPress={onBack} style={styles.backButton}>
|
||||
<ChevronLeft size={16} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
<Text style={styles.sectionTitle}>{title}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceCard({
|
||||
title,
|
||||
description,
|
||||
Icon,
|
||||
disabled,
|
||||
pending = false,
|
||||
onPress,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
Icon: ComponentType<{ size: number; color: string }>;
|
||||
disabled: boolean;
|
||||
pending?: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.choiceCard,
|
||||
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
|
||||
disabled ? styles.cardDisabled : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.choiceIconWrap}>
|
||||
{pending ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Icon size={16} color={theme.colors.foreground} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.choiceBody}>
|
||||
<Text style={styles.choiceTitle}>{title}</Text>
|
||||
<Text numberOfLines={1} style={styles.choiceDescription}>{description}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderOption({
|
||||
provider,
|
||||
selected,
|
||||
disabled,
|
||||
onPress,
|
||||
}: {
|
||||
provider: { id: AgentProvider; label: string; description: string };
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const Icon = getProviderIcon(provider.id);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.providerCard,
|
||||
selected ? styles.providerCardSelected : null,
|
||||
(hovered || pressed) && !disabled ? styles.choiceCardHovered : null,
|
||||
disabled ? styles.cardDisabled : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.providerIconWrap}>
|
||||
<Icon size={16} color={theme.colors.foreground} />
|
||||
</View>
|
||||
<View style={styles.providerBody}>
|
||||
<Text style={styles.providerTitle}>{provider.label}</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
workspaceTitle: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontWeight: theme.fontWeight.semibold,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
workspacePath: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
section: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
helper: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
lineHeight: 20,
|
||||
},
|
||||
choiceGrid: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
choiceCard: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[3],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
paddingVertical: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
choiceCardHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
cardDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
choiceIconWrap: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
choiceBody: {
|
||||
flex: 1,
|
||||
gap: 2,
|
||||
},
|
||||
choiceTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
choiceDescription: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
composerCard: {
|
||||
minHeight: 180,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
stepHeader: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
backButton: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
providerGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
providerCard: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
providerCardSelected: {
|
||||
borderColor: theme.colors.accent,
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
providerIconWrap: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
providerBody: {
|
||||
flex: 1,
|
||||
},
|
||||
providerTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
field: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
fieldLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
input: {
|
||||
minHeight: 80,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
color: theme.colors.foreground,
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
paddingVertical: theme.spacing[3],
|
||||
textAlignVertical: "top",
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.destructive,
|
||||
lineHeight: 20,
|
||||
},
|
||||
}));
|
||||
@@ -521,9 +521,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
cursor: { seq: cursor.endSeq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
|
||||
@@ -749,13 +748,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
);
|
||||
|
||||
const requestCanonicalCatchUp = useCallback(
|
||||
(agentId: string, cursor: { epoch: string; endSeq: number }) => {
|
||||
(agentId: string, cursor: { endSeq: number }) => {
|
||||
void client
|
||||
.fetchAgentTimeline(agentId, {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
|
||||
cursor: { seq: cursor.endSeq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[Session] failed to fetch canonical catch-up timeline", agentId, error);
|
||||
@@ -858,7 +856,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
current.epoch === result.cursor.epoch &&
|
||||
current.startSeq === result.cursor.startSeq &&
|
||||
current.endSeq === result.cursor.endSeq
|
||||
) {
|
||||
@@ -963,7 +960,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
const unsubAgentStream = client.on("agent_stream", (message) => {
|
||||
if (message.type !== "agent_stream") return;
|
||||
const { agentId, event, timestamp, seq, epoch } = message.payload;
|
||||
const { agentId, event, timestamp, seq } = message.payload;
|
||||
const parsedTimestamp = new Date(timestamp);
|
||||
const streamEvent = event as AgentStreamEventPayload;
|
||||
if (
|
||||
@@ -1005,7 +1002,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const result = processAgentStreamEvent({
|
||||
event: streamEvent,
|
||||
seq,
|
||||
epoch,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
@@ -1029,8 +1025,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (
|
||||
current &&
|
||||
typeof seq === "number" &&
|
||||
typeof epoch === "string" &&
|
||||
current.epoch === epoch &&
|
||||
seq >= current.startSeq &&
|
||||
seq <= current.endSeq
|
||||
) {
|
||||
@@ -1039,7 +1033,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
if (
|
||||
current &&
|
||||
current.epoch === nextCursor.epoch &&
|
||||
current.startSeq === nextCursor.startSeq &&
|
||||
current.endSeq === nextCursor.endSeq
|
||||
) {
|
||||
@@ -1090,7 +1083,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
removeWorkspace(serverId, message.payload.id);
|
||||
removeWorkspace(serverId, String(message.payload.id));
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
||||
|
||||
@@ -7,6 +7,7 @@ function createAgent(status: Agent["status"]): Agent {
|
||||
serverId: "server-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
terminal: false,
|
||||
status,
|
||||
createdAt: new Date(0),
|
||||
updatedAt: new Date(0),
|
||||
@@ -19,6 +20,7 @@ function createAgent(status: Agent["status"]): Agent {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
|
||||
@@ -9,13 +9,9 @@ import {
|
||||
type TimelineCursor,
|
||||
} from "./session-stream-reducers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
|
||||
return {
|
||||
seqStart: seq,
|
||||
seq,
|
||||
provider: "claude",
|
||||
item: { type, text },
|
||||
timestamp: new Date(1000 + seq).toISOString(),
|
||||
@@ -33,22 +29,30 @@ function makeTimelineEvent(
|
||||
} as AgentStreamEventPayload;
|
||||
}
|
||||
|
||||
function makeUserTimelineEvent(text: string): AgentStreamEventPayload {
|
||||
function makeToolCallEvent(status: "running" | "completed"): AgentStreamEventPayload {
|
||||
return {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", text },
|
||||
} as AgentStreamEventPayload;
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call-1",
|
||||
name: "shell",
|
||||
status,
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "pwd",
|
||||
},
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseTimelineInput: ProcessTimelineResponseInput = {
|
||||
payload: {
|
||||
agentId: "agent-1",
|
||||
direction: "after",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
entries: [],
|
||||
error: null,
|
||||
},
|
||||
@@ -63,7 +67,6 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
|
||||
const baseStreamInput: ProcessAgentStreamEventInput = {
|
||||
event: makeTimelineEvent("hello"),
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
@@ -71,10 +74,6 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
|
||||
timestamp: new Date(2000),
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processTimelineResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("processTimelineResponse", () => {
|
||||
it("returns error path when payload.error is set", () => {
|
||||
const result = processTimelineResponse({
|
||||
@@ -93,35 +92,10 @@ describe("processTimelineResponse", () => {
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.head).toBe(baseTimelineInput.currentHead);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns error with no init resolution when no deferred exists", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: false,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
error: "timeout",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.error).toBe("timeout");
|
||||
expect(result.initResolution).toBe(null);
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
});
|
||||
|
||||
it("replaces tail and clears head when reset=true", () => {
|
||||
const existingTail: StreamItem[] = [
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "old",
|
||||
text: "old message",
|
||||
timestamp: new Date(500),
|
||||
},
|
||||
];
|
||||
const existingHead: StreamItem[] = [
|
||||
it("replaces tail during bootstrap tail init and schedules committed catch-up", () => {
|
||||
const provisionalHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-1",
|
||||
@@ -132,518 +106,280 @@ describe("processTimelineResponse", () => {
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: existingTail,
|
||||
currentHead: existingHead,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
reset: true,
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [
|
||||
makeTimelineEntry(1, "first"),
|
||||
makeTimelineEntry(2, "second"),
|
||||
makeTimelineEntry(3, "third"),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail).not.toBe(existingTail);
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
||||
});
|
||||
|
||||
it("sets cursor to null when reset=true but no cursors in payload", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 5 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
reset: true,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.cursor).toBe(null);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
});
|
||||
|
||||
it("performs bootstrap tail init with catch-up side effect", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead: provisionalHead,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "tail",
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "tail",
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 5 },
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(5, "last")],
|
||||
},
|
||||
});
|
||||
|
||||
// Bootstrap tail replaces
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
});
|
||||
|
||||
// Should have catch-up side effect
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 5,
|
||||
const catchUp = result.sideEffects.find((effect) => effect.type === "catch_up");
|
||||
expect(catchUp).toEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("appends incrementally for contiguous seqs", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(4, "next-1"), makeTimelineEntry(5, "next-2")],
|
||||
it("prepends older committed history for before pagination", () => {
|
||||
const currentTail: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "tail-3",
|
||||
text: "newer",
|
||||
timestamp: new Date(3000),
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail.length).toBeGreaterThan(0);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
});
|
||||
expect(result.error).toBe(null);
|
||||
});
|
||||
|
||||
it("detects gap and emits catch-up side effect", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 3,
|
||||
};
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 3, endSeq: 4 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(10, "far ahead")],
|
||||
},
|
||||
});
|
||||
|
||||
// Gap should trigger catch-up
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.type === "catch_up" && catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops stale entries silently", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 8,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(5, "old"), makeTimelineEntry(7, "also old")],
|
||||
},
|
||||
});
|
||||
|
||||
// No new items appended (all dropped as stale)
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("drops entries with epoch mismatch", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
};
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: existingCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-2",
|
||||
entries: [makeTimelineEntry(6, "different epoch")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves init when deferred matches direction", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "after",
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.initResolution).toBe("resolve");
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
});
|
||||
|
||||
it("does not resolve init when directions differ (before vs after)", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
initRequestDirection: "after",
|
||||
currentTail,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "before",
|
||||
entries: [],
|
||||
startSeq: 1,
|
||||
endSeq: 2,
|
||||
entries: [
|
||||
makeTimelineEntry(1, "hello", "user_message"),
|
||||
makeTimelineEntry(2, "older"),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// "before" direction doesn't match "after" initRequestDirection,
|
||||
// and "before" is not a bootstrap tail path, so init should NOT resolve
|
||||
expect(result.initResolution).toBe(null);
|
||||
expect(result.clearInitializing).toBe(false);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
});
|
||||
expect(result.tail).toHaveLength(3);
|
||||
expect(result.tail[0]?.kind).toBe("user_message");
|
||||
expect(result.tail[1]?.kind).toBe("assistant_message");
|
||||
expect(result.tail[2]).toBe(currentTail[0]);
|
||||
});
|
||||
|
||||
it("clears initializing even without deferred", () => {
|
||||
it("replaces stale provisional assistant UI when fetch-after returns committed row 121", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "partial",
|
||||
timestamp: new Date(120000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: false,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.clearInitializing).toBe(true);
|
||||
expect(result.initResolution).toBe(null);
|
||||
});
|
||||
|
||||
it("always includes flush_pending_updates side effect on success", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
||||
});
|
||||
|
||||
it("initializes cursor when no existing cursor on first entries", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor: undefined,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
entries: [makeTimelineEntry(1, "first"), makeTimelineEntry(2, "second")],
|
||||
startSeq: 121,
|
||||
endSeq: 121,
|
||||
entries: [makeTimelineEntry(121, "finalized reply")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 2,
|
||||
endSeq: 121,
|
||||
});
|
||||
expect(result.tail[result.tail.length - 1]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "finalized reply",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps provisional head when reconnect catch-up has no new committed rows yet", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "still streaming",
|
||||
timestamp: new Date(120000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startSeq: null,
|
||||
endSeq: null,
|
||||
entries: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.head).toBe(currentHead);
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
});
|
||||
|
||||
it("requests catch-up when committed rows arrive with a forward gap", () => {
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentCursor,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
startSeq: 125,
|
||||
endSeq: 125,
|
||||
entries: [makeTimelineEntry(125, "far ahead")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.tail).toBe(baseTimelineInput.currentTail);
|
||||
expect(result.sideEffects).toContainEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 120 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processAgentStreamEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("processAgentStreamEvent", () => {
|
||||
it("passes through non-timeline events without cursor changes", () => {
|
||||
const turnEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
it("treats seq-less timeline events as provisional head updates", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnEvent,
|
||||
event: makeTimelineEvent("partial"),
|
||||
seq: undefined,
|
||||
epoch: undefined,
|
||||
});
|
||||
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.head).toHaveLength(1);
|
||||
expect(result.head[0]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "partial",
|
||||
});
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.cursor).toBe(null);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts timeline event with cursor advance", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
};
|
||||
it("appends committed live rows to tail and clears superseded provisional assistant state", () => {
|
||||
const currentHead: StreamItem[] = [
|
||||
{
|
||||
kind: "assistant_message",
|
||||
id: "head-assistant",
|
||||
text: "partial",
|
||||
timestamp: new Date(1000),
|
||||
},
|
||||
];
|
||||
const currentCursor: TimelineCursor = { startSeq: 1, endSeq: 120 };
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("new chunk"),
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
event: makeTimelineEvent("finalized reply"),
|
||||
seq: 121,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
});
|
||||
|
||||
expect(result.changedTail).toBe(true);
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
endSeq: 121,
|
||||
});
|
||||
expect(result.tail[result.tail.length - 1]).toMatchObject({
|
||||
kind: "assistant_message",
|
||||
text: "finalized reply",
|
||||
});
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects gap and emits catch-up side effect", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 4,
|
||||
};
|
||||
it("replaces provisional tool progress when the committed tool row arrives", () => {
|
||||
const provisional = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeToolCallEvent("running"),
|
||||
seq: undefined,
|
||||
});
|
||||
|
||||
const committed = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeToolCallEvent("completed"),
|
||||
seq: 8,
|
||||
currentHead: provisional.head,
|
||||
currentTail: provisional.tail,
|
||||
currentCursor: { startSeq: 1, endSeq: 7 },
|
||||
});
|
||||
|
||||
expect(committed.head).toEqual([]);
|
||||
expect(committed.tail).toHaveLength(1);
|
||||
expect(committed.tail[0]).toMatchObject({
|
||||
kind: "tool_call",
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
callId: "call-1",
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("requests catch-up when a committed live row skips ahead", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("far ahead"),
|
||||
seq: 10,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
seq: 125,
|
||||
currentCursor: { startSeq: 1, endSeq: 120 },
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
|
||||
const catchUp = result.sideEffects.find((e) => e.type === "catch_up");
|
||||
expect(catchUp).toBeDefined();
|
||||
expect(catchUp!.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops stale timeline event", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 8,
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("old"),
|
||||
seq: 5,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.sideEffects).toContainEqual({
|
||||
type: "catch_up",
|
||||
cursor: { endSeq: 120 },
|
||||
});
|
||||
});
|
||||
|
||||
it("clears provisional head on terminal turn events without committing it to tail", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
},
|
||||
currentHead: [
|
||||
{
|
||||
kind: "thought",
|
||||
id: "reasoning-1",
|
||||
text: "thinking",
|
||||
timestamp: new Date(1000),
|
||||
status: "loading",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.changedHead).toBe(true);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops timeline event with epoch mismatch", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("wrong epoch"),
|
||||
seq: 6,
|
||||
epoch: "epoch-2",
|
||||
currentCursor: existingCursor,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(false);
|
||||
expect(result.changedTail).toBe(false);
|
||||
expect(result.changedHead).toBe(false);
|
||||
expect(result.sideEffects).toEqual([]);
|
||||
});
|
||||
|
||||
it("initializes cursor when none exists", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("first"),
|
||||
seq: 1,
|
||||
epoch: "epoch-1",
|
||||
currentCursor: undefined,
|
||||
});
|
||||
|
||||
expect(result.cursorChanged).toBe(true);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
endSeq: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives optimistic idle status on turn_completed for running agent", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent).not.toBe(null);
|
||||
expect(result.agent!.status).toBe("idle");
|
||||
expect(result.agent!.updatedAt.getTime()).toBe(2000);
|
||||
expect(result.agent!.lastActivityAt.getTime()).toBe(2000);
|
||||
});
|
||||
|
||||
it("derives optimistic error status on turn_failed for running agent", () => {
|
||||
const turnFailedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_failed",
|
||||
provider: "claude",
|
||||
error: "something broke",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnFailedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent!.status).toBe("error");
|
||||
});
|
||||
|
||||
it("does not change agent when status is not running", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "idle",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
});
|
||||
|
||||
it("does not change agent when no agent is provided", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: null,
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
});
|
||||
|
||||
it("preserves updatedAt when agent timestamp is newer than event", () => {
|
||||
const turnCompletedEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
provider: "claude",
|
||||
};
|
||||
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: turnCompletedEvent,
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(5000),
|
||||
lastActivityAt: new Date(5000),
|
||||
},
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(true);
|
||||
expect(result.agent!.updatedAt.getTime()).toBe(5000);
|
||||
expect(result.agent!.lastActivityAt.getTime()).toBe(5000);
|
||||
});
|
||||
|
||||
it("does not produce agent patch for non-terminal events", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeTimelineEvent("just text"),
|
||||
currentAgent: {
|
||||
status: "running",
|
||||
updatedAt: new Date(1000),
|
||||
lastActivityAt: new Date(1000),
|
||||
},
|
||||
seq: 1,
|
||||
epoch: "epoch-1",
|
||||
timestamp: new Date(2000),
|
||||
});
|
||||
|
||||
expect(result.agentChanged).toBe(false);
|
||||
expect(result.agent).toBe(null);
|
||||
expect(result.head).toEqual([]);
|
||||
expect(result.tail).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { applyStreamEvent, hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||
import { hydrateStreamState, reduceStreamUpdate } from "@/types/stream";
|
||||
import {
|
||||
classifySessionTimelineSeq,
|
||||
type SessionTimelineSeqDecision,
|
||||
@@ -12,38 +12,25 @@ import {
|
||||
} from "@/contexts/session-timeline-bootstrap-policy";
|
||||
import { deriveOptimisticLifecycleStatus } from "@/contexts/session-stream-lifecycle";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared cursor type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TimelineCursor = {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Side-effect discriminated unions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TimelineReducerSideEffect =
|
||||
| { type: "catch_up"; cursor: { epoch: string; endSeq: number } }
|
||||
| { type: "catch_up"; cursor: { endSeq: number } }
|
||||
| { type: "flush_pending_updates" };
|
||||
|
||||
export type AgentStreamReducerSideEffect = {
|
||||
type: "catch_up";
|
||||
cursor: { epoch: string; endSeq: number };
|
||||
cursor: { endSeq: number };
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processTimelineResponse
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TimelineDirection = "tail" | "before" | "after";
|
||||
type InitRequestDirection = "tail" | "after";
|
||||
|
||||
type TimelineResponseEntry = {
|
||||
seqStart: number;
|
||||
seq: number;
|
||||
provider: string;
|
||||
item: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
@@ -53,10 +40,8 @@ export interface ProcessTimelineResponseInput {
|
||||
payload: {
|
||||
agentId: string;
|
||||
direction: TimelineDirection;
|
||||
reset: boolean;
|
||||
epoch: string;
|
||||
startCursor: { seq: number } | null;
|
||||
endCursor: { seq: number } | null;
|
||||
startSeq: number | null;
|
||||
endSeq: number | null;
|
||||
entries: TimelineResponseEntry[];
|
||||
error: string | null;
|
||||
};
|
||||
@@ -79,205 +64,9 @@ export interface ProcessTimelineResponseOutput {
|
||||
sideEffects: TimelineReducerSideEffect[];
|
||||
}
|
||||
|
||||
export function processTimelineResponse(
|
||||
input: ProcessTimelineResponseInput,
|
||||
): ProcessTimelineResponseOutput {
|
||||
const {
|
||||
payload,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection,
|
||||
} = input;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Error path: reject init and leave stream state unchanged
|
||||
// ------------------------------------------------------------------
|
||||
if (payload.error) {
|
||||
return {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
cursor: currentCursor,
|
||||
cursorChanged: false,
|
||||
initResolution: hasActiveInitDeferred ? "reject" : null,
|
||||
clearInitializing: isInitializing,
|
||||
error: payload.error,
|
||||
sideEffects: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Convert entries to timeline units
|
||||
// ------------------------------------------------------------------
|
||||
const timelineUnits = payload.entries.map((entry) => ({
|
||||
seq: entry.seqStart,
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
item: entry.item,
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
const toHydratedEvents = (
|
||||
units: typeof timelineUnits,
|
||||
): Array<{ event: AgentStreamEventPayload; timestamp: Date }> =>
|
||||
units.map(({ event, timestamp }) => ({ event, timestamp }));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Derive bootstrap policy (replace vs incremental)
|
||||
// ------------------------------------------------------------------
|
||||
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: payload.direction,
|
||||
reset: payload.reset,
|
||||
epoch: payload.epoch,
|
||||
endCursor: payload.endCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
});
|
||||
const replace = bootstrapPolicy.replace;
|
||||
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||
|
||||
if (replace) {
|
||||
// ----------------------------------------------------------------
|
||||
// Replace path: full hydration from scratch
|
||||
// ----------------------------------------------------------------
|
||||
nextTail = hydrateStreamState(toHydratedEvents(timelineUnits), {
|
||||
source: "canonical",
|
||||
});
|
||||
nextHead = [];
|
||||
|
||||
if (payload.startCursor && payload.endCursor) {
|
||||
nextCursor = {
|
||||
epoch: payload.epoch,
|
||||
startSeq: payload.startCursor.seq,
|
||||
endSeq: payload.endCursor.seq,
|
||||
};
|
||||
cursorChanged = true;
|
||||
} else {
|
||||
nextCursor = null;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (bootstrapPolicy.catchUpCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: bootstrapPolicy.catchUpCursor,
|
||||
});
|
||||
}
|
||||
} else if (timelineUnits.length > 0) {
|
||||
// ----------------------------------------------------------------
|
||||
// Incremental append path
|
||||
// ----------------------------------------------------------------
|
||||
const acceptedUnits: typeof timelineUnits = [];
|
||||
let cursor = currentCursor;
|
||||
let gapCursor: { epoch: string; endSeq: number } | null = null;
|
||||
|
||||
for (const unit of timelineUnits) {
|
||||
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
|
||||
cursor: cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null,
|
||||
epoch: payload.epoch,
|
||||
seq: unit.seq,
|
||||
});
|
||||
|
||||
if (decision === "gap") {
|
||||
gapCursor = cursor ? { epoch: cursor.epoch, endSeq: cursor.endSeq } : null;
|
||||
break;
|
||||
}
|
||||
if (decision === "drop_stale" || decision === "drop_epoch") {
|
||||
continue;
|
||||
}
|
||||
|
||||
acceptedUnits.push(unit);
|
||||
if (decision === "init") {
|
||||
cursor = {
|
||||
epoch: payload.epoch,
|
||||
startSeq: unit.seq,
|
||||
endSeq: unit.seq,
|
||||
};
|
||||
continue;
|
||||
}
|
||||
if (!cursor) {
|
||||
continue;
|
||||
}
|
||||
cursor = {
|
||||
...cursor,
|
||||
endSeq: unit.seq,
|
||||
};
|
||||
}
|
||||
|
||||
if (acceptedUnits.length > 0) {
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
currentTail,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
cursor &&
|
||||
(!currentCursor ||
|
||||
currentCursor.epoch !== cursor.epoch ||
|
||||
currentCursor.startSeq !== cursor.startSeq ||
|
||||
currentCursor.endSeq !== cursor.endSeq)
|
||||
) {
|
||||
nextCursor = cursor;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (gapCursor) {
|
||||
sideEffects.push({ type: "catch_up", cursor: gapCursor });
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Flush pending agent updates side effect
|
||||
// ------------------------------------------------------------------
|
||||
sideEffects.push({ type: "flush_pending_updates" });
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Init resolution
|
||||
// ------------------------------------------------------------------
|
||||
const shouldResolveDeferredInit = shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection: payload.direction,
|
||||
reset: payload.reset,
|
||||
});
|
||||
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
|
||||
|
||||
const initResolution: "resolve" | "reject" | null = shouldResolveDeferredInit ? "resolve" : null;
|
||||
|
||||
return {
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
cursor: nextCursor,
|
||||
cursorChanged,
|
||||
initResolution,
|
||||
clearInitializing,
|
||||
error: null,
|
||||
sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// processAgentStreamEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ProcessAgentStreamEventInput {
|
||||
event: AgentStreamEventPayload;
|
||||
seq: number | undefined;
|
||||
epoch: string | undefined;
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
currentCursor: TimelineCursor | undefined;
|
||||
@@ -307,75 +96,250 @@ export interface ProcessAgentStreamEventOutput {
|
||||
sideEffects: AgentStreamReducerSideEffect[];
|
||||
}
|
||||
|
||||
function cursorsEqual(
|
||||
left: TimelineCursor | null | undefined,
|
||||
right: TimelineCursor | null | undefined,
|
||||
): boolean {
|
||||
if (!left || !right) {
|
||||
return left === right;
|
||||
}
|
||||
return left.startSeq === right.startSeq && left.endSeq === right.endSeq;
|
||||
}
|
||||
|
||||
function removeSupersededProvisionalItems(
|
||||
head: StreamItem[],
|
||||
event: AgentStreamEventPayload,
|
||||
): StreamItem[] {
|
||||
if (head.length === 0 || event.type !== "timeline") {
|
||||
return head;
|
||||
}
|
||||
|
||||
let nextHead = head;
|
||||
if (event.item.type === "assistant_message") {
|
||||
nextHead = head.filter((item) => item.kind !== "assistant_message");
|
||||
} else if (event.item.type === "tool_call") {
|
||||
const committedToolCall = event.item;
|
||||
nextHead = head.filter(
|
||||
(item) =>
|
||||
item.kind !== "tool_call" ||
|
||||
item.payload.source !== "agent" ||
|
||||
item.payload.data.callId !== committedToolCall.callId,
|
||||
);
|
||||
}
|
||||
|
||||
return nextHead.length === head.length ? head : nextHead;
|
||||
}
|
||||
|
||||
export function processTimelineResponse(
|
||||
input: ProcessTimelineResponseInput,
|
||||
): ProcessTimelineResponseOutput {
|
||||
const {
|
||||
payload,
|
||||
currentTail,
|
||||
currentHead,
|
||||
currentCursor,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
initRequestDirection,
|
||||
} = input;
|
||||
|
||||
if (payload.error) {
|
||||
return {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
cursor: currentCursor,
|
||||
cursorChanged: false,
|
||||
initResolution: hasActiveInitDeferred ? "reject" : null,
|
||||
clearInitializing: isInitializing,
|
||||
error: payload.error,
|
||||
sideEffects: [],
|
||||
};
|
||||
}
|
||||
|
||||
const timelineUnits = payload.entries.map((entry) => ({
|
||||
seq: entry.seq,
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: entry.provider,
|
||||
item: entry.item,
|
||||
} as AgentStreamEventPayload,
|
||||
timestamp: new Date(entry.timestamp),
|
||||
}));
|
||||
|
||||
const bootstrapPolicy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: payload.direction,
|
||||
endSeq: payload.endSeq,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
});
|
||||
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let nextCursor: TimelineCursor | null | undefined = currentCursor;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: TimelineReducerSideEffect[] = [];
|
||||
|
||||
if (bootstrapPolicy.replace) {
|
||||
nextTail = hydrateStreamState(
|
||||
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextHead = [];
|
||||
nextCursor =
|
||||
typeof payload.startSeq === "number" && typeof payload.endSeq === "number"
|
||||
? {
|
||||
startSeq: payload.startSeq,
|
||||
endSeq: payload.endSeq,
|
||||
}
|
||||
: null;
|
||||
cursorChanged = !cursorsEqual(currentCursor, nextCursor);
|
||||
|
||||
if (bootstrapPolicy.catchUpCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: bootstrapPolicy.catchUpCursor,
|
||||
});
|
||||
}
|
||||
} else if (payload.direction === "before") {
|
||||
const prepended = hydrateStreamState(
|
||||
timelineUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextTail = prepended.length > 0 ? [...prepended, ...currentTail] : currentTail;
|
||||
const derivedCursor =
|
||||
typeof payload.startSeq === "number"
|
||||
? {
|
||||
startSeq: payload.startSeq,
|
||||
endSeq: currentCursor?.endSeq ?? payload.endSeq ?? payload.startSeq,
|
||||
}
|
||||
: currentCursor;
|
||||
nextCursor = derivedCursor;
|
||||
cursorChanged = !cursorsEqual(currentCursor, derivedCursor);
|
||||
} else if (timelineUnits.length > 0) {
|
||||
const acceptedUnits: typeof timelineUnits = [];
|
||||
let cursor = currentCursor;
|
||||
let gapCursor: { endSeq: number } | null = null;
|
||||
|
||||
for (const unit of timelineUnits) {
|
||||
const decision: SessionTimelineSeqDecision = classifySessionTimelineSeq({
|
||||
cursor: cursor ? { endSeq: cursor.endSeq } : null,
|
||||
seq: unit.seq,
|
||||
});
|
||||
|
||||
if (decision === "gap") {
|
||||
gapCursor = cursor ? { endSeq: cursor.endSeq } : null;
|
||||
break;
|
||||
}
|
||||
if (decision === "drop_stale") {
|
||||
continue;
|
||||
}
|
||||
|
||||
acceptedUnits.push(unit);
|
||||
cursor =
|
||||
decision === "init"
|
||||
? { startSeq: unit.seq, endSeq: unit.seq }
|
||||
: { ...(cursor ?? { startSeq: unit.seq, endSeq: unit.seq }), endSeq: unit.seq };
|
||||
nextHead = removeSupersededProvisionalItems(nextHead, unit.event);
|
||||
}
|
||||
|
||||
if (acceptedUnits.length > 0) {
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
currentTail,
|
||||
);
|
||||
}
|
||||
|
||||
if (cursor && !cursorsEqual(currentCursor, cursor)) {
|
||||
nextCursor = cursor;
|
||||
cursorChanged = true;
|
||||
}
|
||||
|
||||
if (gapCursor) {
|
||||
sideEffects.push({ type: "catch_up", cursor: gapCursor });
|
||||
}
|
||||
}
|
||||
|
||||
sideEffects.push({ type: "flush_pending_updates" });
|
||||
|
||||
const shouldResolveDeferredInit = shouldResolveTimelineInit({
|
||||
hasActiveInitDeferred,
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection: payload.direction,
|
||||
});
|
||||
const clearInitializing = shouldResolveDeferredInit || (isInitializing && !hasActiveInitDeferred);
|
||||
|
||||
return {
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
cursor: nextCursor,
|
||||
cursorChanged,
|
||||
initResolution: shouldResolveDeferredInit ? "resolve" : null,
|
||||
clearInitializing,
|
||||
error: null,
|
||||
sideEffects,
|
||||
};
|
||||
}
|
||||
|
||||
export function processAgentStreamEvent(
|
||||
input: ProcessAgentStreamEventInput,
|
||||
): ProcessAgentStreamEventOutput {
|
||||
const { event, seq, epoch, currentTail, currentHead, currentCursor, currentAgent, timestamp } =
|
||||
input;
|
||||
const { event, seq, currentTail, currentHead, currentCursor, currentAgent, timestamp } = input;
|
||||
|
||||
let shouldApplyStreamEvent = true;
|
||||
let nextTail = currentTail;
|
||||
let nextHead = currentHead;
|
||||
let changedTail = false;
|
||||
let changedHead = false;
|
||||
let nextTimelineCursor: TimelineCursor | null = null;
|
||||
let cursorChanged = false;
|
||||
const sideEffects: AgentStreamReducerSideEffect[] = [];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Timeline sequencing gate
|
||||
// ------------------------------------------------------------------
|
||||
if (event.type === "timeline" && typeof seq === "number" && typeof epoch === "string") {
|
||||
if (event.type === "timeline" && typeof seq === "number") {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: currentCursor ? { epoch: currentCursor.epoch, endSeq: currentCursor.endSeq } : null,
|
||||
epoch,
|
||||
cursor: currentCursor ? { endSeq: currentCursor.endSeq } : null,
|
||||
seq,
|
||||
});
|
||||
|
||||
if (decision === "init") {
|
||||
nextTimelineCursor = { epoch, startSeq: seq, endSeq: seq };
|
||||
cursorChanged = true;
|
||||
} else if (decision === "accept") {
|
||||
nextTimelineCursor = {
|
||||
...(currentCursor ?? { epoch, startSeq: seq, endSeq: seq }),
|
||||
epoch,
|
||||
endSeq: seq,
|
||||
};
|
||||
cursorChanged = true;
|
||||
} else if (decision === "gap") {
|
||||
shouldApplyStreamEvent = false;
|
||||
if (decision === "gap") {
|
||||
if (currentCursor) {
|
||||
sideEffects.push({
|
||||
type: "catch_up",
|
||||
cursor: {
|
||||
epoch: currentCursor.epoch,
|
||||
endSeq: currentCursor.endSeq,
|
||||
},
|
||||
cursor: { endSeq: currentCursor.endSeq },
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// drop_stale or drop_epoch
|
||||
shouldApplyStreamEvent = false;
|
||||
} else if (decision !== "drop_stale") {
|
||||
nextTail = reduceStreamUpdate(currentTail, event, timestamp, {
|
||||
source: "canonical",
|
||||
});
|
||||
changedTail = nextTail !== currentTail;
|
||||
|
||||
nextHead = removeSupersededProvisionalItems(currentHead, event);
|
||||
changedHead = nextHead !== currentHead;
|
||||
|
||||
nextTimelineCursor =
|
||||
decision === "init"
|
||||
? { startSeq: seq, endSeq: seq }
|
||||
: { ...(currentCursor ?? { startSeq: seq, endSeq: seq }), endSeq: seq };
|
||||
cursorChanged = !cursorsEqual(currentCursor, nextTimelineCursor);
|
||||
}
|
||||
} else if (event.type === "timeline") {
|
||||
nextHead = reduceStreamUpdate(currentHead, event, timestamp, {
|
||||
source: "live",
|
||||
});
|
||||
changedHead = nextHead !== currentHead;
|
||||
} else if (
|
||||
(event.type === "turn_completed" ||
|
||||
event.type === "turn_canceled" ||
|
||||
event.type === "turn_failed") &&
|
||||
currentHead.length > 0
|
||||
) {
|
||||
nextHead = [];
|
||||
changedHead = true;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Apply stream event to tail/head
|
||||
// ------------------------------------------------------------------
|
||||
const { tail, head, changedTail, changedHead } = shouldApplyStreamEvent
|
||||
? applyStreamEvent({
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
event,
|
||||
timestamp,
|
||||
source: "live",
|
||||
})
|
||||
: {
|
||||
tail: currentTail,
|
||||
head: currentHead,
|
||||
changedTail: false,
|
||||
changedHead: false,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Optimistic lifecycle status
|
||||
// ------------------------------------------------------------------
|
||||
let agentPatch: AgentPatch | null = null;
|
||||
let agentChanged = false;
|
||||
|
||||
@@ -402,8 +366,8 @@ export function processAgentStreamEvent(
|
||||
}
|
||||
|
||||
return {
|
||||
tail,
|
||||
head,
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
changedTail,
|
||||
changedHead,
|
||||
cursor: nextTimelineCursor,
|
||||
|
||||
@@ -2,26 +2,42 @@ import { describe, expect, it } from "vitest";
|
||||
import { classifySessionTimelineSeq } from "./session-timeline-seq-gate";
|
||||
import {
|
||||
deriveBootstrapTailTimelinePolicy,
|
||||
deriveInitialTimelineRequest,
|
||||
shouldResolveTimelineInit,
|
||||
} from "./session-timeline-bootstrap-policy";
|
||||
|
||||
describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("always replaces on explicit reset without catch-up cursor", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "after",
|
||||
reset: true,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
describe("deriveInitialTimelineRequest", () => {
|
||||
it("uses tail bootstrap when history has not synced yet", () => {
|
||||
expect(
|
||||
deriveInitialTimelineRequest({
|
||||
cursor: { seq: 42 },
|
||||
hasAuthoritativeHistory: false,
|
||||
initialTimelineLimit: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toBeNull();
|
||||
});
|
||||
|
||||
it("uses catch-up after the committed cursor once history is synced", () => {
|
||||
expect(
|
||||
deriveInitialTimelineRequest({
|
||||
cursor: { seq: 42 },
|
||||
hasAuthoritativeHistory: true,
|
||||
initialTimelineLimit: 200,
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
cursor: { seq: 42 },
|
||||
limit: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("forces baseline replace and canonical catch-up for init tail race", () => {
|
||||
const advancedCursor = { epoch: "epoch-1", endSeq: 205 };
|
||||
const advancedCursor = { endSeq: 205 };
|
||||
const tailSeqStart = 101;
|
||||
const tailSeqEnd = 200;
|
||||
|
||||
@@ -29,7 +45,6 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
for (let seq = tailSeqStart; seq <= tailSeqEnd; seq += 1) {
|
||||
const decision = classifySessionTimelineSeq({
|
||||
cursor: advancedCursor,
|
||||
epoch: "epoch-1",
|
||||
seq,
|
||||
});
|
||||
if (decision === "accept" || decision === "init") {
|
||||
@@ -40,16 +55,13 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
endSeq: 200,
|
||||
isInitializing: true,
|
||||
hasActiveInitDeferred: true,
|
||||
});
|
||||
|
||||
expect(policy.replace).toBe(true);
|
||||
expect(policy.catchUpCursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
endSeq: 200,
|
||||
});
|
||||
});
|
||||
@@ -57,9 +69,7 @@ describe("deriveBootstrapTailTimelinePolicy", () => {
|
||||
it("does not replace non-bootstrap, non-reset responses", () => {
|
||||
const policy = deriveBootstrapTailTimelinePolicy({
|
||||
direction: "tail",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
endCursor: { seq: 200 },
|
||||
endSeq: 200,
|
||||
isInitializing: false,
|
||||
hasActiveInitDeferred: false,
|
||||
});
|
||||
@@ -77,7 +87,6 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "tail",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -89,7 +98,6 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "tail",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -101,7 +109,6 @@ describe("shouldResolveTimelineInit", () => {
|
||||
isInitializing: true,
|
||||
initRequestDirection: "after",
|
||||
responseDirection: "after",
|
||||
reset: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ type BootstrapTailCursor = {
|
||||
} | null;
|
||||
|
||||
type InitialTimelineCursor = {
|
||||
epoch: string;
|
||||
seq: number;
|
||||
} | null;
|
||||
|
||||
@@ -20,48 +19,37 @@ export function deriveInitialTimelineRequest({
|
||||
initialTimelineLimit: number;
|
||||
}): {
|
||||
direction: "tail" | "after";
|
||||
cursor?: { epoch: string; seq: number };
|
||||
cursor?: { seq: number };
|
||||
limit: number;
|
||||
projection: "canonical";
|
||||
} {
|
||||
if (!hasAuthoritativeHistory || !cursor) {
|
||||
return {
|
||||
direction: "tail",
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
direction: "after",
|
||||
cursor: { epoch: cursor.epoch, seq: cursor.seq },
|
||||
cursor: { seq: cursor.seq },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
};
|
||||
}
|
||||
|
||||
export function deriveBootstrapTailTimelinePolicy({
|
||||
direction,
|
||||
reset,
|
||||
epoch,
|
||||
endCursor,
|
||||
endSeq,
|
||||
isInitializing,
|
||||
hasActiveInitDeferred,
|
||||
}: {
|
||||
direction: TimelineDirection;
|
||||
reset: boolean;
|
||||
epoch: string;
|
||||
endCursor: BootstrapTailCursor;
|
||||
endSeq: number | null;
|
||||
isInitializing: boolean;
|
||||
hasActiveInitDeferred: boolean;
|
||||
}): {
|
||||
replace: boolean;
|
||||
catchUpCursor: { epoch: string; endSeq: number } | null;
|
||||
catchUpCursor: { endSeq: number } | null;
|
||||
} {
|
||||
if (reset) {
|
||||
return { replace: true, catchUpCursor: null };
|
||||
}
|
||||
|
||||
const isBootstrapTailInit = direction === "tail" && isInitializing && hasActiveInitDeferred;
|
||||
if (!isBootstrapTailInit) {
|
||||
return { replace: false, catchUpCursor: null };
|
||||
@@ -69,7 +57,7 @@ export function deriveBootstrapTailTimelinePolicy({
|
||||
|
||||
return {
|
||||
replace: true,
|
||||
catchUpCursor: endCursor ? { epoch, endSeq: endCursor.seq } : null,
|
||||
catchUpCursor: typeof endSeq === "number" ? { endSeq } : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,19 +66,14 @@ export function shouldResolveTimelineInit({
|
||||
isInitializing,
|
||||
initRequestDirection,
|
||||
responseDirection,
|
||||
reset,
|
||||
}: {
|
||||
hasActiveInitDeferred: boolean;
|
||||
isInitializing: boolean;
|
||||
initRequestDirection: InitRequestDirection;
|
||||
responseDirection: TimelineDirection;
|
||||
reset: boolean;
|
||||
}): boolean {
|
||||
if (!hasActiveInitDeferred || !isInitializing) {
|
||||
return false;
|
||||
}
|
||||
if (reset) {
|
||||
return true;
|
||||
}
|
||||
return responseDirection === initRequestDirection;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("accepts contiguous forward seq", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-1",
|
||||
cursor: { endSeq: 4 },
|
||||
seq: 5,
|
||||
}),
|
||||
).toBe("accept");
|
||||
@@ -15,8 +14,7 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("drops stale seq older than the current end", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
||||
epoch: "epoch-1",
|
||||
cursor: { endSeq: 8 },
|
||||
seq: 7,
|
||||
}),
|
||||
).toBe("drop_stale");
|
||||
@@ -25,28 +23,16 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("drops duplicate replay seq equal to the current end", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 8 },
|
||||
epoch: "epoch-1",
|
||||
cursor: { endSeq: 8 },
|
||||
seq: 8,
|
||||
}),
|
||||
).toBe("drop_stale");
|
||||
});
|
||||
|
||||
it("drops epoch mismatch", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-2",
|
||||
seq: 5,
|
||||
}),
|
||||
).toBe("drop_epoch");
|
||||
});
|
||||
|
||||
it("initializes when cursor is null", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: null,
|
||||
epoch: "epoch-1",
|
||||
seq: 1,
|
||||
}),
|
||||
).toBe("init");
|
||||
@@ -55,8 +41,7 @@ describe("classifySessionTimelineSeq", () => {
|
||||
it("classifies forward gaps", () => {
|
||||
expect(
|
||||
classifySessionTimelineSeq({
|
||||
cursor: { epoch: "epoch-1", endSeq: 4 },
|
||||
epoch: "epoch-1",
|
||||
cursor: { endSeq: 4 },
|
||||
seq: 9,
|
||||
}),
|
||||
).toBe("gap");
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
export type SessionTimelineSeqCursor =
|
||||
| {
|
||||
epoch: string;
|
||||
endSeq: number;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "drop_epoch" | "gap" | "init";
|
||||
export type SessionTimelineSeqDecision = "accept" | "drop_stale" | "gap" | "init";
|
||||
|
||||
export function classifySessionTimelineSeq({
|
||||
cursor,
|
||||
epoch,
|
||||
seq,
|
||||
}: {
|
||||
cursor: SessionTimelineSeqCursor;
|
||||
epoch: string;
|
||||
seq: number;
|
||||
}): SessionTimelineSeqDecision {
|
||||
if (!cursor) {
|
||||
return "init";
|
||||
}
|
||||
if (cursor.epoch !== epoch) {
|
||||
return "drop_epoch";
|
||||
}
|
||||
if (seq <= cursor.endSeq) {
|
||||
return "drop_stale";
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ type UseAgentFormStateOptions = {
|
||||
onlineServerIds?: string[];
|
||||
};
|
||||
|
||||
type UseAgentFormStateResult = {
|
||||
export type UseAgentFormStateResult = {
|
||||
selectedServerId: string | null;
|
||||
setSelectedServerId: (value: string | null) => void;
|
||||
setSelectedServerIdFromUser: (value: string | null) => void;
|
||||
|
||||
@@ -2,11 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-agent-initialization";
|
||||
|
||||
describe("useAgentInitialization timeline request policy", () => {
|
||||
it("uses canonical tail bootstrap when history has not synced yet", () => {
|
||||
it("uses committed tail bootstrap when history has not synced yet", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
},
|
||||
hasAuthoritativeHistory: false,
|
||||
@@ -15,11 +14,10 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical tail bootstrap when cursor is missing", () => {
|
||||
it("uses committed tail bootstrap when cursor is missing", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: null,
|
||||
@@ -29,15 +27,13 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 200,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses canonical catch-up after the current cursor once history is synced", () => {
|
||||
it("uses committed catch-up after the current cursor once history is synced", () => {
|
||||
expect(
|
||||
__private__.deriveInitialTimelineRequest({
|
||||
cursor: {
|
||||
epoch: "epoch-1",
|
||||
seq: 42,
|
||||
},
|
||||
hasAuthoritativeHistory: true,
|
||||
@@ -45,9 +41,8 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
}),
|
||||
).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-1", seq: 42 },
|
||||
cursor: { seq: 42 },
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +56,6 @@ describe("useAgentInitialization timeline request policy", () => {
|
||||
).toEqual({
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "canonical",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function useAgentInitialization({
|
||||
const hasAuthoritativeHistory =
|
||||
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
|
||||
const timelineRequest = deriveInitialTimelineRequest({
|
||||
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
|
||||
cursor: cursor ? { seq: cursor.endSeq } : null,
|
||||
hasAuthoritativeHistory,
|
||||
initialTimelineLimit,
|
||||
});
|
||||
@@ -107,7 +107,6 @@ export function useAgentInitialization({
|
||||
await client.fetchAgentTimeline(agentId, {
|
||||
direction: "tail",
|
||||
limit: initialTimelineLimit,
|
||||
projection: "canonical",
|
||||
});
|
||||
} catch (error) {
|
||||
setAgentInitializing(agentId, false);
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
|
||||
const { asyncStorage } = vi.hoisted(() => ({
|
||||
asyncStorage: new Map<string, string>(),
|
||||
}));
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => ({
|
||||
default: {
|
||||
getItem: async (key: string) => asyncStorage.get(key) ?? null,
|
||||
setItem: async (key: string, value: string) => {
|
||||
asyncStorage.set(key, value);
|
||||
},
|
||||
removeItem: async (key: string) => {
|
||||
asyncStorage.delete(key);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/attachments/service", () => ({
|
||||
garbageCollectAttachments: async () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("./use-agent-form-state", () => ({
|
||||
useAgentFormState: () => ({
|
||||
selectedServerId: "host-1",
|
||||
setSelectedServerId: () => undefined,
|
||||
setSelectedServerIdFromUser: () => undefined,
|
||||
selectedProvider: "codex",
|
||||
setProviderFromUser: () => undefined,
|
||||
selectedMode: "auto",
|
||||
setModeFromUser: () => undefined,
|
||||
selectedModel: "",
|
||||
setModelFromUser: () => undefined,
|
||||
selectedThinkingOptionId: "",
|
||||
setThinkingOptionFromUser: () => undefined,
|
||||
workingDir: "/repo",
|
||||
setWorkingDir: () => undefined,
|
||||
setWorkingDirFromUser: () => undefined,
|
||||
providerDefinitions: [{ id: "codex", label: "Codex", modes: [{ id: "auto", label: "Auto" }] }],
|
||||
providerDefinitionMap: new Map(),
|
||||
agentDefinition: undefined,
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
availableModels: [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
allProviderModels: new Map([
|
||||
[
|
||||
"codex",
|
||||
[
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
]),
|
||||
isAllModelsLoading: false,
|
||||
availableThinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
isModelLoading: false,
|
||||
modelError: null,
|
||||
refreshProviderModels: () => undefined,
|
||||
setProviderAndModelFromUser: () => undefined,
|
||||
workingDirIsEmpty: false,
|
||||
persistFormPreferences: async () => undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
let useAgentInputDraft: typeof import("./use-agent-input-draft").useAgentInputDraft;
|
||||
|
||||
beforeAll(async () => {
|
||||
const storage = new Map<string, string>();
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
({ useAgentInputDraft } = await import("./use-agent-input-draft"));
|
||||
});
|
||||
|
||||
describe("useAgentInputDraft live contract", () => {
|
||||
beforeEach(() => {
|
||||
asyncStorage.clear();
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id='root'></div></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
value: dom.window.document,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: dom.window.navigator,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
useDraftStore.setState({ drafts: {}, createModalDraft: null });
|
||||
});
|
||||
|
||||
it("hydrates persisted text and images and returns draft-mode composer state for a caller-provided key", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
const image: AttachmentMetadata = {
|
||||
id: "attachment-1",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "attachments/1",
|
||||
createdAt: 1,
|
||||
fileName: "image.png",
|
||||
byteSize: 128,
|
||||
};
|
||||
|
||||
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
||||
if (!latest) {
|
||||
throw new Error("Expected hook result");
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function Probe({ draftKey }: { draftKey: string }) {
|
||||
latest = useAgentInputDraft({
|
||||
draftKey,
|
||||
composer: {
|
||||
initialServerId: "host-1",
|
||||
initialValues: { workingDir: "/repo" },
|
||||
isVisible: true,
|
||||
onlineServerIds: ["host-1"],
|
||||
lockedWorkingDir: "/repo",
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
let root: Root | null = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(<Probe draftKey="draft:setup" />);
|
||||
});
|
||||
|
||||
expect(getLatest().composerState?.statusControls.selectedProvider).toBe("codex");
|
||||
expect(getLatest().composerState?.commandDraftConfig).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("hello world");
|
||||
getLatest().setImages([image]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root!.unmount();
|
||||
});
|
||||
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<Probe draftKey="draft:setup" />);
|
||||
});
|
||||
|
||||
expect(getLatest().text).toBe("hello world");
|
||||
expect(getLatest().images).toEqual([image]);
|
||||
});
|
||||
|
||||
it("clears drafts with sent and abandoned lifecycle tombstones", async () => {
|
||||
let latest: ReturnType<typeof useAgentInputDraft> | null = null;
|
||||
const sentImage: AttachmentMetadata = {
|
||||
id: "attachment-sent",
|
||||
mimeType: "image/png",
|
||||
storageType: "web-indexeddb",
|
||||
storageKey: "attachments/sent",
|
||||
createdAt: 2,
|
||||
};
|
||||
|
||||
function getLatest(): ReturnType<typeof useAgentInputDraft> {
|
||||
if (!latest) {
|
||||
throw new Error("Expected hook result");
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
function Probe() {
|
||||
latest = useAgentInputDraft({ draftKey: "draft:lifecycle" });
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.getElementById("root");
|
||||
if (!container) {
|
||||
throw new Error("Missing root container");
|
||||
}
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(<Probe />);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("queued message");
|
||||
getLatest().setImages([sentImage]);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().clear("sent");
|
||||
});
|
||||
|
||||
expect(getLatest().text).toBe("");
|
||||
expect(getLatest().images).toEqual([]);
|
||||
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
||||
lifecycle: "sent",
|
||||
input: { text: "", images: [] },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().setText("draft again");
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getLatest().clear("abandoned");
|
||||
});
|
||||
|
||||
expect(useDraftStore.getState().drafts["draft:lifecycle"]).toMatchObject({
|
||||
lifecycle: "abandoned",
|
||||
input: { text: "", images: [] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
let __private__: typeof import("./use-agent-input-draft").__private__;
|
||||
|
||||
beforeAll(async () => {
|
||||
const storage = new Map<string, string>();
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: {
|
||||
localStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
({ __private__ } = await import("./use-agent-input-draft"));
|
||||
});
|
||||
|
||||
describe("useAgentInputDraft", () => {
|
||||
describe("__private__.resolveDraftKey", () => {
|
||||
it("returns an object draft key string unchanged", () => {
|
||||
expect(
|
||||
__private__.resolveDraftKey({
|
||||
draftKey: "draft:key",
|
||||
selectedServerId: "host-1",
|
||||
}),
|
||||
).toBe("draft:key");
|
||||
});
|
||||
|
||||
it("resolves a computed draft key from the selected server", () => {
|
||||
expect(
|
||||
__private__.resolveDraftKey({
|
||||
draftKey: ({ selectedServerId }) => `draft:${selectedServerId ?? "none"}`,
|
||||
selectedServerId: "host-1",
|
||||
}),
|
||||
).toBe("draft:host-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("__private__.resolveEffectiveComposerModelId", () => {
|
||||
const models = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4-mini",
|
||||
label: "gpt-5.4-mini",
|
||||
},
|
||||
];
|
||||
|
||||
it("prefers the selected model when present", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerModelId({
|
||||
selectedModel: "gpt-5.4-mini",
|
||||
availableModels: models,
|
||||
}),
|
||||
).toBe("gpt-5.4-mini");
|
||||
});
|
||||
|
||||
it("falls back to the provider default model", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerModelId({
|
||||
selectedModel: "",
|
||||
availableModels: models,
|
||||
}),
|
||||
).toBe("gpt-5.4");
|
||||
});
|
||||
});
|
||||
|
||||
describe("__private__.resolveEffectiveComposerThinkingOptionId", () => {
|
||||
const models = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.4",
|
||||
label: "gpt-5.4",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "high",
|
||||
thinkingOptions: [
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it("prefers the selected thinking option when present", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "medium",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
).toBe("medium");
|
||||
});
|
||||
|
||||
it("falls back to the model default thinking option", () => {
|
||||
expect(
|
||||
__private__.resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: "",
|
||||
availableModels: models,
|
||||
effectiveModelId: "gpt-5.4",
|
||||
}),
|
||||
).toBe("high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("__private__.buildDraftComposerCommandConfig", () => {
|
||||
it("returns undefined when cwd is empty", () => {
|
||||
expect(
|
||||
__private__.buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
cwd: " ",
|
||||
modeOptions: [],
|
||||
selectedMode: "",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds the draft command config from derived composer state", () => {
|
||||
expect(
|
||||
__private__.buildDraftComposerCommandConfig({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeOptions: [{ id: "auto", label: "Auto" }],
|
||||
selectedMode: "auto",
|
||||
effectiveModelId: "gpt-5.4",
|
||||
effectiveThinkingOptionId: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/repo",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,44 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import type { DraftAgentStatusBarProps } from "@/components/agent-status-bar";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import {
|
||||
useAgentFormState,
|
||||
type CreateAgentInitialValues,
|
||||
type UseAgentFormStateResult,
|
||||
} from "@/hooks/use-agent-form-state";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
type ImageUpdater = AttachmentMetadata[] | ((prev: AttachmentMetadata[]) => AttachmentMetadata[]);
|
||||
|
||||
type AgentInputDraftComposerOptions = {
|
||||
initialServerId: string | null;
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
isVisible?: boolean;
|
||||
onlineServerIds?: string[];
|
||||
lockedWorkingDir?: string;
|
||||
};
|
||||
|
||||
type DraftKeyContext = {
|
||||
selectedServerId: string | null;
|
||||
};
|
||||
|
||||
type DraftKeyInput = string | ((context: DraftKeyContext) => string);
|
||||
|
||||
type UseAgentInputDraftInput = {
|
||||
draftKey: DraftKeyInput;
|
||||
composer?: AgentInputDraftComposerOptions;
|
||||
};
|
||||
|
||||
type DraftComposerState = UseAgentFormStateResult & {
|
||||
workingDir: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
statusControls: DraftAgentStatusBarProps;
|
||||
commandDraftConfig: DraftCommandConfig | undefined;
|
||||
};
|
||||
|
||||
interface AgentInputDraft {
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
@@ -11,6 +46,7 @@ interface AgentInputDraft {
|
||||
setImages: (updater: ImageUpdater) => void;
|
||||
clear: (lifecycle: "sent" | "abandoned") => void;
|
||||
isHydrated: boolean;
|
||||
composerState: DraftComposerState | null;
|
||||
}
|
||||
|
||||
function hasDraftContent(input: { text: string; images: AttachmentMetadata[] }): boolean {
|
||||
@@ -36,7 +72,108 @@ function areImagesEqual(input: {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAgentInputDraft(draftKey: string): AgentInputDraft {
|
||||
function resolveDraftKey(input: {
|
||||
draftKey: DraftKeyInput;
|
||||
selectedServerId: string | null;
|
||||
}): string {
|
||||
if (typeof input.draftKey === "function") {
|
||||
return input.draftKey({ selectedServerId: input.selectedServerId });
|
||||
}
|
||||
return input.draftKey;
|
||||
}
|
||||
|
||||
function resolveEffectiveComposerModelId(input: {
|
||||
selectedModel: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
}): string {
|
||||
const selectedModel = input.selectedModel.trim();
|
||||
if (selectedModel) {
|
||||
return selectedModel;
|
||||
}
|
||||
|
||||
return input.availableModels.find((model) => model.isDefault)?.id ?? input.availableModels[0]?.id ?? "";
|
||||
}
|
||||
|
||||
function resolveEffectiveComposerThinkingOptionId(input: {
|
||||
selectedThinkingOptionId: string;
|
||||
availableModels: AgentModelDefinition[];
|
||||
effectiveModelId: string;
|
||||
}): string {
|
||||
const selectedThinkingOptionId = input.selectedThinkingOptionId.trim();
|
||||
if (selectedThinkingOptionId) {
|
||||
return selectedThinkingOptionId;
|
||||
}
|
||||
|
||||
const selectedModelDefinition =
|
||||
input.availableModels.find((model) => model.id === input.effectiveModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}
|
||||
|
||||
function buildDraftComposerCommandConfig(input: {
|
||||
provider: DraftAgentStatusBarProps["selectedProvider"];
|
||||
cwd: string;
|
||||
modeOptions: DraftAgentStatusBarProps["modeOptions"];
|
||||
selectedMode: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
}): DraftCommandConfig | undefined {
|
||||
const cwd = input.cwd.trim();
|
||||
if (!cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: input.provider,
|
||||
cwd,
|
||||
...(input.modeOptions.length > 0 && input.selectedMode !== "" ? { modeId: input.selectedMode } : {}),
|
||||
...(input.effectiveModelId ? { model: input.effectiveModelId } : {}),
|
||||
...(input.effectiveThinkingOptionId
|
||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDraftStatusControls(input: {
|
||||
formState: UseAgentFormStateResult;
|
||||
}): DraftAgentStatusBarProps {
|
||||
const { formState } = input;
|
||||
return {
|
||||
providerDefinitions: formState.providerDefinitions,
|
||||
selectedProvider: formState.selectedProvider,
|
||||
onSelectProvider: formState.setProviderFromUser,
|
||||
modeOptions: formState.modeOptions,
|
||||
selectedMode: formState.selectedMode,
|
||||
onSelectMode: formState.setModeFromUser,
|
||||
models: formState.availableModels,
|
||||
selectedModel: formState.selectedModel,
|
||||
onSelectModel: formState.setModelFromUser,
|
||||
isModelLoading: formState.isModelLoading,
|
||||
allProviderModels: formState.allProviderModels,
|
||||
isAllModelsLoading: formState.isAllModelsLoading,
|
||||
onSelectProviderAndModel: formState.setProviderAndModelFromUser,
|
||||
thinkingOptions: formState.availableThinkingOptions,
|
||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDraft {
|
||||
const composerOptions = input.composer ?? null;
|
||||
const formState = useAgentFormState({
|
||||
initialServerId: composerOptions?.initialServerId ?? null,
|
||||
initialValues: composerOptions?.initialValues,
|
||||
isVisible: composerOptions?.isVisible ?? false,
|
||||
isCreateFlow: true,
|
||||
onlineServerIds: composerOptions?.onlineServerIds ?? [],
|
||||
});
|
||||
const draftKey = useMemo(
|
||||
() =>
|
||||
resolveDraftKey({
|
||||
draftKey: input.draftKey,
|
||||
selectedServerId: formState.selectedServerId,
|
||||
}),
|
||||
[formState.selectedServerId, input.draftKey],
|
||||
);
|
||||
const [text, setText] = useState("");
|
||||
const [images, setImagesState] = useState<AttachmentMetadata[]>([]);
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
@@ -148,6 +285,83 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
|
||||
});
|
||||
}, [draftKey, images, text]);
|
||||
|
||||
const lockedWorkingDir = composerOptions?.lockedWorkingDir?.trim() ?? "";
|
||||
useEffect(() => {
|
||||
if (!composerOptions || !lockedWorkingDir) {
|
||||
return;
|
||||
}
|
||||
if (formState.workingDir.trim() === lockedWorkingDir) {
|
||||
return;
|
||||
}
|
||||
formState.setWorkingDir(lockedWorkingDir);
|
||||
}, [composerOptions, formState, lockedWorkingDir]);
|
||||
|
||||
const effectiveModelId = useMemo(
|
||||
() =>
|
||||
resolveEffectiveComposerModelId({
|
||||
selectedModel: formState.selectedModel,
|
||||
availableModels: formState.availableModels,
|
||||
}),
|
||||
[formState.availableModels, formState.selectedModel],
|
||||
);
|
||||
|
||||
const effectiveThinkingOptionId = useMemo(
|
||||
() =>
|
||||
resolveEffectiveComposerThinkingOptionId({
|
||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||
availableModels: formState.availableModels,
|
||||
effectiveModelId,
|
||||
}),
|
||||
[effectiveModelId, formState.availableModels, formState.selectedThinkingOptionId],
|
||||
);
|
||||
|
||||
const workingDir = lockedWorkingDir || formState.workingDir;
|
||||
|
||||
const commandDraftConfig = useMemo(
|
||||
() =>
|
||||
composerOptions
|
||||
? buildDraftComposerCommandConfig({
|
||||
provider: formState.selectedProvider,
|
||||
cwd: workingDir,
|
||||
modeOptions: formState.modeOptions,
|
||||
selectedMode: formState.selectedMode,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
})
|
||||
: undefined,
|
||||
[
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
workingDir,
|
||||
formState.modeOptions,
|
||||
formState.selectedMode,
|
||||
formState.selectedProvider,
|
||||
],
|
||||
);
|
||||
|
||||
const composerState = useMemo<DraftComposerState | null>(() => {
|
||||
if (!composerOptions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...formState,
|
||||
workingDir,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
statusControls: buildDraftStatusControls({ formState }),
|
||||
commandDraftConfig,
|
||||
};
|
||||
}, [
|
||||
commandDraftConfig,
|
||||
composerOptions,
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
formState,
|
||||
workingDir,
|
||||
]);
|
||||
|
||||
return {
|
||||
text,
|
||||
setText,
|
||||
@@ -155,5 +369,14 @@ export function useAgentInputDraft(draftKey: string): AgentInputDraft {
|
||||
setImages,
|
||||
clear,
|
||||
isHydrated,
|
||||
composerState,
|
||||
};
|
||||
}
|
||||
|
||||
export const __private__ = {
|
||||
resolveDraftKey,
|
||||
resolveEffectiveComposerModelId,
|
||||
resolveEffectiveComposerThinkingOptionId,
|
||||
buildDraftComposerCommandConfig,
|
||||
buildDraftStatusControls,
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ function createAgent(id: string): Agent {
|
||||
serverId: "server-1",
|
||||
id,
|
||||
provider: "claude",
|
||||
terminal: false,
|
||||
status: "running",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -29,6 +30,7 @@ function createAgent(id: string): Agent {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
|
||||
@@ -5,6 +5,14 @@ export interface AgentScreenAgent {
|
||||
id: string;
|
||||
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||
cwd: string;
|
||||
lastError?: string | null;
|
||||
terminalExit?: {
|
||||
command: string;
|
||||
message: string;
|
||||
exitCode: number | null;
|
||||
signal: number | null;
|
||||
outputLines: string[];
|
||||
} | null;
|
||||
projectPlacement?: {
|
||||
checkout?: {
|
||||
cwd?: string;
|
||||
|
||||
@@ -65,6 +65,7 @@ export function useAggregatedAgents(options?: {
|
||||
serverId,
|
||||
serverLabel,
|
||||
title: agent.title ?? null,
|
||||
terminal: agent.terminal,
|
||||
status: agent.status,
|
||||
lastActivityAt: agent.lastActivityAt,
|
||||
cwd: agent.cwd,
|
||||
|
||||
@@ -8,6 +8,7 @@ function makeAgent(input?: Partial<Agent>): Agent {
|
||||
serverId: "server-1",
|
||||
id: input?.id ?? "agent-1",
|
||||
provider: input?.provider ?? "codex",
|
||||
terminal: input?.terminal ?? false,
|
||||
status: input?.status ?? "idle",
|
||||
createdAt: input?.createdAt ?? timestamp,
|
||||
updatedAt: input?.updatedAt ?? timestamp,
|
||||
@@ -20,6 +21,7 @@ function makeAgent(input?: Partial<Agent>): Agent {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: input?.currentModeId ?? null,
|
||||
availableModes: input?.availableModes ?? [],
|
||||
|
||||
@@ -19,6 +19,7 @@ function toAggregatedAgent(params: {
|
||||
serverId: params.serverId,
|
||||
serverLabel: params.serverLabel,
|
||||
title: source.title ?? null,
|
||||
terminal: source.terminal,
|
||||
status: source.status,
|
||||
lastActivityAt: source.lastActivityAt,
|
||||
cwd: source.cwd,
|
||||
|
||||
@@ -72,6 +72,7 @@ type CreateRequestContext = {
|
||||
interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
||||
draftId: string;
|
||||
getPendingServerId: () => string | null;
|
||||
allowEmptyText?: boolean;
|
||||
validateBeforeSubmit?: (ctx: SubmitContext) => string | null;
|
||||
onBeforeSubmit?: (ctx: CreateRequestContext) => void;
|
||||
onCreateStart?: () => void;
|
||||
@@ -84,6 +85,7 @@ interface UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult> {
|
||||
export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
draftId,
|
||||
getPendingServerId,
|
||||
allowEmptyText = false,
|
||||
validateBeforeSubmit,
|
||||
onBeforeSubmit,
|
||||
onCreateStart,
|
||||
@@ -110,6 +112,10 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
if (!machine.attempt.text && (!machine.attempt.images || machine.attempt.images.length === 0)) {
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
kind: "user_message",
|
||||
@@ -139,7 +145,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: "" });
|
||||
|
||||
const trimmedPrompt = text.trim();
|
||||
if (!trimmedPrompt) {
|
||||
if (!trimmedPrompt && !allowEmptyText) {
|
||||
const error = new Error("Initial prompt is required");
|
||||
dispatch({ type: "DRAFT_SET_ERROR", message: error.message });
|
||||
throw error;
|
||||
@@ -215,6 +221,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
setPendingCreateAttempt,
|
||||
updatePendingAgentId,
|
||||
validateBeforeSubmit,
|
||||
allowEmptyText,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { replaceRoute } = vi.hoisted(() => ({
|
||||
replaceRoute: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("expo-router", () => ({
|
||||
router: {
|
||||
replace: replaceRoute,
|
||||
},
|
||||
}));
|
||||
|
||||
import { openProjectDirectly } from "@/hooks/use-open-project";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
collectAllTabs,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "/repo/project";
|
||||
|
||||
describe("openProjectDirectly", () => {
|
||||
beforeEach(() => {
|
||||
replaceRoute.mockReset();
|
||||
useSessionStore.setState({
|
||||
sessions: {},
|
||||
});
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, {} as never);
|
||||
useWorkspaceLayoutStore.setState({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("opens the workspace directly, marks workspaces hydrated, and seeds a launcher tab", async () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"11111111-1111-1111-1111-111111111111",
|
||||
);
|
||||
|
||||
const result = await openProjectDirectly({
|
||||
serverId: SERVER_ID,
|
||||
projectPath: WORKSPACE_ID,
|
||||
isConnected: true,
|
||||
client: {
|
||||
openProject: vi.fn(async () => ({
|
||||
requestId: "request-1",
|
||||
error: null,
|
||||
workspace: {
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
projectDisplayName: "project",
|
||||
projectRootPath: WORKSPACE_ID,
|
||||
projectKind: "git" as const,
|
||||
workspaceKind: "checkout" as const,
|
||||
name: "project",
|
||||
status: "done" as const,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
},
|
||||
})),
|
||||
},
|
||||
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
|
||||
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
|
||||
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab,
|
||||
replaceRoute,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true);
|
||||
expect(Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? [])).toEqual([
|
||||
expect.objectContaining({ id: "1", projectId: "1", projectRootPath: WORKSPACE_ID }),
|
||||
]);
|
||||
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: "1",
|
||||
});
|
||||
expect(workspaceKey).toBeTruthy();
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey as string];
|
||||
expect(layout.root.kind).toBe("pane");
|
||||
const tabs = collectAllTabs(layout.root);
|
||||
expect(tabs).toHaveLength(1);
|
||||
expect(tabs[0]?.target).toEqual({
|
||||
kind: "launcher",
|
||||
launcherId: "11111111-1111-1111-1111-111111111111",
|
||||
});
|
||||
expect(replaceRoute).toHaveBeenCalledWith("/h/server-1/workspace/MQ");
|
||||
});
|
||||
|
||||
it("does not navigate or seed tabs when openProject fails", async () => {
|
||||
const result = await openProjectDirectly({
|
||||
serverId: SERVER_ID,
|
||||
projectPath: WORKSPACE_ID,
|
||||
isConnected: true,
|
||||
client: {
|
||||
openProject: vi.fn(async () => ({
|
||||
requestId: "request-2",
|
||||
error: "Failed to open project",
|
||||
workspace: null,
|
||||
})),
|
||||
},
|
||||
mergeWorkspaces: useSessionStore.getState().mergeWorkspaces,
|
||||
setHasHydratedWorkspaces: useSessionStore.getState().setHasHydratedWorkspaces,
|
||||
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab,
|
||||
replaceRoute,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(false);
|
||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.size).toBe(0);
|
||||
expect(useWorkspaceLayoutStore.getState().layoutByWorkspace).toEqual({});
|
||||
expect(replaceRoute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,76 @@
|
||||
import { router } from "expo-router";
|
||||
import { useCallback } from "react";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
||||
import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { router } from "expo-router";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { normalizeWorkspaceDescriptor, type WorkspaceDescriptor, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
useWorkspaceLayoutStore,
|
||||
} from "@/stores/workspace-layout-store";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
interface OpenProjectDirectlyInput {
|
||||
serverId: string;
|
||||
projectPath: string;
|
||||
isConnected: boolean;
|
||||
client: Pick<DaemonClient, "openProject"> | null;
|
||||
mergeWorkspaces: (serverId: string, workspaces: Iterable<WorkspaceDescriptor>) => void;
|
||||
setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void;
|
||||
openLauncherTab: (workspaceKey: string) => string | null;
|
||||
replaceRoute: (route: string) => void;
|
||||
}
|
||||
|
||||
export async function openProjectDirectly(input: OpenProjectDirectlyInput): Promise<boolean> {
|
||||
const normalizedServerId = input.serverId.trim();
|
||||
const trimmedPath = input.projectPath.trim();
|
||||
if (!normalizedServerId || !trimmedPath || !input.client || !input.isConnected) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = await input.client.openProject(trimmedPath);
|
||||
if (payload.error || !payload.workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspace = normalizeWorkspaceDescriptor(payload.workspace);
|
||||
input.mergeWorkspaces(normalizedServerId, [workspace]);
|
||||
input.setHasHydratedWorkspaces(normalizedServerId, true);
|
||||
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
if (!workspaceKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
input.openLauncherTab(workspaceKey);
|
||||
input.replaceRoute(buildHostWorkspaceRoute(normalizedServerId, workspace.id));
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useOpenProject(serverId: string | null): (path: string) => Promise<boolean> {
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const toast = useToast();
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces);
|
||||
const setHasHydratedWorkspaces = useSessionStore((state) => state.setHasHydratedWorkspaces);
|
||||
|
||||
return useCallback(
|
||||
async (path: string) => {
|
||||
const trimmedPath = path.trim();
|
||||
if (!trimmedPath || !client || !normalizedServerId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await client.openProject(trimmedPath);
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error || "Failed to open project");
|
||||
}
|
||||
|
||||
mergeWorkspaces(normalizedServerId, [normalizeWorkspaceDescriptor(payload.workspace)]);
|
||||
setHasHydratedWorkspaces(normalizedServerId, true);
|
||||
router.replace(
|
||||
prepareWorkspaceTab({
|
||||
serverId: normalizedServerId,
|
||||
workspaceId: payload.workspace.id,
|
||||
target: { kind: "draft", draftId: "new" },
|
||||
}) as any,
|
||||
);
|
||||
return true;
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to open project");
|
||||
return false;
|
||||
}
|
||||
return openProjectDirectly({
|
||||
serverId: normalizedServerId,
|
||||
projectPath: path,
|
||||
isConnected,
|
||||
client,
|
||||
mergeWorkspaces,
|
||||
setHasHydratedWorkspaces,
|
||||
openLauncherTab: useWorkspaceLayoutStore.getState().openLauncherTab,
|
||||
replaceRoute: (route) => {
|
||||
router.replace(route as any);
|
||||
},
|
||||
});
|
||||
},
|
||||
[client, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces, toast],
|
||||
[client, isConnected, mergeWorkspaces, normalizedServerId, setHasHydratedWorkspaces],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ function workspace(
|
||||
projectDisplayName: input.projectDisplayName ?? input.projectId,
|
||||
projectRootPath: input.projectRootPath ?? input.id,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "local_checkout",
|
||||
workspaceKind: input.workspaceKind ?? "checkout",
|
||||
name: input.name,
|
||||
status: input.status,
|
||||
activityAt: input.activityAt,
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface SidebarWorkspaceEntry {
|
||||
workspaceKey: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
name: string;
|
||||
activityAt: Date | null;
|
||||
@@ -130,6 +131,7 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
workspaceKey: `${input.serverId}:${workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: workspace.id,
|
||||
projectKind: workspace.projectKind,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
activityAt: workspace.activityAt,
|
||||
@@ -248,8 +250,8 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
|
||||
}
|
||||
|
||||
function toWorkspaceDescriptor(payload: {
|
||||
id: string;
|
||||
projectId: string;
|
||||
id: number;
|
||||
projectId: number;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
|
||||
@@ -204,7 +204,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
help: {
|
||||
id: "workspace-tab-new",
|
||||
section: "tabs-panes",
|
||||
label: "New agent tab",
|
||||
label: "New tab",
|
||||
keys: ["mod", "T"],
|
||||
},
|
||||
},
|
||||
@@ -216,7 +216,7 @@ const SHORTCUT_BINDINGS: readonly ShortcutBinding[] = [
|
||||
help: {
|
||||
id: "workspace-tab-new",
|
||||
section: "tabs-panes",
|
||||
label: "New agent tab",
|
||||
label: "New tab",
|
||||
keys: ["mod", "T"],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -4,16 +4,14 @@ import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { Bot } from "lucide-react-native";
|
||||
import invariant from "tiny-invariant";
|
||||
import { AgentStreamView, type AgentStreamViewHandle } from "@/components/agent-stream-view";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { ArchivedAgentCallout } from "@/components/archived-agent-callout";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { ToastViewport, useToastHost } from "@/components/toast-host";
|
||||
import { ClaudeIcon } from "@/components/icons/claude-icon";
|
||||
import { CodexIcon } from "@/components/icons/codex-icon";
|
||||
import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear";
|
||||
import { useAgentInitialization } from "@/hooks/use-agent-initialization";
|
||||
import {
|
||||
@@ -28,6 +26,7 @@ import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
|
||||
import { TerminalAgentPanel } from "@/panels/terminal-agent-panel";
|
||||
import {
|
||||
useHostRuntimeClient,
|
||||
useHostRuntimeConnectionStatus,
|
||||
@@ -96,7 +95,7 @@ function useAgentPanelDescriptor(
|
||||
);
|
||||
const provider = descriptorState.provider;
|
||||
const label = resolveWorkspaceAgentTabLabel(descriptorState.title);
|
||||
const icon = provider === "claude" ? ClaudeIcon : provider === "codex" ? CodexIcon : Bot;
|
||||
const icon = getProviderIcon(provider);
|
||||
|
||||
return {
|
||||
label: label ?? "",
|
||||
@@ -155,6 +154,12 @@ function isNotFoundErrorMessage(message: string): boolean {
|
||||
return /agent not found|not found/i.test(message);
|
||||
}
|
||||
|
||||
type AgentLookupState =
|
||||
| { tag: "idle" }
|
||||
| { tag: "loading" }
|
||||
| { tag: "not_found"; message: string }
|
||||
| { tag: "error"; message: string };
|
||||
|
||||
function AgentPanelContent({
|
||||
serverId,
|
||||
agentId,
|
||||
@@ -227,6 +232,219 @@ function AgentPanelBody({
|
||||
isConnected: boolean;
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions);
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
agentId ? (state.sessions[serverId]?.agents?.get(agentId)?.projectPlacement ?? null) : null,
|
||||
(a, b) => a === b || JSON.stringify(a) === JSON.stringify(b),
|
||||
);
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const agent = agentId ? state.sessions[serverId]?.agents?.get(agentId) ?? null : null;
|
||||
return {
|
||||
serverId: agent?.serverId ?? null,
|
||||
id: agent?.id ?? null,
|
||||
terminal: agent?.terminal ?? false,
|
||||
status: agent?.status ?? null,
|
||||
cwd: agent?.cwd ?? null,
|
||||
lastError: agent?.lastError ?? null,
|
||||
terminalExit: agent?.terminalExit ?? null,
|
||||
archivedAt: agent?.archivedAt ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const [lookupState, setLookupState] = useState<AgentLookupState>({ tag: "idle" });
|
||||
const lookupAttemptTokenRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
lookupAttemptTokenRef.current += 1;
|
||||
setLookupState({ tag: "idle" });
|
||||
}, [agentId, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (agentState.id) {
|
||||
if (lookupState.tag !== "idle") {
|
||||
setLookupState({ tag: "idle" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isConnected || !hasSession) {
|
||||
return;
|
||||
}
|
||||
if (lookupState.tag === "loading" || lookupState.tag === "not_found") {
|
||||
return;
|
||||
}
|
||||
|
||||
setLookupState({ tag: "loading" });
|
||||
const attemptToken = ++lookupAttemptTokenRef.current;
|
||||
|
||||
client
|
||||
.fetchAgent(agentId)
|
||||
.then((result) => {
|
||||
if (attemptToken !== lookupAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!result) {
|
||||
setLookupState({
|
||||
tag: "not_found",
|
||||
message: `Agent not found: ${agentId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeAgentSnapshot(result.agent, serverId);
|
||||
const hydrated = {
|
||||
...normalized,
|
||||
projectPlacement: result.project,
|
||||
};
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(hydrated.id, hydrated);
|
||||
return next;
|
||||
});
|
||||
setPendingPermissions(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
for (const [key, pending] of next.entries()) {
|
||||
if (pending.agentId === hydrated.id) {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
for (const request of hydrated.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(hydrated.id, request);
|
||||
next.set(key, { key, agentId: hydrated.id, request });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLookupState({ tag: "idle" });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (attemptToken !== lookupAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
const message = toErrorMessage(error);
|
||||
if (isNotFoundErrorMessage(message)) {
|
||||
setLookupState({ tag: "not_found", message });
|
||||
return;
|
||||
}
|
||||
setLookupState({ tag: "error", message });
|
||||
});
|
||||
}, [
|
||||
agentId,
|
||||
agentState.id,
|
||||
client,
|
||||
hasSession,
|
||||
isConnected,
|
||||
lookupState.tag,
|
||||
serverId,
|
||||
setAgents,
|
||||
setPendingPermissions,
|
||||
]);
|
||||
|
||||
if (lookupState.tag === "not_found") {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-not-found">
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Agent not found</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (lookupState.tag === "error") {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-load-error">
|
||||
<View style={styles.errorContainer}>
|
||||
<Text style={styles.errorText}>Failed to load agent</Text>
|
||||
<Text style={styles.statusText}>{lookupState.message}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const agent: AgentScreenAgent | null =
|
||||
agentState.serverId && agentState.id && agentState.status && agentState.cwd
|
||||
? {
|
||||
serverId: agentState.serverId,
|
||||
id: agentState.id,
|
||||
status: agentState.status,
|
||||
cwd: agentState.cwd,
|
||||
lastError: agentState.lastError ?? null,
|
||||
terminalExit: agentState.terminalExit ?? null,
|
||||
projectPlacement,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!agent) {
|
||||
return (
|
||||
<View style={styles.container} testID="agent-loading">
|
||||
<View style={styles.errorContainer}>
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const isArchivingCurrentAgent = Boolean(agentId && isArchivingAgent({ serverId, agentId }));
|
||||
|
||||
if (agentState.terminal) {
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<TerminalAgentPanel
|
||||
serverId={serverId}
|
||||
client={client}
|
||||
agent={agent}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
|
||||
{isArchivingCurrentAgent ? (
|
||||
<View style={styles.archivingOverlay} testID="agent-archiving-overlay">
|
||||
<ActivityIndicator size="large" color={theme.colors.foreground} />
|
||||
<Text style={styles.archivingTitle}>Archiving agent...</Text>
|
||||
<Text style={styles.archivingSubtitle}>Please wait while we archive this agent.</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatAgentContent
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
client={client}
|
||||
isConnected={isConnected}
|
||||
connectionStatus={connectionStatus}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatAgentContent({
|
||||
serverId,
|
||||
agentId,
|
||||
isPaneFocused,
|
||||
client,
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
isPaneFocused: boolean;
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
isConnected: boolean;
|
||||
connectionStatus: HostRuntimeConnectionStatus;
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const panelToast = useToastHost();
|
||||
@@ -241,12 +459,12 @@ function AgentPanelBody({
|
||||
routeKey: string;
|
||||
reason: "initial-entry" | "resume";
|
||||
} | null>(null);
|
||||
const agentInputDraft = useAgentInputDraft(
|
||||
buildDraftStoreKey({
|
||||
const agentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
agentId: agentId ?? "__pending__",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||
addImagesRef.current?.(files);
|
||||
@@ -262,8 +480,11 @@ function AgentPanelBody({
|
||||
return {
|
||||
serverId: agent?.serverId ?? null,
|
||||
id: agent?.id ?? null,
|
||||
terminal: agent?.terminal ?? false,
|
||||
status: agent?.status ?? null,
|
||||
cwd: agent?.cwd ?? null,
|
||||
lastError: agent?.lastError ?? null,
|
||||
terminalExit: agent?.terminalExit ?? null,
|
||||
archivedAt: agent?.archivedAt ?? null,
|
||||
requiresAttention: agent?.requiresAttention ?? false,
|
||||
attentionReason: agent?.attentionReason ?? null,
|
||||
@@ -494,6 +715,8 @@ function AgentPanelBody({
|
||||
id: agentState.id,
|
||||
status: agentState.status,
|
||||
cwd: agentState.cwd,
|
||||
lastError: agentState.lastError ?? null,
|
||||
terminalExit: agentState.terminalExit ?? null,
|
||||
projectPlacement,
|
||||
}
|
||||
: null;
|
||||
@@ -615,98 +838,6 @@ function AgentPanelBody({
|
||||
setMissingAgentState({ kind: "idle" });
|
||||
}, [agentId, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
if (agentState.id || shouldUseOptimisticStream) {
|
||||
if (missingAgentState.kind !== "idle") {
|
||||
setMissingAgentState({ kind: "idle" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!isConnected || !hasSession) {
|
||||
return;
|
||||
}
|
||||
if (missingAgentState.kind === "resolving" || missingAgentState.kind === "not_found") {
|
||||
return;
|
||||
}
|
||||
|
||||
setMissingAgentState({ kind: "resolving" });
|
||||
const attemptToken = ++initAttemptTokenRef.current;
|
||||
|
||||
ensureAgentIsInitialized(agentId)
|
||||
.then(async () => {
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
const currentAgent = useSessionStore.getState().sessions[serverId]?.agents.get(agentId);
|
||||
if (!currentAgent) {
|
||||
const result = await client.fetchAgent(agentId);
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!result) {
|
||||
setMissingAgentState({
|
||||
kind: "not_found",
|
||||
message: `Agent not found: ${agentId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeAgentSnapshot(result.agent, serverId);
|
||||
const hydrated = {
|
||||
...normalized,
|
||||
projectPlacement: result.project,
|
||||
};
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(hydrated.id, hydrated);
|
||||
return next;
|
||||
});
|
||||
setPendingPermissions(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
for (const [key, pending] of next.entries()) {
|
||||
if (pending.agentId === hydrated.id) {
|
||||
next.delete(key);
|
||||
}
|
||||
}
|
||||
for (const request of hydrated.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(hydrated.id, request);
|
||||
next.set(key, { key, agentId: hydrated.id, request });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
setMissingAgentState({ kind: "idle" });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
const message = toErrorMessage(error);
|
||||
if (isNotFoundErrorMessage(message)) {
|
||||
setMissingAgentState({ kind: "not_found", message });
|
||||
return;
|
||||
}
|
||||
setMissingAgentState({ kind: "error", message });
|
||||
});
|
||||
}, [
|
||||
agentState.id,
|
||||
agentId,
|
||||
client,
|
||||
ensureAgentIsInitialized,
|
||||
hasSession,
|
||||
isConnected,
|
||||
missingAgentState.kind,
|
||||
serverId,
|
||||
setAgents,
|
||||
setPendingPermissions,
|
||||
shouldUseOptimisticStream,
|
||||
]);
|
||||
|
||||
const isHistoryRefreshCatchingUp =
|
||||
viewState.tag === "ready" &&
|
||||
viewState.sync.status === "catching_up" &&
|
||||
@@ -781,7 +912,7 @@ function AgentPanelBody({
|
||||
</View>
|
||||
|
||||
{agentId && !isArchivingCurrentAgent && !agentState.archivedAt ? (
|
||||
<AgentInputArea
|
||||
<Composer
|
||||
agentId={agentId}
|
||||
serverId={serverId}
|
||||
isInputActive={isPaneFocused}
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
import { useCallback, useMemo, useState, type ComponentType } from "react";
|
||||
import { ActivityIndicator, Pressable, ScrollView, Text, View } from "react-native";
|
||||
import { Bot, ChevronDown, Plus, SquarePen, SquareTerminal } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { PanelRegistration } from "@/panels/panel-registry";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { useProviderRecency } from "@/stores/provider-recency-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
|
||||
const MAX_VISIBLE_PROVIDER_TILES = 4;
|
||||
|
||||
function useLauncherPanelDescriptor() {
|
||||
return {
|
||||
label: "New Tab",
|
||||
subtitle: "New Tab",
|
||||
titleState: "ready" as const,
|
||||
icon: Plus,
|
||||
statusBucket: null,
|
||||
};
|
||||
}
|
||||
|
||||
function LauncherPanel() {
|
||||
const { serverId, workspaceId, target, retargetCurrentTab, isPaneFocused } = usePaneContext();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { providers, recordUsage } = useProviderRecency();
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
invariant(target.kind === "launcher", "LauncherPanel requires launcher target");
|
||||
|
||||
const visibleProviders = useMemo(
|
||||
() => providers.slice(0, MAX_VISIBLE_PROVIDER_TILES),
|
||||
[providers],
|
||||
);
|
||||
const overflowProviders = useMemo(
|
||||
() => providers.slice(MAX_VISIBLE_PROVIDER_TILES),
|
||||
[providers],
|
||||
);
|
||||
|
||||
const launchTerminalAgent = useCallback(
|
||||
async (providerId: AgentProvider) => {
|
||||
if (!client || !isConnected) {
|
||||
setErrorMessage("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAction(providerId);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const agent = await client.createAgent({
|
||||
provider: providerId,
|
||||
cwd: workspaceId,
|
||||
terminal: true,
|
||||
});
|
||||
recordUsage(providerId);
|
||||
// Retarget first so the launcher converts in place before session reconciliation
|
||||
// can materialize the new agent as a separate tab.
|
||||
retargetCurrentTab({ kind: "agent", agentId: agent.id });
|
||||
setAgents(serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
next.set(agent.id, normalizeAgentSnapshot(agent, serverId));
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorMessage(toErrorMessage(error));
|
||||
} finally {
|
||||
setPendingAction((current) => (current === providerId ? null : current));
|
||||
}
|
||||
},
|
||||
[client, isConnected, recordUsage, retargetCurrentTab, serverId, setAgents, workspaceId],
|
||||
);
|
||||
|
||||
const openDraftTab = useCallback(() => {
|
||||
setErrorMessage(null);
|
||||
setPendingAction("draft");
|
||||
retargetCurrentTab({
|
||||
kind: "draft",
|
||||
draftId: generateDraftId(),
|
||||
});
|
||||
setPendingAction(null);
|
||||
}, [retargetCurrentTab]);
|
||||
|
||||
const openTerminalTab = useCallback(async () => {
|
||||
if (!client || !isConnected) {
|
||||
setErrorMessage("Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAction("terminal");
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const payload = await client.createTerminal(workspaceId);
|
||||
if (payload.error || !payload.terminal) {
|
||||
throw new Error(payload.error ?? "Failed to open terminal");
|
||||
}
|
||||
retargetCurrentTab({
|
||||
kind: "terminal",
|
||||
terminalId: payload.terminal.id,
|
||||
});
|
||||
} catch (error) {
|
||||
setErrorMessage(toErrorMessage(error));
|
||||
} finally {
|
||||
setPendingAction((current) => (current === "terminal" ? null : current));
|
||||
}
|
||||
}, [client, isConnected, retargetCurrentTab, workspaceId]);
|
||||
|
||||
const actionsDisabled = pendingAction !== null;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.content,
|
||||
!isPaneFocused ? styles.contentUnfocused : null,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View style={styles.inner}>
|
||||
<View style={styles.primaryRow}>
|
||||
<LauncherTile
|
||||
title="New Chat"
|
||||
Icon={SquarePen}
|
||||
accent
|
||||
disabled={actionsDisabled}
|
||||
pending={pendingAction === "draft"}
|
||||
onPress={openDraftTab}
|
||||
/>
|
||||
<LauncherTile
|
||||
title="Terminal"
|
||||
Icon={SquareTerminal}
|
||||
disabled={actionsDisabled}
|
||||
pending={pendingAction === "terminal"}
|
||||
onPress={() => {
|
||||
void openTerminalTab();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={styles.sectionLabel}>Terminal Agents</Text>
|
||||
|
||||
<View style={styles.providerGrid}>
|
||||
{visibleProviders.map((provider) => (
|
||||
<ProviderTile
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
disabled={actionsDisabled}
|
||||
pending={pendingAction === provider.id}
|
||||
onPress={() => {
|
||||
void launchTerminalAgent(provider.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{overflowProviders.length > 0 ? (
|
||||
<ViewAllProvidersTile
|
||||
providers={overflowProviders}
|
||||
disabled={actionsDisabled}
|
||||
pendingProviderId={pendingAction}
|
||||
onSelectProvider={(providerId) => {
|
||||
void launchTerminalAgent(providerId);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{errorMessage ? <Text style={styles.errorText}>{errorMessage}</Text> : null}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function LauncherTile({
|
||||
title,
|
||||
Icon,
|
||||
accent = false,
|
||||
disabled,
|
||||
pending,
|
||||
onPress,
|
||||
}: {
|
||||
title: string;
|
||||
Icon: ComponentType<{ size: number; color: string }>;
|
||||
accent?: boolean;
|
||||
disabled: boolean;
|
||||
pending: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const iconColor = accent ? theme.colors.accentForeground : theme.colors.foreground;
|
||||
const titleColor = accent ? theme.colors.accentForeground : theme.colors.foreground;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.primaryTile,
|
||||
accent ? styles.primaryTileAccent : null,
|
||||
(hovered || pressed) && !disabled
|
||||
? accent
|
||||
? styles.primaryTileAccentInteractive
|
||||
: styles.tileInteractive
|
||||
: null,
|
||||
disabled ? styles.tileDisabled : null,
|
||||
]}
|
||||
>
|
||||
<View style={[styles.primaryIconWrap, accent ? styles.primaryIconWrapAccent : null]}>
|
||||
{pending ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={accent ? theme.colors.accentForeground : theme.colors.foreground}
|
||||
/>
|
||||
) : (
|
||||
<Icon size={16} color={iconColor} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={[styles.primaryTileTitle, { color: titleColor }]}>{title}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderTile({
|
||||
provider,
|
||||
disabled,
|
||||
pending,
|
||||
onPress,
|
||||
}: {
|
||||
provider: { id: string; label: string; description: string };
|
||||
disabled: boolean;
|
||||
pending: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
const Icon = getProviderIcon(provider.id);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
disabled={disabled}
|
||||
onPress={onPress}
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.providerTile,
|
||||
(hovered || pressed) && !disabled ? styles.tileInteractive : null,
|
||||
disabled ? styles.tileDisabled : null,
|
||||
]}
|
||||
>
|
||||
<View style={styles.providerIconWrap}>
|
||||
{pending ? (
|
||||
<ActivityIndicator size="small" color={theme.colors.foreground} />
|
||||
) : (
|
||||
<Icon size={16} color={theme.colors.foreground} />
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.providerLabel}>{provider.label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewAllProvidersTile({
|
||||
providers,
|
||||
disabled,
|
||||
pendingProviderId,
|
||||
onSelectProvider,
|
||||
}: {
|
||||
providers: Array<{ id: string; label: string; description: string }>;
|
||||
disabled: boolean;
|
||||
pendingProviderId: string | null;
|
||||
onSelectProvider: (providerId: AgentProvider) => void;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger disabled={disabled} style={styles.providerTile}>
|
||||
{({ open }) => (
|
||||
<>
|
||||
<View style={styles.providerIconWrap}>
|
||||
<Bot size={16} color={theme.colors.foreground} />
|
||||
</View>
|
||||
<Text style={styles.providerLabel}>More</Text>
|
||||
<ChevronDown size={14} color={theme.colors.foregroundMuted} />
|
||||
{open ? <View style={styles.dropdownOutline} /> : null}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" width={260}>
|
||||
{providers.map((provider) => {
|
||||
const Icon = getProviderIcon(provider.id);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={provider.id}
|
||||
description={provider.description}
|
||||
onSelect={() => onSelectProvider(provider.id as AgentProvider)}
|
||||
leading={<Icon size={16} color={theme.colors.foregroundMuted} />}
|
||||
status={pendingProviderId === provider.id ? "pending" : "idle"}
|
||||
pendingLabel={`Launching ${provider.label}...`}
|
||||
>
|
||||
{provider.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export const launcherPanelRegistration: PanelRegistration<"launcher"> = {
|
||||
kind: "launcher",
|
||||
component: LauncherPanel,
|
||||
useDescriptor: useLauncherPanelDescriptor,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[8],
|
||||
},
|
||||
contentUnfocused: {
|
||||
opacity: 0.96,
|
||||
},
|
||||
inner: {
|
||||
width: "100%",
|
||||
maxWidth: 360,
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
primaryRow: {
|
||||
flexDirection: "row",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
tileInteractive: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
tileDisabled: {
|
||||
opacity: theme.opacity[50],
|
||||
},
|
||||
primaryTile: {
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
primaryTileAccent: {
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
primaryTileAccentInteractive: {
|
||||
backgroundColor: theme.colors.accentBright,
|
||||
borderColor: theme.colors.accentBright,
|
||||
},
|
||||
primaryIconWrap: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
primaryIconWrapAccent: {
|
||||
backgroundColor: "rgba(255,255,255,0.14)",
|
||||
},
|
||||
primaryTileTitle: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
},
|
||||
sectionLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
providerGrid: {
|
||||
flexDirection: "row",
|
||||
flexWrap: "wrap",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
providerTile: {
|
||||
position: "relative",
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.borderAccent,
|
||||
backgroundColor: theme.colors.surface1,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[3],
|
||||
},
|
||||
providerIconWrap: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: theme.borderRadius.md,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
providerLabel: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontWeight: theme.fontWeight.medium,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
dropdownOutline: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: theme.colors.accent,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.destructive,
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,7 @@
|
||||
import { agentPanelRegistration } from "@/panels/agent-panel";
|
||||
import { draftPanelRegistration } from "@/panels/draft-panel";
|
||||
import { filePanelRegistration } from "@/panels/file-panel";
|
||||
import { launcherPanelRegistration } from "@/panels/launcher-panel";
|
||||
import { registerPanel } from "@/panels/panel-registry";
|
||||
import { terminalPanelRegistration } from "@/panels/terminal-panel";
|
||||
|
||||
@@ -14,5 +15,6 @@ export function ensurePanelsRegistered(): void {
|
||||
registerPanel(agentPanelRegistration);
|
||||
registerPanel(terminalPanelRegistration);
|
||||
registerPanel(filePanelRegistration);
|
||||
registerPanel(launcherPanelRegistration);
|
||||
panelsRegistered = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ActivityIndicator, Text, View } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { TerminalPane } from "@/components/terminal-pane";
|
||||
import { Fonts } from "@/constants/theme";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { usePaneContext } from "@/panels/pane-context";
|
||||
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
|
||||
import {
|
||||
buildTerminalAgentReopenKey,
|
||||
useTerminalAgentReopenStore,
|
||||
} from "@/stores/terminal-agent-reopen-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
|
||||
type TerminalAgentPanelProps = {
|
||||
serverId: string;
|
||||
client: DaemonClient;
|
||||
agent: AgentScreenAgent;
|
||||
isPaneFocused: boolean;
|
||||
};
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function getTerminalExitTitle(agent: AgentScreenAgent): string {
|
||||
const exitCode = agent.terminalExit?.exitCode;
|
||||
const signal = agent.terminalExit?.signal;
|
||||
if (
|
||||
agent.status === "error" ||
|
||||
(exitCode != null && exitCode !== 0) ||
|
||||
signal != null
|
||||
) {
|
||||
return "Terminal session failed";
|
||||
}
|
||||
return "Terminal session ended";
|
||||
}
|
||||
|
||||
function getTerminalExitMessage(agent: AgentScreenAgent): string {
|
||||
const summary = agent.terminalExit?.message?.trim();
|
||||
if (summary) {
|
||||
return summary;
|
||||
}
|
||||
const lastError = agent.lastError?.trim();
|
||||
if (lastError) {
|
||||
return lastError;
|
||||
}
|
||||
return "Reopen the agent from the sessions list to start it again.";
|
||||
}
|
||||
|
||||
function isCleanTerminalExit(agent: AgentScreenAgent): boolean {
|
||||
return agent.status === "closed" && agent.terminalExit?.exitCode === 0 && agent.terminalExit.signal == null;
|
||||
}
|
||||
|
||||
export function TerminalAgentPanel({
|
||||
serverId,
|
||||
client,
|
||||
agent,
|
||||
isPaneFocused,
|
||||
}: TerminalAgentPanelProps) {
|
||||
const isScreenFocused = useIsFocused();
|
||||
const { theme } = useUnistyles();
|
||||
const { tabId, workspaceId } = usePaneContext();
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||
const [terminalId, setTerminalId] = useState<string | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [didExitInPanel, setDidExitInPanel] = useState(false);
|
||||
const reopenKey = buildTerminalAgentReopenKey({ serverId, agentId: agent.id });
|
||||
const reopenIntentVersion = useTerminalAgentReopenStore((state) =>
|
||||
reopenKey ? (state.reopenIntentVersionByAgentKey[reopenKey] ?? 0) : 0,
|
||||
);
|
||||
|
||||
// Refs for effect guards — these values gate whether the creation effect
|
||||
// should run, but changes to them should NOT re-trigger the effect.
|
||||
const isCreatingRef = useRef(false);
|
||||
const didExitRef = useRef(false);
|
||||
const lastHandledReopenIntentRef = useRef(reopenIntentVersion);
|
||||
const isAutoClosingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTerminalId(null);
|
||||
setIsCreating(false);
|
||||
setCreateError(null);
|
||||
setDidExitInPanel(false);
|
||||
isCreatingRef.current = false;
|
||||
didExitRef.current = false;
|
||||
}, [agent.id, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (reopenIntentVersion <= lastHandledReopenIntentRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastHandledReopenIntentRef.current = reopenIntentVersion;
|
||||
if (!didExitRef.current && !didExitInPanel && !createError) {
|
||||
return;
|
||||
}
|
||||
|
||||
didExitRef.current = false;
|
||||
setDidExitInPanel(false);
|
||||
setCreateError(null);
|
||||
}, [createError, didExitInPanel, reopenIntentVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalId) {
|
||||
return;
|
||||
}
|
||||
return client.on("terminal_stream_exit", (message) => {
|
||||
if (message.type !== "terminal_stream_exit" || message.payload.terminalId !== terminalId) {
|
||||
return;
|
||||
}
|
||||
setTerminalId((current) => (current === message.payload.terminalId ? null : current));
|
||||
setDidExitInPanel(true);
|
||||
didExitRef.current = true;
|
||||
});
|
||||
}, [client, terminalId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCleanTerminalExit(agent) || isAutoClosingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
if (!workspaceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAutoClosingRef.current = true;
|
||||
void archiveAgent({ serverId, agentId: agent.id })
|
||||
.then(() => {
|
||||
unpinWorkspaceAgent(workspaceKey, agent.id);
|
||||
closeWorkspaceTab(workspaceKey, tabId);
|
||||
})
|
||||
.finally(() => {
|
||||
isAutoClosingRef.current = false;
|
||||
});
|
||||
}, [
|
||||
agent,
|
||||
archiveAgent,
|
||||
closeWorkspaceTab,
|
||||
serverId,
|
||||
tabId,
|
||||
unpinWorkspaceAgent,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
// Create the terminal when the panel becomes visible and no terminal exists yet.
|
||||
// Guards (isCreatingRef, didExitRef) are refs to avoid re-triggering the effect
|
||||
// when their values change — we only want this to fire on genuine state transitions
|
||||
// (focus change, terminal cleared, agent change).
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isScreenFocused ||
|
||||
!isPaneFocused ||
|
||||
terminalId ||
|
||||
isCreatingRef.current ||
|
||||
didExitRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
isCreatingRef.current = true;
|
||||
setIsCreating(true);
|
||||
setCreateError(null);
|
||||
|
||||
void client
|
||||
.createTerminal(agent.cwd, undefined, undefined, { agentId: agent.id })
|
||||
.then((payload) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (payload.error || !payload.terminal) {
|
||||
setCreateError(payload.error ?? "Failed to open terminal");
|
||||
return;
|
||||
}
|
||||
setTerminalId(payload.terminal.id);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setCreateError(toErrorMessage(error));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
isCreatingRef.current = false;
|
||||
setIsCreating(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agent.cwd, agent.id, client, isPaneFocused, isScreenFocused, terminalId]);
|
||||
|
||||
if (!isScreenFocused) {
|
||||
return <View style={styles.container} />;
|
||||
}
|
||||
|
||||
if (terminalId) {
|
||||
return (
|
||||
<TerminalPane
|
||||
serverId={serverId}
|
||||
cwd={agent.cwd}
|
||||
terminalId={terminalId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCreating) {
|
||||
return (
|
||||
<View style={styles.state} testID="terminal-agent-loading">
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
<Text style={styles.message}>Opening terminal…</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (createError) {
|
||||
return (
|
||||
<View style={styles.state} testID="terminal-agent-error">
|
||||
<Text style={styles.title}>Failed to open terminal</Text>
|
||||
<Text style={styles.message}>{createError}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (didExitInPanel || agent.status === "closed" || agent.status === "error") {
|
||||
const terminalExit = agent.terminalExit ?? null;
|
||||
const exitMeta =
|
||||
terminalExit?.exitCode != null
|
||||
? `Exit code ${terminalExit.exitCode}`
|
||||
: terminalExit?.signal != null
|
||||
? `Signal ${terminalExit.signal}`
|
||||
: null;
|
||||
return (
|
||||
<View style={styles.state} testID="terminal-agent-closed">
|
||||
<Text style={styles.title}>{getTerminalExitTitle(agent)}</Text>
|
||||
<Text style={styles.message}>{getTerminalExitMessage(agent)}</Text>
|
||||
{terminalExit ? (
|
||||
<View style={styles.detailsCard}>
|
||||
{exitMeta ? <Text style={styles.detailsLabel}>{exitMeta}</Text> : null}
|
||||
{terminalExit.outputLines.length > 0 ? (
|
||||
<Text style={styles.output}>{terminalExit.outputLines.join("\n")}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
) : null}
|
||||
<Text style={styles.message}>Reopen the agent from the sessions list to start it again.</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.state} testID="terminal-agent-idle">
|
||||
<ActivityIndicator size="large" color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
state: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[6],
|
||||
backgroundColor: theme.colors.surface0,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.fontSize.lg,
|
||||
color: theme.colors.foreground,
|
||||
textAlign: "center",
|
||||
},
|
||||
message: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textAlign: "center",
|
||||
},
|
||||
detailsCard: {
|
||||
width: "100%",
|
||||
maxWidth: 560,
|
||||
padding: theme.spacing[4],
|
||||
gap: theme.spacing[2],
|
||||
borderRadius: theme.spacing[3],
|
||||
backgroundColor: theme.colors.surface1,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
detailsLabel: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
color: theme.colors.foregroundMuted,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
output: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
fontFamily: Fonts.mono,
|
||||
lineHeight: 20,
|
||||
},
|
||||
}));
|
||||
@@ -39,7 +39,7 @@ function useTerminalPanelDescriptor(
|
||||
terminalsQuery.data?.terminals.find((entry) => entry.id === target.terminalId) ?? null;
|
||||
|
||||
return {
|
||||
label: trimNonEmpty(terminal?.name ?? null) ?? "Terminal",
|
||||
label: trimNonEmpty(terminal?.title ?? terminal?.name ?? null) ?? "Terminal",
|
||||
subtitle: "Terminal",
|
||||
titleState: "ready",
|
||||
icon: Terminal,
|
||||
|
||||
@@ -133,6 +133,7 @@ function makeFetchAgentsEntry(input: {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
|
||||
@@ -11,15 +11,14 @@ import Animated from "react-native-reanimated";
|
||||
import { Folder, GitBranch, PanelRight } from "lucide-react-native";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import { FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
|
||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAgentFormState, type CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query";
|
||||
import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state";
|
||||
import {
|
||||
CHECKOUT_STATUS_STALE_TIME,
|
||||
checkoutStatusQueryKey,
|
||||
@@ -65,6 +64,7 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsTerminalMode: false,
|
||||
};
|
||||
const PROVIDER_DEFINITION_MAP = new Map(
|
||||
AGENT_PROVIDER_DEFINITIONS.map((definition) => [definition.id, definition]),
|
||||
@@ -202,37 +202,45 @@ function DraftAgentScreenContent({
|
||||
return values;
|
||||
}, [resolvedMode, resolvedModel, resolvedProvider, resolvedThinkingOptionId, resolvedWorkingDir]);
|
||||
|
||||
const draftIdRef = useRef(generateDraftId());
|
||||
const draftAgentIdRef = useRef(generateDraftId());
|
||||
const draftInput = useAgentInputDraft(
|
||||
{
|
||||
draftKey: ({ selectedServerId }) =>
|
||||
buildDraftStoreKey({
|
||||
serverId: selectedServerId ?? "",
|
||||
agentId: draftAgentIdRef.current,
|
||||
draftId: draftIdRef.current,
|
||||
}),
|
||||
composer: {
|
||||
initialServerId: resolvedServerId ?? null,
|
||||
initialValues,
|
||||
isVisible,
|
||||
onlineServerIds,
|
||||
},
|
||||
},
|
||||
);
|
||||
const composerState = draftInput.composerState;
|
||||
if (!composerState) {
|
||||
throw new Error("Draft agent composer state is required");
|
||||
}
|
||||
|
||||
const {
|
||||
selectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
selectedProvider,
|
||||
setProviderFromUser,
|
||||
selectedMode,
|
||||
setModeFromUser,
|
||||
selectedModel,
|
||||
setModelFromUser,
|
||||
selectedThinkingOptionId,
|
||||
setThinkingOptionFromUser,
|
||||
providerDefinitions,
|
||||
workingDir,
|
||||
setWorkingDirFromUser,
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
setProviderAndModelFromUser,
|
||||
persistFormPreferences,
|
||||
} = useAgentFormState({
|
||||
initialServerId: resolvedServerId ?? null,
|
||||
initialValues,
|
||||
isVisible,
|
||||
isCreateFlow: true,
|
||||
onlineServerIds,
|
||||
});
|
||||
effectiveModelId,
|
||||
effectiveThinkingOptionId,
|
||||
commandDraftConfig,
|
||||
statusControls,
|
||||
} = composerState;
|
||||
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const mobileView = usePanelStore((state) => state.mobileView);
|
||||
const desktopFileExplorerOpen = usePanelStore((state) => state.desktop.fileExplorerOpen);
|
||||
@@ -245,15 +253,6 @@ function DraftAgentScreenContent({
|
||||
);
|
||||
const dragHandlers = useDesktopDragHandlers();
|
||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
const draftIdRef = useRef(generateDraftId());
|
||||
const draftAgentIdRef = useRef(generateDraftId());
|
||||
const draftInput = useAgentInputDraft(
|
||||
buildDraftStoreKey({
|
||||
serverId: selectedServerId ?? "",
|
||||
agentId: draftAgentIdRef.current,
|
||||
draftId: draftIdRef.current,
|
||||
}),
|
||||
);
|
||||
|
||||
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">(
|
||||
initialWorktreeMode,
|
||||
@@ -744,47 +743,6 @@ function DraftAgentScreenContent({
|
||||
}, [baseBranch, branchSearchQuery, branchSuggestionsQuery.data, checkout, worktreeOptions]);
|
||||
|
||||
const createAgentClient = sessionClient;
|
||||
const effectiveDraftModelId = useMemo(() => {
|
||||
if (selectedModel.trim()) {
|
||||
return selectedModel.trim();
|
||||
}
|
||||
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
|
||||
}, [availableModels, selectedModel]);
|
||||
const effectiveDraftThinkingOptionId = useMemo(() => {
|
||||
if (selectedThinkingOptionId.trim()) {
|
||||
return selectedThinkingOptionId.trim();
|
||||
}
|
||||
const selectedModelDefinition =
|
||||
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||
const draftCommandConfig = useMemo<DraftCommandConfig | undefined>(() => {
|
||||
const cwd = (
|
||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir
|
||||
).trim();
|
||||
if (!cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: selectedProvider,
|
||||
cwd,
|
||||
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
}, [
|
||||
effectiveDraftModelId,
|
||||
effectiveDraftThinkingOptionId,
|
||||
isAttachWorktree,
|
||||
modeOptions.length,
|
||||
selectedMode,
|
||||
selectedProvider,
|
||||
selectedWorktreePath,
|
||||
workingDir,
|
||||
]);
|
||||
|
||||
const {
|
||||
formErrorMessage,
|
||||
@@ -818,7 +776,7 @@ function DraftAgentScreenContent({
|
||||
if (isModelLoading) {
|
||||
return "Model defaults are still loading";
|
||||
}
|
||||
if (!effectiveDraftModelId) {
|
||||
if (!effectiveModelId) {
|
||||
return "No model is available for the selected provider";
|
||||
}
|
||||
if (isAttachWorktree && !selectedWorktreePath) {
|
||||
@@ -851,15 +809,19 @@ function DraftAgentScreenContent({
|
||||
const cwd =
|
||||
(isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() ||
|
||||
".";
|
||||
const provider = selectedProvider;
|
||||
const model = effectiveDraftModelId || null;
|
||||
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||
const provider = composerState.selectedProvider;
|
||||
const model = effectiveModelId || null;
|
||||
const thinkingOptionId = effectiveThinkingOptionId || null;
|
||||
const modeId =
|
||||
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? composerState.selectedMode
|
||||
: null;
|
||||
|
||||
return {
|
||||
serverId,
|
||||
id: draftAgentIdRef.current,
|
||||
provider,
|
||||
terminal: false,
|
||||
status: "running",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -888,14 +850,17 @@ function DraftAgentScreenContent({
|
||||
const resolvedWorkingDir =
|
||||
isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : trimmedPath;
|
||||
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||
const modeId =
|
||||
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? composerState.selectedMode
|
||||
: undefined;
|
||||
const config: AgentSessionConfig = {
|
||||
provider: selectedProvider,
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: resolvedWorkingDir,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
...(effectiveModelId ? { model: effectiveModelId } : {}),
|
||||
...(effectiveThinkingOptionId
|
||||
? { thinkingOptionId: effectiveThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -1216,7 +1181,7 @@ function DraftAgentScreenContent({
|
||||
)}
|
||||
</Animated.View>
|
||||
<View style={styles.inputAreaWrapper}>
|
||||
<AgentInputArea
|
||||
<Composer
|
||||
agentId={draftAgentIdRef.current}
|
||||
serverId={selectedServerId ?? ""}
|
||||
isInputActive={isFocused}
|
||||
@@ -1230,24 +1195,9 @@ function DraftAgentScreenContent({
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={!isSubmitting}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
commandDraftConfig={draftCommandConfig}
|
||||
commandDraftConfig={commandDraftConfig}
|
||||
statusControls={{
|
||||
providerDefinitions,
|
||||
selectedProvider,
|
||||
onSelectProvider: setProviderFromUser,
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
onSelectMode: setModeFromUser,
|
||||
models: availableModels,
|
||||
selectedModel,
|
||||
onSelectModel: setModelFromUser,
|
||||
isModelLoading,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
onSelectProviderAndModel: setProviderAndModelFromUser,
|
||||
thinkingOptions: availableThinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
||||
...statusControls,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -19,6 +19,7 @@ function makeAgent(input: {
|
||||
serverId: "srv",
|
||||
id: input.id,
|
||||
provider: "codex",
|
||||
terminal: false,
|
||||
status: "idle",
|
||||
createdAt,
|
||||
updatedAt: createdAt,
|
||||
@@ -31,6 +32,7 @@ function makeAgent(input: {
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
supportsTerminalMode: false,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
} from "react-native";
|
||||
import { Columns2, Rows2, SquarePen, SquareTerminal, X } from "lucide-react-native";
|
||||
import { Columns2, Plus, Rows2, X } from "lucide-react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { SortableInlineList } from "@/components/sortable-inline-list";
|
||||
import {
|
||||
@@ -36,11 +36,6 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
|
||||
|
||||
const DROPDOWN_WIDTH = 220;
|
||||
const LOADING_TAB_LABEL_SKELETON_WIDTH = 80;
|
||||
type NewTabOptionId = "__new_tab_agent__" | "__new_tab_terminal__";
|
||||
type NewTabSelection = {
|
||||
optionId: NewTabOptionId;
|
||||
paneId?: string;
|
||||
};
|
||||
|
||||
export interface WorkspaceDesktopTabRowItem {
|
||||
tab: WorkspaceTabDescriptor;
|
||||
@@ -64,10 +59,8 @@ type WorkspaceDesktopTabsRowProps = {
|
||||
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
|
||||
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
|
||||
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
|
||||
onSelectNewTabOption: (selection: NewTabSelection) => void;
|
||||
newTabAgentOptionId: NewTabOptionId;
|
||||
onCreateLauncherTab: (input: { paneId?: string }) => void;
|
||||
onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void;
|
||||
onNewTerminalTab: (input: { paneId?: string }) => void;
|
||||
onSplitRight: () => void;
|
||||
onSplitDown: () => void;
|
||||
externalDndContext?: boolean;
|
||||
@@ -76,6 +69,9 @@ type WorkspaceDesktopTabsRowProps = {
|
||||
};
|
||||
|
||||
function getFallbackTabLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "launcher") {
|
||||
return "New Tab";
|
||||
}
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
}
|
||||
@@ -298,10 +294,8 @@ export function WorkspaceDesktopTabsRow({
|
||||
onCloseTabsToLeft,
|
||||
onCloseTabsToRight,
|
||||
onCloseOtherTabs,
|
||||
onSelectNewTabOption,
|
||||
newTabAgentOptionId,
|
||||
onCreateLauncherTab,
|
||||
onReorderTabs,
|
||||
onNewTerminalTab,
|
||||
onSplitRight,
|
||||
onSplitDown,
|
||||
externalDndContext = false,
|
||||
@@ -309,8 +303,7 @@ export function WorkspaceDesktopTabsRow({
|
||||
tabDropPreviewIndex = null,
|
||||
}: WorkspaceDesktopTabsRowProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const newAgentTabKeys = useShortcutKeys("workspace-tab-new");
|
||||
const newTerminalTabKeys = useShortcutKeys("workspace-terminal-new");
|
||||
const newTabKeys = useShortcutKeys("workspace-tab-new");
|
||||
const splitRightKeys = useShortcutKeys("workspace-pane-split-right");
|
||||
const splitDownKeys = useShortcutKeys("workspace-pane-split-down");
|
||||
const [tabsContainerWidth, setTabsContainerWidth] = useState<number>(0);
|
||||
@@ -437,48 +430,22 @@ export function WorkspaceDesktopTabsRow({
|
||||
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
testID="workspace-new-agent-tab"
|
||||
onPress={() =>
|
||||
onSelectNewTabOption({
|
||||
optionId: newTabAgentOptionId,
|
||||
paneId,
|
||||
})
|
||||
}
|
||||
testID="workspace-new-tab"
|
||||
onPress={() => onCreateLauncherTab({ paneId })}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New agent tab"
|
||||
accessibilityLabel="New tab"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||
{newAgentTabKeys ? (
|
||||
<Shortcut chord={newAgentTabKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger
|
||||
onPress={() => onNewTerminalTab({ paneId })}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="New terminal tab"
|
||||
style={({ hovered, pressed }) => [
|
||||
styles.newTabActionButton,
|
||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||
]}
|
||||
>
|
||||
<SquareTerminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" offset={8}>
|
||||
<View style={styles.newTabTooltipRow}>
|
||||
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||
{newTerminalTabKeys ? (
|
||||
<Shortcut chord={newTerminalTabKeys} style={styles.newTabTooltipShortcut} />
|
||||
<Text style={styles.newTabTooltipText}>New tab</Text>
|
||||
{newTabKeys ? (
|
||||
<Shortcut chord={newTabKeys} style={styles.newTabTooltipShortcut} />
|
||||
) : null}
|
||||
</View>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildWorkspaceDraftAgentConfig } from "./workspace-draft-agent-config";
|
||||
|
||||
describe("workspace-draft-agent-config", () => {
|
||||
it("builds chat-only config for workspace draft agents", () => {
|
||||
expect(
|
||||
buildWorkspaceDraftAgentConfig({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
}),
|
||||
).toEqual({
|
||||
provider: "codex",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
export function buildWorkspaceDraftAgentConfig(input: {
|
||||
provider: AgentSessionConfig["provider"];
|
||||
cwd: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
}): AgentSessionConfig {
|
||||
return {
|
||||
provider: input.provider,
|
||||
cwd: input.cwd,
|
||||
...(input.modeId ? { modeId: input.modeId } : {}),
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
@@ -1,22 +1,19 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { AgentInputArea } from "@/components/agent-input-area";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
import type { ImageAttachment } from "@/components/message-input";
|
||||
import { useAgentFormState } from "@/hooks/use-agent-form-state";
|
||||
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft";
|
||||
import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentSessionConfig,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
import type { AgentSnapshotPayload } from "@server/shared/messages";
|
||||
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map();
|
||||
@@ -27,6 +24,7 @@ const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
supportsTerminalMode: false,
|
||||
};
|
||||
|
||||
type WorkspaceDraftAgentTabProps = {
|
||||
@@ -51,65 +49,31 @@ export function WorkspaceDraftAgentTab({
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
const draftInput = useAgentInputDraft(
|
||||
buildDraftStoreKey({
|
||||
serverId,
|
||||
agentId: tabId,
|
||||
draftId,
|
||||
}),
|
||||
const draftStoreKey = useMemo(
|
||||
() =>
|
||||
buildDraftStoreKey({
|
||||
serverId,
|
||||
agentId: tabId,
|
||||
draftId,
|
||||
}),
|
||||
[draftId, serverId, tabId],
|
||||
);
|
||||
|
||||
const {
|
||||
selectedProvider,
|
||||
setProviderFromUser,
|
||||
selectedMode,
|
||||
setModeFromUser,
|
||||
selectedModel,
|
||||
setModelFromUser,
|
||||
selectedThinkingOptionId,
|
||||
setThinkingOptionFromUser,
|
||||
workingDir,
|
||||
setWorkingDir,
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
setProviderAndModelFromUser,
|
||||
persistFormPreferences,
|
||||
} = useAgentFormState({
|
||||
initialServerId: serverId,
|
||||
initialValues: { workingDir: workspaceId },
|
||||
isVisible: true,
|
||||
isCreateFlow: true,
|
||||
onlineServerIds: isConnected ? [serverId] : [],
|
||||
});
|
||||
|
||||
// Lock working directory to workspace.
|
||||
useEffect(() => {
|
||||
if (workingDir.trim() === workspaceId.trim()) {
|
||||
return;
|
||||
}
|
||||
setWorkingDir(workspaceId);
|
||||
}, [setWorkingDir, workingDir, workspaceId]);
|
||||
|
||||
const effectiveDraftModelId = useMemo(() => {
|
||||
if (selectedModel.trim()) {
|
||||
return selectedModel.trim();
|
||||
}
|
||||
return availableModels.find((model) => model.isDefault)?.id ?? availableModels[0]?.id ?? "";
|
||||
}, [availableModels, selectedModel]);
|
||||
|
||||
const effectiveDraftThinkingOptionId = useMemo(() => {
|
||||
if (selectedThinkingOptionId.trim()) {
|
||||
return selectedThinkingOptionId.trim();
|
||||
}
|
||||
const selectedModelDefinition =
|
||||
availableModels.find((model) => model.id === effectiveDraftModelId) ?? null;
|
||||
return selectedModelDefinition?.defaultThinkingOptionId ?? "";
|
||||
}, [availableModels, effectiveDraftModelId, selectedThinkingOptionId]);
|
||||
const draftInput = useAgentInputDraft(
|
||||
{
|
||||
draftKey: draftStoreKey,
|
||||
composer: {
|
||||
initialServerId: serverId,
|
||||
initialValues: { workingDir: workspaceId },
|
||||
isVisible: true,
|
||||
onlineServerIds: isConnected ? [serverId] : [],
|
||||
lockedWorkingDir: workspaceId,
|
||||
},
|
||||
},
|
||||
);
|
||||
const composerState = draftInput.composerState;
|
||||
if (!composerState) {
|
||||
throw new Error("Workspace draft composer state is required");
|
||||
}
|
||||
|
||||
const {
|
||||
formErrorMessage,
|
||||
@@ -124,13 +88,13 @@ export function WorkspaceDraftAgentTab({
|
||||
if (!text.trim()) {
|
||||
return "Initial prompt is required";
|
||||
}
|
||||
if (providerDefinitions.length === 0) {
|
||||
if (composerState.providerDefinitions.length === 0) {
|
||||
return "No available providers on the selected host";
|
||||
}
|
||||
if (isModelLoading) {
|
||||
if (composerState.isModelLoading) {
|
||||
return "Model defaults are still loading";
|
||||
}
|
||||
if (!effectiveDraftModelId) {
|
||||
if (!composerState.effectiveModelId) {
|
||||
return "No model is available for the selected provider";
|
||||
}
|
||||
if (!client) {
|
||||
@@ -139,7 +103,7 @@ export function WorkspaceDraftAgentTab({
|
||||
return null;
|
||||
},
|
||||
onBeforeSubmit: () => {
|
||||
void persistFormPreferences();
|
||||
void composerState.persistFormPreferences();
|
||||
if (Platform.OS === "web") {
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
}
|
||||
@@ -147,13 +111,17 @@ export function WorkspaceDraftAgentTab({
|
||||
},
|
||||
buildDraftAgent: (attempt) => {
|
||||
const now = attempt.timestamp;
|
||||
const model = effectiveDraftModelId || null;
|
||||
const thinkingOptionId = effectiveDraftThinkingOptionId || null;
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||
const model = composerState.effectiveModelId || null;
|
||||
const thinkingOptionId = composerState.effectiveThinkingOptionId || null;
|
||||
const modeId =
|
||||
composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? composerState.selectedMode
|
||||
: null;
|
||||
return {
|
||||
serverId,
|
||||
id: tabId,
|
||||
provider: selectedProvider,
|
||||
provider: composerState.selectedProvider,
|
||||
terminal: false,
|
||||
status: "running",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
@@ -164,7 +132,7 @@ export function WorkspaceDraftAgentTab({
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: { provider: selectedProvider, sessionId: null, model, modeId },
|
||||
runtimeInfo: { provider: composerState.selectedProvider, sessionId: null, model, modeId },
|
||||
title: "Agent",
|
||||
cwd: workspaceId,
|
||||
model,
|
||||
@@ -177,21 +145,20 @@ export function WorkspaceDraftAgentTab({
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||
const config: AgentSessionConfig = {
|
||||
provider: selectedProvider,
|
||||
const config = buildWorkspaceDraftAgentConfig({
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: workspaceId,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? { modeId: composerState.selectedMode }
|
||||
: {}),
|
||||
};
|
||||
model: composerState.effectiveModelId || undefined,
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
||||
});
|
||||
|
||||
const imagesData = await encodeImages(images);
|
||||
const result = await client.createAgent({
|
||||
config,
|
||||
initialPrompt: text,
|
||||
...(text ? { initialPrompt: text } : {}),
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
|
||||
});
|
||||
@@ -206,25 +173,6 @@ export function WorkspaceDraftAgentTab({
|
||||
},
|
||||
});
|
||||
|
||||
const draftCommandConfig = useMemo(() => {
|
||||
return {
|
||||
provider: selectedProvider,
|
||||
cwd: workspaceId,
|
||||
...(modeOptions.length > 0 && selectedMode !== "" ? { modeId: selectedMode } : {}),
|
||||
...(effectiveDraftModelId ? { model: effectiveDraftModelId } : {}),
|
||||
...(effectiveDraftThinkingOptionId
|
||||
? { thinkingOptionId: effectiveDraftThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
}, [
|
||||
effectiveDraftModelId,
|
||||
effectiveDraftThinkingOptionId,
|
||||
modeOptions.length,
|
||||
selectedMode,
|
||||
selectedProvider,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
const handleFilesDropped = useCallback((files: ImageAttachment[]) => {
|
||||
addImagesRef.current?.(files);
|
||||
}, []);
|
||||
@@ -265,11 +213,12 @@ export function WorkspaceDraftAgentTab({
|
||||
</View>
|
||||
|
||||
<View style={styles.inputAreaWrapper}>
|
||||
<AgentInputArea
|
||||
<Composer
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
isInputActive={isPaneFocused}
|
||||
onSubmitMessage={handleCreateFromInput}
|
||||
allowEmptySubmit={false}
|
||||
isSubmitLoading={isSubmitting}
|
||||
blurOnSubmit={true}
|
||||
value={draftInput.text}
|
||||
@@ -279,24 +228,9 @@ export function WorkspaceDraftAgentTab({
|
||||
clearDraft={draftInput.clear}
|
||||
autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })}
|
||||
onAddImages={handleAddImagesCallback}
|
||||
commandDraftConfig={draftCommandConfig}
|
||||
commandDraftConfig={composerState.commandDraftConfig}
|
||||
statusControls={{
|
||||
providerDefinitions,
|
||||
selectedProvider,
|
||||
onSelectProvider: setProviderFromUser,
|
||||
modeOptions,
|
||||
selectedMode,
|
||||
onSelectMode: setModeFromUser,
|
||||
models: availableModels,
|
||||
selectedModel,
|
||||
onSelectModel: setModelFromUser,
|
||||
isModelLoading,
|
||||
allProviderModels,
|
||||
isAllModelsLoading,
|
||||
onSelectProviderAndModel: setProviderAndModelFromUser,
|
||||
thinkingOptions: availableThinkingOptions,
|
||||
selectedThinkingOptionId,
|
||||
onSelectThinkingOption: setThinkingOptionFromUser,
|
||||
...composerState.statusControls,
|
||||
disabled: isSubmitting,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -36,7 +36,7 @@ export function buildWorkspacePaneContentModel({
|
||||
const registration = getPanelRegistration(tab.kind);
|
||||
invariant(registration, `No panel registration for kind: ${tab.kind}`);
|
||||
return {
|
||||
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}`,
|
||||
key: `${normalizedServerId}:${normalizedWorkspaceId}:${tab.tabId}:${tab.kind}`,
|
||||
Component: registration.component,
|
||||
paneContextValue: {
|
||||
serverId: normalizedServerId,
|
||||
|
||||
@@ -90,7 +90,6 @@ import {
|
||||
} from "@/screens/workspace/workspace-header-source";
|
||||
import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
shouldPruneWorkspaceAgentTab,
|
||||
workspaceAgentVisibilityEqual,
|
||||
} from "@/screens/workspace/workspace-agent-visibility";
|
||||
import { deriveWorkspacePaneState } from "@/screens/workspace/workspace-pane-state";
|
||||
@@ -107,11 +106,7 @@ import {
|
||||
import { findAdjacentPane } from "@/utils/split-navigation";
|
||||
|
||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||
const NEW_TAB_AGENT_OPTION_ID = "__new_tab_agent__";
|
||||
const NEW_TAB_TERMINAL_OPTION_ID = "__new_tab_terminal__";
|
||||
type NewTabOptionId = typeof NEW_TAB_AGENT_OPTION_ID | typeof NEW_TAB_TERMINAL_OPTION_ID;
|
||||
const EMPTY_UI_TABS: WorkspaceTab[] = [];
|
||||
const EMPTY_PINNED_AGENT_IDS = new Set<string>();
|
||||
const EMPTY_SET = new Set<string>();
|
||||
|
||||
type WorkspaceScreenProps = {
|
||||
@@ -136,6 +131,9 @@ function decodeSegment(value: string): string {
|
||||
}
|
||||
|
||||
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "launcher") {
|
||||
return "New Tab";
|
||||
}
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
}
|
||||
@@ -149,6 +147,9 @@ function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
||||
}
|
||||
|
||||
function getFallbackTabOptionDescription(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "launcher") {
|
||||
return "New Tab";
|
||||
}
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
}
|
||||
@@ -798,10 +799,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
[workspaceLayout],
|
||||
);
|
||||
const openWorkspaceTab = useWorkspaceLayoutStore((state) => state.openTab);
|
||||
const openWorkspaceLauncherTab = useWorkspaceLayoutStore((state) => state.openLauncherTab);
|
||||
const focusWorkspaceTab = useWorkspaceLayoutStore((state) => state.focusTab);
|
||||
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
|
||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||
const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab);
|
||||
const convertWorkspaceDraftToAgent = useWorkspaceLayoutStore((state) => state.convertDraftToAgent);
|
||||
const reconcileWorkspaceTabs = useWorkspaceLayoutStore((state) => state.reconcileTabs);
|
||||
const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent);
|
||||
const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane);
|
||||
const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty);
|
||||
const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane);
|
||||
@@ -809,11 +813,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const paneFocusSuppressedRef = useRef(false);
|
||||
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
|
||||
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
|
||||
const pinnedAgentIds = useWorkspaceLayoutStore((state) =>
|
||||
persistenceKey
|
||||
? (state.pinnedAgentIdsByWorkspace[persistenceKey] ?? EMPTY_PINNED_AGENT_IDS)
|
||||
: EMPTY_PINNED_AGENT_IDS,
|
||||
);
|
||||
const pendingByDraftId = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||
const { closingTabIds, closeTab } = useCloseTabs();
|
||||
const closeWorkspaceTabWithCleanup = useCallback(
|
||||
@@ -917,7 +916,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return;
|
||||
}
|
||||
|
||||
const terminalIds = new Set(terminals.map((terminal) => terminal.id));
|
||||
const hasActivePendingDraftCreateInWorkspace = uiTabs.some((tab) => {
|
||||
if (tab.target.kind !== "draft") {
|
||||
return false;
|
||||
@@ -926,57 +924,19 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return pending?.serverId === normalizedServerId && pending.lifecycle === "active";
|
||||
});
|
||||
|
||||
for (const agentId of workspaceAgentVisibility.activeAgentIds) {
|
||||
const representedByTarget = uiTabs.some(
|
||||
(tab) => tab.target.kind === "agent" && tab.target.agentId === agentId,
|
||||
);
|
||||
const representedByDeterministicTabId = uiTabs.some(
|
||||
(tab) => tab.tabId === `agent_${agentId}`,
|
||||
);
|
||||
if (
|
||||
hasActivePendingDraftCreateInWorkspace &&
|
||||
!representedByTarget &&
|
||||
!representedByDeterministicTabId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
ensureWorkspaceTab({ kind: "agent", agentId });
|
||||
}
|
||||
for (const terminal of terminals) {
|
||||
ensureWorkspaceTab({ kind: "terminal", terminalId: terminal.id });
|
||||
}
|
||||
|
||||
const canPruneAgentTabs = hasHydratedAgents;
|
||||
const canPruneTerminalTabs = terminalsQuery.isSuccess;
|
||||
for (const tab of uiTabs) {
|
||||
if (
|
||||
canPruneAgentTabs &&
|
||||
tab.target.kind === "agent" &&
|
||||
!pinnedAgentIds.has(tab.target.agentId) &&
|
||||
shouldPruneWorkspaceAgentTab({
|
||||
agentId: tab.target.agentId,
|
||||
agentsHydrated: hasHydratedAgents,
|
||||
knownAgentIds: workspaceAgentVisibility.knownAgentIds,
|
||||
activeAgentIds: workspaceAgentVisibility.activeAgentIds,
|
||||
})
|
||||
) {
|
||||
closeWorkspaceTabWithCleanup({ tabId: tab.tabId, target: tab.target });
|
||||
}
|
||||
if (
|
||||
canPruneTerminalTabs &&
|
||||
tab.target.kind === "terminal" &&
|
||||
!terminalIds.has(tab.target.terminalId)
|
||||
) {
|
||||
closeWorkspaceTabWithCleanup({ tabId: tab.tabId, target: tab.target });
|
||||
}
|
||||
}
|
||||
reconcileWorkspaceTabs(persistenceKey, {
|
||||
agentsHydrated: hasHydratedAgents,
|
||||
terminalsHydrated: terminalsQuery.isSuccess,
|
||||
activeAgentIds: workspaceAgentVisibility.activeAgentIds,
|
||||
knownAgentIds: workspaceAgentVisibility.knownAgentIds,
|
||||
standaloneTerminalIds: terminals.map((terminal) => terminal.id),
|
||||
hasActivePendingDraftCreate: hasActivePendingDraftCreateInWorkspace,
|
||||
});
|
||||
}, [
|
||||
closeWorkspaceTabWithCleanup,
|
||||
ensureWorkspaceTab,
|
||||
hasHydratedAgents,
|
||||
pendingByDraftId,
|
||||
pinnedAgentIds,
|
||||
persistenceKey,
|
||||
reconcileWorkspaceTabs,
|
||||
terminals,
|
||||
terminalsQuery.isSuccess,
|
||||
uiTabs,
|
||||
@@ -986,13 +946,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const activeTabId = focusedPaneTabState.activeTabId;
|
||||
const activeTab = focusedPaneTabState.activeTab;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTabId || !persistenceKey) {
|
||||
return;
|
||||
}
|
||||
focusWorkspaceTab(persistenceKey, activeTabId);
|
||||
}, [activeTabId, focusWorkspaceTab, persistenceKey]);
|
||||
|
||||
const tabs = useMemo<WorkspaceTabDescriptor[]>(
|
||||
() => focusedPaneTabState.tabs.map((tab) => tab.descriptor),
|
||||
[focusedPaneTabState.tabs],
|
||||
@@ -1104,6 +1057,25 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
openWorkspaceDraftTab();
|
||||
}, [openWorkspaceDraftTab]);
|
||||
|
||||
const handleCreateLauncherTab = useCallback(
|
||||
(input?: { paneId?: string }) => {
|
||||
if (!persistenceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input?.paneId) {
|
||||
focusWorkspacePane(persistenceKey, input.paneId);
|
||||
}
|
||||
|
||||
const tabId = openWorkspaceLauncherTab(persistenceKey);
|
||||
if (tabId) {
|
||||
focusWorkspaceTab(persistenceKey, tabId);
|
||||
}
|
||||
return tabId;
|
||||
},
|
||||
[focusWorkspacePane, focusWorkspaceTab, openWorkspaceLauncherTab, persistenceKey],
|
||||
);
|
||||
|
||||
const handleCreateTerminal = useCallback(
|
||||
(input?: { paneId?: string }) => {
|
||||
if (createTerminalMutation.isPending) {
|
||||
@@ -1124,21 +1096,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
[navigateToTabId],
|
||||
);
|
||||
|
||||
const handleSelectNewTabOption = useCallback(
|
||||
(selection: { optionId: NewTabOptionId; paneId?: string }) => {
|
||||
if (selection.paneId && persistenceKey) {
|
||||
focusWorkspacePane(persistenceKey, selection.paneId);
|
||||
}
|
||||
if (selection.optionId === NEW_TAB_AGENT_OPTION_ID) {
|
||||
handleCreateDraftTab();
|
||||
} else if (selection.optionId === NEW_TAB_TERMINAL_OPTION_ID) {
|
||||
handleCreateTerminal({ paneId: selection.paneId });
|
||||
}
|
||||
},
|
||||
[focusWorkspacePane, handleCreateDraftTab, handleCreateTerminal, persistenceKey],
|
||||
);
|
||||
|
||||
const handleCreateDraftSplit = useCallback(
|
||||
const handleCreateLauncherSplit = useCallback(
|
||||
(input: { targetPaneId: string; position: "left" | "right" | "top" | "bottom" }) => {
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
@@ -1149,10 +1107,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return;
|
||||
}
|
||||
|
||||
focusWorkspacePane(persistenceKey, paneId);
|
||||
openWorkspaceDraftTab();
|
||||
handleCreateLauncherTab({ paneId });
|
||||
},
|
||||
[focusWorkspacePane, openWorkspaceDraftTab, persistenceKey, splitWorkspacePaneEmpty],
|
||||
[handleCreateLauncherTab, persistenceKey, splitWorkspacePaneEmpty],
|
||||
);
|
||||
|
||||
const killTerminalAsync = killTerminalMutation.mutateAsync;
|
||||
@@ -1267,29 +1224,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
[allTabDescriptorsById, handleCloseAgentTab, handleCloseDraftOrFileTab, handleCloseTerminalTab],
|
||||
);
|
||||
|
||||
const prevCloseTabDeps = useRef({
|
||||
allTabDescriptorsById,
|
||||
handleCloseAgentTab,
|
||||
handleCloseDraftOrFileTab,
|
||||
handleCloseTerminalTab,
|
||||
});
|
||||
useEffect(() => {
|
||||
const prev = prevCloseTabDeps.current;
|
||||
const changed: string[] = [];
|
||||
if (prev.allTabDescriptorsById !== allTabDescriptorsById) changed.push("allTabDescriptorsById");
|
||||
if (prev.handleCloseAgentTab !== handleCloseAgentTab) changed.push("handleCloseAgentTab");
|
||||
if (prev.handleCloseDraftOrFileTab !== handleCloseDraftOrFileTab)
|
||||
changed.push("handleCloseDraftOrFileTab");
|
||||
if (prev.handleCloseTerminalTab !== handleCloseTerminalTab)
|
||||
changed.push("handleCloseTerminalTab");
|
||||
if (changed.length > 0) console.log("[handleCloseTabById] deps changed:", changed.join(", "));
|
||||
prevCloseTabDeps.current = {
|
||||
allTabDescriptorsById,
|
||||
handleCloseAgentTab,
|
||||
handleCloseDraftOrFileTab,
|
||||
handleCloseTerminalTab,
|
||||
};
|
||||
});
|
||||
const handleCopyAgentId = useCallback(
|
||||
async (agentId: string) => {
|
||||
if (!agentId) return;
|
||||
@@ -1523,7 +1457,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
(action: KeyboardActionDefinition): boolean => {
|
||||
switch (action.id) {
|
||||
case "workspace.tab.new":
|
||||
handleCreateDraftTab();
|
||||
handleCreateLauncherTab();
|
||||
return true;
|
||||
case "workspace.terminal.new":
|
||||
handleCreateTerminal();
|
||||
@@ -1556,7 +1490,14 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[activeTabId, handleCloseTabById, handleCreateDraftTab, handleCreateTerminal, navigateToTabId, tabs],
|
||||
[
|
||||
activeTabId,
|
||||
handleCloseTabById,
|
||||
handleCreateLauncherTab,
|
||||
handleCreateTerminal,
|
||||
navigateToTabId,
|
||||
tabs,
|
||||
],
|
||||
);
|
||||
|
||||
const handleWorkspacePaneAction = useCallback(
|
||||
@@ -1571,7 +1512,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
}
|
||||
|
||||
if (action.id === "workspace.pane.split.right") {
|
||||
handleCreateDraftSplit({
|
||||
handleCreateLauncherSplit({
|
||||
targetPaneId: focusedPane.id,
|
||||
position: "right",
|
||||
});
|
||||
@@ -1579,7 +1520,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
}
|
||||
|
||||
if (action.id === "workspace.pane.split.down") {
|
||||
handleCreateDraftSplit({
|
||||
handleCreateLauncherSplit({
|
||||
targetPaneId: focusedPane.id,
|
||||
position: "bottom",
|
||||
});
|
||||
@@ -1649,7 +1590,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
allTabDescriptorsById,
|
||||
closeWorkspaceTabWithCleanup,
|
||||
focusWorkspacePane,
|
||||
handleCreateDraftSplit,
|
||||
handleCreateLauncherSplit,
|
||||
moveWorkspaceTabToPane,
|
||||
persistenceKey,
|
||||
focusedPaneTabState.activeTabId,
|
||||
@@ -1732,6 +1673,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
if (!persistenceKey) {
|
||||
return;
|
||||
}
|
||||
if (input.tab.kind === "draft" && target.kind === "agent") {
|
||||
convertWorkspaceDraftToAgent(persistenceKey, input.tab.tabId, target.agentId);
|
||||
return;
|
||||
}
|
||||
retargetWorkspaceTab(persistenceKey, input.tab.tabId, target);
|
||||
},
|
||||
onOpenWorkspaceFile: (filePath) => {
|
||||
@@ -1750,47 +1695,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
normalizedWorkspaceId,
|
||||
openWorkspaceTab,
|
||||
persistenceKey,
|
||||
convertWorkspaceDraftToAgent,
|
||||
retargetWorkspaceTab,
|
||||
],
|
||||
);
|
||||
const prevBuildDeps = useRef({
|
||||
handleCloseTabById,
|
||||
handleOpenFileFromChat,
|
||||
focusWorkspacePane,
|
||||
navigateToTabId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
openWorkspaceTab,
|
||||
persistenceKey,
|
||||
retargetWorkspaceTab,
|
||||
});
|
||||
useEffect(() => {
|
||||
const prev = prevBuildDeps.current;
|
||||
const changed: string[] = [];
|
||||
if (prev.handleCloseTabById !== handleCloseTabById) changed.push("handleCloseTabById");
|
||||
if (prev.handleOpenFileFromChat !== handleOpenFileFromChat)
|
||||
changed.push("handleOpenFileFromChat");
|
||||
if (prev.focusWorkspacePane !== focusWorkspacePane) changed.push("focusWorkspacePane");
|
||||
if (prev.navigateToTabId !== navigateToTabId) changed.push("navigateToTabId");
|
||||
if (prev.normalizedServerId !== normalizedServerId) changed.push("normalizedServerId");
|
||||
if (prev.normalizedWorkspaceId !== normalizedWorkspaceId) changed.push("normalizedWorkspaceId");
|
||||
if (prev.openWorkspaceTab !== openWorkspaceTab) changed.push("openWorkspaceTab");
|
||||
if (prev.persistenceKey !== persistenceKey) changed.push("persistenceKey");
|
||||
if (prev.retargetWorkspaceTab !== retargetWorkspaceTab) changed.push("retargetWorkspaceTab");
|
||||
if (changed.length > 0)
|
||||
console.log("[buildPaneContentModel] deps changed:", changed.join(", "));
|
||||
prevBuildDeps.current = {
|
||||
handleCloseTabById,
|
||||
handleOpenFileFromChat,
|
||||
focusWorkspacePane,
|
||||
navigateToTabId,
|
||||
normalizedServerId,
|
||||
normalizedWorkspaceId,
|
||||
openWorkspaceTab,
|
||||
persistenceKey,
|
||||
retargetWorkspaceTab,
|
||||
};
|
||||
});
|
||||
const focusedPaneId = focusedPaneTabState.pane?.id ?? null;
|
||||
const focusedPaneTabIds = useMemo(() => tabs.map((tab) => tab.tabId), [tabs]);
|
||||
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
|
||||
@@ -2205,13 +2113,11 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
onCloseTabsToLeft={handleCloseTabsToLeftInPane}
|
||||
onCloseTabsToRight={handleCloseTabsToRightInPane}
|
||||
onCloseOtherTabs={handleCloseOtherTabsInPane}
|
||||
onSelectNewTabOption={handleSelectNewTabOption}
|
||||
newTabAgentOptionId={NEW_TAB_AGENT_OPTION_ID}
|
||||
onCreateLauncherTab={handleCreateLauncherTab}
|
||||
buildPaneContentModel={buildDesktopPaneContentModel}
|
||||
onFocusPane={handleFocusPane}
|
||||
onNewTerminalTab={handleCreateTerminal}
|
||||
onSplitPane={handleSplitPane}
|
||||
onSplitPaneEmpty={handleCreateDraftSplit}
|
||||
onSplitPaneEmpty={handleCreateLauncherSplit}
|
||||
onMoveTabToPane={handleMoveTabToPane}
|
||||
onResizeSplit={handleResizePaneSplit}
|
||||
onReorderTabsInPane={handleReorderTabsInPane}
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("workspace source of truth consumption", () => {
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
projectRootPath: "/repo/main",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
workspaceKind: "checkout",
|
||||
name: "feat/workspace-sot",
|
||||
status: "running",
|
||||
activityAt: new Date("2026-03-01T00:00:00.000Z"),
|
||||
|
||||
@@ -76,6 +76,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return `workspace-draft-close-${tab.target.draftId}`;
|
||||
}
|
||||
if (tab.target.kind === "launcher") {
|
||||
return `workspace-launcher-close-${tab.target.launcherId}`;
|
||||
}
|
||||
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export function WorkspaceTabPresentationResolver({
|
||||
|
||||
return (
|
||||
<WorkspaceTabPresentationResolverInner
|
||||
key={tab.kind}
|
||||
key={`${tab.key}:${tab.kind}`}
|
||||
registration={registration}
|
||||
tab={tab}
|
||||
serverId={serverId}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
const DRAFT_STORE_VERSION = 2;
|
||||
const DRAFT_STORE_VERSION = 4;
|
||||
const FINALIZED_DRAFT_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
type LegacyDraftImage = {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import {
|
||||
__providerRecencyStoreTestUtils,
|
||||
sortProvidersByRecency,
|
||||
useProviderRecencyStore,
|
||||
} from "./provider-recency-store";
|
||||
|
||||
describe("provider-recency-store", () => {
|
||||
beforeEach(() => {
|
||||
useProviderRecencyStore.setState({
|
||||
recentProviderIds: [],
|
||||
recordUsage: useProviderRecencyStore.getState().recordUsage,
|
||||
});
|
||||
});
|
||||
|
||||
it("sorts used providers first and keeps unused providers in default order", () => {
|
||||
const sorted = sortProvidersByRecency(AGENT_PROVIDER_DEFINITIONS, ["codex"]);
|
||||
|
||||
expect(sorted.map((provider) => provider.id)).toEqual(["codex", "claude", "opencode"]);
|
||||
});
|
||||
|
||||
it("moves the latest provider to the front without duplicating prior entries", () => {
|
||||
useProviderRecencyStore.getState().recordUsage("codex");
|
||||
useProviderRecencyStore.getState().recordUsage("opencode");
|
||||
useProviderRecencyStore.getState().recordUsage("codex");
|
||||
|
||||
expect(useProviderRecencyStore.getState().recentProviderIds).toEqual([
|
||||
"codex",
|
||||
"opencode",
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters invalid and duplicate providers during migration", () => {
|
||||
expect(
|
||||
__providerRecencyStoreTestUtils.migratePersistedState({
|
||||
recentProviderIds: ["codex", "invalid", "codex", "claude"],
|
||||
}),
|
||||
).toEqual({
|
||||
recentProviderIds: ["codex", "claude"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useMemo } from "react";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
isValidAgentProvider,
|
||||
type AgentProviderDefinition,
|
||||
} from "@server/server/agent/provider-manifest";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
|
||||
const PROVIDER_RECENCY_STORE_VERSION = 1;
|
||||
|
||||
interface ProviderRecencyStoreState {
|
||||
recentProviderIds: AgentProvider[];
|
||||
recordUsage: (providerId: AgentProvider) => void;
|
||||
}
|
||||
|
||||
function sanitizeRecentProviderIds(providerIds: readonly string[] | undefined): AgentProvider[] {
|
||||
if (!providerIds || providerIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<AgentProvider>();
|
||||
const sanitized: AgentProvider[] = [];
|
||||
for (const providerId of providerIds) {
|
||||
if (!isValidAgentProvider(providerId)) {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(providerId)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(providerId);
|
||||
sanitized.push(providerId);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function sortProvidersByRecency<T extends { id: string }>(
|
||||
providers: readonly T[],
|
||||
recentProviderIds: readonly string[],
|
||||
): T[] {
|
||||
if (providers.length <= 1) {
|
||||
return [...providers];
|
||||
}
|
||||
|
||||
const recentRank = new Map<string, number>();
|
||||
for (const providerId of recentProviderIds) {
|
||||
if (recentRank.has(providerId)) {
|
||||
continue;
|
||||
}
|
||||
recentRank.set(providerId, recentRank.size);
|
||||
}
|
||||
|
||||
return providers
|
||||
.map((provider, defaultIndex) => ({
|
||||
provider,
|
||||
defaultIndex,
|
||||
recentIndex: recentRank.get(provider.id) ?? Number.POSITIVE_INFINITY,
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (left.recentIndex !== right.recentIndex) {
|
||||
return left.recentIndex - right.recentIndex;
|
||||
}
|
||||
return left.defaultIndex - right.defaultIndex;
|
||||
})
|
||||
.map((entry) => entry.provider);
|
||||
}
|
||||
|
||||
function migratePersistedState(state: unknown): Pick<ProviderRecencyStoreState, "recentProviderIds"> {
|
||||
const record = state as { recentProviderIds?: string[] } | null | undefined;
|
||||
return {
|
||||
recentProviderIds: sanitizeRecentProviderIds(record?.recentProviderIds),
|
||||
};
|
||||
}
|
||||
|
||||
export const useProviderRecencyStore = create<ProviderRecencyStoreState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
recentProviderIds: [],
|
||||
recordUsage: (providerId) => {
|
||||
if (!isValidAgentProvider(providerId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
recentProviderIds: [
|
||||
providerId,
|
||||
...state.recentProviderIds.filter((id) => id !== providerId),
|
||||
],
|
||||
}));
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "terminal-agent-provider-recency",
|
||||
version: PROVIDER_RECENCY_STORE_VERSION,
|
||||
storage: createJSONStorage(() => AsyncStorage),
|
||||
partialize: (state) => ({
|
||||
recentProviderIds: state.recentProviderIds,
|
||||
}),
|
||||
migrate: (persistedState) => migratePersistedState(persistedState),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export function useProviderRecency(
|
||||
availableProviders: readonly AgentProviderDefinition[] = AGENT_PROVIDER_DEFINITIONS,
|
||||
): {
|
||||
providers: AgentProviderDefinition[];
|
||||
recordUsage: (providerId: AgentProvider) => void;
|
||||
} {
|
||||
const recentProviderIds = useProviderRecencyStore((state) => state.recentProviderIds);
|
||||
const recordUsage = useProviderRecencyStore((state) => state.recordUsage);
|
||||
|
||||
const providers = useMemo(
|
||||
() => sortProvidersByRecency(availableProviders, recentProviderIds),
|
||||
[availableProviders, recentProviderIds],
|
||||
);
|
||||
|
||||
return {
|
||||
providers,
|
||||
recordUsage,
|
||||
};
|
||||
}
|
||||
|
||||
export const __providerRecencyStoreTestUtils = {
|
||||
migratePersistedState,
|
||||
sanitizeRecentProviderIds,
|
||||
};
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
GitSetupOptions,
|
||||
ProjectPlacementPayload,
|
||||
ServerCapabilities,
|
||||
AgentSnapshotPayload,
|
||||
WorkspaceDescriptorPayload,
|
||||
} from "@server/shared/messages";
|
||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||
@@ -79,10 +80,13 @@ export interface AgentRuntimeInfo {
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type TerminalExitDetails = NonNullable<AgentSnapshotPayload["terminalExit"]>;
|
||||
|
||||
export interface Agent {
|
||||
serverId: string;
|
||||
id: string;
|
||||
provider: AgentProvider;
|
||||
terminal: boolean;
|
||||
status: AgentLifecycleStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -96,6 +100,7 @@ export interface Agent {
|
||||
runtimeInfo?: AgentRuntimeInfo;
|
||||
lastUsage?: AgentUsage;
|
||||
lastError?: string | null;
|
||||
terminalExit?: TerminalExitDetails | null;
|
||||
title: string | null;
|
||||
cwd: string;
|
||||
model: string | null;
|
||||
@@ -126,8 +131,8 @@ export function normalizeWorkspaceDescriptor(
|
||||
): WorkspaceDescriptor {
|
||||
const activityAt = payload.activityAt ? new Date(payload.activityAt) : null;
|
||||
return {
|
||||
id: normalizeWorkspaceIdentity(payload.id) ?? payload.id,
|
||||
projectId: payload.projectId,
|
||||
id: normalizeWorkspaceIdentity(String(payload.id)) ?? String(payload.id),
|
||||
projectId: String(payload.projectId),
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
projectKind: payload.projectKind,
|
||||
@@ -191,7 +196,6 @@ export type DaemonServerInfo = {
|
||||
};
|
||||
|
||||
export interface AgentTimelineCursorState {
|
||||
epoch: string;
|
||||
startSeq: number;
|
||||
endSeq: number;
|
||||
}
|
||||
@@ -1119,6 +1123,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
id: agent.id,
|
||||
serverId,
|
||||
title: agent.title ?? null,
|
||||
terminal: agent.terminal,
|
||||
status: agent.status,
|
||||
lastActivityAt,
|
||||
cwd: agent.cwd,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface BuildTerminalAgentReopenKeyInput {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface RequestTerminalAgentReopenInput {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface TerminalAgentReopenStore {
|
||||
reopenIntentVersionByAgentKey: Record<string, number>;
|
||||
requestReopen: (input: RequestTerminalAgentReopenInput) => void;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function buildTerminalAgentReopenKey(
|
||||
input: BuildTerminalAgentReopenKeyInput,
|
||||
): string | null {
|
||||
const serverId = trimNonEmpty(input.serverId);
|
||||
const agentId = trimNonEmpty(input.agentId);
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return `${serverId}:${agentId}`;
|
||||
}
|
||||
|
||||
export const useTerminalAgentReopenStore = create<TerminalAgentReopenStore>()((set) => ({
|
||||
reopenIntentVersionByAgentKey: {},
|
||||
requestReopen: ({ serverId, agentId }) => {
|
||||
const key = buildTerminalAgentReopenKey({ serverId, agentId });
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
reopenIntentVersionByAgentKey: {
|
||||
...state.reopenIntentVersionByAgentKey,
|
||||
[key]: (state.reopenIntentVersionByAgentKey[key] ?? 0) + 1,
|
||||
},
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@@ -2,6 +2,7 @@ import invariant from "tiny-invariant";
|
||||
import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
createLauncherId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
@@ -110,6 +111,11 @@ interface OpenTabInLayoutResult {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface OpenLauncherTabInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
now: number;
|
||||
}
|
||||
|
||||
interface RetargetTabInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
tabId: string;
|
||||
@@ -121,6 +127,17 @@ interface RetargetTabInLayoutResult {
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface ConvertDraftToAgentInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
tabId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface ConvertDraftToAgentInLayoutResult {
|
||||
layout: WorkspaceLayout;
|
||||
tabId: string;
|
||||
}
|
||||
|
||||
interface ReorderFocusedPaneTabsInLayoutInput {
|
||||
layout: WorkspaceLayout;
|
||||
tabIds: string[];
|
||||
@@ -181,6 +198,20 @@ interface ReorderPaneTabsInLayoutInput {
|
||||
tabIds: string[];
|
||||
}
|
||||
|
||||
export interface WorkspaceTabReconcileState {
|
||||
layout: WorkspaceLayout;
|
||||
pinnedAgentIds?: ReadonlySet<string> | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceTabSnapshot {
|
||||
agentsHydrated: boolean;
|
||||
terminalsHydrated: boolean;
|
||||
activeAgentIds: Iterable<string>;
|
||||
knownAgentIds: Iterable<string>;
|
||||
standaloneTerminalIds: Iterable<string>;
|
||||
hasActivePendingDraftCreate?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PANE_ID = "main";
|
||||
const MIN_SPLIT_SIZE = 0.1;
|
||||
|
||||
@@ -750,6 +781,37 @@ function updateTabInTree(root: SplitNodeInternal, input: UpdateTabInTreeInput):
|
||||
});
|
||||
}
|
||||
|
||||
function replaceTabInTree(
|
||||
root: SplitNodeInternal,
|
||||
input: {
|
||||
tabId: string;
|
||||
nextTabId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
},
|
||||
): SplitNodeInternal {
|
||||
const panePath = findPanePathContainingTab(root, input.tabId);
|
||||
invariant(panePath, `Tab not found: ${input.tabId}`);
|
||||
return replaceNodeAtPath(root, panePath, (node) => {
|
||||
invariant(node.kind === "pane", "Expected pane while replacing tab");
|
||||
return {
|
||||
kind: "pane",
|
||||
pane: normalizePaneAfterTabChange({
|
||||
...node.pane,
|
||||
tabs: node.pane.tabs.map((tab) =>
|
||||
tab.tabId === input.tabId
|
||||
? {
|
||||
...tab,
|
||||
tabId: input.nextTabId,
|
||||
target: input.target,
|
||||
}
|
||||
: tab,
|
||||
),
|
||||
focusedTabId: node.pane.focusedTabId === input.tabId ? input.nextTabId : node.pane.focusedTabId,
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function updateGroupSizesInTree(
|
||||
root: SplitNodeInternal,
|
||||
input: UpdateGroupSizesInTreeInput,
|
||||
@@ -957,22 +1019,12 @@ export function removeTabFromTree(root: SplitNode, tabId: string): SplitNode {
|
||||
return detachTabFromTree(asInternalNode(root), { tabId }).root;
|
||||
}
|
||||
|
||||
export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
||||
function insertNewTabIntoFocusedPane(input: {
|
||||
layout: WorkspaceLayout;
|
||||
target: WorkspaceTabTarget;
|
||||
now: number;
|
||||
}): OpenTabInLayoutResult {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const existingTab = collectAllTabs(layout.root).find((tab) =>
|
||||
workspaceTabTargetsEqual(tab.target, input.target),
|
||||
);
|
||||
if (existingTab) {
|
||||
return {
|
||||
tabId: existingTab.tabId,
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout,
|
||||
tabId: existingTab.tabId,
|
||||
}) ?? input.layout,
|
||||
};
|
||||
}
|
||||
|
||||
const focusedPane =
|
||||
findPaneById(layout.root, layout.focusedPaneId) ??
|
||||
collectAllPanes(layout.root)[0] ??
|
||||
@@ -999,6 +1051,38 @@ export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutRes
|
||||
};
|
||||
}
|
||||
|
||||
export function openTabInLayout(input: OpenTabInLayoutInput): OpenTabInLayoutResult {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const existingTab = collectAllTabs(layout.root).find((tab) =>
|
||||
workspaceTabTargetsEqual(tab.target, input.target),
|
||||
);
|
||||
if (existingTab) {
|
||||
return {
|
||||
tabId: existingTab.tabId,
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout,
|
||||
tabId: existingTab.tabId,
|
||||
}) ?? input.layout,
|
||||
};
|
||||
}
|
||||
|
||||
return insertNewTabIntoFocusedPane(input);
|
||||
}
|
||||
|
||||
export function openLauncherTabInLayout(
|
||||
input: OpenLauncherTabInLayoutInput,
|
||||
): OpenTabInLayoutResult {
|
||||
return insertNewTabIntoFocusedPane({
|
||||
layout: input.layout,
|
||||
target: {
|
||||
kind: "launcher",
|
||||
launcherId: createLauncherId(),
|
||||
},
|
||||
now: input.now,
|
||||
});
|
||||
}
|
||||
|
||||
export function closeTabInLayout(input: CloseTabInLayoutInput): WorkspaceLayout | null {
|
||||
const internalLayout = asInternalLayout(input.layout);
|
||||
const pane = findPaneContainingTab(internalLayout.root, input.tabId);
|
||||
@@ -1054,11 +1138,34 @@ export function retargetTabInLayout(
|
||||
};
|
||||
}
|
||||
|
||||
const existingTargetTab =
|
||||
collectAllTabs(layout.root).find(
|
||||
(tab) => tab.tabId !== input.tabId && workspaceTabTargetsEqual(tab.target, input.target),
|
||||
) ?? null;
|
||||
if (existingTargetTab) {
|
||||
const nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: input.layout,
|
||||
tabId: input.tabId,
|
||||
}) ?? input.layout;
|
||||
return {
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: existingTargetTab.tabId,
|
||||
}) ?? nextLayout,
|
||||
tabId: existingTargetTab.tabId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
// Preserve the existing tab id so launcher->entity transitions keep the same
|
||||
// React key during the first render. Reconciliation can canonicalize later.
|
||||
tabId: input.tabId,
|
||||
layout: {
|
||||
root: updateTabInTree(layout.root, {
|
||||
root: replaceTabInTree(layout.root, {
|
||||
tabId: input.tabId,
|
||||
nextTabId: input.tabId,
|
||||
target: input.target,
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
@@ -1066,6 +1173,52 @@ export function retargetTabInLayout(
|
||||
};
|
||||
}
|
||||
|
||||
export function convertDraftToAgentInLayout(
|
||||
input: ConvertDraftToAgentInLayoutInput,
|
||||
): ConvertDraftToAgentInLayoutResult | null {
|
||||
const layout = asInternalLayout(input.layout);
|
||||
const currentTab = collectAllTabs(layout.root).find((tab) => tab.tabId === input.tabId) ?? null;
|
||||
if (!currentTab || currentTab.target.kind !== "draft") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const target: WorkspaceTabTarget = {
|
||||
kind: "agent",
|
||||
agentId: input.agentId,
|
||||
};
|
||||
const canonicalTabId = buildDeterministicWorkspaceTabId(target);
|
||||
const existingCanonicalTab =
|
||||
collectAllTabs(layout.root).find((tab) => tab.tabId === canonicalTabId) ?? null;
|
||||
|
||||
if (existingCanonicalTab && existingCanonicalTab.tabId !== input.tabId) {
|
||||
const nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: input.layout,
|
||||
tabId: input.tabId,
|
||||
}) ?? input.layout;
|
||||
return {
|
||||
layout:
|
||||
focusTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: canonicalTabId,
|
||||
}) ?? nextLayout,
|
||||
tabId: canonicalTabId,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tabId: canonicalTabId,
|
||||
layout: {
|
||||
root: replaceTabInTree(layout.root, {
|
||||
tabId: input.tabId,
|
||||
nextTabId: canonicalTabId,
|
||||
target,
|
||||
}),
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function reorderFocusedPaneTabsInLayout(
|
||||
input: ReorderFocusedPaneTabsInLayoutInput,
|
||||
): WorkspaceLayout | null {
|
||||
@@ -1234,3 +1387,201 @@ export function reorderPaneTabsInLayout(
|
||||
focusedPaneId: layout.focusedPaneId,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStringSet(values: Iterable<string>): Set<string> {
|
||||
const next = new Set<string>();
|
||||
for (const value of values) {
|
||||
const normalized = trimNonEmpty(value);
|
||||
if (normalized) {
|
||||
next.add(normalized);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function isEntityTarget(
|
||||
target: WorkspaceTabTarget,
|
||||
): target is Extract<WorkspaceTabTarget, { kind: "agent" | "terminal" }> {
|
||||
return target.kind === "agent" || target.kind === "terminal";
|
||||
}
|
||||
|
||||
function isAgentTab(tab: WorkspaceTab): tab is WorkspaceTab & { target: { kind: "agent"; agentId: string } } {
|
||||
return tab.target.kind === "agent";
|
||||
}
|
||||
|
||||
function isTerminalTab(
|
||||
tab: WorkspaceTab,
|
||||
): tab is WorkspaceTab & { target: { kind: "terminal"; terminalId: string } } {
|
||||
return tab.target.kind === "terminal";
|
||||
}
|
||||
|
||||
function openEntityTabWithoutFocusing(layout: WorkspaceLayout, target: WorkspaceTabTarget): WorkspaceLayout {
|
||||
const internalLayout = asInternalLayout(layout);
|
||||
const focusedPane =
|
||||
findPaneById(internalLayout.root, internalLayout.focusedPaneId) ??
|
||||
collectAllPanes(internalLayout.root)[0] ??
|
||||
findPaneById(createDefaultLayout().root, DEFAULT_PANE_ID);
|
||||
invariant(focusedPane, "Workspace layout must always have a pane");
|
||||
|
||||
const tabId = buildDeterministicWorkspaceTabId(target);
|
||||
return {
|
||||
root: insertTabIntoPane(internalLayout.root, {
|
||||
paneId: focusedPane.id,
|
||||
tab: {
|
||||
tabId,
|
||||
target,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
focusTabId: focusedPane.focusedTabId ?? tabId,
|
||||
}),
|
||||
focusedPaneId: internalLayout.focusedPaneId,
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileWorkspaceTabs(
|
||||
state: WorkspaceTabReconcileState,
|
||||
snapshot: WorkspaceTabSnapshot,
|
||||
): WorkspaceTabReconcileState {
|
||||
let nextLayout = state.layout;
|
||||
const originalFocusedTabId =
|
||||
findPaneById(nextLayout.root, nextLayout.focusedPaneId)?.focusedTabId ?? null;
|
||||
let reconciledFocusedTabId = originalFocusedTabId;
|
||||
const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []);
|
||||
const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds);
|
||||
const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds);
|
||||
const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds);
|
||||
const visibleAgentIds = new Set(activeAgentIds);
|
||||
for (const agentId of pinnedAgentIds) {
|
||||
if (knownAgentIds.has(agentId)) {
|
||||
visibleAgentIds.add(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
const initialTabs = collectAllTabs(nextLayout.root);
|
||||
const representedAgentIds = new Set(initialTabs.filter(isAgentTab).map((tab) => tab.target.agentId));
|
||||
|
||||
const entityGroups = new Map<
|
||||
string,
|
||||
{
|
||||
target: WorkspaceTabTarget;
|
||||
tabs: WorkspaceTab[];
|
||||
}
|
||||
>();
|
||||
for (const tab of initialTabs) {
|
||||
if (!isEntityTarget(tab.target)) {
|
||||
continue;
|
||||
}
|
||||
const canonicalTarget = normalizeWorkspaceTabTarget(tab.target);
|
||||
if (!canonicalTarget) {
|
||||
continue;
|
||||
}
|
||||
const canonicalTabId = buildDeterministicWorkspaceTabId(canonicalTarget);
|
||||
const currentGroup = entityGroups.get(canonicalTabId);
|
||||
if (currentGroup) {
|
||||
currentGroup.tabs.push(tab);
|
||||
continue;
|
||||
}
|
||||
entityGroups.set(canonicalTabId, {
|
||||
target: canonicalTarget,
|
||||
tabs: [tab],
|
||||
});
|
||||
}
|
||||
|
||||
for (const [canonicalTabId, group] of entityGroups) {
|
||||
const keeper = group.tabs.find((tab) => tab.tabId === canonicalTabId) ?? group.tabs[0] ?? null;
|
||||
if (!keeper) {
|
||||
continue;
|
||||
}
|
||||
if (group.tabs.some((tab) => tab.tabId === originalFocusedTabId)) {
|
||||
reconciledFocusedTabId = canonicalTabId;
|
||||
}
|
||||
if (
|
||||
keeper.tabId !== canonicalTabId ||
|
||||
!workspaceTabTargetsEqual(keeper.target, group.target)
|
||||
) {
|
||||
nextLayout = {
|
||||
root: replaceTabInTree(asInternalLayout(nextLayout).root, {
|
||||
tabId: keeper.tabId,
|
||||
nextTabId: canonicalTabId,
|
||||
target: group.target,
|
||||
}),
|
||||
focusedPaneId: nextLayout.focusedPaneId,
|
||||
};
|
||||
}
|
||||
for (const tab of group.tabs) {
|
||||
if (tab.tabId === keeper.tabId) {
|
||||
continue;
|
||||
}
|
||||
nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: tab.tabId,
|
||||
}) ?? nextLayout;
|
||||
}
|
||||
}
|
||||
|
||||
for (const tab of collectAllTabs(nextLayout.root)) {
|
||||
if (isAgentTab(tab) && snapshot.agentsHydrated && !visibleAgentIds.has(tab.target.agentId)) {
|
||||
nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: tab.tabId,
|
||||
}) ?? nextLayout;
|
||||
}
|
||||
if (isTerminalTab(tab) && snapshot.terminalsHydrated && !standaloneTerminalIds.has(tab.target.terminalId)) {
|
||||
nextLayout =
|
||||
closeTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: tab.tabId,
|
||||
}) ?? nextLayout;
|
||||
}
|
||||
}
|
||||
|
||||
const currentEntityTabs = collectAllTabs(nextLayout.root);
|
||||
const currentAgentIds = new Set(
|
||||
currentEntityTabs.filter(isAgentTab).map((tab) => tab.target.agentId),
|
||||
);
|
||||
const currentTerminalIds = new Set(
|
||||
currentEntityTabs.filter(isTerminalTab).map((tab) => tab.target.terminalId),
|
||||
);
|
||||
|
||||
const sortedVisibleAgentIds = [...visibleAgentIds].sort();
|
||||
for (const agentId of sortedVisibleAgentIds) {
|
||||
if (currentAgentIds.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
if (snapshot.hasActivePendingDraftCreate && !representedAgentIds.has(agentId)) {
|
||||
continue;
|
||||
}
|
||||
nextLayout = openEntityTabWithoutFocusing(nextLayout, {
|
||||
kind: "agent",
|
||||
agentId,
|
||||
});
|
||||
currentAgentIds.add(agentId);
|
||||
}
|
||||
|
||||
const sortedTerminalIds = [...standaloneTerminalIds].sort();
|
||||
for (const terminalId of sortedTerminalIds) {
|
||||
if (currentTerminalIds.has(terminalId)) {
|
||||
continue;
|
||||
}
|
||||
nextLayout = openEntityTabWithoutFocusing(nextLayout, {
|
||||
kind: "terminal",
|
||||
terminalId,
|
||||
});
|
||||
currentTerminalIds.add(terminalId);
|
||||
}
|
||||
|
||||
if (reconciledFocusedTabId) {
|
||||
nextLayout =
|
||||
focusTabInLayout({
|
||||
layout: nextLayout,
|
||||
tabId: reconciledFocusedTabId,
|
||||
}) ?? nextLayout;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
layout: nextLayout,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -262,6 +262,58 @@ describe("workspace-layout-store actions", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("openLauncherTab creates duplicate launcher tabs for repeated Cmd+T/new-tab opens", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const firstTabId = store.openLauncherTab(workspaceKey);
|
||||
const secondTabId = store.openLauncherTab(workspaceKey);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(firstTabId).toBe("launcher_11111111-1111-1111-1111-111111111111");
|
||||
expect(secondTabId).toBe("launcher_22222222-2222-2222-2222-222222222222");
|
||||
expect(firstTabId).not.toBe(secondTabId);
|
||||
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([firstTabId, secondTabId]);
|
||||
expect(collectAllTabs(layout.root)).toEqual([
|
||||
{
|
||||
tabId: firstTabId,
|
||||
target: { kind: "launcher", launcherId: "11111111-1111-1111-1111-111111111111" },
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
{
|
||||
tabId: secondTabId,
|
||||
target: { kind: "launcher", launcherId: "22222222-2222-2222-2222-222222222222" },
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("splitPaneEmpty plus openLauncherTab opens a launcher tab in the new pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValueOnce(
|
||||
"77777777-7777-7777-7777-777777777777",
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const newPaneId = store.splitPaneEmpty(workspaceKey, {
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
const launcherTabId = store.openLauncherTab(workspaceKey);
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
|
||||
expect(launcherTabId).toMatch(/^launcher_/);
|
||||
expect(layout.focusedPaneId).toBe(newPaneId);
|
||||
expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["file_/repo/worktree/a.ts"]);
|
||||
expect(findPaneById(layout.root, newPaneId!)?.tabIds).toEqual([launcherTabId!]);
|
||||
expect(findPaneById(layout.root, newPaneId!)?.focusedTabId).toBe(launcherTabId);
|
||||
});
|
||||
|
||||
it("focusTab moves workspace focus to the pane containing the tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
@@ -288,7 +340,7 @@ describe("workspace-layout-store actions", () => {
|
||||
expect(findPaneById(layout.root, splitPaneId!)?.focusedTabId).toBe(terminalTabId);
|
||||
});
|
||||
|
||||
it("retargetTab updates the existing tab target without moving it to a different pane", () => {
|
||||
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"12121212-1212-1212-1212-121212121212",
|
||||
);
|
||||
@@ -296,30 +348,125 @@ describe("workspace-layout-store actions", () => {
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/a.ts" });
|
||||
const secondTabId = store.openTab(workspaceKey, { kind: "file", path: "/repo/worktree/b.ts" });
|
||||
const secondTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-2" });
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: secondTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
|
||||
const nextTabId = store.retargetTab(workspaceKey, secondTabId!, {
|
||||
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const splitPane = findPaneById(layout.root, splitPaneId!);
|
||||
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
|
||||
|
||||
expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212");
|
||||
expect(nextTabId).toBe("agent_agent-1");
|
||||
expect(splitPane?.tabIds).toEqual(["agent_agent-1"]);
|
||||
expect(findPaneContainingTab(layout.root, "agent_agent-1")?.id).toBe(splitPaneId);
|
||||
expect(convertedTab).toEqual({
|
||||
tabId: "agent_agent-1",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("retargetTab keeps a launcher tab in place while updating its target", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"33333333-3333-3333-3333-333333333333",
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const launcherTabId = store.openLauncherTab(workspaceKey);
|
||||
const nextTabId = store.retargetTab(workspaceKey, launcherTabId!, {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/launcher.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(launcherTabId).toBe("launcher_33333333-3333-3333-3333-333333333333");
|
||||
expect(nextTabId).toBe(launcherTabId);
|
||||
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([launcherTabId!]);
|
||||
expect(collectAllTabs(layout.root)).toEqual([
|
||||
{
|
||||
tabId: launcherTabId!,
|
||||
target: { kind: "file", path: "/repo/worktree/launcher.ts" },
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("retargetTab closes a launcher tab and focuses the existing canonical target tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("44444444-4444-4444-4444-444444444444")
|
||||
.mockReturnValueOnce("55555555-5555-5555-5555-555555555555")
|
||||
.mockReturnValueOnce("66666666-6666-6666-6666-666666666666");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const existingFileTabId = store.openTab(workspaceKey, {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/existing.ts",
|
||||
});
|
||||
const launcherTabId = store.openLauncherTab(workspaceKey);
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: launcherTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
const secondLauncherTabId = store.openLauncherTab(workspaceKey);
|
||||
|
||||
const nextTabId = store.retargetTab(workspaceKey, secondLauncherTabId!, {
|
||||
kind: "file",
|
||||
path: "/repo/worktree/existing.ts",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts");
|
||||
expect(launcherTabId).toBe("launcher_44444444-4444-4444-4444-444444444444");
|
||||
expect(splitPaneId).toBe("pane_55555555-5555-5555-5555-555555555555");
|
||||
expect(secondLauncherTabId).toMatch(/^launcher_/);
|
||||
expect(secondLauncherTabId).not.toBe(launcherTabId);
|
||||
expect(nextTabId).toBe(existingFileTabId);
|
||||
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([
|
||||
existingFileTabId!,
|
||||
launcherTabId!,
|
||||
]);
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(existingFileTabId);
|
||||
});
|
||||
|
||||
it("retargetTab closes a launcher tab and focuses an existing matching target tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("77777777-7777-7777-7777-777777777777")
|
||||
.mockReturnValueOnce("88888888-8888-8888-8888-888888888888");
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const firstLauncherTabId = store.openLauncherTab(workspaceKey);
|
||||
const firstAgentTabId = store.retargetTab(workspaceKey, firstLauncherTabId!, {
|
||||
kind: "agent",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
const secondLauncherTabId = store.openLauncherTab(workspaceKey);
|
||||
|
||||
const nextTabId = store.retargetTab(workspaceKey, secondLauncherTabId!, {
|
||||
kind: "agent",
|
||||
agentId: "agent-1",
|
||||
});
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const splitPane = findPaneById(layout.root, splitPaneId!);
|
||||
const retargetedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === secondTabId);
|
||||
|
||||
expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212");
|
||||
expect(nextTabId).toBe(secondTabId);
|
||||
expect(splitPane?.tabIds).toEqual([secondTabId!]);
|
||||
expect(findPaneContainingTab(layout.root, secondTabId!)?.id).toBe(splitPaneId);
|
||||
expect(retargetedTab).toEqual({
|
||||
tabId: secondTabId,
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(firstAgentTabId).toBe(firstLauncherTabId);
|
||||
expect(nextTabId).toBe(firstLauncherTabId);
|
||||
expect(collectAllTabs(layout.root)).toEqual([
|
||||
{
|
||||
tabId: firstLauncherTabId!,
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(firstLauncherTabId);
|
||||
});
|
||||
|
||||
it("reorderTabs reorders tabs within the focused pane", () => {
|
||||
@@ -702,4 +849,102 @@ describe("workspace-layout-store actions", () => {
|
||||
splitSizesByWorkspace: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID").mockReturnValue(
|
||||
"67676767-6767-6767-6767-676767676767",
|
||||
);
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
const store = useWorkspaceLayoutStore.getState();
|
||||
|
||||
const draftTabId = store.openTab(workspaceKey, { kind: "draft", draftId: "draft-existing" });
|
||||
const agentTabId = store.openTab(workspaceKey, { kind: "agent", agentId: "agent-1" });
|
||||
const splitPaneId = store.splitPane(workspaceKey, {
|
||||
tabId: agentTabId!,
|
||||
targetPaneId: "main",
|
||||
position: "right",
|
||||
});
|
||||
|
||||
const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1");
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
|
||||
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
|
||||
expect(nextTabId).toBe("agent_agent-1");
|
||||
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
|
||||
expect(layout.focusedPaneId).toBe(splitPaneId);
|
||||
expect(findPaneContainingTab(layout.root, "agent_agent-1")?.id).toBe(splitPaneId);
|
||||
});
|
||||
|
||||
it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => {
|
||||
const workspaceKey = createWorkspaceKey();
|
||||
|
||||
useWorkspaceLayoutStore.setState((state) => ({
|
||||
...state,
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[workspaceKey]: {
|
||||
root: {
|
||||
kind: "pane",
|
||||
pane: {
|
||||
id: "main",
|
||||
tabIds: ["draft_agent", "agent_agent-1", "terminal_orphan", "draft-1"],
|
||||
focusedTabId: "draft_agent",
|
||||
tabs: [
|
||||
{
|
||||
tabId: "draft_agent",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: 1,
|
||||
},
|
||||
{
|
||||
tabId: "agent_agent-1",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: 2,
|
||||
},
|
||||
{
|
||||
tabId: "terminal_orphan",
|
||||
target: { kind: "terminal", terminalId: "term-stale" },
|
||||
createdAt: 3,
|
||||
},
|
||||
{
|
||||
tabId: "draft-1",
|
||||
target: { kind: "draft", draftId: "draft-1" },
|
||||
createdAt: 4,
|
||||
},
|
||||
],
|
||||
} as any,
|
||||
},
|
||||
focusedPaneId: "main",
|
||||
},
|
||||
},
|
||||
pinnedAgentIdsByWorkspace: {
|
||||
[workspaceKey]: new Set<string>(["agent-2"]),
|
||||
},
|
||||
}));
|
||||
|
||||
useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
||||
agentsHydrated: true,
|
||||
terminalsHydrated: true,
|
||||
activeAgentIds: ["agent-1"],
|
||||
knownAgentIds: ["agent-1", "agent-2"],
|
||||
standaloneTerminalIds: ["term-1"],
|
||||
hasActivePendingDraftCreate: false,
|
||||
});
|
||||
|
||||
const layout = useWorkspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
||||
const tabs = collectAllTabs(layout.root);
|
||||
|
||||
expect(tabs.map((tab) => tab.tabId)).toEqual([
|
||||
"agent_agent-1",
|
||||
"draft-1",
|
||||
"agent_agent-2",
|
||||
"terminal_term-1",
|
||||
]);
|
||||
expect(tabs.find((tab) => tab.tabId === "agent_agent-1")).toEqual({
|
||||
tabId: "agent_agent-1",
|
||||
target: { kind: "agent", agentId: "agent-1" },
|
||||
createdAt: 2,
|
||||
});
|
||||
expect(layout.focusedPaneId).toBe("main");
|
||||
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
closeTabInLayout,
|
||||
collectAllPanes,
|
||||
collectAllTabs,
|
||||
convertDraftToAgentInLayout,
|
||||
createDefaultLayout,
|
||||
findPaneById,
|
||||
findPaneContainingTab,
|
||||
@@ -20,7 +21,9 @@ import {
|
||||
insertSplit,
|
||||
moveTabToPaneInLayout,
|
||||
normalizeLayout,
|
||||
openLauncherTabInLayout,
|
||||
openTabInLayout,
|
||||
reconcileWorkspaceTabs,
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
reorderFocusedPaneTabsInLayout,
|
||||
@@ -31,6 +34,8 @@ import {
|
||||
type SplitGroup,
|
||||
type SplitNode,
|
||||
type SplitPane,
|
||||
type WorkspaceTabReconcileState,
|
||||
type WorkspaceTabSnapshot,
|
||||
type WorkspaceLayout,
|
||||
} from "@/stores/workspace-layout-actions";
|
||||
import { normalizeWorkspaceTabTarget } from "@/utils/workspace-tab-identity";
|
||||
@@ -48,16 +53,26 @@ export {
|
||||
removePaneFromTree,
|
||||
removeTabFromTree,
|
||||
};
|
||||
export type { SplitGroup, SplitNode, SplitPane, WorkspaceLayout };
|
||||
export type {
|
||||
SplitGroup,
|
||||
SplitNode,
|
||||
SplitPane,
|
||||
WorkspaceLayout,
|
||||
WorkspaceTabReconcileState,
|
||||
WorkspaceTabSnapshot,
|
||||
};
|
||||
|
||||
interface WorkspaceLayoutStore {
|
||||
layoutByWorkspace: Record<string, WorkspaceLayout>;
|
||||
splitSizesByWorkspace: Record<string, Record<string, number[]>>;
|
||||
pinnedAgentIdsByWorkspace: Record<string, Set<string>>;
|
||||
openTab: (workspaceKey: string, target: WorkspaceTabTarget) => string | null;
|
||||
openLauncherTab: (workspaceKey: string) => string | null;
|
||||
closeTab: (workspaceKey: string, tabId: string) => void;
|
||||
focusTab: (workspaceKey: string, tabId: string) => void;
|
||||
retargetTab: (workspaceKey: string, tabId: string, target: WorkspaceTabTarget) => string | null;
|
||||
convertDraftToAgent: (workspaceKey: string, tabId: string, agentId: string) => string | null;
|
||||
reconcileTabs: (workspaceKey: string, snapshot: WorkspaceTabSnapshot) => void;
|
||||
reorderTabs: (workspaceKey: string, tabIds: string[]) => void;
|
||||
getWorkspaceTabs: (workspaceKey: string) => WorkspaceTab[];
|
||||
splitPane: (
|
||||
@@ -128,6 +143,26 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
openLauncherTab: (workspaceKey) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = openLauncherTabInLayout({
|
||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||
now: Date.now(),
|
||||
});
|
||||
|
||||
set((state) => ({
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
},
|
||||
}));
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
closeTab: (workspaceKey, tabId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
@@ -202,6 +237,59 @@ export const useWorkspaceLayoutStore = create<WorkspaceLayoutStore>()(
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
convertDraftToAgent: (workspaceKey, tabId, agentId) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedAgentId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = convertDraftToAgentInLayout({
|
||||
layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey),
|
||||
tabId: normalizedTabId,
|
||||
agentId: normalizedAgentId,
|
||||
});
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: result.layout,
|
||||
},
|
||||
}));
|
||||
|
||||
return result.tabId;
|
||||
},
|
||||
reconcileTabs: (workspaceKey, snapshot) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const currentLayout = getWorkspaceLayout(state.layoutByWorkspace, normalizedWorkspaceKey);
|
||||
const nextState = reconcileWorkspaceTabs(
|
||||
{
|
||||
layout: currentLayout,
|
||||
pinnedAgentIds: state.pinnedAgentIdsByWorkspace[normalizedWorkspaceKey] ?? null,
|
||||
},
|
||||
snapshot,
|
||||
);
|
||||
if (nextState.layout === currentLayout) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
layoutByWorkspace: {
|
||||
...state.layoutByWorkspace,
|
||||
[normalizedWorkspaceKey]: nextState.layout,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
reorderTabs: (workspaceKey, tabIds) => {
|
||||
const normalizedWorkspaceKey = trimNonEmpty(workspaceKey);
|
||||
if (!normalizedWorkspaceKey) {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useWorkspaceSetupStore } from "./workspace-setup-store";
|
||||
|
||||
describe("workspace-setup-store", () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceSetupStore.setState({ pendingWorkspaceSetup: null });
|
||||
});
|
||||
|
||||
it("tracks deferred project setup by path instead of a created workspace", () => {
|
||||
useWorkspaceSetupStore.getState().beginWorkspaceSetup({
|
||||
serverId: "server-1",
|
||||
projectPath: "/Users/test/project",
|
||||
projectName: "project",
|
||||
creationMethod: "open_project",
|
||||
navigationMethod: "replace",
|
||||
});
|
||||
|
||||
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toEqual({
|
||||
serverId: "server-1",
|
||||
projectPath: "/Users/test/project",
|
||||
projectName: "project",
|
||||
creationMethod: "open_project",
|
||||
navigationMethod: "replace",
|
||||
});
|
||||
});
|
||||
|
||||
it("clears pending setup state", () => {
|
||||
useWorkspaceSetupStore.getState().beginWorkspaceSetup({
|
||||
serverId: "server-1",
|
||||
projectPath: "/Users/test/project",
|
||||
creationMethod: "create_worktree",
|
||||
navigationMethod: "navigate",
|
||||
});
|
||||
|
||||
useWorkspaceSetupStore.getState().clearWorkspaceSetup();
|
||||
|
||||
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
export type WorkspaceSetupNavigationMethod = "navigate" | "replace";
|
||||
export type WorkspaceCreationMethod = "open_project" | "create_worktree";
|
||||
|
||||
export interface PendingWorkspaceSetup {
|
||||
serverId: string;
|
||||
projectPath: string;
|
||||
projectName?: string;
|
||||
creationMethod: WorkspaceCreationMethod;
|
||||
navigationMethod: WorkspaceSetupNavigationMethod;
|
||||
}
|
||||
|
||||
interface WorkspaceSetupStoreState {
|
||||
pendingWorkspaceSetup: PendingWorkspaceSetup | null;
|
||||
beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void;
|
||||
clearWorkspaceSetup: () => void;
|
||||
}
|
||||
|
||||
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) => ({
|
||||
pendingWorkspaceSetup: null,
|
||||
beginWorkspaceSetup: (value) => {
|
||||
set({ pendingWorkspaceSetup: value });
|
||||
},
|
||||
clearWorkspaceSetup: () => {
|
||||
set({ pendingWorkspaceSetup: null });
|
||||
},
|
||||
}));
|
||||
@@ -140,6 +140,41 @@ describe("workspace-tabs-store retargetTab", () => {
|
||||
expect(order).toEqual([draftTabId]);
|
||||
});
|
||||
|
||||
it("openLauncherTab creates distinct launcher tabs without deduplicating", () => {
|
||||
vi.spyOn(globalThis.crypto, "randomUUID")
|
||||
.mockReturnValueOnce("11111111-1111-1111-1111-111111111111")
|
||||
.mockReturnValueOnce("22222222-2222-2222-2222-222222222222");
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
expect(key).toBeTruthy();
|
||||
const workspaceKey = key as string;
|
||||
|
||||
const firstTabId = useWorkspaceTabsStore.getState().openLauncherTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
const secondTabId = useWorkspaceTabsStore.getState().openLauncherTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
const state = useWorkspaceTabsStore.getState();
|
||||
expect(firstTabId).toBe("launcher_11111111-1111-1111-1111-111111111111");
|
||||
expect(secondTabId).toBe("launcher_22222222-2222-2222-2222-222222222222");
|
||||
expect(state.tabOrderByWorkspace[workspaceKey]).toEqual([firstTabId, secondTabId]);
|
||||
expect(state.uiTabsByWorkspace[workspaceKey]).toEqual([
|
||||
{
|
||||
tabId: "launcher_11111111-1111-1111-1111-111111111111",
|
||||
target: { kind: "launcher", launcherId: "11111111-1111-1111-1111-111111111111" },
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
{
|
||||
tabId: "launcher_22222222-2222-2222-2222-222222222222",
|
||||
target: { kind: "launcher", launcherId: "22222222-2222-2222-2222-222222222222" },
|
||||
createdAt: expect.any(Number),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("retargeting a background draft keeps the currently focused tab focused", () => {
|
||||
const draftTabId = "draft_background";
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import {
|
||||
buildDeterministicWorkspaceTabId,
|
||||
createLauncherId,
|
||||
normalizeWorkspaceTabTarget,
|
||||
workspaceTabTargetsEqual,
|
||||
} from "@/utils/workspace-tab-identity";
|
||||
|
||||
export type WorkspaceTabTarget =
|
||||
| { kind: "draft"; draftId: string }
|
||||
| { kind: "agent"; agentId: string }
|
||||
| { kind: "terminal"; terminalId: string }
|
||||
| { kind: "file"; path: string };
|
||||
| { kind: "file"; path: string }
|
||||
| { kind: "launcher"; launcherId: string };
|
||||
|
||||
export type WorkspaceTab = {
|
||||
tabId: string;
|
||||
@@ -38,63 +45,6 @@ export function buildWorkspaceTabPersistenceKey(input: {
|
||||
return `${serverId}:${normalizeWorkspaceId(workspaceId)}`;
|
||||
}
|
||||
|
||||
function normalizeTabTarget(
|
||||
value: WorkspaceTabTarget | null | undefined,
|
||||
): WorkspaceTabTarget | null {
|
||||
if (!value || typeof value !== "object" || typeof value.kind !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (value.kind === "draft") {
|
||||
const draftId = trimNonEmpty(value.draftId);
|
||||
return draftId ? { kind: "draft", draftId } : null;
|
||||
}
|
||||
if (value.kind === "agent") {
|
||||
const agentId = trimNonEmpty(value.agentId);
|
||||
return agentId ? { kind: "agent", agentId } : null;
|
||||
}
|
||||
if (value.kind === "terminal") {
|
||||
const terminalId = trimNonEmpty(value.terminalId);
|
||||
return terminalId ? { kind: "terminal", terminalId } : null;
|
||||
}
|
||||
if (value.kind === "file") {
|
||||
const path = trimNonEmpty(value.path);
|
||||
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tabTargetsEqual(left: WorkspaceTabTarget, right: WorkspaceTabTarget): boolean {
|
||||
if (left.kind !== right.kind) {
|
||||
return false;
|
||||
}
|
||||
if (left.kind === "draft" && right.kind === "draft") {
|
||||
return left.draftId === right.draftId;
|
||||
}
|
||||
if (left.kind === "agent" && right.kind === "agent") {
|
||||
return left.agentId === right.agentId;
|
||||
}
|
||||
if (left.kind === "terminal" && right.kind === "terminal") {
|
||||
return left.terminalId === right.terminalId;
|
||||
}
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildDeterministicTabId(target: WorkspaceTabTarget): string {
|
||||
if (target.kind === "draft") {
|
||||
return target.draftId;
|
||||
}
|
||||
if (target.kind === "agent") {
|
||||
return `agent_${target.agentId}`;
|
||||
}
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
function normalizeTabOrder(list: unknown): string[] {
|
||||
if (!Array.isArray(list)) {
|
||||
return [];
|
||||
@@ -133,6 +83,7 @@ type WorkspaceTabsState = {
|
||||
workspaceId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
}) => string | null;
|
||||
openLauncherTab: (input: { serverId: string; workspaceId: string }) => string | null;
|
||||
openOrFocusTab: (input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -169,19 +120,20 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
},
|
||||
ensureTab: ({ serverId, workspaceId, target }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedTarget = normalizeTabTarget(target);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
if (!key || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deterministicTabId = buildDeterministicTabId(normalizedTarget);
|
||||
const deterministicTabId = buildDeterministicWorkspaceTabId(normalizedTarget);
|
||||
let resolvedTabId = deterministicTabId;
|
||||
const now = Date.now();
|
||||
|
||||
set((state) => {
|
||||
const currentTabs = state.uiTabsByWorkspace[key] ?? [];
|
||||
const tabWithSameTarget =
|
||||
currentTabs.find((tab) => tabTargetsEqual(tab.target, normalizedTarget)) ?? null;
|
||||
currentTabs.find((tab) => workspaceTabTargetsEqual(tab.target, normalizedTarget)) ??
|
||||
null;
|
||||
const effectiveTabId = tabWithSameTarget?.tabId ?? deterministicTabId;
|
||||
resolvedTabId = effectiveTabId;
|
||||
|
||||
@@ -196,7 +148,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
];
|
||||
}
|
||||
const existing = currentTabs[existingIndex];
|
||||
if (existing && tabTargetsEqual(existing.target, normalizedTarget)) {
|
||||
if (existing && workspaceTabTargetsEqual(existing.target, normalizedTarget)) {
|
||||
return currentTabs;
|
||||
}
|
||||
return currentTabs.map((tab, index) =>
|
||||
@@ -218,6 +170,13 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
|
||||
return resolvedTabId;
|
||||
},
|
||||
openLauncherTab: ({ serverId, workspaceId }) => {
|
||||
return get().openOrFocusTab({
|
||||
serverId,
|
||||
workspaceId,
|
||||
target: { kind: "launcher", launcherId: createLauncherId() },
|
||||
});
|
||||
},
|
||||
openOrFocusTab: ({ serverId, workspaceId, target }) => {
|
||||
const tabId = get().ensureTab({ serverId, workspaceId, target });
|
||||
if (!tabId) {
|
||||
@@ -310,7 +269,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
retargetTab: ({ serverId, workspaceId, tabId, target }) => {
|
||||
const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
const normalizedTabId = trimNonEmpty(tabId);
|
||||
const normalizedTarget = normalizeTabTarget(target);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(target);
|
||||
if (!key || !normalizedTabId || !normalizedTarget) {
|
||||
return null;
|
||||
}
|
||||
@@ -325,7 +284,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
}
|
||||
|
||||
const currentTarget = currentTabs[index]?.target;
|
||||
if (currentTarget && tabTargetsEqual(currentTarget, normalizedTarget)) {
|
||||
if (currentTarget && workspaceTabTargetsEqual(currentTarget, normalizedTarget)) {
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -390,7 +349,7 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
for (const key in state.uiTabsByWorkspace) {
|
||||
const tabs = (state.uiTabsByWorkspace[key] ?? [])
|
||||
.map((tab) => {
|
||||
const normalizedTarget = normalizeTabTarget(tab.target);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget(tab.target);
|
||||
const normalizedTabId = trimNonEmpty(tab.tabId);
|
||||
if (!normalizedTarget || !normalizedTabId) {
|
||||
return null;
|
||||
@@ -479,13 +438,13 @@ export const useWorkspaceTabsStore = create<WorkspaceTabsState>()(
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedTarget = normalizeTabTarget((rawTab as WorkspaceTab).target);
|
||||
const normalizedTarget = normalizeWorkspaceTabTarget((rawTab as WorkspaceTab).target);
|
||||
const rawTabId = trimNonEmpty((rawTab as WorkspaceTab).tabId);
|
||||
if (!normalizedTarget) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tabId = rawTabId ?? buildDeterministicTabId(normalizedTarget);
|
||||
const tabId = rawTabId ?? buildDeterministicWorkspaceTabId(normalizedTarget);
|
||||
if (!usedOrder.has(tabId)) {
|
||||
usedOrder.add(tabId);
|
||||
orderFromTabs.push(tabId);
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AgentDirectoryEntry = Pick<
|
||||
| "id"
|
||||
| "serverId"
|
||||
| "title"
|
||||
| "terminal"
|
||||
| "status"
|
||||
| "lastActivityAt"
|
||||
| "cwd"
|
||||
|
||||
@@ -31,6 +31,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
serverId,
|
||||
id: snapshot.id,
|
||||
provider: snapshot.provider,
|
||||
terminal: snapshot.terminal === true,
|
||||
status: snapshot.status as AgentLifecycleStatus,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
@@ -44,6 +45,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
runtimeInfo: snapshot.runtimeInfo,
|
||||
lastUsage: snapshot.lastUsage,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
terminalExit: snapshot.terminalExit ?? null,
|
||||
title: snapshot.title ?? null,
|
||||
cwd: snapshot.cwd,
|
||||
model: snapshot.model ?? null,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
decodeFilePathFromPathSegment,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
@@ -89,4 +90,10 @@ describe("workspace route parsing", () => {
|
||||
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds workspace routes with a one-shot open intent", () => {
|
||||
expect(buildHostWorkspaceOpenRoute("local", "/tmp/repo", "draft:new")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv?open=draft%3Anew",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,6 +281,19 @@ export function buildHostWorkspaceRoute(serverId: string, workspaceId: string):
|
||||
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceOpenRoute(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
openIntent: string,
|
||||
): string {
|
||||
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
const normalizedOpenIntent = trimNonEmpty(openIntent);
|
||||
if (base === "/" || !normalizedOpenIntent) {
|
||||
return base;
|
||||
}
|
||||
return `${base}?open=${encodeURIComponent(normalizedOpenIntent)}`;
|
||||
}
|
||||
|
||||
export function buildHostAgentDetailRoute(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
@@ -292,11 +305,7 @@ export function buildHostAgentDetailRoute(
|
||||
if (!normalizedAgentId) {
|
||||
return "/";
|
||||
}
|
||||
const base = buildHostWorkspaceRoute(serverId, normalizedWorkspaceId);
|
||||
if (base === "/") {
|
||||
return "/";
|
||||
}
|
||||
return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}`;
|
||||
return buildHostWorkspaceOpenRoute(serverId, normalizedWorkspaceId, `agent:${normalizedAgentId}`);
|
||||
}
|
||||
const normalizedServerId = trimNonEmpty(serverId);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
|
||||
@@ -13,7 +13,8 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
workspaceKey: "srv:/repo",
|
||||
serverId: "srv",
|
||||
workspaceId: "/repo",
|
||||
workspaceKind: "directory",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "paseo",
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
@@ -41,13 +42,13 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
it("flattens non-git projects with one workspace into a direct workspace row model", () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
workspaceId: "/repo/non-git",
|
||||
workspaceKind: "directory",
|
||||
workspaceKind: "checkout",
|
||||
statusBucket: "running",
|
||||
});
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "non_git",
|
||||
projectKind: "directory",
|
||||
workspaces: [flattenedWorkspace],
|
||||
}),
|
||||
collapsed: false,
|
||||
@@ -70,7 +71,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "non_git",
|
||||
projectKind: "directory",
|
||||
workspaces: [flattenedWorkspace],
|
||||
}),
|
||||
collapsed: false,
|
||||
@@ -87,25 +88,23 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("flattens git projects with a single workspace and keeps the new worktree action", () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
it("keeps single-workspace git projects as sections with the new worktree action", () => {
|
||||
const onlyWorkspace = workspace({
|
||||
workspaceId: "/repo/main",
|
||||
workspaceKind: "local_checkout",
|
||||
workspaceKind: "checkout",
|
||||
});
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "git",
|
||||
workspaces: [flattenedWorkspace],
|
||||
workspaces: [onlyWorkspace],
|
||||
}),
|
||||
collapsed: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "workspace_link",
|
||||
workspace: flattenedWorkspace,
|
||||
selected: false,
|
||||
chevron: null,
|
||||
kind: "project_section",
|
||||
chevron: "expand",
|
||||
trailingAction: "new_worktree",
|
||||
});
|
||||
});
|
||||
@@ -115,7 +114,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
project: project({
|
||||
projectKind: "git",
|
||||
workspaces: [
|
||||
workspace({ workspaceId: "/repo/main", workspaceKind: "local_checkout" }),
|
||||
workspace({ workspaceId: "/repo/main", workspaceKind: "checkout" }),
|
||||
workspace({ workspaceId: "/repo/feature", workspaceKind: "worktree" }),
|
||||
],
|
||||
}),
|
||||
@@ -131,12 +130,12 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
});
|
||||
|
||||
describe("isSidebarProjectFlattened", () => {
|
||||
it("returns true for single-workspace projects regardless of kind", () => {
|
||||
it("returns true only for single-workspace directory projects", () => {
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: "non_git", workspaces: [workspace()] })),
|
||||
isSidebarProjectFlattened(project({ projectKind: "directory", workspaces: [workspace()] })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||
workspaceKey: `${serverId}:${cwd}`,
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
workspaceKind: "local_checkout",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: cwd,
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
@@ -76,7 +77,7 @@ describe("buildSidebarShortcutModel", () => {
|
||||
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
|
||||
});
|
||||
|
||||
it("ignores collapsed state for flattened single-workspace projects", () => {
|
||||
it("respects collapsed state for single-workspace git projects", () => {
|
||||
const projects = [project("p1", [workspace("s1", "/repo/main")])];
|
||||
|
||||
const model = buildSidebarShortcutModel({
|
||||
@@ -84,7 +85,7 @@ describe("buildSidebarShortcutModel", () => {
|
||||
collapsedProjectKeys: new Set<string>(["p1"]),
|
||||
});
|
||||
|
||||
expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
|
||||
expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
|
||||
expect(model.visibleTargets).toEqual([]);
|
||||
expect(model.shortcutTargets).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,4 +50,18 @@ describe("terminal-list", () => {
|
||||
{ id: "term-2", name: "Renamed Terminal" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves terminal titles from create responses", () => {
|
||||
const result = upsertTerminalListEntry({
|
||||
terminals: [],
|
||||
terminal: {
|
||||
id: "term-3",
|
||||
name: "Terminal 3",
|
||||
title: "Build Output",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ id: "term-3", name: "Terminal 3", title: "Build Output" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ function toTerminalListEntry(input: { terminal: CreatedTerminal }): TerminalList
|
||||
return {
|
||||
id: input.terminal.id,
|
||||
name: input.terminal.name,
|
||||
...(input.terminal.title ? { title: input.terminal.title } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ function workspace(
|
||||
describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
||||
it("redirects an archived worktree to the visible local checkout for the same project", () => {
|
||||
const workspaces = [
|
||||
workspace({ id: "/repo", workspaceKind: "local_checkout", name: "main" }),
|
||||
workspace({ id: "/repo", workspaceKind: "checkout", name: "main" }),
|
||||
workspace({ id: "/repo/.paseo/worktrees/feature", name: "feature" }),
|
||||
];
|
||||
|
||||
@@ -60,8 +60,8 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
||||
id: "/notes",
|
||||
projectId: "notes",
|
||||
projectRootPath: "/notes",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
projectKind: "directory",
|
||||
workspaceKind: "checkout",
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ export function resolveWorkspaceArchiveRedirectWorkspaceId(input: {
|
||||
const rootCheckoutWorkspace =
|
||||
sameProjectWorkspaces.find(
|
||||
(workspace) =>
|
||||
workspace.workspaceKind === "local_checkout" && workspace.id !== archivedWorkspace.id,
|
||||
workspace.workspaceKind === "checkout" && workspace.id !== archivedWorkspace.id,
|
||||
) ?? null;
|
||||
if (rootCheckoutWorkspace) {
|
||||
return rootCheckoutWorkspace.id;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
buildTerminalAgentReopenKey,
|
||||
useTerminalAgentReopenStore,
|
||||
} from "@/stores/terminal-agent-reopen-store";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "/repo/worktree";
|
||||
const AGENT_ID = "agent-1";
|
||||
|
||||
describe("prepareWorkspaceTab", () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceLayoutStore.setState({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
});
|
||||
useTerminalAgentReopenStore.setState({
|
||||
reopenIntentVersionByAgentKey: {},
|
||||
requestReopen: useTerminalAgentReopenStore.getState().requestReopen,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes a reopen intent when requested for an agent tab", () => {
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: AGENT_ID },
|
||||
requestReopen: true,
|
||||
});
|
||||
|
||||
const reopenKey = buildTerminalAgentReopenKey({ serverId: SERVER_ID, agentId: AGENT_ID });
|
||||
expect(reopenKey).toBeTruthy();
|
||||
expect(route).toBe("/h/server-1/workspace/L3JlcG8vd29ya3RyZWU");
|
||||
expect(
|
||||
useTerminalAgentReopenStore.getState().reopenIntentVersionByAgentKey[reopenKey as string],
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("does not publish a reopen intent unless explicitly requested", () => {
|
||||
prepareWorkspaceTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: AGENT_ID },
|
||||
});
|
||||
|
||||
const reopenKey = buildTerminalAgentReopenKey({ serverId: SERVER_ID, agentId: AGENT_ID });
|
||||
expect(reopenKey).toBeTruthy();
|
||||
expect(
|
||||
useTerminalAgentReopenStore.getState().reopenIntentVersionByAgentKey[reopenKey as string],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { router } from "expo-router";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { useTerminalAgentReopenStore } from "@/stores/terminal-agent-reopen-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceTabTarget,
|
||||
@@ -11,6 +13,11 @@ interface PrepareWorkspaceTabInput {
|
||||
workspaceId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
pin?: boolean;
|
||||
requestReopen?: boolean;
|
||||
}
|
||||
|
||||
interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput {
|
||||
navigationMethod?: "navigate" | "replace";
|
||||
}
|
||||
|
||||
function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
|
||||
@@ -38,5 +45,22 @@ export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string {
|
||||
useWorkspaceLayoutStore.getState().pinAgent(key, target.agentId);
|
||||
}
|
||||
|
||||
if (input.requestReopen && target.kind === "agent") {
|
||||
useTerminalAgentReopenStore.getState().requestReopen({
|
||||
serverId: input.serverId,
|
||||
agentId: target.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
return buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
}
|
||||
|
||||
export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string {
|
||||
const route = prepareWorkspaceTab(input);
|
||||
if (input.navigationMethod === "replace") {
|
||||
router.replace(route as any);
|
||||
} else {
|
||||
router.navigate(route as any);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export function normalizeWorkspaceTabTarget(
|
||||
const path = trimNonEmpty(value.path);
|
||||
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||
}
|
||||
if (value.kind === "launcher") {
|
||||
const launcherId = trimNonEmpty(value.launcherId);
|
||||
return launcherId ? { kind: "launcher", launcherId } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -44,6 +48,10 @@ export function workspaceTabTargetsEqual(
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
if (left.kind === "launcher" && right.kind === "launcher") {
|
||||
// Launcher tabs are intentionally always unique, even when reopened repeatedly.
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -57,9 +65,19 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
if (target.kind === "launcher") {
|
||||
return `launcher_${target.launcherId}`;
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
export function createLauncherId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface AgentListItem {
|
||||
shortId: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
terminal: boolean;
|
||||
thinking: string;
|
||||
status: string;
|
||||
cwd: string;
|
||||
@@ -66,6 +67,7 @@ export const agentLsSchema: OutputSchema<AgentListItem> = {
|
||||
{ header: "AGENT ID", field: "shortId", width: 12 },
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "PROVIDER", field: "provider", width: 15 },
|
||||
{ header: "TERM", field: "terminal", width: 6 },
|
||||
{ header: "THINKING", field: "thinking", width: 12 },
|
||||
{
|
||||
header: "STATUS",
|
||||
@@ -91,6 +93,7 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
shortId: agent.id.slice(0, 7),
|
||||
name: agent.title ?? "-",
|
||||
provider: model ? `${agent.provider}/${model}` : agent.provider,
|
||||
terminal: agent.terminal === true,
|
||||
thinking: agent.effectiveThinkingOptionId ?? "auto",
|
||||
status: agent.status,
|
||||
cwd: shortenPath(agent.cwd),
|
||||
|
||||
@@ -193,7 +193,6 @@ export async function resolveStructuredResponseMessage(options: {
|
||||
try {
|
||||
const timeline = await options.client.fetchAgentTimeline(options.agentId, {
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
limit: 200,
|
||||
});
|
||||
for (let index = timeline.entries.length - 1; index >= 0; index -= 1) {
|
||||
|
||||
@@ -17,6 +17,11 @@ export interface AgentSendResult {
|
||||
message: string;
|
||||
}
|
||||
|
||||
function isTerminalAgentSendError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /terminal agents do not support structured send operations/i.test(message);
|
||||
}
|
||||
|
||||
/** Schema for agent send output */
|
||||
export const agentSendSchema: OutputSchema<AgentSendResult> = {
|
||||
idField: "agentId",
|
||||
@@ -260,6 +265,15 @@ export async function runSendCommand(
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
|
||||
if (isTerminalAgentSendError(err)) {
|
||||
const error: CommandError = {
|
||||
code: "TERMINAL_AGENT_UNSUPPORTED",
|
||||
message: "Cannot send messages to terminal agents",
|
||||
details: "Open the terminal agent from the Sessions UI and interact through its terminal.",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Re-throw CommandError as-is
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { SingleResult } from "../../output/index.js";
|
||||
import {
|
||||
attachAgentNamesToMessages,
|
||||
connectChatClient,
|
||||
resolveChatAuthorAgentId,
|
||||
toChatCommandError,
|
||||
type ChatCommandOptions,
|
||||
} from "./shared.js";
|
||||
@@ -24,7 +23,6 @@ export async function runPostCommand(
|
||||
const payload = await client.postChatMessage({
|
||||
room,
|
||||
body,
|
||||
authorAgentId: resolveChatAuthorAgentId(),
|
||||
replyToMessageId: options.replyTo,
|
||||
});
|
||||
const [message] = await attachAgentNamesToMessages(client, [toChatMessageRow(payload.message!)]);
|
||||
|
||||
@@ -23,11 +23,29 @@ const PROVIDERS: ProviderListItem[] = [
|
||||
defaultMode: "auto",
|
||||
modes: "read-only, auto, full-access",
|
||||
},
|
||||
{
|
||||
provider: "gemini",
|
||||
status: "available",
|
||||
defaultMode: "-",
|
||||
modes: "-",
|
||||
},
|
||||
{
|
||||
provider: "amp",
|
||||
status: "available",
|
||||
defaultMode: "-",
|
||||
modes: "-",
|
||||
},
|
||||
{
|
||||
provider: "aider",
|
||||
status: "available",
|
||||
defaultMode: "-",
|
||||
modes: "-",
|
||||
},
|
||||
{
|
||||
provider: "opencode",
|
||||
status: "available",
|
||||
defaultMode: "default",
|
||||
modes: "plan, default, bypass",
|
||||
defaultMode: "build",
|
||||
modes: "build, plan",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ export async function fetchProjectedTimelineItems(
|
||||
const timeline = await input.client.fetchAgentTimeline(input.agentId, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
return timeline.entries.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": [
|
||||
"./dist/index.js",
|
||||
"./src/index.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./packages/server/src/server/db/schema.ts",
|
||||
out: "./packages/server/src/server/db/migrations",
|
||||
dialect: "sqlite",
|
||||
strict: true,
|
||||
verbose: true,
|
||||
});
|
||||
@@ -35,7 +35,7 @@
|
||||
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
|
||||
"dev:tsx": "NODE_ENV=development tsx watch --ignore '**/*.timestamp-*' src/server/index.ts",
|
||||
"build": "node -e \"require('node:fs').rmSync('dist',{ recursive: true, force: true })\" && npm run build:lib && npm run build:scripts",
|
||||
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx');\"",
|
||||
"build:lib": "tsc -p tsconfig.server.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/server/server/speech/providers/local/sherpa/assets',{recursive:true}); fs.copyFileSync('src/server/speech/providers/local/sherpa/assets/silero_vad.onnx','dist/server/server/speech/providers/local/sherpa/assets/silero_vad.onnx'); fs.cpSync('src/terminal/shell-integration','dist/server/terminal/shell-integration',{recursive:true}); fs.cpSync('src/terminal/shell-integration','dist/src/terminal/shell-integration',{recursive:true});\"",
|
||||
"build:scripts": "tsc -p tsconfig.scripts.json --incremental false && node -e \"const fs=require('node:fs'); fs.mkdirSync('dist/scripts',{recursive:true}); fs.copyFileSync('scripts/mcp-stdio-socket-bridge-cli.mjs','dist/scripts/mcp-stdio-socket-bridge-cli.mjs');\"",
|
||||
"prepack": "npm run build",
|
||||
"start": "NODE_ENV=production node dist/server/server/index.js",
|
||||
@@ -45,6 +45,7 @@
|
||||
"speech:download": "tsx scripts/download-speech-models.ts",
|
||||
"speech:tts:matrix": "tsx scripts/generate-sherpa-tts-matrix.ts",
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"db:query": "tsx scripts/db-query.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 --minWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts",
|
||||
@@ -73,6 +74,8 @@
|
||||
"ai": "5.0.78",
|
||||
"ajv": "^8.17.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"express": "^4.18.2",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"fast-uri": "^3.1.0",
|
||||
@@ -96,6 +99,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"@types/express": "^4.17.20",
|
||||
"@types/node": "^20.9.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Run arbitrary SQL against the Paseo SQLite database.
|
||||
*
|
||||
* Usage:
|
||||
* npx tsx packages/server/scripts/db-query.ts "SELECT * FROM agent_snapshots"
|
||||
* npx tsx packages/server/scripts/db-query.ts --db ~/.paseo/db "SELECT count(*) FROM agent_timeline_rows"
|
||||
*
|
||||
* Without args, shows table row counts.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
function resolveHomeDirectory(value: string): string {
|
||||
if (value === "~") {
|
||||
return os.homedir();
|
||||
}
|
||||
|
||||
if (value.startsWith("~/")) {
|
||||
return path.join(os.homedir(), value.slice(2));
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseListenPort(listen: unknown): number | null {
|
||||
if (typeof listen !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const portMatch = listen.match(/:(\d+)$/);
|
||||
return portMatch ? parseInt(portMatch[1]!, 10) : null;
|
||||
}
|
||||
|
||||
function findDevDatabaseDirectory(): string | null {
|
||||
const tmpDir = os.tmpdir();
|
||||
for (const entry of fs.readdirSync(tmpDir)) {
|
||||
if (entry.startsWith("paseo-dev.")) {
|
||||
const configPath = path.join(tmpDir, entry, "config.json");
|
||||
if (fs.existsSync(configPath)) {
|
||||
try {
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
const dbDir = config.paseoHome ? path.join(config.paseoHome, "db") : null;
|
||||
const port = parseListenPort(config.daemon?.listen);
|
||||
if (dbDir && port === 6767) {
|
||||
return dbDir;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveDatabasePath(explicitPath?: string): string {
|
||||
if (explicitPath) {
|
||||
const resolvedPath = path.resolve(resolveHomeDirectory(explicitPath));
|
||||
return fs.statSync(resolvedPath).isDirectory()
|
||||
? path.join(resolvedPath, "paseo.sqlite")
|
||||
: resolvedPath;
|
||||
}
|
||||
|
||||
const detectedDevDir = findDevDatabaseDirectory();
|
||||
if (detectedDevDir) {
|
||||
return path.join(detectedDevDir, "paseo.sqlite");
|
||||
}
|
||||
|
||||
const paseoHome = process.env.PASEO_HOME
|
||||
? path.resolve(resolveHomeDirectory(process.env.PASEO_HOME))
|
||||
: path.join(os.homedir(), ".paseo");
|
||||
return path.join(paseoHome, "db", "paseo.sqlite");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
let dbPath: string | undefined;
|
||||
const queries: string[] = [];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--db" && args[i + 1]) {
|
||||
dbPath = args[++i];
|
||||
} else {
|
||||
queries.push(args[i]!);
|
||||
}
|
||||
}
|
||||
|
||||
if (queries.length === 0) {
|
||||
queries.push(
|
||||
"SELECT 'agent_snapshots' AS table_name, count(*) AS rows FROM agent_snapshots UNION ALL " +
|
||||
"SELECT 'agent_timeline_rows', count(*) FROM agent_timeline_rows UNION ALL " +
|
||||
"SELECT 'projects', count(*) FROM projects UNION ALL " +
|
||||
"SELECT 'workspaces', count(*) FROM workspaces " +
|
||||
"ORDER BY table_name",
|
||||
);
|
||||
}
|
||||
|
||||
let databasePath = "";
|
||||
let client: Database.Database | null = null;
|
||||
|
||||
try {
|
||||
if (dbPath) {
|
||||
const resolvedDbPath = path.resolve(resolveHomeDirectory(dbPath));
|
||||
databasePath =
|
||||
fs.existsSync(resolvedDbPath) && fs.statSync(resolvedDbPath).isDirectory()
|
||||
? path.join(resolvedDbPath, "paseo.sqlite")
|
||||
: resolvedDbPath;
|
||||
} else {
|
||||
databasePath = resolveDatabasePath();
|
||||
}
|
||||
|
||||
client = new Database(databasePath, { readonly: true, fileMustExist: true });
|
||||
|
||||
for (const sql of queries) {
|
||||
const statement = client.prepare(sql);
|
||||
if (statement.reader) {
|
||||
const rows = statement.all();
|
||||
if (rows.length === 0) {
|
||||
console.log("(0 rows)\n");
|
||||
} else {
|
||||
console.table(rows);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = statement.run();
|
||||
console.log(`OK (${result.changes} changes)\n`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`Error: ${err.message}\nDatabase: ${databasePath}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
client?.close();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1704,24 +1704,15 @@ describe("DaemonClient", () => {
|
||||
agentId: "agent_cli",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 1 },
|
||||
startSeq: 1,
|
||||
endSeq: 1,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
timestamp: "2026-02-08T20:20:00.000Z",
|
||||
provider: "codex",
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
seq: 1,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_cli_snapshot",
|
||||
@@ -1798,24 +1789,15 @@ describe("DaemonClient", () => {
|
||||
agentId: "agent_cli",
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
epoch: "epoch-1",
|
||||
reset: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
||||
startCursor: { epoch: "epoch-1", seq: 1 },
|
||||
endCursor: { epoch: "epoch-1", seq: 1 },
|
||||
startSeq: 1,
|
||||
endSeq: 1,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
entries: [
|
||||
{
|
||||
timestamp: "2026-02-08T20:20:00.000Z",
|
||||
provider: "codex",
|
||||
seqStart: 1,
|
||||
seqEnd: 1,
|
||||
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
||||
collapsed: [],
|
||||
seq: 1,
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: "call_cli_invalid",
|
||||
|
||||
@@ -119,9 +119,9 @@ export type DaemonEvent =
|
||||
agentId: string;
|
||||
payload: Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
|
||||
}
|
||||
| {
|
||||
| {
|
||||
type: "workspace_update";
|
||||
workspaceId: string;
|
||||
workspaceId: number;
|
||||
payload: Extract<SessionOutboundMessage, { type: "workspace_update" }>["payload"];
|
||||
}
|
||||
| {
|
||||
@@ -130,7 +130,6 @@ export type DaemonEvent =
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: string;
|
||||
seq?: number;
|
||||
epoch?: string;
|
||||
}
|
||||
| { type: "status"; payload: { status: string } & Record<string, unknown> }
|
||||
| { type: "agent_deleted"; agentId: string }
|
||||
@@ -320,13 +319,13 @@ type ScheduleDeletePayload = Extract<
|
||||
export type FetchAgentTimelinePayload = FetchAgentTimelineResponseMessage["payload"];
|
||||
|
||||
export type FetchAgentTimelineDirection = FetchAgentTimelinePayload["direction"];
|
||||
export type FetchAgentTimelineProjection = FetchAgentTimelinePayload["projection"];
|
||||
export type FetchAgentTimelineCursor = NonNullable<FetchAgentTimelinePayload["startCursor"]>;
|
||||
export type FetchAgentTimelineCursor = NonNullable<
|
||||
Extract<SessionInboundMessage, { type: "fetch_agent_timeline_request" }>["cursor"]
|
||||
>;
|
||||
export type FetchAgentTimelineOptions = {
|
||||
direction?: FetchAgentTimelineDirection;
|
||||
cursor?: FetchAgentTimelineCursor;
|
||||
limit?: number;
|
||||
projection?: FetchAgentTimelineProjection;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
@@ -1301,7 +1300,7 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
async archiveWorkspace(
|
||||
workspaceId: string,
|
||||
workspaceId: number,
|
||||
requestId?: string,
|
||||
): Promise<ArchiveWorkspacePayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
@@ -1576,7 +1575,6 @@ export class DaemonClient {
|
||||
...(options.direction ? { direction: options.direction } : {}),
|
||||
...(options.cursor ? { cursor: options.cursor } : {}),
|
||||
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
|
||||
...(options.projection ? { projection: options.projection } : {}),
|
||||
});
|
||||
|
||||
const payload = await this.sendRequest({
|
||||
@@ -2759,12 +2757,16 @@ export class DaemonClient {
|
||||
cwd: string,
|
||||
name?: string,
|
||||
requestId?: string,
|
||||
options?: { agentId?: string; command?: string; args?: string[] },
|
||||
): Promise<CreateTerminalPayload> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
const message = SessionInboundMessageSchema.parse({
|
||||
type: "create_terminal_request",
|
||||
cwd,
|
||||
name,
|
||||
agentId: options?.agentId,
|
||||
command: options?.command,
|
||||
args: options?.args,
|
||||
requestId: resolvedRequestId,
|
||||
});
|
||||
return this.sendCorrelatedRequest({
|
||||
@@ -3535,7 +3537,6 @@ export class DaemonClient {
|
||||
event: msg.payload.event,
|
||||
timestamp: msg.payload.timestamp,
|
||||
...(typeof msg.payload.seq === "number" ? { seq: msg.payload.seq } : {}),
|
||||
...(typeof msg.payload.epoch === "string" ? { epoch: msg.payload.epoch } : {}),
|
||||
};
|
||||
case "status":
|
||||
return { type: "status", payload: msg.payload };
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type pino from "pino";
|
||||
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { AgentPersistenceHandle, AgentSessionConfig } from "./agent/agent-sdk-types.js";
|
||||
import type { AgentSnapshotStore } from "./agent/agent-snapshot-store.js";
|
||||
import {
|
||||
buildConfigOverrides,
|
||||
buildSessionConfig,
|
||||
extractTimestamps,
|
||||
toAgentPersistenceHandle,
|
||||
} from "./persistence-hooks.js";
|
||||
|
||||
const pendingAgentBootstrapLoads = new Map<string, Promise<ManagedAgent>>();
|
||||
|
||||
export type AgentLoadingServiceOptions = {
|
||||
agentManager: Pick<
|
||||
AgentManager,
|
||||
| "createAgent"
|
||||
| "getAgent"
|
||||
| "reloadAgentSession"
|
||||
| "resumeAgentFromPersistence"
|
||||
>;
|
||||
agentStorage: Pick<AgentSnapshotStore, "get">;
|
||||
logger: pino.Logger;
|
||||
};
|
||||
|
||||
// Coordinates cold loads, explicit resumes, and refreshes for persisted agents.
|
||||
export class AgentLoadingService {
|
||||
private readonly agentManager: AgentLoadingServiceOptions["agentManager"];
|
||||
private readonly agentStorage: AgentLoadingServiceOptions["agentStorage"];
|
||||
private readonly logger: pino.Logger;
|
||||
|
||||
constructor(options: AgentLoadingServiceOptions) {
|
||||
this.agentManager = options.agentManager;
|
||||
this.agentStorage = options.agentStorage;
|
||||
this.logger = options.logger.child({ component: "agent-loading" });
|
||||
}
|
||||
|
||||
async ensureAgentLoaded(options: { agentId: string }): Promise<ManagedAgent> {
|
||||
const existing = this.agentManager.getAgent(options.agentId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const inflight = pendingAgentBootstrapLoads.get(options.agentId);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const initPromise = this.loadStoredAgent(options);
|
||||
pendingAgentBootstrapLoads.set(options.agentId, initPromise);
|
||||
|
||||
try {
|
||||
return await initPromise;
|
||||
} finally {
|
||||
const current = pendingAgentBootstrapLoads.get(options.agentId);
|
||||
if (current === initPromise) {
|
||||
pendingAgentBootstrapLoads.delete(options.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async resumeAgent(options: {
|
||||
handle: AgentPersistenceHandle;
|
||||
overrides?: Partial<AgentSessionConfig>;
|
||||
}): Promise<ManagedAgent> {
|
||||
return this.agentManager.resumeAgentFromPersistence(options.handle, options.overrides);
|
||||
}
|
||||
|
||||
async refreshAgent(options: { agentId: string }): Promise<ManagedAgent> {
|
||||
const existing = this.agentManager.getAgent(options.agentId);
|
||||
if (existing) {
|
||||
return existing.persistence
|
||||
? await this.agentManager.reloadAgentSession(options.agentId)
|
||||
: existing;
|
||||
}
|
||||
|
||||
const record = await this.agentStorage.get(options.agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${options.agentId}`);
|
||||
}
|
||||
|
||||
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
|
||||
if (!handle) {
|
||||
throw new Error(`Agent ${options.agentId} cannot be refreshed because it lacks persistence`);
|
||||
}
|
||||
|
||||
return this.agentManager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
options.agentId,
|
||||
extractTimestamps(record),
|
||||
);
|
||||
}
|
||||
|
||||
private async loadStoredAgent(options: { agentId: string }): Promise<ManagedAgent> {
|
||||
const record = await this.agentStorage.get(options.agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${options.agentId}`);
|
||||
}
|
||||
|
||||
const handle = toAgentPersistenceHandle(this.logger, record.persistence);
|
||||
let snapshot: ManagedAgent;
|
||||
if (handle) {
|
||||
snapshot = await this.agentManager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
options.agentId,
|
||||
extractTimestamps(record),
|
||||
);
|
||||
this.logger.info(
|
||||
{ agentId: options.agentId, provider: record.provider },
|
||||
"Agent resumed from persistence",
|
||||
);
|
||||
} else {
|
||||
snapshot = await this.agentManager.createAgent(buildSessionConfig(record), options.agentId, {
|
||||
labels: record.labels,
|
||||
});
|
||||
this.logger.info(
|
||||
{ agentId: options.agentId, provider: record.provider },
|
||||
"Agent created from stored config",
|
||||
);
|
||||
}
|
||||
|
||||
return this.agentManager.getAgent(options.agentId) ?? snapshot;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentSnapshotStore } from "./agent-snapshot-store.js";
|
||||
import {
|
||||
appendTimelineItemIfAgentKnown,
|
||||
emitLiveTimelineItemIfAgentKnown,
|
||||
@@ -51,7 +51,7 @@ import { createAgentWorktree, runAsyncWorktreeBootstrap } from "../worktree-boot
|
||||
|
||||
export interface AgentManagementMcpOptions {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
agentStorage: AgentSnapshotStore;
|
||||
terminalManager?: TerminalManager | null;
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
@@ -178,7 +178,7 @@ function sanitizePermissionRequest(
|
||||
}
|
||||
|
||||
async function resolveAgentTitle(
|
||||
agentStorage: AgentStorage,
|
||||
agentStorage: AgentSnapshotStore,
|
||||
agentId: string,
|
||||
logger: Logger,
|
||||
): Promise<string | null> {
|
||||
@@ -192,7 +192,7 @@ async function resolveAgentTitle(
|
||||
}
|
||||
|
||||
async function serializeSnapshotWithMetadata(
|
||||
agentStorage: AgentStorage,
|
||||
agentStorage: AgentSnapshotStore,
|
||||
snapshot: ManagedAgent,
|
||||
logger: Logger,
|
||||
) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user