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. */
|
/** Return all tab test IDs currently in the tab bar. */
|
||||||
export async function getTabTestIds(page: Page): Promise<string[]> {
|
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 count = await tabs.count();
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (let i = 0; i < count; i++) {
|
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). */
|
/** 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> {
|
export async function getActiveTabTestId(page: Page): Promise<string | null> {
|
||||||
// Active tab has the focused highlight — check for the aria-selected or data-active attribute
|
// 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)) {
|
if (await activeTab.isVisible().catch(() => false)) {
|
||||||
return activeTab.getAttribute("data-testid");
|
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. */
|
/** Click the "Terminal" tile on the launcher panel. */
|
||||||
export async function clickTerminal(page: Page): Promise<void> {
|
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 expect(button).toBeVisible({ timeout: 10_000 });
|
||||||
await button.click();
|
await button.click();
|
||||||
}
|
}
|
||||||
@@ -132,8 +138,12 @@ export async function waitForTabWithTitle(
|
|||||||
timeout = 30_000,
|
timeout = 30_000,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
|
const matcher = typeof title === "string" ? new RegExp(title, "i") : title;
|
||||||
await expect(page.locator('[data-testid^="workspace-tab-"]').filter({ hasText: matcher }).first())
|
await expect(
|
||||||
.toBeVisible({ timeout });
|
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. */
|
/** 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 = {
|
export type TerminalPerfDaemonClient = {
|
||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
|
openProject(
|
||||||
|
cwd: string,
|
||||||
|
): Promise<{
|
||||||
|
workspace: { id: number; name: string; projectRootPath: string } | null;
|
||||||
|
error: string | null;
|
||||||
|
}>;
|
||||||
createTerminal(
|
createTerminal(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
name?: string,
|
name?: string,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 (
|
export const createTempGitRepo = async (
|
||||||
prefix = "paseo-e2e-",
|
prefix = "paseo-e2e-",
|
||||||
options?: { withRemote?: boolean },
|
options?: {
|
||||||
|
withRemote?: boolean;
|
||||||
|
paseoConfig?: Record<string, unknown>;
|
||||||
|
files?: Array<{ path: string; content: string }>;
|
||||||
|
},
|
||||||
): Promise<TempRepo> => {
|
): Promise<TempRepo> => {
|
||||||
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
// Keep E2E repo paths short so terminal prompt + typed commands stay visible without zsh clipping.
|
||||||
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
|
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 user.name "Paseo E2E"', { cwd: repoPath, stdio: "ignore" });
|
||||||
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
execSync("git config commit.gpgsign false", { cwd: repoPath, stdio: "ignore" });
|
||||||
await writeFile(path.join(repoPath, "README.md"), "# Temp Repo\n");
|
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" });
|
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" });
|
execSync('git commit -m "Initial commit"', { cwd: repoPath, stdio: "ignore" });
|
||||||
|
|
||||||
if (withRemote) {
|
if (withRemote) {
|
||||||
|
|||||||
@@ -28,12 +28,19 @@ import {
|
|||||||
// ─── Shared state ──────────────────────────────────────────────────────────
|
// ─── Shared state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
let tempRepo: { path: string; cleanup: () => Promise<void> };
|
||||||
|
let workspaceId: string;
|
||||||
|
let seedClient: TerminalPerfDaemonClient;
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
tempRepo = await createTempGitRepo("launcher-e2e-");
|
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 () => {
|
test.afterAll(async () => {
|
||||||
|
if (seedClient) await seedClient.close();
|
||||||
if (tempRepo) await tempRepo.cleanup();
|
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 ({
|
test("Cmd+T opens launcher panel with New Chat, Terminal, and provider tiles", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await gotoWorkspace(page, tempRepo.path);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
await pressNewTabShortcut(page);
|
await pressNewTabShortcut(page);
|
||||||
|
|
||||||
@@ -56,7 +63,7 @@ test.describe("Launcher tab", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("opening two new tabs creates two launcher tabs", async ({ page }) => {
|
test("opening two new tabs creates two launcher tabs", async ({ page }) => {
|
||||||
await gotoWorkspace(page, tempRepo.path);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
await pressNewTabShortcut(page);
|
await pressNewTabShortcut(page);
|
||||||
await waitForLauncherPanel(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 }) => {
|
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 clickNewTabButton(page);
|
||||||
await waitForLauncherPanel(page);
|
await waitForLauncherPanel(page);
|
||||||
@@ -97,7 +104,7 @@ test.describe("Launcher tab", () => {
|
|||||||
|
|
||||||
test("clicking Terminal replaces launcher with standalone terminal", async ({ page }) => {
|
test("clicking Terminal replaces launcher with standalone terminal", async ({ page }) => {
|
||||||
test.setTimeout(45_000);
|
test.setTimeout(45_000);
|
||||||
await gotoWorkspace(page, tempRepo.path);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
await clickNewTabButton(page);
|
await clickNewTabButton(page);
|
||||||
await waitForLauncherPanel(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("clicking a provider tile replaces launcher with terminal agent tab", async ({ page }) => {
|
||||||
test.setTimeout(45_000);
|
test.setTimeout(45_000);
|
||||||
await gotoWorkspace(page, tempRepo.path);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
await clickNewTabButton(page);
|
await clickNewTabButton(page);
|
||||||
await waitForLauncherPanel(page);
|
await waitForLauncherPanel(page);
|
||||||
@@ -175,7 +182,7 @@ test.describe("Launcher tab", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("tab bar shows a single + button per pane", async ({ page }) => {
|
test("tab bar shows a single + button per pane", async ({ page }) => {
|
||||||
await gotoWorkspace(page, tempRepo.path);
|
await gotoWorkspace(page, workspaceId);
|
||||||
await assertSingleNewTabButton(page);
|
await assertSingleNewTabButton(page);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -204,7 +211,7 @@ test.describe("Terminal title propagation", () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Navigate to workspace and open the terminal
|
// Navigate to workspace and open the terminal
|
||||||
await gotoWorkspace(page, tempRepo.path);
|
await gotoWorkspace(page, workspaceId);
|
||||||
await clickNewTabButton(page);
|
await clickNewTabButton(page);
|
||||||
await waitForLauncherPanel(page);
|
await waitForLauncherPanel(page);
|
||||||
await clickTerminal(page);
|
await clickTerminal(page);
|
||||||
@@ -236,7 +243,7 @@ test.describe("Terminal title propagation", () => {
|
|||||||
const terminalId = result.terminal.id;
|
const terminalId = result.terminal.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await gotoWorkspace(page, tempRepo.path);
|
await gotoWorkspace(page, workspaceId);
|
||||||
await clickNewTabButton(page);
|
await clickNewTabButton(page);
|
||||||
await waitForLauncherPanel(page);
|
await waitForLauncherPanel(page);
|
||||||
await clickTerminal(page);
|
await clickTerminal(page);
|
||||||
@@ -272,7 +279,7 @@ test.describe("Terminal title propagation", () => {
|
|||||||
|
|
||||||
test.describe("Launcher transitions (no flash)", () => {
|
test.describe("Launcher transitions (no flash)", () => {
|
||||||
test("New Chat transition has no blank intermediate tab state", async ({ page }) => {
|
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 clickNewTabButton(page);
|
||||||
await waitForLauncherPanel(page);
|
await waitForLauncherPanel(page);
|
||||||
@@ -301,7 +308,7 @@ test.describe("Launcher transitions (no flash)", () => {
|
|||||||
|
|
||||||
test("Terminal transition completes within visual budget", async ({ page }) => {
|
test("Terminal transition completes within visual budget", async ({ page }) => {
|
||||||
test.setTimeout(30_000);
|
test.setTimeout(30_000);
|
||||||
await gotoWorkspace(page, tempRepo.path);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
await clickNewTabButton(page);
|
await clickNewTabButton(page);
|
||||||
await waitForLauncherPanel(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 }) => {
|
test("New Chat click → composer appears without launcher flash", async ({ page }) => {
|
||||||
await gotoWorkspace(page, tempRepo.path);
|
await gotoWorkspace(page, workspaceId);
|
||||||
|
|
||||||
await clickNewTabButton(page);
|
await clickNewTabButton(page);
|
||||||
await waitForLauncherPanel(page);
|
await waitForLauncherPanel(page);
|
||||||
|
|||||||
@@ -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 () => {
|
test.beforeAll(async () => {
|
||||||
tempRepo = await createTempGitRepo("perf-");
|
tempRepo = await createTempGitRepo("perf-");
|
||||||
client = await connectTerminalClient();
|
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 () => {
|
test.afterAll(async () => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -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,
|
parseServerIdFromPathname,
|
||||||
parseHostAgentRouteFromPathname,
|
parseHostAgentRouteFromPathname,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
|
decodeWorkspaceIdFromPathSegment,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
import { syncNavigationActiveWorkspace } from "@/stores/navigation-active-workspace-store";
|
||||||
|
|
||||||
@@ -602,8 +603,6 @@ function FaviconStatusSync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RootStack() {
|
function RootStack() {
|
||||||
const storeReady = useStoreReady();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
@@ -614,20 +613,32 @@ function RootStack() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack.Protected guard={storeReady}>
|
<Stack.Screen name="welcome" />
|
||||||
<Stack.Screen name="welcome" />
|
<Stack.Screen name="settings" />
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen
|
||||||
<Stack.Screen name="h/[serverId]/workspace/[workspaceId]" />
|
name="h/[serverId]/workspace/[workspaceId]"
|
||||||
<Stack.Screen
|
getId={({ params }) => {
|
||||||
name="h/[serverId]/agent/[agentId]"
|
const serverValue = Array.isArray(params?.serverId) ? params.serverId[0] : params?.serverId;
|
||||||
options={{ gestureEnabled: false }}
|
const workspaceValue = Array.isArray(params?.workspaceId)
|
||||||
/>
|
? params.workspaceId[0]
|
||||||
<Stack.Screen name="h/[serverId]/index" />
|
: params?.workspaceId;
|
||||||
<Stack.Screen name="h/[serverId]/sessions" />
|
const serverId = typeof serverValue === "string" ? serverValue.trim() : "";
|
||||||
<Stack.Screen name="h/[serverId]/open-project" />
|
const workspaceId =
|
||||||
<Stack.Screen name="h/[serverId]/settings" />
|
typeof workspaceValue === "string"
|
||||||
<Stack.Screen name="pair-scan" />
|
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? workspaceValue.trim())
|
||||||
</Stack.Protected>
|
: "";
|
||||||
|
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.Screen name="index" />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||||
|
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
@@ -22,6 +23,21 @@ export default function HostAgentReadyRoute() {
|
|||||||
}
|
}
|
||||||
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
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(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -33,18 +49,17 @@ export default function HostAgentReadyRoute() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedCwd = agentCwd?.trim();
|
if (resolvedWorkspaceId) {
|
||||||
if (normalizedCwd) {
|
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(
|
router.replace(
|
||||||
prepareWorkspaceTab({
|
prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: normalizedCwd,
|
workspaceId: resolvedWorkspaceId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, router, serverId]);
|
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -53,14 +68,14 @@ export default function HostAgentReadyRoute() {
|
|||||||
if (!serverId || !agentId) {
|
if (!serverId || !agentId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (agentCwd?.trim()) {
|
if (agentCwd?.trim() && !hasHydratedWorkspaces) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected) {
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
router.replace(buildHostRootRoute(serverId) as any);
|
router.replace(buildHostRootRoute(serverId) as any);
|
||||||
}
|
}
|
||||||
}, [agentCwd, agentId, client, isConnected, router, serverId]);
|
}, [agentCwd, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
if (redirectedRef.current) {
|
||||||
@@ -78,12 +93,19 @@ export default function HostAgentReadyRoute() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const cwd = result?.agent?.cwd?.trim();
|
const cwd = result?.agent?.cwd?.trim();
|
||||||
|
const workspaceId = resolveHydratedWorkspaceId({
|
||||||
|
workspaces: sessionWorkspaces?.values(),
|
||||||
|
path: cwd,
|
||||||
|
});
|
||||||
|
if (!workspaceId && !hasHydratedWorkspaces) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
redirectedRef.current = true;
|
redirectedRef.current = true;
|
||||||
if (cwd) {
|
if (workspaceId) {
|
||||||
router.replace(
|
router.replace(
|
||||||
prepareWorkspaceTab({
|
prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: cwd,
|
workspaceId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
@@ -102,7 +124,7 @@ export default function HostAgentReadyRoute() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [agentId, client, isConnected, router, serverId]);
|
}, [agentId, client, hasHydratedWorkspaces, isConnected, router, serverId, sessionWorkspaces]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,23 @@ import { useLocalSearchParams, usePathname, useRouter } from "expo-router";
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
import { useFormPreferences } from "@/hooks/use-form-preferences";
|
||||||
import {
|
import {
|
||||||
|
buildHostAgentDetailRoute,
|
||||||
buildHostOpenProjectRoute,
|
buildHostOpenProjectRoute,
|
||||||
buildHostRootRoute,
|
buildHostRootRoute,
|
||||||
buildHostWorkspaceOpenRoute,
|
buildHostWorkspaceOpenRoute,
|
||||||
buildHostWorkspaceRoute,
|
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
|
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
|
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() {
|
export default function HostIndexRoute() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
@@ -33,11 +41,13 @@ export default function HostIndexRoute() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rootRoute = buildHostRootRoute(serverId);
|
const rootRoute = buildHostRootRoute(serverId);
|
||||||
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
|
const currentPathname = getCurrentPathname(pathname);
|
||||||
|
if (currentPathname !== rootRoute && currentPathname !== `${rootRoute}/`) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
if (pathname !== rootRoute && pathname !== `${rootRoute}/`) {
|
const latestPathname = getCurrentPathname(pathname);
|
||||||
|
if (latestPathname !== rootRoute && latestPathname !== `${rootRoute}/`) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,16 +66,24 @@ export default function HostIndexRoute() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const primaryAgent = visibleAgents[0];
|
const primaryAgent = visibleAgents[0];
|
||||||
if (primaryAgent?.cwd?.trim()) {
|
const primaryAgentWorkspaceId = resolveHydratedWorkspaceId({
|
||||||
|
workspaces: sessionWorkspaces?.values(),
|
||||||
|
path: primaryAgent?.cwd,
|
||||||
|
});
|
||||||
|
if (primaryAgent && primaryAgentWorkspaceId) {
|
||||||
router.replace(
|
router.replace(
|
||||||
prepareWorkspaceTab({
|
prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: primaryAgent.cwd.trim(),
|
workspaceId: primaryAgentWorkspaceId,
|
||||||
target: { kind: "agent", agentId: primaryAgent.id },
|
target: { kind: "agent", agentId: primaryAgent.id },
|
||||||
}) as any,
|
}) as any,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (primaryAgent) {
|
||||||
|
router.replace(buildHostAgentDetailRoute(serverId, primaryAgent.id) as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const primaryWorkspace = visibleWorkspaces[0];
|
const primaryWorkspace = visibleWorkspaces[0];
|
||||||
if (primaryWorkspace?.id?.trim()) {
|
if (primaryWorkspace?.id?.trim()) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect, useRef } from "react";
|
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 type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||||
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
import { WorkspaceScreen } from "@/screens/workspace/workspace-screen";
|
||||||
import {
|
import {
|
||||||
buildHostWorkspaceRoute,
|
buildHostWorkspaceRoute,
|
||||||
decodeWorkspaceIdFromPathSegment,
|
parseHostWorkspaceRouteFromPathname,
|
||||||
parseWorkspaceOpenIntent,
|
parseWorkspaceOpenIntent,
|
||||||
type WorkspaceOpenIntent,
|
type WorkspaceOpenIntent,
|
||||||
} from "@/utils/host-routes";
|
} from "@/utils/host-routes";
|
||||||
@@ -37,18 +37,13 @@ function getOpenIntentTarget(openIntent: WorkspaceOpenIntent): WorkspaceTabTarge
|
|||||||
export default function HostWorkspaceLayout() {
|
export default function HostWorkspaceLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const consumedIntentRef = useRef<string | null>(null);
|
const consumedIntentRef = useRef<string | null>(null);
|
||||||
const params = useLocalSearchParams<{
|
const pathname = usePathname();
|
||||||
serverId?: string | string[];
|
|
||||||
workspaceId?: string | string[];
|
|
||||||
}>();
|
|
||||||
const globalParams = useGlobalSearchParams<{
|
const globalParams = useGlobalSearchParams<{
|
||||||
open?: string | string[];
|
open?: string | string[];
|
||||||
}>();
|
}>();
|
||||||
const serverId = getParamValue(params.serverId);
|
const parsedWorkspaceRoute = parseHostWorkspaceRouteFromPathname(pathname);
|
||||||
const workspaceValue = getParamValue(params.workspaceId);
|
const serverId = parsedWorkspaceRoute?.serverId ?? "";
|
||||||
const workspaceId = workspaceValue
|
const workspaceId = parsedWorkspaceRoute?.workspaceId ?? "";
|
||||||
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
|
|
||||||
: "";
|
|
||||||
const openValue = getParamValue(globalParams.open);
|
const openValue = getParamValue(globalParams.open);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ import { buildHostRootRoute } from "@/utils/host-routes";
|
|||||||
|
|
||||||
const WELCOME_ROUTE = "/welcome";
|
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 {
|
function useAnyOnlineHostServerId(serverIds: string[]): string | null {
|
||||||
const runtime = getHostRuntimeStore();
|
const runtime = getHostRuntimeStore();
|
||||||
|
|
||||||
@@ -51,7 +58,8 @@ export default function Index() {
|
|||||||
if (!storeReady) {
|
if (!storeReady) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (pathname !== "/" && pathname !== "") {
|
const currentPathname = getCurrentPathname(pathname);
|
||||||
|
if (currentPathname !== "/" && currentPathname !== "") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
|||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { Archive, SquareTerminal } from "lucide-react-native";
|
import { Archive, SquareTerminal } from "lucide-react-native";
|
||||||
import { getProviderIcon } from "@/components/provider-icons";
|
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";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
|
|
||||||
interface AgentListProps {
|
interface AgentListProps {
|
||||||
@@ -242,12 +244,21 @@ export function AgentList({
|
|||||||
|
|
||||||
const serverId = agent.serverId;
|
const serverId = agent.serverId;
|
||||||
const agentId = agent.id;
|
const agentId = agent.id;
|
||||||
|
const workspaceId = resolveHydratedWorkspaceId({
|
||||||
|
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
|
||||||
|
path: agent.cwd,
|
||||||
|
});
|
||||||
|
|
||||||
onAgentSelect?.();
|
onAgentSelect?.();
|
||||||
|
|
||||||
|
if (!workspaceId) {
|
||||||
|
router.navigate(buildHostAgentDetailRoute(serverId, agentId) as any);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const route = prepareWorkspaceTab({
|
const route = prepareWorkspaceTab({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceId: agent.cwd,
|
workspaceId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
pin: Boolean(agent.archivedAt),
|
pin: Boolean(agent.archivedAt),
|
||||||
requestReopen: agent.terminal && agent.status === "closed",
|
requestReopen: agent.terminal && agent.status === "closed",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
|
|||||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||||
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
import { getMarkdownListMarker } from "@/utils/markdown-list";
|
||||||
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
||||||
|
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import {
|
import {
|
||||||
@@ -132,10 +133,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
|
|
||||||
const workspaceRoot = agent.cwd?.trim() || "";
|
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({
|
const { requestDirectoryListing } = useFileExplorerActions({
|
||||||
serverId: resolvedServerId,
|
serverId: resolvedServerId,
|
||||||
workspaceId,
|
workspaceId: workspaceId ?? undefined,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
});
|
});
|
||||||
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
|
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
|
||||||
@@ -175,12 +180,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const route = prepareWorkspaceTab({
|
if (workspaceId) {
|
||||||
serverId: resolvedServerId,
|
const route = prepareWorkspaceTab({
|
||||||
workspaceId,
|
serverId: resolvedServerId,
|
||||||
target: { kind: "file", path: normalized.file },
|
workspaceId,
|
||||||
});
|
target: { kind: "file", path: normalized.file },
|
||||||
router.navigate(route as any);
|
});
|
||||||
|
router.navigate(route as any);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ export function ProjectPickerModal() {
|
|||||||
|
|
||||||
const recommendedPaths = useMemo(() => {
|
const recommendedPaths = useMemo(() => {
|
||||||
if (!workspaces) return [];
|
if (!workspaces) return [];
|
||||||
return Array.from(workspaces.values()).map(
|
return Array.from(workspaces.values())
|
||||||
(workspace) => workspace.projectRootPath || workspace.id,
|
.map((workspace) => workspace.projectRootPath)
|
||||||
);
|
.filter((path) => path.length > 0);
|
||||||
}, [workspaces]);
|
}, [workspaces]);
|
||||||
|
|
||||||
const directorySuggestionsQuery = useQuery({
|
const directorySuggestionsQuery = useQuery({
|
||||||
|
|||||||
@@ -817,8 +817,8 @@ function WorkspaceRowInner({
|
|||||||
const isMobile = Platform.OS !== "web";
|
const isMobile = Platform.OS !== "web";
|
||||||
const prHint = useWorkspacePrHint({
|
const prHint = useWorkspacePrHint({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
cwd: workspace.workspaceId,
|
cwd: workspace.projectRootPath ?? "",
|
||||||
enabled: workspace.projectKind === "git",
|
enabled: workspace.projectKind === "git" && Boolean(workspace.projectRootPath),
|
||||||
});
|
});
|
||||||
const interaction = useLongPressDragInteraction({
|
const interaction = useLongPressDragInteraction({
|
||||||
drag,
|
drag,
|
||||||
@@ -985,7 +985,7 @@ function WorkspaceRowWithMenu({
|
|||||||
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
const archiveStatus = useCheckoutGitActionsStore((state) =>
|
||||||
state.getStatus({
|
state.getStatus({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
cwd: workspace.workspaceId,
|
cwd: workspace.workspaceDirectory ?? workspace.projectRootPath ?? "",
|
||||||
actionId: "archive-worktree",
|
actionId: "archive-worktree",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1025,11 +1025,16 @@ function WorkspaceRowWithMenu({
|
|||||||
if (!confirmed) {
|
if (!confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const workspaceDirectory = workspace.workspaceDirectory ?? workspace.projectRootPath;
|
||||||
|
if (!workspaceDirectory) {
|
||||||
|
toast.error("Workspace path not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void archiveWorktree({
|
void archiveWorktree({
|
||||||
serverId: workspace.serverId,
|
serverId: workspace.serverId,
|
||||||
cwd: workspace.workspaceId,
|
cwd: workspaceDirectory,
|
||||||
worktreePath: workspace.workspaceId,
|
worktreePath: workspaceDirectory,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
redirectAfterArchive();
|
redirectAfterArchive();
|
||||||
@@ -1045,6 +1050,8 @@ function WorkspaceRowWithMenu({
|
|||||||
redirectAfterArchive,
|
redirectAfterArchive,
|
||||||
toast,
|
toast,
|
||||||
workspace.name,
|
workspace.name,
|
||||||
|
workspace.projectRootPath,
|
||||||
|
workspace.workspaceDirectory,
|
||||||
workspace.serverId,
|
workspace.serverId,
|
||||||
workspace.workspaceId,
|
workspace.workspaceId,
|
||||||
]);
|
]);
|
||||||
@@ -1095,9 +1102,14 @@ function WorkspaceRowWithMenu({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleCopyPath = useCallback(() => {
|
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.copied("Path copied");
|
||||||
}, [toast, workspace.workspaceId]);
|
}, [toast, workspace.projectRootPath, workspace.workspaceDirectory]);
|
||||||
|
|
||||||
const handleCopyBranchName = useCallback(() => {
|
const handleCopyBranchName = useCallback(() => {
|
||||||
void Clipboard.setStringAsync(workspace.name);
|
void Clipboard.setStringAsync(workspace.name);
|
||||||
|
|||||||
@@ -244,6 +244,11 @@ export function WelcomeScreen({ onHostAdded }: WelcomeScreenProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const currentPathname =
|
||||||
|
typeof window === "undefined" ? null : (window.location.pathname || null);
|
||||||
|
if (currentPathname && currentPathname !== "/welcome") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!anyOnlineServerId) {
|
if (!anyOnlineServerId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function WorkspaceSetupDialog() {
|
|||||||
initialValues: projectPath ? { workingDir: projectPath } : undefined,
|
initialValues: projectPath ? { workingDir: projectPath } : undefined,
|
||||||
isVisible: pendingWorkspaceSetup !== null,
|
isVisible: pendingWorkspaceSetup !== null,
|
||||||
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
onlineServerIds: isConnected && serverId ? [serverId] : [],
|
||||||
lockedWorkingDir: workspace?.id ?? projectPath,
|
lockedWorkingDir: workspace?.projectRootPath ?? projectPath,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const composerState = chatDraft.composerState;
|
const composerState = chatDraft.composerState;
|
||||||
@@ -170,9 +170,10 @@ export function WorkspaceSetupDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const encodedImages = await encodeImages(images);
|
const encodedImages = await encodeImages(images);
|
||||||
|
const workspaceDirectory = workspace.projectRootPath ?? projectPath;
|
||||||
const agent = await connectedClient.createAgent({
|
const agent = await connectedClient.createAgent({
|
||||||
provider: composerState.selectedProvider,
|
provider: composerState.selectedProvider,
|
||||||
cwd: workspace.id,
|
cwd: workspaceDirectory,
|
||||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
? { modeId: composerState.selectedMode }
|
? { modeId: composerState.selectedMode }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -226,9 +227,10 @@ export function WorkspaceSetupDialog() {
|
|||||||
throw new Error("Workspace setup composer state is required");
|
throw new Error("Workspace setup composer state is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const workspaceDirectory = workspace.projectRootPath ?? projectPath;
|
||||||
const agent = await connectedClient.createAgent({
|
const agent = await connectedClient.createAgent({
|
||||||
provider: composerState.selectedProvider,
|
provider: composerState.selectedProvider,
|
||||||
cwd: workspace.id,
|
cwd: workspaceDirectory,
|
||||||
terminal: true,
|
terminal: true,
|
||||||
...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}),
|
...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}),
|
||||||
});
|
});
|
||||||
@@ -272,8 +274,13 @@ export function WorkspaceSetupDialog() {
|
|||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
const workspace = await ensureWorkspace();
|
const workspace = await ensureWorkspace();
|
||||||
const connectedClient = withConnectedClient();
|
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) {
|
if (payload.error || !payload.terminal) {
|
||||||
throw new Error(payload.error ?? "Failed to open 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 type { TextInput } from "react-native";
|
||||||
import { router, usePathname, type Href } from "expo-router";
|
import { router, usePathname, type Href } from "expo-router";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||||
@@ -11,13 +12,18 @@ import {
|
|||||||
clearCommandCenterFocusRestoreElement,
|
clearCommandCenterFocusRestoreElement,
|
||||||
takeCommandCenterFocusRestoreElement,
|
takeCommandCenterFocusRestoreElement,
|
||||||
} from "@/utils/command-center-focus-restore";
|
} 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 type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||||
import { getIsDesktop } from "@/constants/layout";
|
import { getIsDesktop } from "@/constants/layout";
|
||||||
|
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
|
||||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { focusWithRetries } from "@/utils/web-focus";
|
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.
|
// Don't restore focus back to the prior element after we navigate.
|
||||||
clearCommandCenterFocusRestoreElement();
|
clearCommandCenterFocusRestoreElement();
|
||||||
setOpen(false);
|
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({
|
const route = prepareWorkspaceTab({
|
||||||
serverId: agent.serverId,
|
serverId: agent.serverId,
|
||||||
workspaceId: agent.cwd,
|
workspaceId,
|
||||||
target: { kind: "agent", agentId: agent.id },
|
target: { kind: "agent", agentId: agent.id },
|
||||||
});
|
});
|
||||||
router.navigate(route as any);
|
router.navigate(route as any);
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ describe("openProjectDirectly", () => {
|
|||||||
projectId: 1,
|
projectId: 1,
|
||||||
projectDisplayName: "project",
|
projectDisplayName: "project",
|
||||||
projectRootPath: WORKSPACE_ID,
|
projectRootPath: WORKSPACE_ID,
|
||||||
|
workspaceDirectory: WORKSPACE_ID,
|
||||||
projectKind: "git" as const,
|
projectKind: "git" as const,
|
||||||
workspaceKind: "checkout" as const,
|
workspaceKind: "checkout" as const,
|
||||||
name: "project",
|
name: "project",
|
||||||
@@ -87,7 +88,12 @@ describe("openProjectDirectly", () => {
|
|||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true);
|
expect(useSessionStore.getState().sessions[SERVER_ID]?.hasHydratedWorkspaces).toBe(true);
|
||||||
expect(Array.from(useSessionStore.getState().sessions[SERVER_ID]?.workspaces.values() ?? [])).toEqual([
|
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({
|
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ function workspace(
|
|||||||
Partial<
|
Partial<
|
||||||
Pick<
|
Pick<
|
||||||
WorkspaceDescriptor,
|
WorkspaceDescriptor,
|
||||||
"projectDisplayName" | "projectRootPath" | "projectKind" | "workspaceKind"
|
| "projectDisplayName"
|
||||||
|
| "projectRootPath"
|
||||||
|
| "workspaceDirectory"
|
||||||
|
| "projectKind"
|
||||||
|
| "workspaceKind"
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
): WorkspaceDescriptor {
|
): WorkspaceDescriptor {
|
||||||
@@ -28,6 +32,7 @@ function workspace(
|
|||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
projectDisplayName: input.projectDisplayName ?? input.projectId,
|
projectDisplayName: input.projectDisplayName ?? input.projectId,
|
||||||
projectRootPath: input.projectRootPath ?? input.id,
|
projectRootPath: input.projectRootPath ?? input.id,
|
||||||
|
workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? input.id,
|
||||||
projectKind: input.projectKind ?? "git",
|
projectKind: input.projectKind ?? "git",
|
||||||
workspaceKind: input.workspaceKind ?? "checkout",
|
workspaceKind: input.workspaceKind ?? "checkout",
|
||||||
name: input.name,
|
name: input.name,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export interface SidebarWorkspaceEntry {
|
|||||||
workspaceKey: string;
|
workspaceKey: string;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
|
projectRootPath?: string;
|
||||||
|
workspaceDirectory?: string;
|
||||||
projectKind: WorkspaceDescriptor["projectKind"];
|
projectKind: WorkspaceDescriptor["projectKind"];
|
||||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||||
name: string;
|
name: string;
|
||||||
@@ -119,7 +121,7 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
|||||||
projectName:
|
projectName:
|
||||||
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
|
workspace.projectDisplayName || projectDisplayNameFromProjectId(workspace.projectId),
|
||||||
projectKind: workspace.projectKind,
|
projectKind: workspace.projectKind,
|
||||||
iconWorkingDir: workspace.projectRootPath || workspace.id,
|
iconWorkingDir: workspace.projectRootPath,
|
||||||
statusBucket: "done",
|
statusBucket: "done",
|
||||||
activeCount: 0,
|
activeCount: 0,
|
||||||
totalWorkspaces: 0,
|
totalWorkspaces: 0,
|
||||||
@@ -131,6 +133,8 @@ export function buildSidebarProjectsFromWorkspaces(input: {
|
|||||||
workspaceKey: `${input.serverId}:${workspace.id}`,
|
workspaceKey: `${input.serverId}:${workspace.id}`,
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
|
projectRootPath: workspace.projectRootPath,
|
||||||
|
workspaceDirectory: workspace.workspaceDirectory,
|
||||||
projectKind: workspace.projectKind,
|
projectKind: workspace.projectKind,
|
||||||
workspaceKind: workspace.workspaceKind,
|
workspaceKind: workspace.workspaceKind,
|
||||||
name: workspace.name,
|
name: workspace.name,
|
||||||
@@ -254,6 +258,7 @@ function toWorkspaceDescriptor(payload: {
|
|||||||
projectId: number;
|
projectId: number;
|
||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
projectKind: WorkspaceDescriptor["projectKind"];
|
projectKind: WorkspaceDescriptor["projectKind"];
|
||||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -36,6 +36,11 @@ function LauncherPanel() {
|
|||||||
const { serverId, workspaceId, target, retargetCurrentTab, isPaneFocused } = usePaneContext();
|
const { serverId, workspaceId, target, retargetCurrentTab, isPaneFocused } = usePaneContext();
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(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 { providers, recordUsage } = useProviderRecency();
|
||||||
const setAgents = useSessionStore((state) => state.setAgents);
|
const setAgents = useSessionStore((state) => state.setAgents);
|
||||||
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
const [pendingAction, setPendingAction] = useState<string | null>(null);
|
||||||
@@ -53,8 +58,8 @@ function LauncherPanel() {
|
|||||||
|
|
||||||
const launchTerminalAgent = useCallback(
|
const launchTerminalAgent = useCallback(
|
||||||
async (providerId: AgentProvider) => {
|
async (providerId: AgentProvider) => {
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected || !workspaceDirectory) {
|
||||||
setErrorMessage("Host is not connected");
|
setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +69,7 @@ function LauncherPanel() {
|
|||||||
try {
|
try {
|
||||||
const agent = await client.createAgent({
|
const agent = await client.createAgent({
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
cwd: workspaceId,
|
cwd: workspaceDirectory,
|
||||||
terminal: true,
|
terminal: true,
|
||||||
});
|
});
|
||||||
recordUsage(providerId);
|
recordUsage(providerId);
|
||||||
@@ -82,7 +87,7 @@ function LauncherPanel() {
|
|||||||
setPendingAction((current) => (current === providerId ? null : current));
|
setPendingAction((current) => (current === providerId ? null : current));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[client, isConnected, recordUsage, retargetCurrentTab, serverId, setAgents, workspaceId],
|
[client, isConnected, recordUsage, retargetCurrentTab, serverId, setAgents, workspaceDirectory],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openDraftTab = useCallback(() => {
|
const openDraftTab = useCallback(() => {
|
||||||
@@ -96,8 +101,8 @@ function LauncherPanel() {
|
|||||||
}, [retargetCurrentTab]);
|
}, [retargetCurrentTab]);
|
||||||
|
|
||||||
const openTerminalTab = useCallback(async () => {
|
const openTerminalTab = useCallback(async () => {
|
||||||
if (!client || !isConnected) {
|
if (!client || !isConnected || !workspaceDirectory) {
|
||||||
setErrorMessage("Host is not connected");
|
setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +110,7 @@ function LauncherPanel() {
|
|||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = await client.createTerminal(workspaceId);
|
const payload = await client.createTerminal(workspaceDirectory);
|
||||||
if (payload.error || !payload.terminal) {
|
if (payload.error || !payload.terminal) {
|
||||||
throw new Error(payload.error ?? "Failed to open terminal");
|
throw new Error(payload.error ?? "Failed to open terminal");
|
||||||
}
|
}
|
||||||
@@ -118,10 +123,20 @@ function LauncherPanel() {
|
|||||||
} finally {
|
} finally {
|
||||||
setPendingAction((current) => (current === "terminal" ? null : current));
|
setPendingAction((current) => (current === "terminal" ? null : current));
|
||||||
}
|
}
|
||||||
}, [client, isConnected, retargetCurrentTab, workspaceId]);
|
}, [client, isConnected, retargetCurrentTab, workspaceDirectory]);
|
||||||
|
|
||||||
const actionsDisabled = pendingAction !== null;
|
const actionsDisabled = pendingAction !== null;
|
||||||
|
|
||||||
|
if (!workspaceDirectory) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={[styles.content, styles.loadingContent]}>
|
||||||
|
<ActivityIndicator />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -339,6 +354,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingVertical: theme.spacing[8],
|
paddingVertical: theme.spacing[8],
|
||||||
},
|
},
|
||||||
|
loadingContent: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
contentUnfocused: {
|
contentUnfocused: {
|
||||||
opacity: 0.96,
|
opacity: 0.96,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,14 +24,20 @@ function useTerminalPanelDescriptor(
|
|||||||
context: { serverId: string; workspaceId: string },
|
context: { serverId: string; workspaceId: string },
|
||||||
): PanelDescriptor {
|
): PanelDescriptor {
|
||||||
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
|
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({
|
const terminalsQuery = useQuery({
|
||||||
queryKey: ["terminals", context.serverId, context.workspaceId] as const,
|
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
|
||||||
enabled: Boolean(client && context.workspaceId),
|
enabled: Boolean(client && workspaceDirectory),
|
||||||
queryFn: async (): Promise<ListTerminalsPayload> => {
|
queryFn: async (): Promise<ListTerminalsPayload> => {
|
||||||
if (!client) {
|
if (!client || !workspaceDirectory) {
|
||||||
return { cwd: context.workspaceId, terminals: [], requestId: "missing-client" };
|
return { cwd: workspaceDirectory ?? "", terminals: [], requestId: "missing-client" };
|
||||||
}
|
}
|
||||||
return client.listTerminals(context.workspaceId);
|
return client.listTerminals(workspaceDirectory);
|
||||||
},
|
},
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
});
|
});
|
||||||
@@ -50,16 +56,22 @@ function useTerminalPanelDescriptor(
|
|||||||
function TerminalPanel() {
|
function TerminalPanel() {
|
||||||
const isFocused = useIsFocused();
|
const isFocused = useIsFocused();
|
||||||
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
|
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");
|
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
|
||||||
|
|
||||||
if (!isFocused) {
|
if (!isFocused || !workspaceDirectory) {
|
||||||
return <View style={{ flex: 1 }} />;
|
return <View style={{ flex: 1 }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TerminalPane
|
<TerminalPane
|
||||||
serverId={serverId}
|
serverId={serverId}
|
||||||
cwd={workspaceId}
|
cwd={workspaceDirectory}
|
||||||
terminalId={target.terminalId}
|
terminalId={target.terminalId}
|
||||||
isPaneFocused={isPaneFocused}
|
isPaneFocused={isPaneFocused}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||||
import { useHosts } from "@/runtime/host-runtime";
|
import { useHosts } from "@/runtime/host-runtime";
|
||||||
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
|
||||||
|
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||||
import { shortenPath } from "@/utils/shorten-path";
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
|
import { collectAgentWorkingDirectorySuggestions } from "@/utils/agent-working-directory-suggestions";
|
||||||
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
import { buildWorkingDirectorySuggestions } from "@/utils/working-directory-suggestions";
|
||||||
@@ -49,6 +50,7 @@ import type {
|
|||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
} from "@server/server/agent/agent-sdk-types";
|
} from "@server/server/agent/agent-sdk-types";
|
||||||
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
|
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 { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
import { useDesktopDragHandlers } from "@/utils/desktop-window";
|
||||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
@@ -750,7 +752,7 @@ function DraftAgentScreenContent({
|
|||||||
optimisticStreamItems,
|
optimisticStreamItems,
|
||||||
draftAgent,
|
draftAgent,
|
||||||
handleCreateFromInput,
|
handleCreateFromInput,
|
||||||
} = useDraftAgentCreateFlow<Agent, { id: string; cwd: string }>({
|
} = useDraftAgentCreateFlow<Agent, { id: string; workspaceId: string | null }>({
|
||||||
draftId: draftIdRef.current,
|
draftId: draftIdRef.current,
|
||||||
getPendingServerId: () => selectedServerId,
|
getPendingServerId: () => selectedServerId,
|
||||||
validateBeforeSubmit: ({ text }) => {
|
validateBeforeSubmit: ({ text }) => {
|
||||||
@@ -908,20 +910,29 @@ function DraftAgentScreenContent({
|
|||||||
|
|
||||||
const createdWorkingDir = typeof result.cwd === "string" ? result.cwd.trim() : "";
|
const createdWorkingDir = typeof result.cwd === "string" ? result.cwd.trim() : "";
|
||||||
const configuredWorkingDir = config.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 {
|
return {
|
||||||
agentId: result.id,
|
agentId: result.id,
|
||||||
result: {
|
result: {
|
||||||
id: result.id,
|
id: result.id,
|
||||||
cwd: workspaceId,
|
workspaceId,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
onCreateSuccess: ({ result }) => {
|
onCreateSuccess: ({ result }) => {
|
||||||
|
if (!result.workspaceId) {
|
||||||
|
router.replace(
|
||||||
|
buildHostAgentDetailRoute(selectedServerId as string, result.id) as any,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const route = prepareWorkspaceTab({
|
const route = prepareWorkspaceTab({
|
||||||
serverId: selectedServerId as string,
|
serverId: selectedServerId as string,
|
||||||
workspaceId: result.cwd,
|
workspaceId: result.workspaceId,
|
||||||
target: { kind: "agent", agentId: result.id },
|
target: { kind: "agent", agentId: result.id },
|
||||||
});
|
});
|
||||||
router.replace(route as any);
|
router.replace(route as any);
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ describe("workspace agent visibility", () => {
|
|||||||
|
|
||||||
const result = deriveWorkspaceAgentVisibility({
|
const result = deriveWorkspaceAgentVisibility({
|
||||||
sessionAgents,
|
sessionAgents,
|
||||||
workspaceId,
|
workspaceDirectory: workspaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
|
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
|
||||||
@@ -142,13 +142,33 @@ describe("workspace agent visibility", () => {
|
|||||||
|
|
||||||
const result = deriveWorkspaceAgentVisibility({
|
const result = deriveWorkspaceAgentVisibility({
|
||||||
sessionAgents,
|
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.activeAgentIds).toEqual(new Set(["slash-agent"]));
|
||||||
expect(result.knownAgentIds.has("slash-agent")).toBe(true);
|
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", () => {
|
describe("workspaceAgentVisibilityEqual", () => {
|
||||||
it("returns true for identical sets", () => {
|
it("returns true for identical sets", () => {
|
||||||
const a = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) };
|
const a = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) };
|
||||||
|
|||||||
@@ -12,11 +12,11 @@ export interface WorkspaceAgentVisibility {
|
|||||||
|
|
||||||
export function deriveWorkspaceAgentVisibility(input: {
|
export function deriveWorkspaceAgentVisibility(input: {
|
||||||
sessionAgents: Map<string, Agent> | undefined;
|
sessionAgents: Map<string, Agent> | undefined;
|
||||||
workspaceId: string;
|
workspaceDirectory: string | null | undefined;
|
||||||
}): WorkspaceAgentVisibility {
|
}): WorkspaceAgentVisibility {
|
||||||
const { sessionAgents, workspaceId } = input;
|
const { sessionAgents, workspaceDirectory } = input;
|
||||||
const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId);
|
const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory);
|
||||||
if (!sessionAgents || !workspaceId) {
|
if (!sessionAgents || !normalizedWorkspaceDirectory) {
|
||||||
return {
|
return {
|
||||||
activeAgentIds: new Set<string>(),
|
activeAgentIds: new Set<string>(),
|
||||||
knownAgentIds: new Set<string>(),
|
knownAgentIds: new Set<string>(),
|
||||||
@@ -26,7 +26,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
|||||||
const activeAgentIds = new Set<string>();
|
const activeAgentIds = new Set<string>();
|
||||||
const knownAgentIds = new Set<string>();
|
const knownAgentIds = new Set<string>();
|
||||||
for (const agent of sessionAgents.values()) {
|
for (const agent of sessionAgents.values()) {
|
||||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceId) {
|
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
knownAgentIds.add(agent.id);
|
knownAgentIds.add(agent.id);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useMemo, useRef } from "react";
|
import { useCallback, useMemo, useRef } from "react";
|
||||||
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
|
import { Keyboard, Platform, ScrollView, Text, View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import invariant from "tiny-invariant";
|
||||||
import { Composer } from "@/components/composer";
|
import { Composer } from "@/components/composer";
|
||||||
import { FileDropZone } from "@/components/file-drop-zone";
|
import { FileDropZone } from "@/components/file-drop-zone";
|
||||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
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 { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
|
||||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
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 { encodeImages } from "@/utils/encode-images";
|
||||||
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
|
||||||
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
|
||||||
@@ -48,6 +49,11 @@ export function WorkspaceDraftAgentTab({
|
|||||||
}: WorkspaceDraftAgentTabProps) {
|
}: WorkspaceDraftAgentTabProps) {
|
||||||
const client = useHostRuntimeClient(serverId);
|
const client = useHostRuntimeClient(serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(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 addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
|
||||||
const draftStoreKey = useMemo(
|
const draftStoreKey = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -63,10 +69,10 @@ export function WorkspaceDraftAgentTab({
|
|||||||
draftKey: draftStoreKey,
|
draftKey: draftStoreKey,
|
||||||
composer: {
|
composer: {
|
||||||
initialServerId: serverId,
|
initialServerId: serverId,
|
||||||
initialValues: { workingDir: workspaceId },
|
initialValues: { workingDir: workspaceDirectory },
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
onlineServerIds: isConnected ? [serverId] : [],
|
onlineServerIds: isConnected ? [serverId] : [],
|
||||||
lockedWorkingDir: workspaceId,
|
lockedWorkingDir: workspaceDirectory,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -97,6 +103,9 @@ export function WorkspaceDraftAgentTab({
|
|||||||
if (!composerState.effectiveModelId) {
|
if (!composerState.effectiveModelId) {
|
||||||
return "No model is available for the selected provider";
|
return "No model is available for the selected provider";
|
||||||
}
|
}
|
||||||
|
if (!workspaceDirectory) {
|
||||||
|
return "Workspace directory not found";
|
||||||
|
}
|
||||||
if (!client) {
|
if (!client) {
|
||||||
return "Host is not connected";
|
return "Host is not connected";
|
||||||
}
|
}
|
||||||
@@ -110,6 +119,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
Keyboard.dismiss();
|
Keyboard.dismiss();
|
||||||
},
|
},
|
||||||
buildDraftAgent: (attempt) => {
|
buildDraftAgent: (attempt) => {
|
||||||
|
invariant(workspaceDirectory, "Workspace directory is required");
|
||||||
const now = attempt.timestamp;
|
const now = attempt.timestamp;
|
||||||
const model = composerState.effectiveModelId || null;
|
const model = composerState.effectiveModelId || null;
|
||||||
const thinkingOptionId = composerState.effectiveThinkingOptionId || null;
|
const thinkingOptionId = composerState.effectiveThinkingOptionId || null;
|
||||||
@@ -134,20 +144,21 @@ export function WorkspaceDraftAgentTab({
|
|||||||
persistence: null,
|
persistence: null,
|
||||||
runtimeInfo: { provider: composerState.selectedProvider, sessionId: null, model, modeId },
|
runtimeInfo: { provider: composerState.selectedProvider, sessionId: null, model, modeId },
|
||||||
title: "Agent",
|
title: "Agent",
|
||||||
cwd: workspaceId,
|
cwd: workspaceDirectory,
|
||||||
model,
|
model,
|
||||||
thinkingOptionId,
|
thinkingOptionId,
|
||||||
labels: {},
|
labels: {},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
createRequest: async ({ attempt, text, images }) => {
|
createRequest: async ({ attempt, text, images }) => {
|
||||||
|
invariant(workspaceDirectory, "Workspace directory is required");
|
||||||
if (!client) {
|
if (!client) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = buildWorkspaceDraftAgentConfig({
|
const config = buildWorkspaceDraftAgentConfig({
|
||||||
provider: composerState.selectedProvider,
|
provider: composerState.selectedProvider,
|
||||||
cwd: workspaceId,
|
cwd: workspaceDirectory,
|
||||||
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
|
||||||
? { modeId: composerState.selectedMode }
|
? { modeId: composerState.selectedMode }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
|
||||||
|
|
||||||
export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }): {
|
export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }): {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -7,7 +6,7 @@ export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }
|
|||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
title: input.workspace.name,
|
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 isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
|
||||||
|
|
||||||
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
|
||||||
const normalizedWorkspaceId =
|
const rawWorkspaceIdentifier =
|
||||||
normalizeWorkspaceIdentity(decodeWorkspaceIdFromPathSegment(workspaceId)) ?? "";
|
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 =
|
const workspaceTerminalScopeKey =
|
||||||
normalizedServerId && normalizedWorkspaceId
|
normalizedServerId && normalizedWorkspaceId
|
||||||
? `${normalizedServerId}:${normalizedWorkspaceId}`
|
? `${normalizedServerId}:${normalizedWorkspaceId}`
|
||||||
@@ -567,43 +583,47 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const client = useHostRuntimeClient(normalizedServerId);
|
const client = useHostRuntimeClient(normalizedServerId);
|
||||||
const isConnected = useHostRuntimeIsConnected(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(
|
const workspaceAgentVisibility = useStoreWithEqualityFn(
|
||||||
useSessionStore,
|
useSessionStore,
|
||||||
(state) =>
|
(state) =>
|
||||||
deriveWorkspaceAgentVisibility({
|
deriveWorkspaceAgentVisibility({
|
||||||
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
||||||
workspaceId: normalizedWorkspaceId,
|
workspaceDirectory,
|
||||||
}),
|
}),
|
||||||
workspaceAgentVisibilityEqual,
|
workspaceAgentVisibilityEqual,
|
||||||
);
|
);
|
||||||
|
|
||||||
const terminalsQueryKey = useMemo(
|
const terminalsQueryKey = useMemo(
|
||||||
() => ["terminals", normalizedServerId, normalizedWorkspaceId] as const,
|
() => ["terminals", normalizedServerId, workspaceDirectory] as const,
|
||||||
[normalizedServerId, normalizedWorkspaceId],
|
[normalizedServerId, workspaceDirectory],
|
||||||
);
|
);
|
||||||
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
type ListTerminalsPayload = ListTerminalsResponse["payload"];
|
||||||
const terminalsQuery = useQuery({
|
const terminalsQuery = useQuery({
|
||||||
queryKey: terminalsQueryKey,
|
queryKey: terminalsQueryKey,
|
||||||
enabled:
|
enabled:
|
||||||
Boolean(client && isConnected) &&
|
Boolean(client && isConnected) &&
|
||||||
normalizedWorkspaceId.length > 0 &&
|
Boolean(workspaceDirectory),
|
||||||
normalizedWorkspaceId.startsWith("/"),
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client || !workspaceDirectory) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
return await client.listTerminals(normalizedWorkspaceId);
|
return await client.listTerminals(workspaceDirectory);
|
||||||
},
|
},
|
||||||
staleTime: TERMINALS_QUERY_STALE_TIME,
|
staleTime: TERMINALS_QUERY_STALE_TIME,
|
||||||
});
|
});
|
||||||
const terminals = terminalsQuery.data?.terminals ?? [];
|
const terminals = terminalsQuery.data?.terminals ?? [];
|
||||||
const createTerminalMutation = useMutation({
|
const createTerminalMutation = useMutation({
|
||||||
mutationFn: async (input?: { paneId?: string }) => {
|
mutationFn: async (input?: { paneId?: string }) => {
|
||||||
if (!client) {
|
if (!client || !workspaceDirectory) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
return await client.createTerminal(normalizedWorkspaceId);
|
return await client.createTerminal(workspaceDirectory);
|
||||||
},
|
},
|
||||||
onSuccess: (payload, input) => {
|
onSuccess: (payload, input) => {
|
||||||
const createdTerminal = payload.terminal;
|
const createdTerminal = payload.terminal;
|
||||||
@@ -613,8 +633,9 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
terminals: current?.terminals ?? [],
|
terminals: current?.terminals ?? [],
|
||||||
terminal: createdTerminal,
|
terminal: createdTerminal,
|
||||||
});
|
});
|
||||||
|
const cwd = current?.cwd ?? workspaceDirectory ?? undefined;
|
||||||
return {
|
return {
|
||||||
cwd: current?.cwd ?? normalizedWorkspaceId,
|
...(cwd ? { cwd } : {}),
|
||||||
terminals: nextTerminals,
|
terminals: nextTerminals,
|
||||||
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
requestId: current?.requestId ?? `terminal-create-${createdTerminal.id}`,
|
||||||
};
|
};
|
||||||
@@ -657,7 +678,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const { archiveAgent } = useArchiveAgent();
|
const { archiveAgent } = useArchiveAgent();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) {
|
if (!client || !isConnected || !workspaceDirectory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,7 +686,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
if (message.type !== "terminals_changed") {
|
if (message.type !== "terminals_changed") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.payload.cwd !== normalizedWorkspaceId) {
|
if (message.payload.cwd !== workspaceDirectory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,32 +697,27 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
client.subscribeTerminals({ cwd: normalizedWorkspaceId });
|
client.subscribeTerminals({ cwd: workspaceDirectory });
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribeChanged();
|
unsubscribeChanged();
|
||||||
client.unsubscribeTerminals({ cwd: normalizedWorkspaceId });
|
client.unsubscribeTerminals({ cwd: workspaceDirectory });
|
||||||
};
|
};
|
||||||
}, [client, isConnected, normalizedWorkspaceId, queryClient, terminalsQueryKey]);
|
}, [client, isConnected, queryClient, terminalsQueryKey, workspaceDirectory]);
|
||||||
|
|
||||||
const checkoutQuery = useQuery({
|
const checkoutQuery = useQuery({
|
||||||
queryKey: checkoutStatusQueryKey(normalizedServerId, normalizedWorkspaceId),
|
queryKey: checkoutStatusQueryKey(normalizedServerId, workspaceDirectory ?? ""),
|
||||||
enabled:
|
enabled:
|
||||||
Boolean(client && isConnected) &&
|
Boolean(client && isConnected) &&
|
||||||
normalizedWorkspaceId.length > 0 &&
|
Boolean(workspaceDirectory),
|
||||||
normalizedWorkspaceId.startsWith("/"),
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
if (!client) {
|
if (!client || !workspaceDirectory) {
|
||||||
throw new Error("Host is not connected");
|
throw new Error("Host is not connected");
|
||||||
}
|
}
|
||||||
return (await client.getCheckoutStatus(normalizedWorkspaceId)) as CheckoutStatusPayload;
|
return (await client.getCheckoutStatus(workspaceDirectory)) as CheckoutStatusPayload;
|
||||||
},
|
},
|
||||||
staleTime: 15_000,
|
staleTime: 15_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const workspaceDescriptor = useSessionStore(
|
|
||||||
(state) => state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ?? null,
|
|
||||||
);
|
|
||||||
const hasHydratedWorkspaces = useSessionStore(
|
const hasHydratedWorkspaces = useSessionStore(
|
||||||
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
(state) => state.sessions[normalizedServerId]?.hasHydratedWorkspaces ?? false,
|
||||||
);
|
);
|
||||||
@@ -731,15 +747,15 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
const isExplorerOpen = isMobile ? mobileView === "file-explorer" : desktopFileExplorerOpen;
|
||||||
|
|
||||||
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
const activeExplorerCheckout = useMemo<ExplorerCheckoutContext | null>(() => {
|
||||||
if (!normalizedServerId || !normalizedWorkspaceId.startsWith("/")) {
|
if (!normalizedServerId || !workspaceDirectory) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
serverId: normalizedServerId,
|
serverId: normalizedServerId,
|
||||||
cwd: normalizedWorkspaceId,
|
cwd: workspaceDirectory,
|
||||||
isGit: isGitCheckout,
|
isGit: isGitCheckout,
|
||||||
};
|
};
|
||||||
}, [isGitCheckout, normalizedServerId, normalizedWorkspaceId]);
|
}, [isGitCheckout, normalizedServerId, workspaceDirectory]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveExplorerCheckout(activeExplorerCheckout);
|
setActiveExplorerCheckout(activeExplorerCheckout);
|
||||||
@@ -1081,12 +1097,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
if (createTerminalMutation.isPending) {
|
if (createTerminalMutation.isPending) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!normalizedWorkspaceId.startsWith("/")) {
|
if (!workspaceDirectory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
createTerminalMutation.mutate(input);
|
createTerminalMutation.mutate(input);
|
||||||
},
|
},
|
||||||
[createTerminalMutation, normalizedWorkspaceId],
|
[createTerminalMutation, workspaceDirectory],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectSwitcherTab = useCallback(
|
const handleSelectSwitcherTab = useCallback(
|
||||||
@@ -1270,18 +1286,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyWorkspacePath = useCallback(async () => {
|
const handleCopyWorkspacePath = useCallback(async () => {
|
||||||
if (!normalizedWorkspaceId.startsWith("/")) {
|
if (!workspaceDirectory) {
|
||||||
toast.error("Workspace path not available");
|
toast.error("Workspace path not available");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Clipboard.setStringAsync(normalizedWorkspaceId);
|
await Clipboard.setStringAsync(workspaceDirectory);
|
||||||
toast.copied("Workspace path");
|
toast.copied("Workspace path");
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Copy failed");
|
toast.error("Copy failed");
|
||||||
}
|
}
|
||||||
}, [normalizedWorkspaceId, toast]);
|
}, [toast, workspaceDirectory]);
|
||||||
|
|
||||||
const handleCopyBranchName = useCallback(async () => {
|
const handleCopyBranchName = useCallback(async () => {
|
||||||
if (!currentBranchName) {
|
if (!currentBranchName) {
|
||||||
@@ -1927,7 +1943,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
testID="workspace-header-copy-path"
|
testID="workspace-header-copy-path"
|
||||||
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
|
leading={<Copy size={16} color={theme.colors.foregroundMuted} />}
|
||||||
disabled={!normalizedWorkspaceId.startsWith("/")}
|
disabled={!workspaceDirectory}
|
||||||
onSelect={handleCopyWorkspacePath}
|
onSelect={handleCopyWorkspacePath}
|
||||||
>
|
>
|
||||||
Copy workspace path
|
Copy workspace path
|
||||||
@@ -1952,7 +1968,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
<>
|
<>
|
||||||
<WorkspaceGitActions
|
<WorkspaceGitActions
|
||||||
serverId={normalizedServerId}
|
serverId={normalizedServerId}
|
||||||
cwd={normalizedWorkspaceId}
|
cwd={workspaceDirectory ?? ""}
|
||||||
/>
|
/>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -2135,7 +2151,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
<ExplorerSidebar
|
<ExplorerSidebar
|
||||||
serverId={normalizedServerId}
|
serverId={normalizedServerId}
|
||||||
workspaceId={normalizedWorkspaceId}
|
workspaceId={normalizedWorkspaceId}
|
||||||
workspaceRoot={normalizedWorkspaceId}
|
workspaceRoot={workspaceDirectory ?? ""}
|
||||||
isGit={isGitCheckout}
|
isGit={isGitCheckout}
|
||||||
onOpenFile={handleOpenFileFromExplorer}
|
onOpenFile={handleOpenFileFromExplorer}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ describe("workspace source of truth consumption", () => {
|
|||||||
projectId: "remote:github.com/getpaseo/paseo",
|
projectId: "remote:github.com/getpaseo/paseo",
|
||||||
projectDisplayName: "getpaseo/paseo",
|
projectDisplayName: "getpaseo/paseo",
|
||||||
projectRootPath: "/repo/main",
|
projectRootPath: "/repo/main",
|
||||||
|
workspaceDirectory: "/repo/main",
|
||||||
projectKind: "git",
|
projectKind: "git",
|
||||||
workspaceKind: "checkout",
|
workspaceKind: "checkout",
|
||||||
name: "feat/workspace-sot",
|
name: "feat/workspace-sot",
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export interface WorkspaceDescriptor {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
|
workspaceDirectory: string;
|
||||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||||
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
|
workspaceKind: WorkspaceDescriptorPayload["workspaceKind"];
|
||||||
name: string;
|
name: string;
|
||||||
@@ -135,6 +136,7 @@ export function normalizeWorkspaceDescriptor(
|
|||||||
projectId: String(payload.projectId),
|
projectId: String(payload.projectId),
|
||||||
projectDisplayName: payload.projectDisplayName,
|
projectDisplayName: payload.projectDisplayName,
|
||||||
projectRootPath: payload.projectRootPath,
|
projectRootPath: payload.projectRootPath,
|
||||||
|
workspaceDirectory: payload.workspaceDirectory,
|
||||||
projectKind: payload.projectKind,
|
projectKind: payload.projectKind,
|
||||||
workspaceKind: payload.workspaceKind,
|
workspaceKind: payload.workspaceKind,
|
||||||
name: payload.name,
|
name: payload.name,
|
||||||
|
|||||||
@@ -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",
|
projectId: input.projectId ?? "project-1",
|
||||||
projectDisplayName: input.projectDisplayName ?? "Project",
|
projectDisplayName: input.projectDisplayName ?? "Project",
|
||||||
projectRootPath: input.projectRootPath ?? "/repo",
|
projectRootPath: input.projectRootPath ?? "/repo",
|
||||||
|
workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? "/repo",
|
||||||
projectKind: input.projectKind ?? "git",
|
projectKind: input.projectKind ?? "git",
|
||||||
workspaceKind: input.workspaceKind ?? "worktree",
|
workspaceKind: input.workspaceKind ?? "worktree",
|
||||||
name: input.name ?? input.id,
|
name: input.name ?? input.id,
|
||||||
|
|||||||
@@ -610,6 +610,104 @@ describe("AgentManager", () => {
|
|||||||
unsubscribe();
|
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 () => {
|
test("terminal agent creation preserves titles propagated during terminal registration", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-registration-title-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-registration-title-"));
|
||||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||||
@@ -669,6 +767,35 @@ describe("AgentManager", () => {
|
|||||||
terminalManager.killAll();
|
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 () => {
|
test("terminal agent closure preserves exit diagnostics for failed launches", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-exit-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-terminal-exit-"));
|
||||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ export class AgentManager {
|
|||||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||||
private readonly agents = new Map<string, LiveManagedAgent>();
|
private readonly agents = new Map<string, LiveManagedAgent>();
|
||||||
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
||||||
|
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
||||||
private readonly sessionEventTails = new Map<string, Promise<void>>();
|
private readonly sessionEventTails = new Map<string, Promise<void>>();
|
||||||
private readonly pendingForegroundRuns = new Map<string, PendingForegroundRun>();
|
private readonly pendingForegroundRuns = new Map<string, PendingForegroundRun>();
|
||||||
private readonly subscribers = new Set<SubscriptionRecord>();
|
private readonly subscribers = new Set<SubscriptionRecord>();
|
||||||
@@ -434,6 +435,10 @@ export class AgentManager {
|
|||||||
withActiveForegroundTurn++;
|
withActiveForegroundTurn++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!this.timelineStore.has(agent.id)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const len = this.timelineStore.getItems(agent.id).length;
|
const len = this.timelineStore.getItems(agent.id).length;
|
||||||
totalItems += len;
|
totalItems += len;
|
||||||
if (len > maxItemsPerAgent) {
|
if (len > maxItemsPerAgent) {
|
||||||
@@ -732,6 +737,7 @@ export class AgentManager {
|
|||||||
persistence,
|
persistence,
|
||||||
{
|
{
|
||||||
labels: options?.labels,
|
labels: options?.labels,
|
||||||
|
workspaceId: options?.workspaceId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1037,6 +1043,13 @@ export class AgentManager {
|
|||||||
if (!normalizedTitle) {
|
if (!normalizedTitle) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
this.agentsAwaitingInitialSnapshotPersist.has(agent.id) &&
|
||||||
|
this.registry &&
|
||||||
|
(await this.registry.get(agent.id)) === null
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.touchUpdatedAt(agent);
|
this.touchUpdatedAt(agent);
|
||||||
await this.persistSnapshot(agent, { title: normalizedTitle });
|
await this.persistSnapshot(agent, { title: normalizedTitle });
|
||||||
this.emitState(agent, { persist: false });
|
this.emitState(agent, { persist: false });
|
||||||
@@ -2110,6 +2123,7 @@ export class AgentManager {
|
|||||||
terminalCommand: TerminalCommand,
|
terminalCommand: TerminalCommand,
|
||||||
persistence: AgentPersistenceHandle,
|
persistence: AgentPersistenceHandle,
|
||||||
options?: {
|
options?: {
|
||||||
|
workspaceId?: number;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
lastUserMessageAt?: Date | null;
|
lastUserMessageAt?: Date | null;
|
||||||
@@ -2173,6 +2187,7 @@ export class AgentManager {
|
|||||||
|
|
||||||
this.agents.set(resolvedAgentId, managed);
|
this.agents.set(resolvedAgentId, managed);
|
||||||
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
|
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
|
||||||
|
this.agentsAwaitingInitialSnapshotPersist.add(resolvedAgentId);
|
||||||
|
|
||||||
let terminalSession: TerminalSession;
|
let terminalSession: TerminalSession;
|
||||||
try {
|
try {
|
||||||
@@ -2187,12 +2202,14 @@ export class AgentManager {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.agents.delete(resolvedAgentId);
|
this.agents.delete(resolvedAgentId);
|
||||||
this.previousStatuses.delete(resolvedAgentId);
|
this.previousStatuses.delete(resolvedAgentId);
|
||||||
|
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (terminalSession.id !== reservedTerminalId) {
|
if (terminalSession.id !== reservedTerminalId) {
|
||||||
this.agents.delete(resolvedAgentId);
|
this.agents.delete(resolvedAgentId);
|
||||||
this.previousStatuses.delete(resolvedAgentId);
|
this.previousStatuses.delete(resolvedAgentId);
|
||||||
|
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Reserved terminal id ${reservedTerminalId} but terminal manager returned ${terminalSession.id}`,
|
`Reserved terminal id ${reservedTerminalId} but terminal manager returned ${terminalSession.id}`,
|
||||||
);
|
);
|
||||||
@@ -2203,12 +2220,17 @@ export class AgentManager {
|
|||||||
});
|
});
|
||||||
managed.unsubscribeTerminalExit = unsubscribeTerminalExit;
|
managed.unsubscribeTerminalExit = unsubscribeTerminalExit;
|
||||||
const terminalSessionTitle = terminalSession.getTitle()?.trim();
|
const terminalSessionTitle = terminalSession.getTitle()?.trim();
|
||||||
await this.persistSnapshot(managed, {
|
try {
|
||||||
title:
|
await this.persistSnapshot(managed, {
|
||||||
terminalSessionTitle && terminalSessionTitle.length > 0
|
workspaceId: options?.workspaceId,
|
||||||
? terminalSessionTitle
|
title:
|
||||||
: initialPersistedTitle,
|
terminalSessionTitle && terminalSessionTitle.length > 0
|
||||||
});
|
? terminalSessionTitle
|
||||||
|
: initialPersistedTitle,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.agentsAwaitingInitialSnapshotPersist.delete(resolvedAgentId);
|
||||||
|
}
|
||||||
this.emitState(managed);
|
this.emitState(managed);
|
||||||
return { ...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 { DbAgentTimelineStore } from "./db/db-agent-timeline-store.js";
|
||||||
import { DbProjectRegistry } from "./db/db-project-registry.js";
|
import { DbProjectRegistry } from "./db/db-project-registry.js";
|
||||||
import { DbWorkspaceRegistry } from "./db/db-workspace-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 { importLegacyAgentSnapshots } from "./db/legacy-agent-snapshot-import.js";
|
||||||
import { importLegacyProjectWorkspaceJson } from "./db/legacy-project-workspace-import.js";
|
import { importLegacyProjectWorkspaceJson } from "./db/legacy-project-workspace-import.js";
|
||||||
import { openPaseoDatabase, type PaseoDatabaseHandle } from "./db/sqlite-database.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 projectRegistry = new DbProjectRegistry(database.db);
|
||||||
const workspaceRegistry = new DbWorkspaceRegistry(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({
|
await importLegacyProjectWorkspaceJson({
|
||||||
db: database.db,
|
db: database.db,
|
||||||
paseoHome: config.paseoHome,
|
paseoHome: config.paseoHome,
|
||||||
@@ -749,6 +759,7 @@ export async function createPaseoDaemon(
|
|||||||
};
|
};
|
||||||
|
|
||||||
const stop = async () => {
|
const stop = async () => {
|
||||||
|
reconciliationService.stop();
|
||||||
await closeAllAgents(logger, agentManager);
|
await closeAllAgents(logger, agentManager);
|
||||||
await agentManager.flush().catch(() => undefined);
|
await agentManager.flush().catch(() => undefined);
|
||||||
await shutdownProviders(logger, {
|
await shutdownProviders(logger, {
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ export class DbAgentSnapshotStore implements AgentSnapshotStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nextWorkspaceId === undefined) {
|
if (nextWorkspaceId === undefined) {
|
||||||
throw new Error(`Workspace ID required for agent ${agent.id}`);
|
return;
|
||||||
}
|
}
|
||||||
await this.upsert(record, nextWorkspaceId);
|
await this.upsert(record, nextWorkspaceId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ import {
|
|||||||
toCheckoutError,
|
toCheckoutError,
|
||||||
} from "./checkout-git-utils.js";
|
} from "./checkout-git-utils.js";
|
||||||
import { CheckoutDiffManager } from "./checkout-diff-manager.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 type { LocalSpeechModelId } from "./speech/providers/local/models.js";
|
||||||
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
|
import { toResolver, type Resolvable } from "./speech/provider-resolver.js";
|
||||||
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js";
|
import type { SpeechReadinessSnapshot, SpeechReadinessState } from "./speech/speech-runtime.js";
|
||||||
@@ -181,12 +182,14 @@ type DeleteFencedAgentSnapshotStore = AgentSnapshotStore & {
|
|||||||
beginDelete(agentId: string): void;
|
beginDelete(agentId: string): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
function beginAgentDeleteIfSupported(agentStorage: AgentSnapshotStore, agentId: string): void {
|
function beginAgentDeleteIfSupported(agentStorage: AgentSnapshotStore, agentId: string): void {
|
||||||
if ("beginDelete" in agentStorage && typeof agentStorage.beginDelete === "function") {
|
if ("beginDelete" in agentStorage && typeof agentStorage.beginDelete === "function") {
|
||||||
(agentStorage as DeleteFencedAgentSnapshotStore).beginDelete(agentId);
|
(agentStorage as DeleteFencedAgentSnapshotStore).beginDelete(agentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function deriveInitialAgentTitle(prompt: string): string | null {
|
function deriveInitialAgentTitle(prompt: string): string | null {
|
||||||
const firstContentLine = prompt
|
const firstContentLine = prompt
|
||||||
.split(/\r?\n/)
|
.split(/\r?\n/)
|
||||||
@@ -4741,6 +4744,7 @@ export class Session {
|
|||||||
projectId: workspace.projectId,
|
projectId: workspace.projectId,
|
||||||
projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId),
|
projectDisplayName: resolvedProjectRecord?.displayName ?? String(workspace.projectId),
|
||||||
projectRootPath: resolvedProjectRecord?.directory ?? workspace.directory,
|
projectRootPath: resolvedProjectRecord?.directory ?? workspace.directory,
|
||||||
|
workspaceDirectory: workspace.directory,
|
||||||
projectKind: resolvedProjectRecord?.kind ?? "directory",
|
projectKind: resolvedProjectRecord?.kind ?? "directory",
|
||||||
workspaceKind: workspace.kind,
|
workspaceKind: workspace.kind,
|
||||||
name: workspace.displayName,
|
name: workspace.displayName,
|
||||||
@@ -5095,11 +5099,12 @@ export class Session {
|
|||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const directoryName = normalizedCwd.split(/[\\/]/).filter(Boolean).at(-1) ?? normalizedCwd;
|
const directoryName = normalizedCwd.split(/[\\/]/).filter(Boolean).at(-1) ?? normalizedCwd;
|
||||||
|
const gitMetadata = detectWorkspaceGitMetadata(normalizedCwd, directoryName);
|
||||||
const projectId = await this.projectRegistry.insert({
|
const projectId = await this.projectRegistry.insert({
|
||||||
directory: normalizedCwd,
|
directory: normalizedCwd,
|
||||||
displayName: directoryName,
|
displayName: gitMetadata.projectDisplayName,
|
||||||
kind: "directory",
|
kind: gitMetadata.projectKind,
|
||||||
gitRemote: null,
|
gitRemote: gitMetadata.gitRemote,
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
@@ -5107,7 +5112,7 @@ export class Session {
|
|||||||
const workspaceId = await this.workspaceRegistry.insert({
|
const workspaceId = await this.workspaceRegistry.insert({
|
||||||
projectId,
|
projectId,
|
||||||
directory: normalizedCwd,
|
directory: normalizedCwd,
|
||||||
displayName: directoryName,
|
displayName: gitMetadata.workspaceDisplayName,
|
||||||
kind: "checkout",
|
kind: "checkout",
|
||||||
createdAt: timestamp,
|
createdAt: timestamp,
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { execSync } from "node:child_process";
|
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 { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
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", () => {
|
describe("workspace aggregation", () => {
|
||||||
test("terminal agents reject timeline fetch without reloading as chat sessions", async () => {
|
test("terminal agents reject timeline fetch without reloading as chat sessions", async () => {
|
||||||
const emitted: Array<{ type: string; payload: any }> = [];
|
const emitted: Array<{ type: string; payload: any }> = [];
|
||||||
@@ -567,7 +589,8 @@ describe("workspace aggregation", () => {
|
|||||||
expect(response?.payload.workspace?.id).toEqual(expect.any(Number));
|
expect(response?.payload.workspace?.id).toEqual(expect.any(Number));
|
||||||
const persistedWorkspace = workspaces.get(response!.payload.workspace.id);
|
const persistedWorkspace = workspaces.get(response!.payload.workspace.id);
|
||||||
expect(persistedWorkspace?.directory).toContain(path.join("worktree-123"));
|
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(workspaces.has(response!.payload.workspace.id)).toBe(true);
|
||||||
expect(projects.has(response?.payload.workspace?.projectId)).toBe(true);
|
expect(projects.has(response?.payload.workspace?.projectId)).toBe(true);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -606,4 +629,94 @@ describe("workspace aggregation", () => {
|
|||||||
error: null,
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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(),
|
projectId: z.number().int(),
|
||||||
projectDisplayName: z.string(),
|
projectDisplayName: z.string(),
|
||||||
projectRootPath: z.string(),
|
projectRootPath: z.string(),
|
||||||
|
workspaceDirectory: z.string(),
|
||||||
projectKind: z.enum(["git", "directory"]),
|
projectKind: z.enum(["git", "directory"]),
|
||||||
workspaceKind: z.enum(["checkout", "worktree"]),
|
workspaceKind: z.enum(["checkout", "worktree"]),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user