mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
WIP: snapshot workspace execution refactor baseline
This commit is contained in:
@@ -30,7 +30,9 @@ export async function waitForTabBar(page: Page): Promise<void> {
|
||||
|
||||
/** 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 tabs = page.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])',
|
||||
);
|
||||
const count = await tabs.count();
|
||||
const ids: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
@@ -49,7 +51,11 @@ export async function countTabsOfKind(page: Page, kind: string): Promise<number>
|
||||
/** 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();
|
||||
const activeTab = page
|
||||
.locator(
|
||||
'[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])[aria-selected="true"]',
|
||||
)
|
||||
.first();
|
||||
if (await activeTab.isVisible().catch(() => false)) {
|
||||
return activeTab.getAttribute("data-testid");
|
||||
}
|
||||
@@ -111,7 +117,7 @@ export async function clickNewChat(page: Page): Promise<void> {
|
||||
|
||||
/** Click the "Terminal" tile on the launcher panel. */
|
||||
export async function clickTerminal(page: Page): Promise<void> {
|
||||
const button = page.getByRole("button", { name: "Terminal" }).first();
|
||||
const button = page.getByRole("button", { name: "Terminal", exact: true }).first();
|
||||
await expect(button).toBeVisible({ timeout: 10_000 });
|
||||
await button.click();
|
||||
}
|
||||
@@ -132,8 +138,12 @@ export async function waitForTabWithTitle(
|
||||
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 });
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid^="workspace-tab-"]:not([data-testid^="workspace-tab-context-"])')
|
||||
.filter({ hasText: matcher })
|
||||
.first(),
|
||||
).toBeVisible({ timeout });
|
||||
}
|
||||
|
||||
/** Assert the new-tab '+' button is visible and there is only one. */
|
||||
|
||||
@@ -7,6 +7,12 @@ import { buildHostWorkspaceRoute } from "../../src/utils/host-routes";
|
||||
export type TerminalPerfDaemonClient = {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
openProject(
|
||||
cwd: string,
|
||||
): Promise<{
|
||||
workspace: { id: number; name: string; projectRootPath: string } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
createTerminal(
|
||||
cwd: string,
|
||||
name?: string,
|
||||
|
||||
51
packages/app/e2e/helpers/workspace-lifecycle.ts
Normal file
51
packages/app/e2e/helpers/workspace-lifecycle.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import {
|
||||
clickNewChat,
|
||||
clickProviderTile,
|
||||
clickTerminal,
|
||||
countTabsOfKind,
|
||||
getTabTestIds,
|
||||
waitForTabWithTitle,
|
||||
} from "./launcher";
|
||||
import { setupDeterministicPrompt, waitForTerminalContent } from "./terminal-perf";
|
||||
|
||||
function terminalSurface(page: Page) {
|
||||
return page.locator('[data-testid="terminal-surface"]').first();
|
||||
}
|
||||
|
||||
function composerInput(page: Page) {
|
||||
return page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
}
|
||||
|
||||
export async function expectTerminalCwd(page: Page, expectedPath: string): Promise<void> {
|
||||
const terminal = terminalSurface(page);
|
||||
await expect(terminal).toBeVisible({ timeout: 20_000 });
|
||||
await terminal.click();
|
||||
await setupDeterministicPrompt(page, `SENTINEL_${Date.now()}`);
|
||||
await terminal.pressSequentially("pwd\n", { delay: 0 });
|
||||
await waitForTerminalContent(page, (text) => text.includes(expectedPath), 10_000);
|
||||
}
|
||||
|
||||
export async function createStandaloneTerminalFromLauncher(page: Page): Promise<void> {
|
||||
const tabIdsBefore = await getTabTestIds(page);
|
||||
const launcherCountBefore = await countTabsOfKind(page, "launcher");
|
||||
await clickTerminal(page);
|
||||
await expect(terminalSurface(page)).toBeVisible({ timeout: 20_000 });
|
||||
await expect.poll(() => countTabsOfKind(page, "launcher")).toBe(launcherCountBefore - 1);
|
||||
await expect.poll(async () => (await getTabTestIds(page)).length).toBe(tabIdsBefore.length);
|
||||
}
|
||||
|
||||
export async function createTerminalAgentFromLauncher(page: Page, providerLabel: string): Promise<void> {
|
||||
await clickProviderTile(page, providerLabel);
|
||||
await expect(page.getByTestId("terminal-agent-loading")).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(terminalSurface(page)).toBeVisible({ timeout: 30_000 });
|
||||
await waitForTabWithTitle(page, /new agent/i);
|
||||
}
|
||||
|
||||
export async function createAgentChatFromLauncher(page: Page): Promise<void> {
|
||||
await clickNewChat(page);
|
||||
await expect(composerInput(page)).toBeVisible({ timeout: 15_000 });
|
||||
await expect(composerInput(page)).toBeEditable({ timeout: 15_000 });
|
||||
await expect(page.getByTestId("agent-loading")).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "New Chat" })).toHaveCount(0);
|
||||
}
|
||||
165
packages/app/e2e/helpers/workspace-setup.ts
Normal file
165
packages/app/e2e/helpers/workspace-setup.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import path from "node:path";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
|
||||
type WorkspaceSetupProgressPayload = {
|
||||
status: "running" | "completed" | "failed";
|
||||
detail: { commands: string[]; log: string };
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type WorkspaceSetupRawMessage = {
|
||||
type: string;
|
||||
payload?: WorkspaceSetupProgressPayload;
|
||||
};
|
||||
|
||||
type WorkspaceSetupDaemonClient = {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
openProject(
|
||||
cwd: string,
|
||||
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>;
|
||||
createPaseoWorktree(
|
||||
input: { cwd: string; worktreeSlug?: string },
|
||||
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>;
|
||||
subscribeRawMessages(handler: (message: WorkspaceSetupRawMessage) => void): () => void;
|
||||
};
|
||||
export type { WorkspaceSetupDaemonClient, WorkspaceSetupProgressPayload };
|
||||
|
||||
function getDaemonWsUrl(): string {
|
||||
const daemonPort = process.env.E2E_DAEMON_PORT;
|
||||
if (!daemonPort) {
|
||||
throw new Error("E2E_DAEMON_PORT is not set.");
|
||||
}
|
||||
return `ws://127.0.0.1:${daemonPort}/ws`;
|
||||
}
|
||||
|
||||
async function loadDaemonClientConstructor(): Promise<
|
||||
new (config: { url: string; clientId: string; clientType: "cli" }) => WorkspaceSetupDaemonClient
|
||||
> {
|
||||
const repoRoot = path.resolve(process.cwd(), "../..");
|
||||
const moduleUrl = pathToFileURL(
|
||||
path.join(repoRoot, "packages/server/dist/server/server/exports.js"),
|
||||
).href;
|
||||
const mod = (await import(moduleUrl)) as {
|
||||
DaemonClient: new (config: {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
}) => WorkspaceSetupDaemonClient;
|
||||
};
|
||||
return mod.DaemonClient;
|
||||
}
|
||||
|
||||
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor();
|
||||
const client = new DaemonClient({
|
||||
url: getDaemonWsUrl(),
|
||||
clientId: `workspace-setup-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function seedProjectForWorkspaceSetup(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
repoPath: string,
|
||||
): Promise<void> {
|
||||
const result = await client.openProject(repoPath);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${repoPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function projectNameFromPath(repoPath: string): string {
|
||||
return repoPath.replace(/\/+$/, "").split("/").filter(Boolean).pop() ?? repoPath;
|
||||
}
|
||||
|
||||
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
|
||||
await gotoAppShell(page);
|
||||
await expect(createWorkspaceButton(page, repoPath)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
function createWorkspaceButton(page: Page, repoPath: string) {
|
||||
return page.getByRole("button", {
|
||||
name: `Create a new workspace for ${projectNameFromPath(repoPath)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function revealWorkspaceButton(page: Page, repoPath: string): Promise<void> {
|
||||
await page.getByTestId(`sidebar-project-row-${repoPath}`).hover();
|
||||
}
|
||||
|
||||
export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise<void> {
|
||||
await revealWorkspaceButton(page, repoPath);
|
||||
await expect(createWorkspaceButton(page, repoPath)).toBeEnabled({ timeout: 30_000 });
|
||||
await createWorkspaceButton(page, repoPath).click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectSetupPanel(page: Page): Promise<void> {
|
||||
await expect(page.getByText("Workspace setup", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function expectSetupStatus(
|
||||
page: Page,
|
||||
status: "Running" | "Completed" | "Failed",
|
||||
): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-setup-status")).toContainText(status, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectSetupLogContains(page: Page, text: string): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-setup-log")).toContainText(text, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectNoSetupMessage(page: Page): Promise<void> {
|
||||
await expect(page.getByText("No setup commands ran for this workspace.", { exact: true })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWorkspaceThroughDaemon(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
input: { cwd: string; worktreeSlug: string },
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const result = await client.createPaseoWorktree(input);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
|
||||
}
|
||||
return result.workspace;
|
||||
}
|
||||
|
||||
export async function waitForWorkspaceSetupProgress(
|
||||
client: WorkspaceSetupDaemonClient,
|
||||
predicate: (payload: WorkspaceSetupProgressPayload) => boolean,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<WorkspaceSetupProgressPayload> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
unsubscribe();
|
||||
reject(new Error(`Timed out waiting for workspace_setup_progress after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
const unsubscribe = client.subscribeRawMessages((message) => {
|
||||
if (message.type !== "workspace_setup_progress") {
|
||||
return;
|
||||
}
|
||||
if (!message.payload) {
|
||||
return;
|
||||
}
|
||||
if (!predicate(message.payload)) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timeout);
|
||||
unsubscribe();
|
||||
resolve(message.payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,11 @@ type TempRepo = {
|
||||
|
||||
export const createTempGitRepo = async (
|
||||
prefix = "paseo-e2e-",
|
||||
options?: { withRemote?: boolean },
|
||||
options?: {
|
||||
withRemote?: boolean;
|
||||
paseoConfig?: Record<string, unknown>;
|
||||
files?: Array<{ path: string; content: string }>;
|
||||
},
|
||||
): Promise<TempRepo> => {
|
||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
||||
@@ -22,7 +26,24 @@ export const createTempGitRepo = async (
|
||||
execSync('git config user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
||||
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
||||
if (options?.paseoConfig) {
|
||||
await writeFile(
|
||||
path.join(repoPath, "paseo.json"),
|
||||
JSON.stringify(options.paseoConfig, null, 2),
|
||||
);
|
||||
}
|
||||
for (const file of options?.files ?? []) {
|
||||
const filePath = path.join(repoPath, file.path);
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, file.content);
|
||||
}
|
||||
execSync("git add README.md", { cwd: repoPath, stdio: "ignore" });
|
||||
if (options?.paseoConfig) {
|
||||
execSync("git add paseo.json", { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
for (const file of options?.files ?? []) {
|
||||
execSync(`git add ${JSON.stringify(file.path)}`, { cwd: repoPath, stdio: "ignore" });
|
||||
}
|
||||
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
if (withRemote) {
|
||||
|
||||
@@ -28,12 +28,19 @@ import {
|
||||
// ─── Shared state ──────────────────────────────────────────────────────────
|
||||
|
||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||
let workspaceId: string;
|
||||
let seedClient: TerminalPerfDaemonClient;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("launcher-e2e-");
|
||||
seedClient = await connectTerminalClient();
|
||||
const result = await seedClient.openProject(tempRepo.path);
|
||||
if (!result.workspace) throw new Error(result.error ?? "Failed to seed workspace");
|
||||
workspaceId = String(result.workspace.id);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (seedClient) await seedClient.close();
|
||||
if (tempRepo) await tempRepo.cleanup();
|
||||
});
|
||||
|
||||
@@ -45,7 +52,7 @@ 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 gotoWorkspace(page, workspaceId);
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
|
||||
@@ -56,7 +63,7 @@ test.describe("Launcher tab", () => {
|
||||
});
|
||||
|
||||
test("opening two new tabs creates two launcher tabs", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await pressNewTabShortcut(page);
|
||||
await waitForLauncherPanel(page);
|
||||
@@ -70,7 +77,7 @@ test.describe("Launcher tab", () => {
|
||||
});
|
||||
|
||||
test("clicking New Chat replaces launcher in-place with draft tab", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
@@ -97,7 +104,7 @@ test.describe("Launcher tab", () => {
|
||||
|
||||
test("clicking Terminal replaces launcher with standalone terminal", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
@@ -121,7 +128,7 @@ test.describe("Launcher tab", () => {
|
||||
|
||||
test("clicking a provider tile replaces launcher with terminal agent tab", async ({ page }) => {
|
||||
test.setTimeout(45_000);
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
@@ -175,7 +182,7 @@ test.describe("Launcher tab", () => {
|
||||
});
|
||||
|
||||
test("tab bar shows a single + button per pane", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await assertSingleNewTabButton(page);
|
||||
});
|
||||
});
|
||||
@@ -204,7 +211,7 @@ test.describe("Terminal title propagation", () => {
|
||||
|
||||
try {
|
||||
// Navigate to workspace and open the terminal
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await clickTerminal(page);
|
||||
@@ -236,7 +243,7 @@ test.describe("Terminal title propagation", () => {
|
||||
const terminalId = result.terminal.id;
|
||||
|
||||
try {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await clickTerminal(page);
|
||||
@@ -272,7 +279,7 @@ test.describe("Terminal title propagation", () => {
|
||||
|
||||
test.describe("Launcher transitions (no flash)", () => {
|
||||
test("New Chat transition has no blank intermediate tab state", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
@@ -301,7 +308,7 @@ test.describe("Launcher transitions (no flash)", () => {
|
||||
|
||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||
test.setTimeout(30_000);
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
@@ -321,7 +328,7 @@ test.describe("Launcher transitions (no flash)", () => {
|
||||
});
|
||||
|
||||
test("New Chat click → composer appears without launcher flash", async ({ page }) => {
|
||||
await gotoWorkspace(page, tempRepo.path);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
|
||||
178
packages/app/e2e/sidebar-workspace.spec.ts
Normal file
178
packages/app/e2e/sidebar-workspace.spec.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { gotoAppShell } from "./helpers/app";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { expectWorkspaceHeader } from "./helpers/workspace-ui";
|
||||
import { connectWorkspaceSetupClient } from "./helpers/workspace-setup";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function getWorkspaceRowTestId(workspaceId: string): string {
|
||||
return `sidebar-workspace-row-${getServerId()}:${workspaceId}`;
|
||||
}
|
||||
|
||||
function escapeRegex(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function setGitHubRemote(repoPath: string): void {
|
||||
execSync("git remote set-url origin https://github.com/test-owner/test-repo.git", {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
}
|
||||
|
||||
async function createTempDirectory(prefix = "paseo-e2e-dir-") {
|
||||
const dirPath = await mkdtemp(path.join(process.platform === "win32" ? tmpdir() : "/tmp", prefix));
|
||||
await writeFile(path.join(dirPath, "README.md"), "# Temp Directory\n");
|
||||
return {
|
||||
path: dirPath,
|
||||
cleanup: async () => {
|
||||
await rm(dirPath, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function openProjectViaDaemon(
|
||||
client: Awaited<ReturnType<typeof connectWorkspaceSetupClient>>,
|
||||
cwd: string,
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const result = await client.openProject(cwd);
|
||||
if (!result.workspace || result.error) {
|
||||
throw new Error(result.error ?? `Failed to open project ${cwd}`);
|
||||
}
|
||||
return result.workspace;
|
||||
}
|
||||
|
||||
async function openWorkspaceFromSidebar(page: import("@playwright/test").Page, workspaceId: string) {
|
||||
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await row.click();
|
||||
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
|
||||
return row;
|
||||
}
|
||||
|
||||
async function waitForSidebarProject(
|
||||
page: import("@playwright/test").Page,
|
||||
projectName: string,
|
||||
) {
|
||||
const row = page
|
||||
.getByRole("button", {
|
||||
name: new RegExp(escapeRegex(projectName), "i"),
|
||||
})
|
||||
.first();
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
return row;
|
||||
}
|
||||
|
||||
async function waitForSidebarWorkspace(page: import("@playwright/test").Page, workspaceId: string) {
|
||||
const row = page.getByTestId(getWorkspaceRowTestId(workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
return row;
|
||||
}
|
||||
|
||||
test.describe("Sidebar workspace list", () => {
|
||||
test("project with GitHub remote shows owner/repo name in sidebar", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-remote-", { withRemote: true });
|
||||
|
||||
try {
|
||||
setGitHubRemote(repo.path);
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarWorkspace(page, workspace.id);
|
||||
|
||||
const projectRow = page
|
||||
.locator('[data-testid^="sidebar-project-row-"]')
|
||||
.filter({ hasText: "test-owner/test-repo" })
|
||||
.first();
|
||||
|
||||
await expect(projectRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(projectRow).not.toContainText(path.basename(repo.path));
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("project shows workspace under it", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-workspace-under-project-");
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
await gotoAppShell(page);
|
||||
|
||||
await waitForSidebarProject(page, path.basename(repo.path));
|
||||
await waitForSidebarWorkspace(page, workspace.id);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("non-git project shows directory name", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const project = await createTempDirectory("sidebar-directory-");
|
||||
|
||||
try {
|
||||
await openProjectViaDaemon(client, project.path);
|
||||
await gotoAppShell(page);
|
||||
|
||||
const projectRow = await waitForSidebarProject(page, path.basename(project.path));
|
||||
await expect(projectRow).toContainText(path.basename(project.path));
|
||||
} finally {
|
||||
await client.close();
|
||||
await project.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("workspace header shows correct title and subtitle", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-header-", { withRemote: true });
|
||||
|
||||
try {
|
||||
setGitHubRemote(repo.path);
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, "test-owner/test-repo");
|
||||
await waitForSidebarWorkspace(page, workspace.id);
|
||||
await openWorkspaceFromSidebar(page, workspace.id);
|
||||
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: workspace.name,
|
||||
subtitle: "test-owner/test-repo",
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("git project shows branch name in workspace row", async ({ page }) => {
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("sidebar-branch-");
|
||||
|
||||
try {
|
||||
const workspace = await openProjectViaDaemon(client, repo.path);
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarProject(page, path.basename(repo.path));
|
||||
|
||||
expect(workspace.name).toBe("main");
|
||||
await expect(await waitForSidebarWorkspace(page, workspace.id)).toContainText("main");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,9 @@ test.describe("Terminal wire performance", () => {
|
||||
test.beforeAll(async () => {
|
||||
tempRepo = await createTempGitRepo("perf-");
|
||||
client = await connectTerminalClient();
|
||||
// Seed the workspace in the daemon so the app can resolve the path
|
||||
const seedResult = await client.openProject(tempRepo.path);
|
||||
if (!seedResult.workspace) throw new Error(seedResult.error ?? "Failed to seed workspace");
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
|
||||
107
packages/app/e2e/workspace-cwd.spec.ts
Normal file
107
packages/app/e2e/workspace-cwd.spec.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "./fixtures";
|
||||
import {
|
||||
clickNewTabButton,
|
||||
clickTerminal,
|
||||
gotoWorkspace,
|
||||
waitForLauncherPanel,
|
||||
} from "./helpers/launcher";
|
||||
import {
|
||||
setupDeterministicPrompt,
|
||||
waitForTerminalContent,
|
||||
} from "./helpers/terminal-perf";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import { connectWorkspaceSetupClient, seedProjectForWorkspaceSetup } from "./helpers/workspace-setup";
|
||||
|
||||
test.describe("Workspace cwd correctness", () => {
|
||||
test("main checkout workspace opens terminals in the project root", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("workspace-cwd-main-");
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
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, `PWD_READY_${Date.now()}`);
|
||||
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
||||
|
||||
await waitForTerminalContent(page, (text) => text.includes(repo.path), 10_000);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("worktree workspace opens terminals in the worktree directory", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("workspace-cwd-worktree-");
|
||||
const worktreePath = path.join(
|
||||
"/tmp",
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `workspace-cwd-${Date.now()}`;
|
||||
let worktreeCreated = false;
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
worktreeCreated = true;
|
||||
|
||||
const workspaceResult = await client.openProject(worktreePath);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
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, `PWD_READY_${Date.now()}`);
|
||||
await terminal.first().pressSequentially("pwd\n", { delay: 0 });
|
||||
await waitForTerminalContent(page, (text) => text.includes(worktreePath), 10_000);
|
||||
} finally {
|
||||
if (worktreeCreated) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup so test failures preserve the original error.
|
||||
}
|
||||
}
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
242
packages/app/e2e/workspace-lifecycle.spec.ts
Normal file
242
packages/app/e2e/workspace-lifecycle.spec.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { test } from "./fixtures";
|
||||
import {
|
||||
clickNewTabButton,
|
||||
gotoWorkspace,
|
||||
waitForLauncherPanel,
|
||||
} from "./helpers/launcher";
|
||||
import { createTempGitRepo } from "./helpers/workspace";
|
||||
import {
|
||||
createAgentChatFromLauncher,
|
||||
createStandaloneTerminalFromLauncher,
|
||||
createTerminalAgentFromLauncher,
|
||||
expectTerminalCwd,
|
||||
} from "./helpers/workspace-lifecycle";
|
||||
import { connectWorkspaceSetupClient, seedProjectForWorkspaceSetup } from "./helpers/workspace-setup";
|
||||
|
||||
test.describe("Workspace lifecycle", () => {
|
||||
// The first test after a spec-file switch can intermittently fail because
|
||||
// the shared daemon still holds stale sessions from the previous spec.
|
||||
// One retry is enough for the daemon to stabilize.
|
||||
test.describe.configure({ retries: 1 });
|
||||
|
||||
test.describe("Main checkout", () => {
|
||||
test("creates a terminal agent via provider tile", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-main-agent-");
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await createTerminalAgentFromLauncher(page, "Claude");
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("creates an agent chat via New Chat", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-main-chat-");
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await createAgentChatFromLauncher(page);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-main-shell-");
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
const workspaceResult = await client.openProject(repo.path);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${repo.path}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, repo.path);
|
||||
} finally {
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Worktree workspace", () => {
|
||||
test("creates a terminal agent via provider tile", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-wt-agent-");
|
||||
const worktreePath = path.join(
|
||||
"/tmp",
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `lifecycle-wt-agent-${Date.now()}`;
|
||||
let worktreeCreated = false;
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
worktreeCreated = true;
|
||||
|
||||
const workspaceResult = await client.openProject(worktreePath);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await createTerminalAgentFromLauncher(page, "Claude");
|
||||
} finally {
|
||||
if (worktreeCreated) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup so test failures preserve the original error.
|
||||
}
|
||||
}
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("creates an agent chat via New Chat", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-wt-chat-");
|
||||
const worktreePath = path.join(
|
||||
"/tmp",
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `lifecycle-wt-chat-${Date.now()}`;
|
||||
let worktreeCreated = false;
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
worktreeCreated = true;
|
||||
|
||||
const workspaceResult = await client.openProject(worktreePath);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await createAgentChatFromLauncher(page);
|
||||
} finally {
|
||||
if (worktreeCreated) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup so test failures preserve the original error.
|
||||
}
|
||||
}
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("creates a terminal with correct CWD", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const client = await connectWorkspaceSetupClient();
|
||||
const repo = await createTempGitRepo("lifecycle-wt-shell-");
|
||||
const worktreePath = path.join(
|
||||
"/tmp",
|
||||
`paseo-wt-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
);
|
||||
const branchName = `lifecycle-wt-shell-${Date.now()}`;
|
||||
let worktreeCreated = false;
|
||||
|
||||
try {
|
||||
await seedProjectForWorkspaceSetup(client, repo.path);
|
||||
|
||||
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(branchName)} main`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
worktreeCreated = true;
|
||||
|
||||
const workspaceResult = await client.openProject(worktreePath);
|
||||
if (!workspaceResult.workspace) {
|
||||
throw new Error(workspaceResult.error ?? `Failed to open project ${worktreePath}`);
|
||||
}
|
||||
const workspaceId = String(workspaceResult.workspace.id);
|
||||
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
await clickNewTabButton(page);
|
||||
await waitForLauncherPanel(page);
|
||||
await createStandaloneTerminalFromLauncher(page);
|
||||
await expectTerminalCwd(page, worktreePath);
|
||||
} finally {
|
||||
if (worktreeCreated) {
|
||||
try {
|
||||
execSync(`git worktree remove ${JSON.stringify(worktreePath)} --force`, {
|
||||
cwd: repo.path,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Best-effort cleanup so test failures preserve the original error.
|
||||
}
|
||||
}
|
||||
await client.close();
|
||||
await repo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,7 @@ import {
|
||||
parseServerIdFromPathname,
|
||||
parseHostAgentRouteFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
} from "@/utils/host-routes";
|
||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||
|
||||
@@ -602,8 +603,6 @@ function FaviconStatusSync() {
|
||||
}
|
||||
|
||||
function RootStack() {
|
||||
const storeReady = useStoreReady();
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
@@ -614,20 +613,32 @@ function RootStack() {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Protected guard={storeReady}>
|
||||
<Stack.Screen name="welcome" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
</Stack.Protected>
|
||||
<Stack.Screen name="welcome" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/workspace/[workspaceId]"
|
||||
getId={({ params }) => {
|
||||
const serverValue = Array.isArray(params?.serverId) ? params.serverId[0] : params?.serverId;
|
||||
const workspaceValue = Array.isArray(params?.workspaceId)
|
||||
? params.workspaceId[0]
|
||||
: params?.workspaceId;
|
||||
const serverId = typeof serverValue === "string" ? serverValue.trim() : "";
|
||||
const workspaceId =
|
||||
typeof workspaceValue === "string"
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? workspaceValue.trim())
|
||||
: "";
|
||||
return `${serverId}:${workspaceId}`;
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="h/[serverId]/agent/[agentId]"
|
||||
options={{ gestureEnabled: false }}
|
||||
/>
|
||||
<Stack.Screen name="h/[serverId]/index" />
|
||||
<Stack.Screen name="h/[serverId]/sessions" />
|
||||
<Stack.Screen name="h/[serverId]/open-project" />
|
||||
<Stack.Screen name="h/[serverId]/settings" />
|
||||
<Stack.Screen name="pair-scan" />
|
||||
<Stack.Screen name="index" />
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
@@ -22,6 +23,21 @@ export default function HostAgentReadyRoute() {
|
||||
}
|
||||
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
||||
});
|
||||
const sessionWorkspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||
);
|
||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||
);
|
||||
const resolvedWorkspaceId = useSessionStore((state) => {
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return resolveHydratedWorkspaceId({
|
||||
workspaces: state.sessions[serverId]?.workspaces?.values(),
|
||||
path: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -33,18 +49,17 @@ export default function HostAgentReadyRoute() {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedCwd = agentCwd?.trim();
|
||||
if (normalizedCwd) {
|
||||
if (resolvedWorkspaceId) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: normalizedCwd,
|
||||
workspaceId: resolvedWorkspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
}) as any,
|
||||
);
|
||||
}
|
||||
}, [agentCwd, agentId, router, serverId]);
|
||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -53,14 +68,14 @@ export default function HostAgentReadyRoute() {
|
||||
if (!serverId || !agentId) {
|
||||
return;
|
||||
}
|
||||
if (agentCwd?.trim()) {
|
||||
if (agentCwd?.trim() && !hasHydratedWorkspaces) {
|
||||
return;
|
||||
}
|
||||
if (!client || !isConnected) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId) as any);
|
||||
}
|
||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
||||
}, [agentCwd, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -78,12 +93,19 @@ export default function HostAgentReadyRoute() {
|
||||
return;
|
||||
}
|
||||
const cwd = result?.agent?.cwd?.trim();
|
||||
const workspaceId = resolveHydratedWorkspaceId({
|
||||
workspaces: sessionWorkspaces?.values(),
|
||||
path: cwd,
|
||||
});
|
||||
if (!workspaceId && !hasHydratedWorkspaces) {
|
||||
return;
|
||||
}
|
||||
redirectedRef.current = true;
|
||||
if (cwd) {
|
||||
if (workspaceId) {
|
||||
router.replace(
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
}) as any,
|
||||
);
|
||||
@@ -102,7 +124,7 @@ export default function HostAgentReadyRoute() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, isConnected, router, serverId]);
|
||||
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,15 +3,23 @@ import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostOpenProjectRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
} from "@/utils/host-routes";
|
||||
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
||||
|
||||
function getCurrentPathname(fallbackPathname: string): string {
|
||||
if (typeof window === "undefined") {
|
||||
return fallbackPathname;
|
||||
}
|
||||
return window.location.pathname || fallbackPathname;
|
||||
}
|
||||
|
||||
export default function HostIndexRoute() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -33,11 +41,13 @@ export default function HostIndexRoute() {
|
||||
return;
|
||||
}
|
||||
const rootRoute = buildHostRootRoute(serverId);
|
||||
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
|
||||
const currentPathname = getCurrentPathname(pathname);
|
||||
if (currentPathname !== rootRoute && currentPathname !== `${rootRoute}/`) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
|
||||
const latestPathname = getCurrentPathname(pathname);
|
||||
if (latestPathname !== rootRoute && latestPathname !== `${rootRoute}/`) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -56,16 +66,24 @@ export default function HostIndexRoute() {
|
||||
});
|
||||
|
||||
const primaryAgent = visibleAgents[0];
|
||||
if (primaryAgent?.cwd?.trim()) {
|
||||
const primaryAgentWorkspaceId = resolveHydratedWorkspaceId({
|
||||
workspaces: sessionWorkspaces?.values(),
|
||||
path: primaryAgent?.cwd,
|
||||
});
|
||||
if (primaryAgent && primaryAgentWorkspaceId) {
|
||||
router.replace(
|
||||
prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: primaryAgent.cwd.trim(),
|
||||
workspaceId: primaryAgentWorkspaceId,
|
||||
target: { kind: "agent", agentId: primaryAgent.id },
|
||||
}) as any,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (primaryAgent) {
|
||||
router.replace(buildHostAgentDetailRoute(serverId, primaryAgent.id) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const primaryWorkspace = visibleWorkspaces[0];
|
||||
if (primaryWorkspace?.id?.trim()) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useGlobalSearchParams, useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useGlobalSearchParams, usePathname, useRouter } from "expo-router";
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||
import {
|
||||
buildHostWorkspaceRoute,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
parseWorkspaceOpenIntent,
|
||||
type WorkspaceOpenIntent,
|
||||
} from "@/utils/host-routes";
|
||||
@@ -37,18 +37,13 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
|
||||
export default function HostWorkspaceLayout() {
|
||||
const router = useRouter();
|
||||
const consumedIntentRef = useRef<string | null>(null);
|
||||
const params = useLocalSearchParams<{
|
||||
serverId?: string | string[];
|
||||
workspaceId?: string | string[];
|
||||
}>();
|
||||
const pathname = usePathname();
|
||||
const globalParams = useGlobalSearchParams<{
|
||||
open?: string | string[];
|
||||
}>();
|
||||
const serverId = getParamValue(params.serverId);
|
||||
const workspaceValue = getParamValue(params.workspaceId);
|
||||
const workspaceId = workspaceValue
|
||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
||||
: "";
|
||||
const parsedWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
|
||||
const serverId = parsedWorkspaceRoute?.serverId ?? "";
|
||||
const workspaceId = parsedWorkspaceRoute?.workspaceId ?? "";
|
||||
const openValue = getParamValue(globalParams.open);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -14,6 +14,13 @@ import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
|
||||
const WELCOME_ROUTE = "/welcome";
|
||||
|
||||
function getCurrentPathname(fallbackPathname: string): string {
|
||||
if (typeof window === "undefined") {
|
||||
return fallbackPathname;
|
||||
}
|
||||
return window.location.pathname || fallbackPathname;
|
||||
}
|
||||
|
||||
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
|
||||
const runtime = getHostRuntimeStore();
|
||||
|
||||
@@ -51,7 +58,8 @@ export default function Index() {
|
||||
if (!storeReady) {
|
||||
return;
|
||||
}
|
||||
if (pathname !== "/" && pathname !== "") {
|
||||
const currentPathname = getCurrentPathname(pathname);
|
||||
if (currentPathname !== "/" && currentPathname !== "") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { Archive, SquareTerminal } from "lucide-react-native";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
interface AgentListProps {
|
||||
@@ -242,12 +244,21 @@ export function AgentList({
|
||||
|
||||
const serverId = agent.serverId;
|
||||
const agentId = agent.id;
|
||||
const workspaceId = resolveHydratedWorkspaceId({
|
||||
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
|
||||
path: agent.cwd,
|
||||
});
|
||||
|
||||
onAgentSelect?.();
|
||||
|
||||
if (!workspaceId) {
|
||||
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
|
||||
return;
|
||||
}
|
||||
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId,
|
||||
workspaceId: agent.cwd,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId },
|
||||
pin: Boolean(agent.archivedAt),
|
||||
requestReopen: agent.terminal && agent.status === "closed",
|
||||
|
||||
@@ -63,6 +63,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import {
|
||||
@@ -132,10 +133,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const workspaceId = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
|
||||
const workspacePath = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
|
||||
const workspaceId = resolveHydratedWorkspaceId({
|
||||
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
|
||||
path: workspacePath,
|
||||
});
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
workspaceRoot,
|
||||
});
|
||||
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
|
||||
@@ -175,12 +180,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return;
|
||||
}
|
||||
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
target: { kind: "file", path: normalized.file },
|
||||
});
|
||||
router.navigate(route as any);
|
||||
if (workspaceId) {
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
target: { kind: "file", path: normalized.file },
|
||||
});
|
||||
router.navigate(route as any);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,9 +40,9 @@ export function ProjectPickerModal() {
|
||||
|
||||
const recommendedPaths = useMemo(() => {
|
||||
if (!workspaces) return [];
|
||||
return Array.from(workspaces.values()).map(
|
||||
(workspace) => workspace.projectRootPath || workspace.id,
|
||||
);
|
||||
return Array.from(workspaces.values())
|
||||
.map((workspace) => workspace.projectRootPath)
|
||||
.filter((path) => path.length > 0);
|
||||
}, [workspaces]);
|
||||
|
||||
const directorySuggestionsQuery = useQuery({
|
||||
|
||||
@@ -817,8 +817,8 @@ function WorkspaceRowInner({
|
||||
const isMobile = Platform.OS !== "web";
|
||||
const prHint = useWorkspacePrHint({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.workspaceId,
|
||||
enabled: workspace.projectKind === "git",
|
||||
cwd: workspace.projectRootPath ?? "",
|
||||
enabled: workspace.projectKind === "git" && Boolean(workspace.projectRootPath),
|
||||
});
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag,
|
||||
@@ -985,7 +985,7 @@ function WorkspaceRowWithMenu({
|
||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||
state.getStatus({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.workspaceId,
|
||||
cwd: workspace.workspaceDirectory ?? workspace.projectRootPath ?? "",
|
||||
actionId: "archive-worktree",
|
||||
}),
|
||||
);
|
||||
@@ -1025,11 +1025,16 @@ function WorkspaceRowWithMenu({
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
const workspaceDirectory = workspace.workspaceDirectory ?? workspace.projectRootPath;
|
||||
if (!workspaceDirectory) {
|
||||
toast.error("Workspace path not available");
|
||||
return;
|
||||
}
|
||||
|
||||
void archiveWorktree({
|
||||
serverId: workspace.serverId,
|
||||
cwd: workspace.workspaceId,
|
||||
worktreePath: workspace.workspaceId,
|
||||
cwd: workspaceDirectory,
|
||||
worktreePath: workspaceDirectory,
|
||||
})
|
||||
.then(() => {
|
||||
redirectAfterArchive();
|
||||
@@ -1045,6 +1050,8 @@ function WorkspaceRowWithMenu({
|
||||
redirectAfterArchive,
|
||||
toast,
|
||||
workspace.name,
|
||||
workspace.projectRootPath,
|
||||
workspace.workspaceDirectory,
|
||||
workspace.serverId,
|
||||
workspace.workspaceId,
|
||||
]);
|
||||
@@ -1095,9 +1102,14 @@ function WorkspaceRowWithMenu({
|
||||
]);
|
||||
|
||||
const handleCopyPath = useCallback(() => {
|
||||
void Clipboard.setStringAsync(workspace.workspaceId);
|
||||
const workspaceDirectory = workspace.workspaceDirectory ?? workspace.projectRootPath;
|
||||
if (!workspaceDirectory) {
|
||||
toast.error("Workspace path not available");
|
||||
return;
|
||||
}
|
||||
void Clipboard.setStringAsync(workspaceDirectory);
|
||||
toast.copied("Path copied");
|
||||
}, [toast, workspace.workspaceId]);
|
||||
}, [toast, workspace.projectRootPath, workspace.workspaceDirectory]);
|
||||
|
||||
const handleCopyBranchName = useCallback(() => {
|
||||
void Clipboard.setStringAsync(workspace.name);
|
||||
|
||||
@@ -244,6 +244,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentPathname =
|
||||
typeof window === "undefined" ? null : (window.location.pathname || null);
|
||||
if (currentPathname && currentPathname !== "/welcome") {
|
||||
return;
|
||||
}
|
||||
if (!anyOnlineServerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export function WorkspaceSetupDialog() {
|
||||
initialValues: projectPath ? { workingDir: projectPath } : undefined,
|
||||
isVisible: pendingWorkspaceSetup !== null,
|
||||
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||
lockedWorkingDir: workspace?.id ?? projectPath,
|
||||
lockedWorkingDir: workspace?.projectRootPath ?? projectPath,
|
||||
},
|
||||
});
|
||||
const composerState = chatDraft.composerState;
|
||||
@@ -170,9 +170,10 @@ export function WorkspaceSetupDialog() {
|
||||
}
|
||||
|
||||
const encodedImages = await encodeImages(images);
|
||||
const workspaceDirectory = workspace.projectRootPath ?? projectPath;
|
||||
const agent = await connectedClient.createAgent({
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: workspace.id,
|
||||
cwd: workspaceDirectory,
|
||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? { modeId: composerState.selectedMode }
|
||||
: {}),
|
||||
@@ -226,9 +227,10 @@ export function WorkspaceSetupDialog() {
|
||||
throw new Error("Workspace setup composer state is required");
|
||||
}
|
||||
|
||||
const workspaceDirectory = workspace.projectRootPath ?? projectPath;
|
||||
const agent = await connectedClient.createAgent({
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: workspace.id,
|
||||
cwd: workspaceDirectory,
|
||||
terminal: true,
|
||||
...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}),
|
||||
});
|
||||
@@ -272,8 +274,13 @@ export function WorkspaceSetupDialog() {
|
||||
setErrorMessage(null);
|
||||
const workspace = await ensureWorkspace();
|
||||
const connectedClient = withConnectedClient();
|
||||
const workspaceDirectory = workspace.projectRootPath ?? projectPath;
|
||||
|
||||
const payload = await connectedClient.createTerminal(workspace.id);
|
||||
if (!workspaceDirectory) {
|
||||
throw new Error("Workspace directory not found");
|
||||
}
|
||||
|
||||
const payload = await connectedClient.createTerminal(workspaceDirectory);
|
||||
if (payload.error || !payload.terminal) {
|
||||
throw new Error(payload.error ?? "Failed to open terminal");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { TextInput } from "react-native";
|
||||
import { router, usePathname, type Href } from "expo-router";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
@@ -11,13 +12,18 @@ import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { buildHostSettingsRoute, parseServerIdFromPathname } from "@/utils/host-routes";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostSettingsRoute,
|
||||
parseServerIdFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsDesktop } from "@/constants/layout";
|
||||
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
|
||||
@@ -215,9 +221,17 @@ export function useCommandCenter() {
|
||||
// Don't restore focus back to the prior element after we navigate.
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
const workspaceId = resolveHydratedWorkspaceId({
|
||||
workspaces: useSessionStore.getState().sessions[agent.serverId]?.workspaces?.values(),
|
||||
path: agent.cwd,
|
||||
});
|
||||
if (!workspaceId) {
|
||||
router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
|
||||
return;
|
||||
}
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId: agent.serverId,
|
||||
workspaceId: agent.cwd,
|
||||
workspaceId,
|
||||
target: { kind: "agent", agentId: agent.id },
|
||||
});
|
||||
router.navigate(route as any);
|
||||
|
||||
@@ -69,6 +69,7 @@ describe("openProjectDirectly", () => {
|
||||
projectId: 1,
|
||||
projectDisplayName: "project",
|
||||
projectRootPath: WORKSPACE_ID,
|
||||
workspaceDirectory: WORKSPACE_ID,
|
||||
projectKind: "git" as const,
|
||||
workspaceKind: "checkout" as const,
|
||||
name: "project",
|
||||
@@ -87,7 +88,12 @@ describe("openProjectDirectly", () => {
|
||||
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 }),
|
||||
expect.objectContaining({
|
||||
id: "1",
|
||||
projectId: "1",
|
||||
projectRootPath: WORKSPACE_ID,
|
||||
workspaceDirectory: WORKSPACE_ID,
|
||||
}),
|
||||
]);
|
||||
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
|
||||
@@ -19,7 +19,11 @@ function workspace(
|
||||
Partial<
|
||||
Pick<
|
||||
WorkspaceDescriptor,
|
||||
"projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind"
|
||||
| "projectDisplayName"
|
||||
| "projectRootPath"
|
||||
| "workspaceDirectory"
|
||||
| "projectKind"
|
||||
| "workspaceKind"
|
||||
>
|
||||
>,
|
||||
): WorkspaceDescriptor {
|
||||
@@ -28,6 +32,7 @@ function workspace(
|
||||
projectId: input.projectId,
|
||||
projectDisplayName: input.projectDisplayName ?? input.projectId,
|
||||
projectRootPath: input.projectRootPath ?? input.id,
|
||||
workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? input.id,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "checkout",
|
||||
name: input.name,
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface SidebarWorkspaceEntry {
|
||||
workspaceKey: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
projectRootPath?: string;
|
||||
workspaceDirectory?: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
name: string;
|
||||
@@ -119,7 +121,7 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
projectName:
|
||||
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
|
||||
projectKind: workspace.projectKind,
|
||||
iconWorkingDir: workspace.projectRootPath || workspace.id,
|
||||
iconWorkingDir: workspace.projectRootPath,
|
||||
statusBucket: "done",
|
||||
activeCount: 0,
|
||||
totalWorkspaces: 0,
|
||||
@@ -131,6 +133,8 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
||||
workspaceKey: `${input.serverId}:${workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: workspace.id,
|
||||
projectRootPath: workspace.projectRootPath,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
projectKind: workspace.projectKind,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
name: workspace.name,
|
||||
@@ -254,6 +258,7 @@ function toWorkspaceDescriptor(payload: {
|
||||
projectId: number;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
name: string;
|
||||
|
||||
@@ -36,6 +36,11 @@ function LauncherPanel() {
|
||||
const { serverId, workspaceId, target, retargetCurrentTab, isPaneFocused } = usePaneContext();
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const workspaceDirectory = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[serverId]?.workspaces.get(workspaceId)?.workspaceDirectory ??
|
||||
state.sessions[serverId]?.workspaces.get(workspaceId)?.projectRootPath,
|
||||
);
|
||||
const { providers, recordUsage } = useProviderRecency();
|
||||
const setAgents = useSessionStore((state) => state.setAgents);
|
||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||
@@ -53,8 +58,8 @@ function LauncherPanel() {
|
||||
|
||||
const launchTerminalAgent = useCallback(
|
||||
async (providerId: AgentProvider) => {
|
||||
if (!client || !isConnected) {
|
||||
setErrorMessage("Host is not connected");
|
||||
if (!client || !isConnected || !workspaceDirectory) {
|
||||
setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -64,7 +69,7 @@ function LauncherPanel() {
|
||||
try {
|
||||
const agent = await client.createAgent({
|
||||
provider: providerId,
|
||||
cwd: workspaceId,
|
||||
cwd: workspaceDirectory,
|
||||
terminal: true,
|
||||
});
|
||||
recordUsage(providerId);
|
||||
@@ -82,7 +87,7 @@ function LauncherPanel() {
|
||||
setPendingAction((current) => (current === providerId ? null : current));
|
||||
}
|
||||
},
|
||||
[client, isConnected, recordUsage, retargetCurrentTab, serverId, setAgents, workspaceId],
|
||||
[client, isConnected, recordUsage, retargetCurrentTab, serverId, setAgents, workspaceDirectory],
|
||||
);
|
||||
|
||||
const openDraftTab = useCallback(() => {
|
||||
@@ -96,8 +101,8 @@ function LauncherPanel() {
|
||||
}, [retargetCurrentTab]);
|
||||
|
||||
const openTerminalTab = useCallback(async () => {
|
||||
if (!client || !isConnected) {
|
||||
setErrorMessage("Host is not connected");
|
||||
if (!client || !isConnected || !workspaceDirectory) {
|
||||
setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,7 +110,7 @@ function LauncherPanel() {
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const payload = await client.createTerminal(workspaceId);
|
||||
const payload = await client.createTerminal(workspaceDirectory);
|
||||
if (payload.error || !payload.terminal) {
|
||||
throw new Error(payload.error ?? "Failed to open terminal");
|
||||
}
|
||||
@@ -118,10 +123,20 @@ function LauncherPanel() {
|
||||
} finally {
|
||||
setPendingAction((current) => (current === "terminal" ? null : current));
|
||||
}
|
||||
}, [client, isConnected, retargetCurrentTab, workspaceId]);
|
||||
}, [client, isConnected, retargetCurrentTab, workspaceDirectory]);
|
||||
|
||||
const actionsDisabled = pendingAction !== null;
|
||||
|
||||
if (!workspaceDirectory) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={[styles.content, styles.loadingContent]}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView
|
||||
@@ -339,6 +354,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingHorizontal: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[8],
|
||||
},
|
||||
loadingContent: {
|
||||
flex: 1,
|
||||
},
|
||||
contentUnfocused: {
|
||||
opacity: 0.96,
|
||||
},
|
||||
|
||||
@@ -24,14 +24,20 @@ function useTerminalPanelDescriptor(
|
||||
context: { serverId: string; workspaceId: string },
|
||||
): PanelDescriptor {
|
||||
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
||||
const workspaceDirectory = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[context.serverId]?.workspaces.get(context.workspaceId)?.workspaceDirectory ??
|
||||
state.sessions[context.serverId]?.workspaces.get(context.workspaceId)?.projectRootPath ??
|
||||
null,
|
||||
);
|
||||
const terminalsQuery = useQuery({
|
||||
queryKey: ["terminals", context.serverId, context.workspaceId] as const,
|
||||
enabled: Boolean(client && context.workspaceId),
|
||||
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
|
||||
enabled: Boolean(client && workspaceDirectory),
|
||||
queryFn: async (): Promise<ListTerminalsPayload> => {
|
||||
if (!client) {
|
||||
return { cwd: context.workspaceId, terminals: [], requestId: "missing-client" };
|
||||
if (!client || !workspaceDirectory) {
|
||||
return { cwd: workspaceDirectory ?? "", terminals: [], requestId: "missing-client" };
|
||||
}
|
||||
return client.listTerminals(context.workspaceId);
|
||||
return client.listTerminals(workspaceDirectory);
|
||||
},
|
||||
staleTime: 5_000,
|
||||
});
|
||||
@@ -50,16 +56,22 @@ function useTerminalPanelDescriptor(
|
||||
function TerminalPanel() {
|
||||
const isFocused = useIsFocused();
|
||||
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
|
||||
const workspaceDirectory = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[serverId]?.workspaces.get(workspaceId)?.workspaceDirectory ??
|
||||
state.sessions[serverId]?.workspaces.get(workspaceId)?.projectRootPath ??
|
||||
null,
|
||||
);
|
||||
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
||||
|
||||
if (!isFocused) {
|
||||
if (!isFocused || !workspaceDirectory) {
|
||||
return <View style={{ flex: 1 }} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TerminalPane
|
||||
serverId={serverId}
|
||||
cwd={workspaceId}
|
||||
cwd={workspaceDirectory}
|
||||
terminalId={target.terminalId}
|
||||
isPaneFocused={isPaneFocused}
|
||||
/>
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { shortenPath } from "@/utils/shorten-path";
|
||||
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
|
||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||
@@ -49,6 +50,7 @@ import type {
|
||||
AgentSessionConfig,
|
||||
} from "@server/server/agent/agent-sdk-types";
|
||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
||||
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
@@ -750,7 +752,7 @@ function DraftAgentScreenContent({
|
||||
optimisticStreamItems,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
} = useDraftAgentCreateFlow<Agent, { id: string; cwd: string }>({
|
||||
} = useDraftAgentCreateFlow<Agent, { id: string; workspaceId: string | null }>({
|
||||
draftId: draftIdRef.current,
|
||||
getPendingServerId: () => selectedServerId,
|
||||
validateBeforeSubmit: ({ text }) => {
|
||||
@@ -908,20 +910,29 @@ function DraftAgentScreenContent({
|
||||
|
||||
const createdWorkingDir = typeof result.cwd === "string" ? result.cwd.trim() : "";
|
||||
const configuredWorkingDir = config.cwd.trim();
|
||||
const workspaceId = createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir;
|
||||
const workspaceId = resolveHydratedWorkspaceId({
|
||||
workspaces: useSessionStore.getState().sessions[selectedServerId]?.workspaces?.values(),
|
||||
path: createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir,
|
||||
});
|
||||
|
||||
return {
|
||||
agentId: result.id,
|
||||
result: {
|
||||
id: result.id,
|
||||
cwd: workspaceId,
|
||||
workspaceId,
|
||||
},
|
||||
};
|
||||
},
|
||||
onCreateSuccess: ({ result }) => {
|
||||
if (!result.workspaceId) {
|
||||
router.replace(
|
||||
buildHostAgentDetailRoute(selectedServerId as string, result.id) as any,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId: selectedServerId as string,
|
||||
workspaceId: result.cwd,
|
||||
workspaceId: result.workspaceId,
|
||||
target: { kind: "agent", agentId: result.id },
|
||||
});
|
||||
router.replace(route as any);
|
||||
|
||||
@@ -81,7 +81,7 @@ describe("workspace agent visibility", () => {
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceId,
|
||||
workspaceDirectory: workspaceId,
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
|
||||
@@ -142,13 +142,33 @@ describe("workspace agent visibility", () => {
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceId: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid",
|
||||
workspaceDirectory: "/Users/moboudra/.paseo/worktrees/1luy0po7/normal-squid",
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["slash-agent"]));
|
||||
expect(result.knownAgentIds.has("slash-agent")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches workspace agents using the workspace directory even when the route uses a numeric workspace id", () => {
|
||||
const sessionAgents = new Map<string, Agent>([
|
||||
[
|
||||
"terminal-agent",
|
||||
makeAgent({
|
||||
id: "terminal-agent",
|
||||
cwd: "/tmp/workspace-lifecycle-main",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceDirectory: "/tmp/workspace-lifecycle-main",
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["terminal-agent"]));
|
||||
expect(result.knownAgentIds).toEqual(new Set(["terminal-agent"]));
|
||||
});
|
||||
|
||||
describe("workspaceAgentVisibilityEqual", () => {
|
||||
it("returns true for identical sets", () => {
|
||||
const a = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) };
|
||||
|
||||
@@ -12,11 +12,11 @@ export interface WorkspaceAgentVisibility {
|
||||
|
||||
export function deriveWorkspaceAgentVisibility(input: {
|
||||
sessionAgents: Map<string, Agent> | undefined;
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): WorkspaceAgentVisibility {
|
||||
const { sessionAgents, workspaceId } = input;
|
||||
const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId);
|
||||
if (!sessionAgents || !workspaceId) {
|
||||
const { sessionAgents, workspaceDirectory } = input;
|
||||
const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory);
|
||||
if (!sessionAgents || !normalizedWorkspaceDirectory) {
|
||||
return {
|
||||
activeAgentIds: new Set<string>(),
|
||||
knownAgentIds: new Set<string>(),
|
||||
@@ -26,7 +26,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
const activeAgentIds = new Set<string>();
|
||||
const knownAgentIds = new Set<string>();
|
||||
for (const agent of sessionAgents.values()) {
|
||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceId) {
|
||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
continue;
|
||||
}
|
||||
knownAgentIds.add(agent.id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useMemo, useRef } from "react";
|
||||
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Composer } from "@/components/composer";
|
||||
import { FileDropZone } from "@/components/file-drop-zone";
|
||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||
@@ -10,7 +11,7 @@ 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 { type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||
@@ -48,6 +49,11 @@ export function WorkspaceDraftAgentTab({
|
||||
}: WorkspaceDraftAgentTabProps) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const workspaceDirectory = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[serverId]?.workspaces.get(workspaceId)?.workspaceDirectory ??
|
||||
state.sessions[serverId]?.workspaces.get(workspaceId)?.projectRootPath,
|
||||
);
|
||||
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||
const draftStoreKey = useMemo(
|
||||
() =>
|
||||
@@ -63,10 +69,10 @@ export function WorkspaceDraftAgentTab({
|
||||
draftKey: draftStoreKey,
|
||||
composer: {
|
||||
initialServerId: serverId,
|
||||
initialValues: { workingDir: workspaceId },
|
||||
initialValues: { workingDir: workspaceDirectory },
|
||||
isVisible: true,
|
||||
onlineServerIds: isConnected ? [serverId] : [],
|
||||
lockedWorkingDir: workspaceId,
|
||||
lockedWorkingDir: workspaceDirectory,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -97,6 +103,9 @@ export function WorkspaceDraftAgentTab({
|
||||
if (!composerState.effectiveModelId) {
|
||||
return "No model is available for the selected provider";
|
||||
}
|
||||
if (!workspaceDirectory) {
|
||||
return "Workspace directory not found";
|
||||
}
|
||||
if (!client) {
|
||||
return "Host is not connected";
|
||||
}
|
||||
@@ -110,6 +119,7 @@ export function WorkspaceDraftAgentTab({
|
||||
Keyboard.dismiss();
|
||||
},
|
||||
buildDraftAgent: (attempt) => {
|
||||
invariant(workspaceDirectory, "Workspace directory is required");
|
||||
const now = attempt.timestamp;
|
||||
const model = composerState.effectiveModelId || null;
|
||||
const thinkingOptionId = composerState.effectiveThinkingOptionId || null;
|
||||
@@ -134,20 +144,21 @@ export function WorkspaceDraftAgentTab({
|
||||
persistence: null,
|
||||
runtimeInfo: { provider: composerState.selectedProvider, sessionId: null, model, modeId },
|
||||
title: "Agent",
|
||||
cwd: workspaceId,
|
||||
cwd: workspaceDirectory,
|
||||
model,
|
||||
thinkingOptionId,
|
||||
labels: {},
|
||||
};
|
||||
},
|
||||
createRequest: async ({ attempt, text, images }) => {
|
||||
invariant(workspaceDirectory, "Workspace directory is required");
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
|
||||
const config = buildWorkspaceDraftAgentConfig({
|
||||
provider: composerState.selectedProvider,
|
||||
cwd: workspaceId,
|
||||
cwd: workspaceDirectory,
|
||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||
? { modeId: composerState.selectedMode }
|
||||
: {}),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||
|
||||
export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }): {
|
||||
title: string;
|
||||
@@ -7,7 +6,7 @@ export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }
|
||||
} {
|
||||
return {
|
||||
title: input.workspace.name,
|
||||
subtitle: projectDisplayNameFromProjectId(input.workspace.projectId),
|
||||
subtitle: input.workspace.projectDisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -554,8 +554,24 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||
|
||||
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
||||
const normalizedWorkspaceId =
|
||||
const rawWorkspaceIdentifier =
|
||||
normalizeWorkspaceIdentity(decodeWorkspaceIdFromPathSegment(workspaceId)) ?? "";
|
||||
|
||||
// Resolve the workspace ID: first try direct map key, then fall back to path-based lookup.
|
||||
// This lets URLs that encode a filesystem path (e.g. from deep links or older bookmarks)
|
||||
// resolve correctly even though the map is keyed by numeric DB IDs.
|
||||
const normalizedWorkspaceId = useSessionStore((state) => {
|
||||
if (!normalizedServerId || !rawWorkspaceIdentifier) return "";
|
||||
const workspaces = state.sessions[normalizedServerId]?.workspaces;
|
||||
if (!workspaces) return rawWorkspaceIdentifier;
|
||||
if (workspaces.has(rawWorkspaceIdentifier)) return rawWorkspaceIdentifier;
|
||||
for (const [id, ws] of workspaces.entries()) {
|
||||
if (normalizeWorkspaceIdentity(ws.workspaceDirectory) === rawWorkspaceIdentifier) return id;
|
||||
if (normalizeWorkspaceIdentity(ws.projectRootPath) === rawWorkspaceIdentifier) return id;
|
||||
}
|
||||
return rawWorkspaceIdentifier;
|
||||
});
|
||||
|
||||
const workspaceTerminalScopeKey =
|
||||
normalizedServerId && normalizedWorkspaceId
|
||||
? `${normalizedServerId}:${normalizedWorkspaceId}`
|
||||
@@ -567,43 +583,47 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const queryClient = useQueryClient();
|
||||
const client = useHostRuntimeClient(normalizedServerId);
|
||||
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
|
||||
const workspaceDescriptor = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ?? null,
|
||||
);
|
||||
const workspaceDirectory =
|
||||
workspaceDescriptor?.workspaceDirectory ?? workspaceDescriptor?.projectRootPath ?? null;
|
||||
|
||||
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
deriveWorkspaceAgentVisibility({
|
||||
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
}),
|
||||
workspaceAgentVisibilityEqual,
|
||||
);
|
||||
|
||||
const terminalsQueryKey = useMemo(
|
||||
() => ["terminals", normalizedServerId, normalizedWorkspaceId] as const,
|
||||
[normalizedServerId, normalizedWorkspaceId],
|
||||
() => ["terminals", normalizedServerId, workspaceDirectory] as const,
|
||||
[normalizedServerId, workspaceDirectory],
|
||||
);
|
||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||
const terminalsQuery = useQuery({
|
||||
queryKey: terminalsQueryKey,
|
||||
enabled:
|
||||
Boolean(client && isConnected) &&
|
||||
normalizedWorkspaceId.length > 0 &&
|
||||
normalizedWorkspaceId.startsWith("/"),
|
||||
Boolean(workspaceDirectory),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.listTerminals(normalizedWorkspaceId);
|
||||
return await client.listTerminals(workspaceDirectory);
|
||||
},
|
||||
staleTime: TERMINALS_QUERY_STALE_TIME,
|
||||
});
|
||||
const terminals = terminalsQuery.data?.terminals ?? [];
|
||||
const createTerminalMutation = useMutation({
|
||||
mutationFn: async (input?: { paneId?: string }) => {
|
||||
if (!client) {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return await client.createTerminal(normalizedWorkspaceId);
|
||||
return await client.createTerminal(workspaceDirectory);
|
||||
},
|
||||
onSuccess: (payload, input) => {
|
||||
const createdTerminal = payload.terminal;
|
||||
@@ -613,8 +633,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
terminals: current?.terminals ?? [],
|
||||
terminal: createdTerminal,
|
||||
});
|
||||
const cwd = current?.cwd ?? workspaceDirectory ?? undefined;
|
||||
return {
|
||||
cwd: current?.cwd ?? normalizedWorkspaceId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
terminals: nextTerminals,
|
||||
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
||||
};
|
||||
@@ -657,7 +678,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
|
||||
useEffect(() => {
|
||||
if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) {
|
||||
if (!client || !isConnected || !workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -665,7 +686,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
if (message.type !== "terminals_changed") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.cwd !== normalizedWorkspaceId) {
|
||||
if (message.payload.cwd !== workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -676,32 +697,27 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
}));
|
||||
});
|
||||
|
||||
client.subscribeTerminals({ cwd: normalizedWorkspaceId });
|
||||
client.subscribeTerminals({ cwd: workspaceDirectory });
|
||||
|
||||
return () => {
|
||||
unsubscribeChanged();
|
||||
client.unsubscribeTerminals({ cwd: normalizedWorkspaceId });
|
||||
client.unsubscribeTerminals({ cwd: workspaceDirectory });
|
||||
};
|
||||
}, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]);
|
||||
}, [client, isConnected, queryClient, terminalsQueryKey, workspaceDirectory]);
|
||||
|
||||
const checkoutQuery = useQuery({
|
||||
queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId),
|
||||
queryKey: checkoutStatusQueryKey(normalizedServerId, workspaceDirectory ?? ""),
|
||||
enabled:
|
||||
Boolean(client && isConnected) &&
|
||||
normalizedWorkspaceId.length > 0 &&
|
||||
normalizedWorkspaceId.startsWith("/"),
|
||||
Boolean(workspaceDirectory),
|
||||
queryFn: async () => {
|
||||
if (!client) {
|
||||
if (!client || !workspaceDirectory) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
return (await client.getCheckoutStatus(normalizedWorkspaceId)) as CheckoutStatusPayload;
|
||||
return (await client.getCheckoutStatus(workspaceDirectory)) as CheckoutStatusPayload;
|
||||
},
|
||||
staleTime: 15_000,
|
||||
});
|
||||
|
||||
const workspaceDescriptor = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ?? null,
|
||||
);
|
||||
const hasHydratedWorkspaces = useSessionStore(
|
||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||
);
|
||||
@@ -731,15 +747,15 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||
|
||||
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
||||
if (!normalizedServerId || !normalizedWorkspaceId.startsWith("/")) {
|
||||
if (!normalizedServerId || !workspaceDirectory) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
serverId: normalizedServerId,
|
||||
cwd: normalizedWorkspaceId,
|
||||
cwd: workspaceDirectory,
|
||||
isGit: isGitCheckout,
|
||||
};
|
||||
}, [isGitCheckout, normalizedServerId, normalizedWorkspaceId]);
|
||||
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveExplorerCheckout(activeExplorerCheckout);
|
||||
@@ -1081,12 +1097,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
if (createTerminalMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
if (!normalizedWorkspaceId.startsWith("/")) {
|
||||
if (!workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
createTerminalMutation.mutate(input);
|
||||
},
|
||||
[createTerminalMutation, normalizedWorkspaceId],
|
||||
[createTerminalMutation, workspaceDirectory],
|
||||
);
|
||||
|
||||
const handleSelectSwitcherTab = useCallback(
|
||||
@@ -1270,18 +1286,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
);
|
||||
|
||||
const handleCopyWorkspacePath = useCallback(async () => {
|
||||
if (!normalizedWorkspaceId.startsWith("/")) {
|
||||
if (!workspaceDirectory) {
|
||||
toast.error("Workspace path not available");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Clipboard.setStringAsync(normalizedWorkspaceId);
|
||||
await Clipboard.setStringAsync(workspaceDirectory);
|
||||
toast.copied("Workspace path");
|
||||
} catch {
|
||||
toast.error("Copy failed");
|
||||
}
|
||||
}, [normalizedWorkspaceId, toast]);
|
||||
}, [toast, workspaceDirectory]);
|
||||
|
||||
const handleCopyBranchName = useCallback(async () => {
|
||||
if (!currentBranchName) {
|
||||
@@ -1927,7 +1943,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-copy-path"
|
||||
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
|
||||
disabled={!normalizedWorkspaceId.startsWith("/")}
|
||||
disabled={!workspaceDirectory}
|
||||
onSelect={handleCopyWorkspacePath}
|
||||
>
|
||||
Copy workspace path
|
||||
@@ -1952,7 +1968,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
<>
|
||||
<WorkspaceGitActions
|
||||
serverId={normalizedServerId}
|
||||
cwd={normalizedWorkspaceId}
|
||||
cwd={workspaceDirectory ?? ""}
|
||||
/>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -2135,7 +2151,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
||||
<ExplorerSidebar
|
||||
serverId={normalizedServerId}
|
||||
workspaceId={normalizedWorkspaceId}
|
||||
workspaceRoot={normalizedWorkspaceId}
|
||||
workspaceRoot={workspaceDirectory ?? ""}
|
||||
isGit={isGitCheckout}
|
||||
onOpenFile={handleOpenFileFromExplorer}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,7 @@ describe("workspace source of truth consumption", () => {
|
||||
projectId: "remote:github.com/getpaseo/paseo",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
projectRootPath: "/repo/main",
|
||||
workspaceDirectory: "/repo/main",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "feat/workspace-sot",
|
||||
|
||||
@@ -118,6 +118,7 @@ export interface WorkspaceDescriptor {
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
projectRootPath: string;
|
||||
workspaceDirectory: string;
|
||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
|
||||
name: string;
|
||||
@@ -135,6 +136,7 @@ export function normalizeWorkspaceDescriptor(
|
||||
projectId: String(payload.projectId),
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
workspaceDirectory: payload.workspaceDirectory,
|
||||
projectKind: payload.projectKind,
|
||||
workspaceKind: payload.workspaceKind,
|
||||
name: payload.name,
|
||||
|
||||
26
packages/app/src/utils/resolve-hydrated-workspace-id.ts
Normal file
26
packages/app/src/utils/resolve-hydrated-workspace-id.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
|
||||
|
||||
export function resolveHydratedWorkspaceId(input: {
|
||||
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
|
||||
path: string | null | undefined;
|
||||
}): string | null {
|
||||
const normalizedPath = normalizeWorkspaceIdentity(input.path);
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const workspace of input.workspaces ?? []) {
|
||||
if (normalizeWorkspaceIdentity(workspace.id) === normalizedPath) {
|
||||
return workspace.id;
|
||||
}
|
||||
if (normalizeWorkspaceIdentity(workspace.workspaceDirectory) === normalizedPath) {
|
||||
return workspace.id;
|
||||
}
|
||||
if (normalizeWorkspaceIdentity(workspace.projectRootPath) === normalizedPath) {
|
||||
return workspace.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ function workspace(
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project",
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "worktree",
|
||||
name: input.name ?? input.id,
|
||||
|
||||
@@ -610,6 +610,104 @@ describe("AgentManager", () => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
test("terminal agent creation ignores title propagation before the initial snapshot is persisted", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-title-race-"));
|
||||
const dataDir = join(workdir, "db");
|
||||
const database = await openPaseoDatabase(dataDir);
|
||||
let manager: AgentManager | null = null;
|
||||
|
||||
try {
|
||||
const workspaceId = await seedWorkspace(database, { directory: workdir });
|
||||
const storage = new DbAgentSnapshotStore(database.db);
|
||||
const terminalManager: TerminalManager = {
|
||||
async getTerminals() {
|
||||
return [];
|
||||
},
|
||||
async createTerminal(options) {
|
||||
const exitListeners = new Set<(info: TerminalExitInfo) => void>();
|
||||
const titleListeners = new Set<(title?: string) => void>();
|
||||
const session: TerminalSession = {
|
||||
id: options.id,
|
||||
name: options.name ?? "Terminal",
|
||||
cwd: options.cwd,
|
||||
send: () => {},
|
||||
subscribe: () => () => {},
|
||||
onExit(listener) {
|
||||
exitListeners.add(listener);
|
||||
return () => {
|
||||
exitListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
onTitleChange(listener) {
|
||||
titleListeners.add(listener);
|
||||
return () => {
|
||||
titleListeners.delete(listener);
|
||||
};
|
||||
},
|
||||
getSize: () => ({ rows: 24, cols: 80 }),
|
||||
getState: () => ({
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
cursor: { row: 0, col: 0 },
|
||||
scrollback: [],
|
||||
grid: [],
|
||||
}),
|
||||
getTitle: () => "Agent Shell",
|
||||
getExitInfo: () => null,
|
||||
kill() {
|
||||
for (const listener of Array.from(exitListeners)) {
|
||||
listener({ exitCode: null, signal: null, lastOutputLines: [] });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const agentId = manager?.getAgentIdForTerminal(options.id) ?? null;
|
||||
if (agentId) {
|
||||
await manager?.setTitle(agentId, "Agent Shell");
|
||||
}
|
||||
|
||||
return session;
|
||||
},
|
||||
registerCwdEnv() {},
|
||||
getTerminal() {
|
||||
return undefined;
|
||||
},
|
||||
killTerminal() {},
|
||||
listDirectories() {
|
||||
return [];
|
||||
},
|
||||
killAll() {},
|
||||
subscribeTerminalsChanged() {
|
||||
return () => {};
|
||||
},
|
||||
};
|
||||
|
||||
manager = new AgentManager({
|
||||
clients: { codex: new TerminalTestAgentClient() },
|
||||
registry: storage,
|
||||
terminalManager,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-00000000aa13",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
terminal: true,
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId },
|
||||
);
|
||||
|
||||
const stored = await storage.get(snapshot.id);
|
||||
expect(stored?.title).toBe("Agent Shell");
|
||||
} finally {
|
||||
await database.close();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("terminal agent creation preserves titles propagated during terminal registration", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-registration-title-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
@@ -669,6 +767,35 @@ describe("AgentManager", () => {
|
||||
terminalManager.killAll();
|
||||
});
|
||||
|
||||
test("getMetricsSnapshot skips agents without in-memory timeline state", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-metrics-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TerminalTestAgentClient() },
|
||||
registry: storage,
|
||||
terminalManager: createStubTerminalManager(),
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-00000000aa14",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
expect(manager.getMetricsSnapshot()).toEqual({
|
||||
total: 1,
|
||||
byLifecycle: { idle: 1 },
|
||||
withActiveForegroundTurn: 0,
|
||||
timelineStats: {
|
||||
totalItems: 0,
|
||||
maxItemsPerAgent: 0,
|
||||
},
|
||||
});
|
||||
expect(snapshot.terminal).toBe(true);
|
||||
});
|
||||
|
||||
test("terminal agent closure preserves exit diagnostics for failed launches", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-exit-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
|
||||
@@ -385,6 +385,7 @@ export class AgentManager {
|
||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||
private readonly agents = new Map<string, LiveManagedAgent>();
|
||||
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
||||
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
||||
private readonly sessionEventTails = new Map<string, Promise<void>>();
|
||||
private readonly pendingForegroundRuns = new Map<string, PendingForegroundRun>();
|
||||
private readonly subscribers = new Set<SubscriptionRecord>();
|
||||
@@ -434,6 +435,10 @@ export class AgentManager {
|
||||
withActiveForegroundTurn++;
|
||||
}
|
||||
|
||||
if (!this.timelineStore.has(agent.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const len = this.timelineStore.getItems(agent.id).length;
|
||||
totalItems += len;
|
||||
if (len > maxItemsPerAgent) {
|
||||
@@ -732,6 +737,7 @@ export class AgentManager {
|
||||
persistence,
|
||||
{
|
||||
labels: options?.labels,
|
||||
workspaceId: options?.workspaceId,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1037,6 +1043,13 @@ export class AgentManager {
|
||||
if (!normalizedTitle) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.agentsAwaitingInitialSnapshotPersist.has(agent.id) &&
|
||||
this.registry &&
|
||||
(await this.registry.get(agent.id)) === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
await this.persistSnapshot(agent, { title: normalizedTitle });
|
||||
this.emitState(agent, { persist: false });
|
||||
@@ -2110,6 +2123,7 @@ export class AgentManager {
|
||||
terminalCommand: TerminalCommand,
|
||||
persistence: AgentPersistenceHandle,
|
||||
options?: {
|
||||
workspaceId?: number;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
@@ -2173,6 +2187,7 @@ export class AgentManager {
|
||||
|
||||
this.agents.set(resolvedAgentId, managed);
|
||||
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
|
||||
this.agentsAwaitingInitialSnapshotPersist.add(resolvedAgentId);
|
||||
|
||||
let terminalSession: TerminalSession;
|
||||
try {
|
||||
@@ -2187,12 +2202,14 @@ export class AgentManager {
|
||||
} catch (error) {
|
||||
this.agents.delete(resolvedAgentId);
|
||||
this.previousStatuses.delete(resolvedAgentId);
|
||||
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (terminalSession.id !== reservedTerminalId) {
|
||||
this.agents.delete(resolvedAgentId);
|
||||
this.previousStatuses.delete(resolvedAgentId);
|
||||
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
|
||||
throw new Error(
|
||||
`Reserved terminal id ${reservedTerminalId} but terminal manager returned ${terminalSession.id}`,
|
||||
);
|
||||
@@ -2203,12 +2220,17 @@ export class AgentManager {
|
||||
});
|
||||
managed.unsubscribeTerminalExit = unsubscribeTerminalExit;
|
||||
const terminalSessionTitle = terminalSession.getTitle()?.trim();
|
||||
await this.persistSnapshot(managed, {
|
||||
title:
|
||||
terminalSessionTitle && terminalSessionTitle.length > 0
|
||||
? terminalSessionTitle
|
||||
: initialPersistedTitle,
|
||||
});
|
||||
try {
|
||||
await this.persistSnapshot(managed, {
|
||||
workspaceId: options?.workspaceId,
|
||||
title:
|
||||
terminalSessionTitle && terminalSessionTitle.length > 0
|
||||
? terminalSessionTitle
|
||||
: initialPersistedTitle,
|
||||
});
|
||||
} finally {
|
||||
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
|
||||
}
|
||||
this.emitState(managed);
|
||||
return { ...managed };
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ import { DbAgentSnapshotStore } from "./db/db-agent-snapshot-store.js";
|
||||
import { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
|
||||
import { DbProjectRegistry } from "./db/db-project-registry.js";
|
||||
import { DbWorkspaceRegistry } from "./db/db-workspace-registry.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
import { importLegacyAgentSnapshots } from "./db/legacy-agent-snapshot-import.js";
|
||||
import { importLegacyProjectWorkspaceJson } from "./db/legacy-project-workspace-import.js";
|
||||
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./db/sqlite-database.js";
|
||||
@@ -398,6 +399,15 @@ export async function createPaseoDaemon(
|
||||
|
||||
const projectRegistry = new DbProjectRegistry(database.db);
|
||||
const workspaceRegistry = new DbWorkspaceRegistry(database.db);
|
||||
|
||||
const reconciliationService = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger,
|
||||
});
|
||||
reconciliationService.start();
|
||||
logger.info({ elapsed: elapsed() }, "Workspace reconciliation service started");
|
||||
|
||||
await importLegacyProjectWorkspaceJson({
|
||||
db: database.db,
|
||||
paseoHome: config.paseoHome,
|
||||
@@ -749,6 +759,7 @@ export async function createPaseoDaemon(
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
reconciliationService.stop();
|
||||
await closeAllAgents(logger, agentManager);
|
||||
await agentManager.flush().catch(() => undefined);
|
||||
await shutdownProviders(logger, {
|
||||
|
||||
@@ -184,7 +184,7 @@ export class DbAgentSnapshotStore implements AgentSnapshotStore {
|
||||
}
|
||||
|
||||
if (nextWorkspaceId === undefined) {
|
||||
throw new Error(`Workspace ID required for agent ${agent.id}`);
|
||||
return;
|
||||
}
|
||||
await this.upsert(record, nextWorkspaceId);
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ import {
|
||||
toCheckoutError,
|
||||
} from "./checkout-git-utils.js";
|
||||
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
|
||||
import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js";
|
||||
import type { LocalSpeechModelId } from "./speech/providers/local/models.js";
|
||||
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
|
||||
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js";
|
||||
@@ -181,12 +182,14 @@ type DeleteFencedAgentSnapshotStore = AgentSnapshotStore & {
|
||||
beginDelete(agentId: string): void;
|
||||
};
|
||||
|
||||
|
||||
function beginAgentDeleteIfSupported(agentStorage: AgentSnapshotStore, agentId: string): void {
|
||||
if ("beginDelete" in agentStorage && typeof agentStorage.beginDelete === "function") {
|
||||
(agentStorage as DeleteFencedAgentSnapshotStore).beginDelete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function deriveInitialAgentTitle(prompt: string): string | null {
|
||||
const firstContentLine = prompt
|
||||
.split(/\r?\n/)
|
||||
@@ -4741,6 +4744,7 @@ export class Session {
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId),
|
||||
projectRootPath: resolvedProjectRecord?.directory ?? workspace.directory,
|
||||
workspaceDirectory: workspace.directory,
|
||||
projectKind: resolvedProjectRecord?.kind ?? "directory",
|
||||
workspaceKind: workspace.kind,
|
||||
name: workspace.displayName,
|
||||
@@ -5095,11 +5099,12 @@ export class Session {
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
const directoryName = normalizedCwd.split(/[\\/]/).filter(Boolean).at(-1) ?? normalizedCwd;
|
||||
const gitMetadata = detectWorkspaceGitMetadata(normalizedCwd, directoryName);
|
||||
const projectId = await this.projectRegistry.insert({
|
||||
directory: normalizedCwd,
|
||||
displayName: directoryName,
|
||||
kind: "directory",
|
||||
gitRemote: null,
|
||||
displayName: gitMetadata.projectDisplayName,
|
||||
kind: gitMetadata.projectKind,
|
||||
gitRemote: gitMetadata.gitRemote,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
archivedAt: null,
|
||||
@@ -5107,7 +5112,7 @@ export class Session {
|
||||
const workspaceId = await this.workspaceRegistry.insert({
|
||||
projectId,
|
||||
directory: normalizedCwd,
|
||||
displayName: directoryName,
|
||||
displayName: gitMetadata.workspaceDisplayName,
|
||||
kind: "checkout",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
@@ -266,6 +266,28 @@ function createStoredTerminalAgentRecord(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function createTempGitRepo(options?: {
|
||||
remoteUrl?: string;
|
||||
branchName?: string;
|
||||
}): { tempDir: string; repoDir: string } {
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-git-")));
|
||||
const repoDir = path.join(tempDir, "repo");
|
||||
execSync(`mkdir -p ${repoDir}`);
|
||||
execSync(`git init -b ${options?.branchName ?? "main"}`, { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.email 'test@test.com'", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.name 'Test'", { cwd: repoDir, stdio: "pipe" });
|
||||
writeFileSync(path.join(repoDir, "file.txt"), "hello\n");
|
||||
execSync("git add .", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'initial'", { cwd: repoDir, stdio: "pipe" });
|
||||
if (options?.remoteUrl) {
|
||||
execSync(`git remote add origin ${JSON.stringify(options.remoteUrl)}`, {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
}
|
||||
return { tempDir, repoDir };
|
||||
}
|
||||
|
||||
describe("workspace aggregation", () => {
|
||||
test("terminal agents reject timeline fetch without reloading as chat sessions", async () => {
|
||||
const emitted: Array<{ type: string; payload: any }> = [];
|
||||
@@ -567,7 +589,8 @@ describe("workspace aggregation", () => {
|
||||
expect(response?.payload.workspace?.id).toEqual(expect.any(Number));
|
||||
const persistedWorkspace = workspaces.get(response!.payload.workspace.id);
|
||||
expect(persistedWorkspace?.directory).toContain(path.join("worktree-123"));
|
||||
expect(existsSync(persistedWorkspace?.directory ?? "")).toBe(true);
|
||||
// The worktree directory is created asynchronously in the background after
|
||||
// the response is sent, so we only verify the DB record here.
|
||||
expect(workspaces.has(response!.payload.workspace.id)).toBe(true);
|
||||
expect(projects.has(response?.payload.workspace?.projectId)).toBe(true);
|
||||
} finally {
|
||||
@@ -606,4 +629,94 @@ describe("workspace aggregation", () => {
|
||||
error: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("open_project_request creates git projects with GitHub owner/repo and branch names", async () => {
|
||||
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
|
||||
const { tempDir, repoDir } = createTempGitRepo({
|
||||
remoteUrl: "git@github.com:acme/repo.git",
|
||||
branchName: "feature/test-branch",
|
||||
});
|
||||
|
||||
try {
|
||||
await (session as any).handleOpenProjectRequest({
|
||||
type: "open_project_request",
|
||||
cwd: repoDir,
|
||||
requestId: "req-open-git",
|
||||
});
|
||||
|
||||
expect(Array.from(projects.values())).toEqual([
|
||||
expect.objectContaining({
|
||||
directory: repoDir,
|
||||
kind: "git",
|
||||
displayName: "acme/repo",
|
||||
gitRemote: "git@github.com:acme/repo.git",
|
||||
}),
|
||||
]);
|
||||
expect(Array.from(workspaces.values())).toEqual([
|
||||
expect.objectContaining({
|
||||
directory: repoDir,
|
||||
displayName: "feature/test-branch",
|
||||
kind: "checkout",
|
||||
}),
|
||||
]);
|
||||
|
||||
const response = emitted.find((message) => message.type === "open_project_response") as any;
|
||||
expect(response?.payload).toMatchObject({
|
||||
error: null,
|
||||
workspace: {
|
||||
projectDisplayName: "acme/repo",
|
||||
projectKind: "git",
|
||||
name: "feature/test-branch",
|
||||
workspaceKind: "checkout",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("open_project_request treats non-git directories as directory projects", async () => {
|
||||
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
|
||||
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-dir-")));
|
||||
const projectDir = path.join(tempDir, "plain-dir");
|
||||
execSync(`mkdir -p ${projectDir}`);
|
||||
writeFileSync(path.join(projectDir, "README.md"), "hello\n");
|
||||
|
||||
try {
|
||||
await (session as any).handleOpenProjectRequest({
|
||||
type: "open_project_request",
|
||||
cwd: projectDir,
|
||||
requestId: "req-open-dir",
|
||||
});
|
||||
|
||||
expect(Array.from(projects.values())).toEqual([
|
||||
expect.objectContaining({
|
||||
directory: projectDir,
|
||||
kind: "directory",
|
||||
displayName: "plain-dir",
|
||||
gitRemote: null,
|
||||
}),
|
||||
]);
|
||||
expect(Array.from(workspaces.values())).toEqual([
|
||||
expect.objectContaining({
|
||||
directory: projectDir,
|
||||
displayName: "plain-dir",
|
||||
kind: "checkout",
|
||||
}),
|
||||
]);
|
||||
|
||||
const response = emitted.find((message) => message.type === "open_project_response") as any;
|
||||
expect(response?.payload).toMatchObject({
|
||||
error: null,
|
||||
workspace: {
|
||||
projectDisplayName: "plain-dir",
|
||||
projectKind: "directory",
|
||||
name: "plain-dir",
|
||||
workspaceKind: "checkout",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
82
packages/server/src/server/workspace-git-metadata.ts
Normal file
82
packages/server/src/server/workspace-git-metadata.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { execSync } from "child_process";
|
||||
import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js";
|
||||
|
||||
export type WorkspaceGitMetadata = {
|
||||
projectKind: "git" | "directory";
|
||||
projectDisplayName: string;
|
||||
workspaceDisplayName: string;
|
||||
gitRemote: string | null;
|
||||
};
|
||||
|
||||
export function readGitCommand(cwd: string, command: string): string | null {
|
||||
try {
|
||||
const output = execSync(command, {
|
||||
cwd,
|
||||
env: READ_ONLY_GIT_ENV,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
const trimmed = output.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseGitHubRepoFromRemote(remoteUrl: string): string | null {
|
||||
let cleaned = remoteUrl.trim();
|
||||
if (!cleaned) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cleaned.startsWith("git@github.com:")) {
|
||||
cleaned = cleaned.slice("git@github.com:".length);
|
||||
} else if (cleaned.startsWith("https://github.com/")) {
|
||||
cleaned = cleaned.slice("https://github.com/".length);
|
||||
} else if (cleaned.startsWith("http://github.com/")) {
|
||||
cleaned = cleaned.slice("http://github.com/".length);
|
||||
} else {
|
||||
const marker = "github.com/";
|
||||
const markerIndex = cleaned.indexOf(marker);
|
||||
if (markerIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
cleaned = cleaned.slice(markerIndex + marker.length);
|
||||
}
|
||||
|
||||
if (cleaned.endsWith(".git")) {
|
||||
cleaned = cleaned.slice(0, -".git".length);
|
||||
}
|
||||
|
||||
if (!cleaned.includes("/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function detectWorkspaceGitMetadata(
|
||||
cwd: string,
|
||||
directoryName: string,
|
||||
): WorkspaceGitMetadata {
|
||||
const gitDir = readGitCommand(cwd, "git rev-parse --git-dir");
|
||||
if (!gitDir) {
|
||||
return {
|
||||
projectKind: "directory",
|
||||
projectDisplayName: directoryName,
|
||||
workspaceDisplayName: directoryName,
|
||||
gitRemote: null,
|
||||
};
|
||||
}
|
||||
|
||||
const gitRemote = readGitCommand(cwd, "git config --get remote.origin.url");
|
||||
const githubRepo = gitRemote ? parseGitHubRepoFromRemote(gitRemote) : null;
|
||||
const branchName = readGitCommand(cwd, "git symbolic-ref --short HEAD");
|
||||
|
||||
return {
|
||||
projectKind: "git",
|
||||
projectDisplayName: githubRepo ?? directoryName,
|
||||
workspaceDisplayName: branchName ?? directoryName,
|
||||
gitRemote,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test, vi, afterEach } from "vitest";
|
||||
import {
|
||||
createPersistedProjectRecord,
|
||||
createPersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import { WorkspaceReconciliationService } from "./workspace-reconciliation-service.js";
|
||||
|
||||
function createTestRegistries() {
|
||||
const projects = new Map<number, PersistedProjectRecord>();
|
||||
const workspaces = new Map<number, PersistedWorkspaceRecord>();
|
||||
let nextProjectId = 1;
|
||||
let nextWorkspaceId = 1;
|
||||
|
||||
const projectRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(projects.values()),
|
||||
get: async (id: number) => projects.get(id) ?? null,
|
||||
insert: async (record: Omit<PersistedProjectRecord, "id">) => {
|
||||
const id = nextProjectId++;
|
||||
projects.set(id, createPersistedProjectRecord({ id, ...record }));
|
||||
return id;
|
||||
},
|
||||
upsert: async (record: PersistedProjectRecord) => {
|
||||
projects.set(record.id, record);
|
||||
},
|
||||
archive: async (id: number, archivedAt: string) => {
|
||||
const existing = projects.get(id);
|
||||
if (existing) {
|
||||
projects.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
|
||||
}
|
||||
},
|
||||
remove: async (id: number) => {
|
||||
projects.delete(id);
|
||||
},
|
||||
};
|
||||
|
||||
const workspaceRegistry = {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
get: async (id: number) => workspaces.get(id) ?? null,
|
||||
insert: async (record: Omit<PersistedWorkspaceRecord, "id">) => {
|
||||
const id = nextWorkspaceId++;
|
||||
workspaces.set(id, createPersistedWorkspaceRecord({ id, ...record }));
|
||||
return id;
|
||||
},
|
||||
upsert: async (record: PersistedWorkspaceRecord) => {
|
||||
workspaces.set(record.id, record);
|
||||
},
|
||||
archive: async (id: number, archivedAt: string) => {
|
||||
const existing = workspaces.get(id);
|
||||
if (existing) {
|
||||
workspaces.set(id, { ...existing, archivedAt, updatedAt: archivedAt });
|
||||
}
|
||||
},
|
||||
remove: async (id: number) => {
|
||||
workspaces.delete(id);
|
||||
},
|
||||
};
|
||||
|
||||
return { projects, workspaces, projectRegistry, workspaceRegistry };
|
||||
}
|
||||
|
||||
function createTestLogger() {
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
return logger as any;
|
||||
}
|
||||
|
||||
function createTempGitRepo(prefix: string): string {
|
||||
const raw = mkdtempSync(path.join(tmpdir(), prefix));
|
||||
const dir = realpathSync(raw);
|
||||
execSync("git init -b main", { cwd: dir, stdio: "ignore" });
|
||||
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: "ignore" });
|
||||
execSync('git config user.name "Test"', { cwd: dir, stdio: "ignore" });
|
||||
execSync("git config commit.gpgsign false", { cwd: dir, stdio: "ignore" });
|
||||
writeFileSync(path.join(dir, "README.md"), "# Test\n");
|
||||
execSync("git add .", { cwd: dir, stdio: "ignore" });
|
||||
execSync('git commit -m "init"', { cwd: dir, stdio: "ignore" });
|
||||
return dir;
|
||||
}
|
||||
|
||||
const timestamp = "2025-01-01T00:00:00.000Z";
|
||||
|
||||
describe("WorkspaceReconciliationService", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
test("archives workspaces whose directories no longer exist", async () => {
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: "/tmp/does-not-exist-reconcile-test",
|
||||
kind: "directory",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: "/tmp/does-not-exist-reconcile-test",
|
||||
kind: "checkout",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
expect(result.changesApplied.length).toBeGreaterThanOrEqual(1);
|
||||
const wsChange = result.changesApplied.find((c) => c.kind === "workspace_archived");
|
||||
expect(wsChange).toBeDefined();
|
||||
expect(workspaces.get(1)!.archivedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("archives orphaned projects after all workspaces are archived", async () => {
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: "/tmp/does-not-exist-reconcile-orphan",
|
||||
kind: "directory",
|
||||
displayName: "orphan",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: "/tmp/does-not-exist-reconcile-orphan",
|
||||
kind: "checkout",
|
||||
displayName: "orphan",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
const projChange = result.changesApplied.find((c) => c.kind === "project_archived");
|
||||
expect(projChange).toBeDefined();
|
||||
expect(projects.get(1)!.archivedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("updates project kind when a directory becomes a git repo", async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), "reconcile-git-init-"));
|
||||
const resolved = realpathSync(dir);
|
||||
tempDirs.push(resolved);
|
||||
writeFileSync(path.join(resolved, "README.md"), "# Test\n");
|
||||
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: resolved,
|
||||
kind: "directory",
|
||||
displayName: path.basename(resolved),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: resolved,
|
||||
kind: "checkout",
|
||||
displayName: path.basename(resolved),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
// Initialize as git repo
|
||||
execSync("git init -b main", { cwd: resolved, stdio: "ignore" });
|
||||
execSync('git config user.email "test@test.com"', { cwd: resolved, stdio: "ignore" });
|
||||
execSync('git config user.name "Test"', { cwd: resolved, stdio: "ignore" });
|
||||
execSync("git config commit.gpgsign false", { cwd: resolved, stdio: "ignore" });
|
||||
execSync("git add .", { cwd: resolved, stdio: "ignore" });
|
||||
execSync('git commit -m "init"', { cwd: resolved, stdio: "ignore" });
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated");
|
||||
expect(projUpdate).toBeDefined();
|
||||
expect(projects.get(1)!.kind).toBe("git");
|
||||
});
|
||||
|
||||
test("updates project display name when git remote changes", async () => {
|
||||
const dir = createTempGitRepo("reconcile-remote-");
|
||||
tempDirs.push(dir);
|
||||
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: dir,
|
||||
kind: "git",
|
||||
displayName: "old-owner/old-repo",
|
||||
gitRemote: "git@github.com:old-owner/old-repo.git",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: dir,
|
||||
kind: "checkout",
|
||||
displayName: "main",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
// Change the remote
|
||||
execSync("git remote add origin git@github.com:new-owner/new-repo.git", {
|
||||
cwd: dir,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
const projUpdate = result.changesApplied.find((c) => c.kind === "project_updated");
|
||||
expect(projUpdate).toBeDefined();
|
||||
expect(projects.get(1)!.displayName).toBe("new-owner/new-repo");
|
||||
expect(projects.get(1)!.gitRemote).toBe("git@github.com:new-owner/new-repo.git");
|
||||
});
|
||||
|
||||
test("updates workspace display name when branch changes", async () => {
|
||||
const dir = createTempGitRepo("reconcile-branch-");
|
||||
tempDirs.push(dir);
|
||||
|
||||
execSync("git checkout -b feature-branch", { cwd: dir, stdio: "ignore" });
|
||||
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: dir,
|
||||
kind: "git",
|
||||
displayName: path.basename(dir),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: dir,
|
||||
kind: "checkout",
|
||||
displayName: "main",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
const wsUpdate = result.changesApplied.find((c) => c.kind === "workspace_updated");
|
||||
expect(wsUpdate).toBeDefined();
|
||||
expect(workspaces.get(1)!.displayName).toBe("feature-branch");
|
||||
});
|
||||
|
||||
test("does not modify already-archived records", async () => {
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: "/tmp/does-not-exist-archived",
|
||||
kind: "directory",
|
||||
displayName: "archived",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
archivedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: "/tmp/does-not-exist-archived",
|
||||
kind: "checkout",
|
||||
displayName: "archived",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
archivedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
|
||||
const result = await service.runOnce();
|
||||
|
||||
expect(result.changesApplied).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("calls onChanges callback when changes are applied", async () => {
|
||||
const { projects, workspaces, projectRegistry, workspaceRegistry } = createTestRegistries();
|
||||
|
||||
projects.set(
|
||||
1,
|
||||
createPersistedProjectRecord({
|
||||
id: 1,
|
||||
directory: "/tmp/does-not-exist-callback-test",
|
||||
kind: "directory",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
workspaces.set(
|
||||
1,
|
||||
createPersistedWorkspaceRecord({
|
||||
id: 1,
|
||||
projectId: 1,
|
||||
directory: "/tmp/does-not-exist-callback-test",
|
||||
kind: "checkout",
|
||||
displayName: "ghost",
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
}),
|
||||
);
|
||||
|
||||
const onChanges = vi.fn();
|
||||
const service = new WorkspaceReconciliationService({
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
onChanges,
|
||||
});
|
||||
|
||||
await service.runOnce();
|
||||
|
||||
expect(onChanges).toHaveBeenCalledTimes(1);
|
||||
expect(onChanges.mock.calls[0][0].length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
239
packages/server/src/server/workspace-reconciliation-service.ts
Normal file
239
packages/server/src/server/workspace-reconciliation-service.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import type pino from "pino";
|
||||
import type {
|
||||
ProjectRegistry,
|
||||
WorkspaceRegistry,
|
||||
PersistedProjectRecord,
|
||||
PersistedWorkspaceRecord,
|
||||
} from "./workspace-registry.js";
|
||||
import { detectWorkspaceGitMetadata } from "./workspace-git-metadata.js";
|
||||
|
||||
const DEFAULT_RECONCILE_INTERVAL_MS = 60_000;
|
||||
|
||||
export type ReconciliationChange =
|
||||
| { kind: "workspace_archived"; workspaceId: number; directory: string; reason: string }
|
||||
| { kind: "project_archived"; projectId: number; directory: string; reason: string }
|
||||
| {
|
||||
kind: "project_updated";
|
||||
projectId: number;
|
||||
directory: string;
|
||||
fields: Partial<Pick<PersistedProjectRecord, "kind" | "displayName" | "gitRemote">>;
|
||||
}
|
||||
| {
|
||||
kind: "workspace_updated";
|
||||
workspaceId: number;
|
||||
directory: string;
|
||||
fields: Partial<Pick<PersistedWorkspaceRecord, "displayName">>;
|
||||
};
|
||||
|
||||
export type ReconciliationResult = {
|
||||
changesApplied: ReconciliationChange[];
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
export type WorkspaceReconciliationServiceOptions = {
|
||||
projectRegistry: ProjectRegistry;
|
||||
workspaceRegistry: WorkspaceRegistry;
|
||||
logger: pino.Logger;
|
||||
intervalMs?: number;
|
||||
onChanges?: (changes: ReconciliationChange[]) => void;
|
||||
};
|
||||
|
||||
export class WorkspaceReconciliationService {
|
||||
private readonly projectRegistry: ProjectRegistry;
|
||||
private readonly workspaceRegistry: WorkspaceRegistry;
|
||||
private readonly logger: pino.Logger;
|
||||
private readonly intervalMs: number;
|
||||
private readonly onChanges: ((changes: ReconciliationChange[]) => void) | null;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
private running = false;
|
||||
|
||||
constructor(options: WorkspaceReconciliationServiceOptions) {
|
||||
this.projectRegistry = options.projectRegistry;
|
||||
this.workspaceRegistry = options.workspaceRegistry;
|
||||
this.logger = options.logger.child({ module: "workspace-reconciliation" });
|
||||
this.intervalMs = options.intervalMs ?? DEFAULT_RECONCILE_INTERVAL_MS;
|
||||
this.onChanges = options.onChanges ?? null;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.timer) return;
|
||||
this.logger.info({ intervalMs: this.intervalMs }, "Starting workspace reconciliation service");
|
||||
this.timer = setInterval(() => void this.runSafe(), this.intervalMs);
|
||||
// Run once immediately on start
|
||||
void this.runSafe();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async runOnce(): Promise<ReconciliationResult> {
|
||||
return this.reconcile();
|
||||
}
|
||||
|
||||
private async runSafe(): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
const result = await this.reconcile();
|
||||
if (result.changesApplied.length > 0) {
|
||||
this.logger.info(
|
||||
{ changeCount: result.changesApplied.length, durationMs: result.durationMs },
|
||||
"Reconciliation pass completed with changes",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error({ err: error }, "Reconciliation pass failed");
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcile(): Promise<ReconciliationResult> {
|
||||
const start = Date.now();
|
||||
const changes: ReconciliationChange[] = [];
|
||||
|
||||
const allProjects = await this.projectRegistry.list();
|
||||
const allWorkspaces = await this.workspaceRegistry.list();
|
||||
|
||||
const activeProjects = allProjects.filter((p) => !p.archivedAt);
|
||||
const activeWorkspaces = allWorkspaces.filter((w) => !w.archivedAt);
|
||||
|
||||
const workspacesByProject = new Map<number, PersistedWorkspaceRecord[]>();
|
||||
for (const workspace of activeWorkspaces) {
|
||||
const list = workspacesByProject.get(workspace.projectId) ?? [];
|
||||
list.push(workspace);
|
||||
workspacesByProject.set(workspace.projectId, list);
|
||||
}
|
||||
|
||||
// 1. Archive workspaces whose directories no longer exist
|
||||
for (const workspace of activeWorkspaces) {
|
||||
if (!existsSync(workspace.directory)) {
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.workspaceRegistry.archive(workspace.id, timestamp);
|
||||
changes.push({
|
||||
kind: "workspace_archived",
|
||||
workspaceId: workspace.id,
|
||||
directory: workspace.directory,
|
||||
reason: "directory_missing",
|
||||
});
|
||||
|
||||
// Update the in-memory list for the project orphan check below
|
||||
const siblings = workspacesByProject.get(workspace.projectId);
|
||||
if (siblings) {
|
||||
const updated = siblings.filter((w) => w.id !== workspace.id);
|
||||
workspacesByProject.set(workspace.projectId, updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Archive orphaned projects (all workspaces archived/removed)
|
||||
for (const project of activeProjects) {
|
||||
const siblings = workspacesByProject.get(project.id) ?? [];
|
||||
if (siblings.length === 0) {
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.projectRegistry.archive(project.id, timestamp);
|
||||
changes.push({
|
||||
kind: "project_archived",
|
||||
projectId: project.id,
|
||||
directory: project.directory,
|
||||
reason: "no_active_workspaces",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Reconcile git metadata for active projects whose directories still exist
|
||||
for (const project of activeProjects) {
|
||||
if (project.archivedAt) continue;
|
||||
const siblings = workspacesByProject.get(project.id) ?? [];
|
||||
if (siblings.length === 0) continue;
|
||||
if (!existsSync(project.directory)) continue;
|
||||
|
||||
const directoryName =
|
||||
project.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? project.directory;
|
||||
const currentGit = detectWorkspaceGitMetadata(project.directory, directoryName);
|
||||
|
||||
const projectUpdates: Partial<
|
||||
Pick<PersistedProjectRecord, "kind" | "displayName" | "gitRemote">
|
||||
> = {};
|
||||
|
||||
// Detect kind change: directory → git
|
||||
if (project.kind !== currentGit.projectKind) {
|
||||
projectUpdates.kind = currentGit.projectKind;
|
||||
projectUpdates.displayName = currentGit.projectDisplayName;
|
||||
projectUpdates.gitRemote = currentGit.gitRemote;
|
||||
}
|
||||
|
||||
// Detect display name change (e.g. remote renamed)
|
||||
if (
|
||||
project.kind === "git" &&
|
||||
currentGit.projectKind === "git" &&
|
||||
project.displayName !== currentGit.projectDisplayName
|
||||
) {
|
||||
projectUpdates.displayName = currentGit.projectDisplayName;
|
||||
}
|
||||
|
||||
// Detect git remote change
|
||||
if (
|
||||
project.kind === "git" &&
|
||||
currentGit.projectKind === "git" &&
|
||||
project.gitRemote !== currentGit.gitRemote
|
||||
) {
|
||||
projectUpdates.gitRemote = currentGit.gitRemote;
|
||||
}
|
||||
|
||||
if (Object.keys(projectUpdates).length > 0) {
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.projectRegistry.upsert({
|
||||
...project,
|
||||
...projectUpdates,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
changes.push({
|
||||
kind: "project_updated",
|
||||
projectId: project.id,
|
||||
directory: project.directory,
|
||||
fields: projectUpdates,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Reconcile workspace display names (branch name changes)
|
||||
for (const workspace of siblings) {
|
||||
if (workspace.kind !== "checkout") continue;
|
||||
if (!existsSync(workspace.directory)) continue;
|
||||
|
||||
const wsDirName =
|
||||
workspace.directory.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.directory;
|
||||
const wsGit = detectWorkspaceGitMetadata(workspace.directory, wsDirName);
|
||||
|
||||
if (
|
||||
wsGit.projectKind === "git" &&
|
||||
workspace.displayName !== wsGit.workspaceDisplayName
|
||||
) {
|
||||
const timestamp = new Date().toISOString();
|
||||
await this.workspaceRegistry.upsert({
|
||||
...workspace,
|
||||
displayName: wsGit.workspaceDisplayName,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
changes.push({
|
||||
kind: "workspace_updated",
|
||||
workspaceId: workspace.id,
|
||||
directory: workspace.directory,
|
||||
fields: { displayName: wsGit.workspaceDisplayName },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.length > 0 && this.onChanges) {
|
||||
this.onChanges(changes);
|
||||
}
|
||||
|
||||
return { changesApplied: changes, durationMs: Date.now() - start };
|
||||
}
|
||||
}
|
||||
@@ -1593,6 +1593,7 @@ export const WorkspaceDescriptorPayloadSchema = z.object({
|
||||
projectId: z.number().int(),
|
||||
projectDisplayName: z.string(),
|
||||
projectRootPath: z.string(),
|
||||
workspaceDirectory: z.string(),
|
||||
projectKind: z.enum(["git", "directory"]),
|
||||
workspaceKind: z.enum(["checkout", "worktree"]),
|
||||
name: z.string(),
|
||||
|
||||
Reference in New Issue
Block a user