Key workspace status and ownership by ID, not directory
Multiple workspaces can share one checkout directory. Agent and terminal status, sidebar tabs, and ownership now attribute strictly by workspace ID, so a running or blocked agent in one workspace no longer shows up on its same-directory siblings. A one-time startup migration backfills workspace IDs onto legacy agents, and every agent and terminal is created with one thereafter. Directory lookups remain only for genuinely folder-derived git facts (branch, diff) and the archive-by-path adapter.
This commit is contained in:
@@ -23,7 +23,7 @@ async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, () => {
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to acquire port")));
|
||||
@@ -34,6 +34,14 @@ async function getAvailablePort(): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
const RESERVED_LOCAL_PORTS = new Set([
|
||||
// Default developer daemon.
|
||||
6767,
|
||||
// OpenCode's default local server port. Some provider probes can spawn it
|
||||
// during daemon startup, so the E2E daemon must not choose the same port.
|
||||
61680,
|
||||
]);
|
||||
|
||||
function createLineBuffer(maxLines = 120): { add: (line: string) => void; dump: () => string } {
|
||||
const lines: string[] = [];
|
||||
return {
|
||||
@@ -486,7 +494,7 @@ async function awaitRelayReady(
|
||||
async function getAvailablePortExcluding(excludedPorts: Set<number>): Promise<number> {
|
||||
for (;;) {
|
||||
const port = await getAvailablePort();
|
||||
if (!excludedPorts.has(port)) {
|
||||
if (!excludedPorts.has(port) && !RESERVED_LOCAL_PORTS.has(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
@@ -677,8 +685,8 @@ export default async function globalSetup() {
|
||||
ensureRelayBuildArtifact(repoRoot);
|
||||
await loadEnvTestFile(repoRoot);
|
||||
|
||||
const port = await getAvailablePort();
|
||||
const metroPort = await getAvailablePort();
|
||||
const port = await getAvailablePortExcluding(new Set());
|
||||
const metroPort = await getAvailablePortExcluding(new Set([port]));
|
||||
const requestedPaseoHome = resolveOptionalPaseoHomeEnv(process.env.E2E_PASEO_HOME);
|
||||
const shouldRemovePaseoHome = !requestedPaseoHome && process.env.E2E_KEEP_PASEO_HOME !== "1";
|
||||
paseoHome = requestedPaseoHome ?? (await mkdtemp(path.join(tmpdir(), "paseo-e2e-home-")));
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface IdleAgentSeedClient {
|
||||
model: string;
|
||||
modeId: string;
|
||||
cwd: string;
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
}): Promise<{ id: string }>;
|
||||
waitForAgentUpsert(
|
||||
@@ -66,6 +67,7 @@ export async function createIdleAgent(
|
||||
model: "opencode/gpt-5-nano",
|
||||
modeId: "bypassPermissions",
|
||||
cwd: input.cwd,
|
||||
workspaceId: opened.workspace.id,
|
||||
title: input.title,
|
||||
});
|
||||
const snapshot = await client.waitForAgentUpsert(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { getE2EDaemonPort } from "./daemon-port";
|
||||
@@ -47,9 +48,18 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
|
||||
url: resolveDaemonWsUrl(),
|
||||
clientId: `${options.clientIdPrefix}-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: options.appVersion,
|
||||
appVersion: options.appVersion ?? loadAppVersion(),
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
function loadAppVersion(): string {
|
||||
const packageJsonPath = path.resolve(__dirname, "../../package.json");
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown };
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error(`Missing app version in ${packageJsonPath}`);
|
||||
}
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ export async function seedMockAgentWorkspace(
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: options.title,
|
||||
modeId: options.modeId ?? "load-test",
|
||||
model: options.model ?? "ten-second-stream",
|
||||
|
||||
@@ -14,7 +14,9 @@ type NewWorkspaceDaemonClient = Pick<
|
||||
| "createPaseoWorktree"
|
||||
| "fetchWorkspaces"
|
||||
| "getPaseoWorktreeList"
|
||||
| "getDaemonConfig"
|
||||
| "openProject"
|
||||
| "patchDaemonConfig"
|
||||
>;
|
||||
|
||||
type OpenProjectPayload = Awaited<ReturnType<NewWorkspaceDaemonClient["openProject"]>>;
|
||||
@@ -314,18 +316,18 @@ export async function assertNewWorkspaceSidebarAndHeader(
|
||||
assertHeader?: boolean;
|
||||
},
|
||||
): Promise<{ workspaceId: string; workspaceName: string; workspaceDirectory: string }> {
|
||||
// Wait for URL to redirect to the newly created workspace.
|
||||
// Uses URL as source of truth to avoid picking up sidebar rows from concurrent tests.
|
||||
let workspaceId: string | null = null;
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
|
||||
if (workspaceId && workspaceId !== input.previousWorkspaceId) {
|
||||
break;
|
||||
}
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
// URL is the source of truth so concurrent sidebar rows cannot satisfy this.
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
|
||||
return workspaceId && workspaceId !== input.previousWorkspaceId ? workspaceId : null;
|
||||
},
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
const workspaceId = parseWorkspaceIdFromPageUrl(page, input.serverId);
|
||||
if (!workspaceId || workspaceId === input.previousWorkspaceId) {
|
||||
throw new Error(`Expected URL to redirect to a new workspace.\nCurrent URL: ${page.url()}`);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,5 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Listens for outbound WebSocket "session" frames of a given inner message type
|
||||
* and accumulates them. The returned array is populated in-place as frames arrive.
|
||||
*/
|
||||
export function captureWsSessionFrames<T extends Record<string, unknown>>(
|
||||
page: Page,
|
||||
messageType: string,
|
||||
extract: (inner: Record<string, unknown>) => T,
|
||||
): T[] {
|
||||
const captured: T[] = [];
|
||||
page.on("websocket", (ws) => {
|
||||
ws.on("framesent", (frame) => {
|
||||
const raw = frame.payload;
|
||||
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
||||
try {
|
||||
const outer = JSON.parse(text) as { type?: string; message?: Record<string, unknown> };
|
||||
if (outer.type === "session" && outer.message?.type === messageType) {
|
||||
captured.push(extract(outer.message));
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON and binary frames.
|
||||
}
|
||||
});
|
||||
});
|
||||
return captured;
|
||||
}
|
||||
|
||||
export function renameModalInput(page: Page, testIdPrefix: string) {
|
||||
return page.getByTestId(`${testIdPrefix}-input`);
|
||||
}
|
||||
|
||||
@@ -58,7 +58,11 @@ export interface SeedDaemonClient {
|
||||
terminal: { id: string; name: string; cwd: string; activity?: TerminalActivity | null } | null;
|
||||
error: string | null;
|
||||
}>;
|
||||
listTerminals(cwd?: string): Promise<{
|
||||
listTerminals(
|
||||
cwd?: string,
|
||||
requestId?: string,
|
||||
options?: { workspaceId?: string },
|
||||
): Promise<{
|
||||
terminals: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -80,7 +84,18 @@ export interface SeedDaemonClient {
|
||||
initialPrompt?: string;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{ agent: { id: string; cwd: string; title?: string | null } }>;
|
||||
entries: Array<{
|
||||
agent: {
|
||||
id: string;
|
||||
provider: string;
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
model: string | null;
|
||||
currentModeId: string | null;
|
||||
status: string;
|
||||
title?: string | null;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
fetchRecentProviderSessions(options: {
|
||||
cwd: string;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { seedWorkspace } from "./helpers/seed-client";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { createIdleAgent, expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
import { renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
|
||||
async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId: string }) {
|
||||
@@ -17,8 +17,13 @@ async function openAgentInWorkspace(page: Page, agent: { id: string; workspaceId
|
||||
await expectWorkspaceTabVisible(page, agent.id);
|
||||
}
|
||||
|
||||
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||
}
|
||||
|
||||
test.describe("Workspace agent tab rename", () => {
|
||||
test("right-click rename sends update_agent_request and updates the tab label", async ({
|
||||
test("right-click rename persists the agent title and updates the tab label", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
@@ -32,12 +37,6 @@ test.describe("Workspace agent tab rename", () => {
|
||||
title: initialTitle,
|
||||
});
|
||||
|
||||
const updateFrames = captureWsSessionFrames(page, "update_agent_request", (inner) => ({
|
||||
agentId: String(inner.agentId ?? ""),
|
||||
name: String(inner.name ?? ""),
|
||||
requestId: String(inner.requestId ?? ""),
|
||||
}));
|
||||
|
||||
await openAgentInWorkspace(page, agent);
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first();
|
||||
@@ -62,12 +61,7 @@ test.describe("Workspace agent tab rename", () => {
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(tab).toContainText(renamed, { timeout: 15_000 });
|
||||
|
||||
expect(updateFrames.length).toBeGreaterThan(0);
|
||||
const lastFrame = updateFrames.at(-1)!;
|
||||
expect(lastFrame.agentId).toBe(agent.id);
|
||||
expect(lastFrame.name).toBe(renamed);
|
||||
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||
await expect.poll(() => fetchAgentTitle(workspace.client, agent.id)).toBe(renamed);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,135 +1,35 @@
|
||||
import { test, expect, type Page } from "./fixtures";
|
||||
import { test, expect } from "./fixtures";
|
||||
import { expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { delayBrowserAgentCreatedStatus } from "./helpers/new-workspace";
|
||||
import { seedWorkspace, type SeedDaemonClient } from "./helpers/seed-client";
|
||||
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
|
||||
import { captureWsSessionFrames } from "./helpers/rename";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { buildHostWorkspaceRoute } from "@/utils/host-routes";
|
||||
|
||||
interface WorkspaceTabProbeRecord {
|
||||
at: number;
|
||||
tabs: Array<{
|
||||
testId: string;
|
||||
text: string;
|
||||
ariaLabel: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CapturedCreateAgentFrame {
|
||||
initialPrompt: string | null;
|
||||
configTitle: string | null;
|
||||
}
|
||||
|
||||
async function installWorkspaceTabProbe(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
type ProbeRecord = WorkspaceTabProbeRecord;
|
||||
type ProbeWindow = Window & {
|
||||
__workspaceTabTitleProbe?: { records: ProbeRecord[]; stop: () => void };
|
||||
};
|
||||
|
||||
const win = window as ProbeWindow;
|
||||
win.__workspaceTabTitleProbe?.stop();
|
||||
|
||||
const records: ProbeRecord[] = [];
|
||||
const isVisible = (element: Element): element is HTMLElement => {
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
return (
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
style.display !== "none" &&
|
||||
style.visibility !== "hidden"
|
||||
);
|
||||
};
|
||||
const snapshot = () => {
|
||||
records.push({
|
||||
at: performance.now(),
|
||||
tabs: Array.from(document.querySelectorAll('[data-testid^="workspace-tab-"]'))
|
||||
.filter(isVisible)
|
||||
.map((element) => ({
|
||||
testId: element.getAttribute("data-testid") ?? "",
|
||||
text: (element.textContent ?? "").replace(/\s+/g, " ").trim(),
|
||||
ariaLabel: element.getAttribute("aria-label") ?? "",
|
||||
})),
|
||||
});
|
||||
};
|
||||
|
||||
snapshot();
|
||||
const observer = new MutationObserver(snapshot);
|
||||
observer.observe(document.body, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["aria-label", "class", "data-testid", "style"],
|
||||
});
|
||||
const interval = window.setInterval(snapshot, 20);
|
||||
win.__workspaceTabTitleProbe = {
|
||||
records,
|
||||
stop: () => {
|
||||
observer.disconnect();
|
||||
window.clearInterval(interval);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function readWorkspaceTabProbe(page: Page): Promise<WorkspaceTabProbeRecord[]> {
|
||||
return page.evaluate(() => {
|
||||
type ProbeWindow = Window & {
|
||||
__workspaceTabTitleProbe?: { records: WorkspaceTabProbeRecord[]; stop: () => void };
|
||||
};
|
||||
const probe = (window as ProbeWindow).__workspaceTabTitleProbe;
|
||||
probe?.stop();
|
||||
return probe?.records ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
function recordHasTabLabel(record: WorkspaceTabProbeRecord, label: string): boolean {
|
||||
return record.tabs.some((tab) => tab.text.includes(label) || tab.ariaLabel.includes(label));
|
||||
}
|
||||
|
||||
function createFrameStartsWithPrompt(
|
||||
frame: CapturedCreateAgentFrame,
|
||||
promptTitle: string,
|
||||
): boolean {
|
||||
return frame.initialPrompt?.startsWith(promptTitle) ?? false;
|
||||
}
|
||||
|
||||
function countCreateFramesForPrompt(
|
||||
frames: CapturedCreateAgentFrame[],
|
||||
promptTitle: string,
|
||||
): number {
|
||||
return frames.filter((frame) => createFrameStartsWithPrompt(frame, promptTitle)).length;
|
||||
}
|
||||
|
||||
function tabHasLoadingTitle(tab: WorkspaceTabProbeRecord["tabs"][number]): boolean {
|
||||
return /Loading agent title|Loading\.\.\./.test(`${tab.text} ${tab.ariaLabel}`);
|
||||
}
|
||||
|
||||
function recordHasLoadingTitle(record: WorkspaceTabProbeRecord): boolean {
|
||||
return record.tabs.some(tabHasLoadingTitle);
|
||||
}
|
||||
|
||||
async function waitForCreatedAgentId(client: SeedDaemonClient, cwd: string): Promise<string> {
|
||||
async function waitForCreatedAgentId(
|
||||
client: SeedDaemonClient,
|
||||
input: { cwd: string; workspaceId: string },
|
||||
): Promise<string> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries
|
||||
.filter((entry) => entry.agent.cwd === cwd)
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.agent.cwd === input.cwd && entry.agent.workspaceId === input.workspaceId,
|
||||
)
|
||||
.map((entry) => entry.agent.id);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toHaveLength(1);
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
const agent = result.entries.find((entry) => entry.agent.cwd === cwd);
|
||||
const agent = result.entries.find(
|
||||
(entry) => entry.agent.cwd === input.cwd && entry.agent.workspaceId === input.workspaceId,
|
||||
);
|
||||
if (!agent) {
|
||||
throw new Error(`Expected one created agent in ${cwd}`);
|
||||
throw new Error(`Expected one created agent in ${input.cwd}`);
|
||||
}
|
||||
return agent.agent.id;
|
||||
}
|
||||
@@ -142,43 +42,17 @@ async function fetchActiveAgentTitle(
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||
}
|
||||
|
||||
async function waitForPromptTabAgentActions(page: Page, promptTitle: string): Promise<void> {
|
||||
const promptTab = page.getByRole("button", { name: promptTitle }).first();
|
||||
await expect(promptTab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const deadline = Date.now() + 15_000;
|
||||
while (Date.now() < deadline) {
|
||||
await promptTab.click({ button: "right" });
|
||||
const renameAction = page.getByText("Rename", { exact: true }).first();
|
||||
if (await renameAction.isVisible().catch(() => false)) {
|
||||
await page.keyboard.press("Escape");
|
||||
return;
|
||||
}
|
||||
await page.keyboard.press("Escape").catch(() => undefined);
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
throw new Error("Prompt tab did not expose agent tab actions after create handoff");
|
||||
}
|
||||
|
||||
test.describe("Workspace agent title handoff", () => {
|
||||
test("keeps the prompt as the optimistic tab title until the generated title arrives", async ({
|
||||
test("shows the prompt tab title and replaces it when the daemon title updates", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
const agentCreatedDelay = await delayBrowserAgentCreatedStatus(page);
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-title-handoff-" });
|
||||
|
||||
try {
|
||||
const createFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
|
||||
const config = (inner.config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
|
||||
configTitle: typeof config.title === "string" ? config.title : null,
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.workspaceId));
|
||||
await waitForWorkspaceTabsVisible(page);
|
||||
await page.getByTestId("workspace-new-agent-tab-inline").click();
|
||||
@@ -186,21 +60,39 @@ test.describe("Workspace agent title handoff", () => {
|
||||
|
||||
const promptTitle = "Investigate optimistic tab title handoff";
|
||||
const generatedTitle = "Generated Handoff Title";
|
||||
await installWorkspaceTabProbe(page);
|
||||
await submitMessage(page, `${promptTitle}\n\nMake the UI state deterministic.`);
|
||||
await agentCreatedDelay.waitForCreateRequest();
|
||||
await agentCreatedDelay.waitForDelayedCreatedStatus();
|
||||
|
||||
const agentId = await waitForCreatedAgentId(workspace.client, workspace.repoPath);
|
||||
await expect
|
||||
.poll(() => countCreateFramesForPrompt(createFrames, promptTitle), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBe(1);
|
||||
expect(createFrames.at(-1)).toEqual({
|
||||
initialPrompt: `${promptTitle}\n\nMake the UI state deterministic.`,
|
||||
configTitle: null,
|
||||
await expect(page.getByRole("button", { name: promptTitle }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText(/Loading agent title|Loading\.\.\./).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
const agentId = await waitForCreatedAgentId(workspace.client, {
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
|
||||
await waitForPromptTabAgentActions(page, promptTitle);
|
||||
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0);
|
||||
agentCreatedDelay.release();
|
||||
|
||||
const agentTab = page.getByTestId(`workspace-tab-agent_${agentId}`).first();
|
||||
await expect(agentTab).toBeVisible({ timeout: 15_000 });
|
||||
await expect
|
||||
.poll(() => fetchActiveAgentTitle(workspace.client, agentId), { timeout: 10_000 })
|
||||
.toBe(promptTitle);
|
||||
await expect(agentTab).toContainText(promptTitle, { timeout: 15_000 });
|
||||
await agentTab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-agent_${agentId}-rename`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(
|
||||
page.getByText(/Loading agent title|Loading\.\.\./).filter({ visible: true }),
|
||||
).toHaveCount(0);
|
||||
|
||||
await workspace.client.updateAgent(agentId, { name: generatedTitle });
|
||||
await expect
|
||||
@@ -209,12 +101,8 @@ test.describe("Workspace agent title handoff", () => {
|
||||
await expect(page.getByRole("button", { name: generatedTitle }).first()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const records = await readWorkspaceTabProbe(page);
|
||||
expect(records.some((record) => recordHasTabLabel(record, promptTitle))).toBe(true);
|
||||
expect(records.some((record) => recordHasTabLabel(record, generatedTitle))).toBe(true);
|
||||
expect(records.filter(recordHasLoadingTitle)).toEqual([]);
|
||||
} finally {
|
||||
agentCreatedDelay.release();
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
import { clickNewChat, clickNewTerminal } from "./helpers/launcher";
|
||||
import { captureWsSessionFrames } from "./helpers/rename";
|
||||
import { clickNewChat, clickNewTerminal, gotoWorkspace } from "./helpers/launcher";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
expectTerminalSurfaceVisible,
|
||||
focusTerminalSurface,
|
||||
@@ -9,13 +9,25 @@ import {
|
||||
waitForTerminalContent,
|
||||
} from "./helpers/terminal-perf";
|
||||
|
||||
interface CreateAgentFrame {
|
||||
initialPrompt: string | null;
|
||||
interface CreatedAgentCwdAssertion {
|
||||
workspaceId: string;
|
||||
cwd: string | null;
|
||||
}
|
||||
|
||||
function cwdForPrompt(frames: CreateAgentFrame[], prompt: string): string | null {
|
||||
return frames.find((frame) => frame.initialPrompt === prompt)?.cwd ?? null;
|
||||
async function fetchSingleAgentForWorkspace(
|
||||
workspace: SeededWorkspace,
|
||||
): Promise<CreatedAgentCwdAssertion | null> {
|
||||
const agents = (await workspace.client.fetchAgents({ scope: "active" })).entries
|
||||
.map((entry) => entry.agent)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId);
|
||||
if (agents.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const [agent] = agents;
|
||||
return {
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: agent.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe("Workspace cwd correctness", () => {
|
||||
@@ -36,37 +48,36 @@ test.describe("Workspace cwd correctness", () => {
|
||||
await waitForTerminalContent(page, (text) => text.includes(workspace.repoPath), 10_000);
|
||||
});
|
||||
|
||||
test("draft tab creates an agent in the workspace cwd", async ({ page, withWorkspace }) => {
|
||||
test("draft tab creates an agent in the workspace cwd", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const createAgentFrames = captureWsSessionFrames(page, "create_agent_request", (inner) => {
|
||||
const config = (inner.config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
|
||||
cwd: typeof config.cwd === "string" ? config.cwd : null,
|
||||
};
|
||||
});
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-cwd-draft-agent-" });
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
|
||||
const workspace = await withWorkspace({ prefix: "workspace-cwd-draft-agent-" });
|
||||
await workspace.navigateTo();
|
||||
await clickNewChat(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
const message = `cwd draft create ${Date.now()}`;
|
||||
await expect(composer).toBeEditable({ timeout: 15_000 });
|
||||
await composer.fill(message);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await clickNewChat(page);
|
||||
const composer = page.getByRole("textbox", { name: "Message agent..." }).first();
|
||||
const message = `cwd draft create ${Date.now()}`;
|
||||
await expect(composer).toBeEditable({ timeout: 15_000 });
|
||||
await composer.fill(message);
|
||||
await composer.press("Enter");
|
||||
await expect(page.getByText(message, { exact: true }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(page.locator('[data-testid^="workspace-tab-agent_"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => cwdForPrompt(createAgentFrames, message), { timeout: 30_000 })
|
||||
.toBe(workspace.repoPath);
|
||||
await expect
|
||||
.poll(() => fetchSingleAgentForWorkspace(workspace), { timeout: 30_000 })
|
||||
.toEqual({
|
||||
workspaceId: workspace.workspaceId,
|
||||
cwd: workspace.repoPath,
|
||||
});
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("worktree workspace opens terminals in the worktree directory", async ({
|
||||
|
||||
@@ -1,24 +1,140 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { expectComposerEditable, expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
|
||||
import { connectNewWorkspaceDaemonClient } from "./helpers/new-workspace";
|
||||
import { captureWsSessionFrames } from "./helpers/rename";
|
||||
import {
|
||||
assertNewWorkspaceSidebarAndHeader,
|
||||
connectNewWorkspaceDaemonClient,
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectWorkspaceBacking,
|
||||
submitNewWorkspaceEmpty,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "./helpers/new-workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import { expectWorkspaceHeader, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
|
||||
|
||||
type NewWorkspaceDaemonClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
type WorkspaceIndicator = "attention" | "done" | "failed" | "loading" | "needs_input" | "running";
|
||||
|
||||
interface CreateAgentFrame extends Record<string, unknown> {
|
||||
initialPrompt: string | null;
|
||||
workspaceId: string | null;
|
||||
provider: string | null;
|
||||
cwd: string | null;
|
||||
interface CreatedAgentAssertion {
|
||||
workspaceId: string;
|
||||
provider: string;
|
||||
cwd: string;
|
||||
modeId: string | null;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
function createFrameForPrompt(frames: CreateAgentFrame[], prompt: string): CreateAgentFrame | null {
|
||||
return frames.find((frame) => frame.initialPrompt === prompt) ?? null;
|
||||
async function fetchCreatedAgentForWorkspace(
|
||||
seeded: SeededWorkspace,
|
||||
workspaceId: string,
|
||||
): Promise<CreatedAgentAssertion | null> {
|
||||
const result = await seeded.client.fetchAgents({ scope: "active" });
|
||||
const agents = result.entries
|
||||
.map((entry) => entry.agent)
|
||||
.filter((agent) => agent.cwd === seeded.repoPath && agent.workspaceId === workspaceId);
|
||||
if (agents.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
const [agent] = agents;
|
||||
return {
|
||||
workspaceId,
|
||||
provider: agent.provider,
|
||||
cwd: agent.cwd,
|
||||
modeId: agent.currentModeId,
|
||||
model: agent.model,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWorkspaceName(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await client.fetchWorkspaces();
|
||||
return result.entries.find((entry) => entry.id === workspaceId)?.name ?? null;
|
||||
}
|
||||
|
||||
async function fetchWorkspaceStatuses(
|
||||
client: NewWorkspaceDaemonClient,
|
||||
workspaceIds: string[],
|
||||
): Promise<Record<string, string | null>> {
|
||||
const result = await client.fetchWorkspaces();
|
||||
const entriesById = new Map(result.entries.map((entry) => [entry.id, entry.status]));
|
||||
return Object.fromEntries(
|
||||
workspaceIds.map((workspaceId) => [workspaceId, entriesById.get(workspaceId) ?? null]),
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promise<string | null> {
|
||||
const result = await seeded.client.fetchAgents({ scope: "active" });
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.status ?? null;
|
||||
}
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-grouping-selector").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
function statusGroupRows(page: import("@playwright/test").Page, bucket: string) {
|
||||
return page.getByTestId(`sidebar-status-group-rows-${bucket}`);
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowInStatusBucket(
|
||||
page: import("@playwright/test").Page,
|
||||
input: { rowTestId: string; bucket: string },
|
||||
) {
|
||||
await expect(statusGroupRows(page, input.bucket).getByTestId(input.rowTestId)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowNotInStatusBuckets(
|
||||
page: import("@playwright/test").Page,
|
||||
input: { rowTestId: string; buckets: string[] },
|
||||
) {
|
||||
for (const bucket of input.buckets) {
|
||||
await expect(statusGroupRows(page, bucket).getByTestId(input.rowTestId)).toHaveCount(0, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowHasOnlyIndicator(
|
||||
page: import("@playwright/test").Page,
|
||||
input: { rowTestId: string; indicator: WorkspaceIndicator },
|
||||
) {
|
||||
const row = page.getByTestId(input.rowTestId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
for (const indicator of [
|
||||
"attention",
|
||||
"done",
|
||||
"failed",
|
||||
"loading",
|
||||
"needs_input",
|
||||
"running",
|
||||
] satisfies WorkspaceIndicator[]) {
|
||||
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
|
||||
if (indicator === input.indicator) {
|
||||
await expect(locator).toBeVisible({ timeout: 30_000 });
|
||||
} else {
|
||||
await expect(locator).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowDoesNotShowIndicator(
|
||||
page: import("@playwright/test").Page,
|
||||
input: { rowTestId: string; indicator: WorkspaceIndicator },
|
||||
) {
|
||||
const row = page.getByTestId(input.rowTestId);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
row.locator(`[data-testid="workspace-status-indicator-${input.indicator}"]`),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
test.describe("Workspace model regressions", () => {
|
||||
@@ -34,14 +150,17 @@ test.describe("Workspace model regressions", () => {
|
||||
await client?.close().catch(() => undefined);
|
||||
});
|
||||
|
||||
test("same-directory workspace does not inherit legacy cwd-only agent tabs", async ({ page }) => {
|
||||
test("same-directory workspace does not show agents owned by another workspace", async ({
|
||||
page,
|
||||
}) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "workspace-legacy-agents-" });
|
||||
|
||||
try {
|
||||
const legacyAgent = await seeded.client.createAgent({
|
||||
const ownedAgent = await seeded.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: seeded.repoPath,
|
||||
title: "Legacy cwd-only agent",
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Agent owned by original workspace",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
@@ -62,7 +181,7 @@ test.describe("Workspace model regressions", () => {
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await expect
|
||||
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
|
||||
.toContain(`workspace-tab-agent_${legacyAgent.id}`);
|
||||
.toContain(`workspace-tab-agent_${ownedAgent.id}`);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
@@ -74,21 +193,6 @@ test.describe("Workspace model regressions", () => {
|
||||
const seeded: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "workspace-new-agent-model-",
|
||||
});
|
||||
const createFrames = captureWsSessionFrames<CreateAgentFrame>(
|
||||
page,
|
||||
"create_agent_request",
|
||||
(inner) => {
|
||||
const config = (inner.config ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
initialPrompt: typeof inner.initialPrompt === "string" ? inner.initialPrompt : null,
|
||||
workspaceId: typeof inner.workspaceId === "string" ? inner.workspaceId : null,
|
||||
provider: typeof config.provider === "string" ? config.provider : null,
|
||||
cwd: typeof config.cwd === "string" ? config.cwd : null,
|
||||
modeId: typeof config.modeId === "string" ? config.modeId : null,
|
||||
model: typeof config.model === "string" ? config.model : null,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
const secondWorkspace = await seeded.client.createWorkspace({
|
||||
@@ -98,6 +202,7 @@ test.describe("Workspace model regressions", () => {
|
||||
if (!secondWorkspace.workspace) {
|
||||
throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace");
|
||||
}
|
||||
const workspace = secondWorkspace.workspace;
|
||||
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
@@ -110,7 +215,7 @@ test.describe("Workspace model regressions", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
await gotoWorkspace(page, secondWorkspace.workspace.id);
|
||||
await gotoWorkspace(page, workspace.id);
|
||||
await clickNewChat(page);
|
||||
|
||||
await expectComposerVisible(page);
|
||||
@@ -121,12 +226,11 @@ test.describe("Workspace model regressions", () => {
|
||||
0,
|
||||
);
|
||||
await expect
|
||||
.poll(() => createFrameForPrompt(createFrames, prompt), {
|
||||
.poll(() => fetchCreatedAgentForWorkspace(seeded, workspace.id), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toEqual({
|
||||
initialPrompt: prompt,
|
||||
workspaceId: secondWorkspace.workspace.id,
|
||||
workspaceId: workspace.id,
|
||||
provider: "mock",
|
||||
cwd: seeded.repoPath,
|
||||
modeId: "load-test",
|
||||
@@ -136,4 +240,291 @@ test.describe("Workspace model regressions", () => {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("local same-directory workspace with an initial prompt shows a generated title", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const seeded: SeededWorkspace = await seedWorkspace({
|
||||
repoPrefix: "workspace-generated-local-title-",
|
||||
});
|
||||
const previousConfig = await client.getDaemonConfig();
|
||||
|
||||
try {
|
||||
await client.patchDaemonConfig({
|
||||
metadataGeneration: {
|
||||
providers: [{ provider: "mock", model: "ten-second-stream" }],
|
||||
},
|
||||
});
|
||||
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
await selectWorkspaceBacking(page, "local");
|
||||
await submitNewWorkspacePrompt(page, "Fix login bug");
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: seeded.workspaceId,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
const createdRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`,
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(() => fetchWorkspaceName(client, createdWorkspace.workspaceId), {
|
||||
timeout: 30_000,
|
||||
})
|
||||
.toBe("Fix login bug");
|
||||
await expectWorkspaceHeader(page, {
|
||||
title: "Fix login bug",
|
||||
subtitle: seeded.projectDisplayName,
|
||||
});
|
||||
await expect(createdRow).toContainText("Fix login bug", { timeout: 30_000 });
|
||||
expect(createdWorkspace.workspaceDirectory).toBe(seeded.workspaceDirectory);
|
||||
} finally {
|
||||
await client
|
||||
.patchDaemonConfig({
|
||||
metadataGeneration: {
|
||||
providers: previousConfig.config.metadataGeneration.providers,
|
||||
},
|
||||
})
|
||||
.catch(() => undefined);
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("running agent in one same-directory workspace only shows the loader on its owning row", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "workspace-same-cwd-running-",
|
||||
});
|
||||
|
||||
try {
|
||||
const secondWorkspace = await seeded.client.createWorkspace({
|
||||
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
|
||||
title: "Existing sibling workspace",
|
||||
});
|
||||
if (!secondWorkspace.workspace) {
|
||||
throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace");
|
||||
}
|
||||
const secondWorkspaceId = secondWorkspace.workspace.id;
|
||||
|
||||
const runningAgent = await seeded.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Running agent",
|
||||
modeId: "load-test",
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: "stay running",
|
||||
});
|
||||
await seeded.client.waitForAgentUpsert(
|
||||
runningAgent.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
15_000,
|
||||
);
|
||||
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
const firstRowTestId = `sidebar-workspace-row-${serverId}:${seeded.workspaceId}`;
|
||||
const secondRowTestId = `sidebar-workspace-row-${serverId}:${secondWorkspaceId}`;
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
rowTestId: firstRowTestId,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
indicator: "running",
|
||||
});
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
await selectWorkspaceBacking(page, "local");
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: seeded.workspaceId,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
const createdRowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
|
||||
|
||||
await expect
|
||||
.poll(() => fetchAgentStatus(seeded, runningAgent.id), { timeout: 10_000 })
|
||||
.toBe("running");
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
fetchWorkspaceStatuses(client, [
|
||||
seeded.workspaceId,
|
||||
secondWorkspaceId,
|
||||
createdWorkspace.workspaceId,
|
||||
]),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toEqual({
|
||||
[seeded.workspaceId]: "running",
|
||||
[secondWorkspaceId]: "done",
|
||||
[createdWorkspace.workspaceId]: "done",
|
||||
});
|
||||
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
rowTestId: firstRowTestId,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
rowTestId: createdRowTestId,
|
||||
indicator: "running",
|
||||
});
|
||||
|
||||
await switchSidebarToStatusGrouping(page);
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
rowTestId: firstRowTestId,
|
||||
bucket: "running",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
bucket: "done",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
rowTestId: createdRowTestId,
|
||||
bucket: "done",
|
||||
});
|
||||
await expectWorkspaceRowNotInStatusBuckets(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
buckets: ["running", "needs_input", "attention"],
|
||||
});
|
||||
await expectWorkspaceRowNotInStatusBuckets(page, {
|
||||
rowTestId: createdRowTestId,
|
||||
buckets: ["running", "needs_input", "attention"],
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pending permission in one same-directory workspace marks only its own row needing input", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({
|
||||
repoPrefix: "workspace-same-cwd-permission-",
|
||||
});
|
||||
|
||||
try {
|
||||
const secondWorkspace = await seeded.client.createWorkspace({
|
||||
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
|
||||
title: "Permission sibling workspace",
|
||||
});
|
||||
if (!secondWorkspace.workspace) {
|
||||
throw new Error(secondWorkspace.error ?? "Failed to create same-directory workspace");
|
||||
}
|
||||
const secondWorkspaceId = secondWorkspace.workspace.id;
|
||||
|
||||
const agent = await seeded.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: seeded.repoPath,
|
||||
workspaceId: seeded.workspaceId,
|
||||
title: "Permission agent",
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
await seeded.client.sendAgentMessage(agent.id, "Emit synthetic plan approval.");
|
||||
const parked = await seeded.client.waitForFinish(agent.id, 15_000);
|
||||
expect(parked.status).toBe("permission");
|
||||
|
||||
await gotoWorkspace(page, seeded.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
const firstRowTestId = `sidebar-workspace-row-${serverId}:${seeded.workspaceId}`;
|
||||
const secondRowTestId = `sidebar-workspace-row-${serverId}:${secondWorkspaceId}`;
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
rowTestId: firstRowTestId,
|
||||
indicator: "needs_input",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
indicator: "needs_input",
|
||||
});
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
await selectWorkspaceBacking(page, "local");
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId,
|
||||
client,
|
||||
previousWorkspaceId: seeded.workspaceId,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
const createdRowTestId = `sidebar-workspace-row-${serverId}:${createdWorkspace.workspaceId}`;
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
fetchWorkspaceStatuses(client, [
|
||||
seeded.workspaceId,
|
||||
secondWorkspaceId,
|
||||
createdWorkspace.workspaceId,
|
||||
]),
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toEqual({
|
||||
[seeded.workspaceId]: "needs_input",
|
||||
[secondWorkspaceId]: "done",
|
||||
[createdWorkspace.workspaceId]: "done",
|
||||
});
|
||||
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
rowTestId: firstRowTestId,
|
||||
indicator: "needs_input",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
indicator: "needs_input",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
rowTestId: createdRowTestId,
|
||||
indicator: "needs_input",
|
||||
});
|
||||
|
||||
await switchSidebarToStatusGrouping(page);
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
rowTestId: firstRowTestId,
|
||||
bucket: "needs_input",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
bucket: "done",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
rowTestId: createdRowTestId,
|
||||
bucket: "done",
|
||||
});
|
||||
await expectWorkspaceRowNotInStatusBuckets(page, {
|
||||
rowTestId: secondRowTestId,
|
||||
buckets: ["running", "needs_input", "attention"],
|
||||
});
|
||||
await expectWorkspaceRowNotInStatusBuckets(page, {
|
||||
rowTestId: createdRowTestId,
|
||||
buckets: ["running", "needs_input", "attention"],
|
||||
});
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execSync, spawn, type ChildProcess } from "node:child_process";
|
||||
import { once } from "node:events";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import net from "node:net";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
||||
import { buildSeededHost } from "./helpers/daemon-registry";
|
||||
import { loadDaemonClientConstructor } from "./helpers/daemon-client-loader";
|
||||
import { createNodeWebSocketFactory, type NodeWebSocketFactory } from "./helpers/node-ws-factory";
|
||||
import {
|
||||
expectNewWorkspaceProjectSelected,
|
||||
openGlobalNewWorkspaceComposer,
|
||||
selectNewWorkspaceProject,
|
||||
submitNewWorkspaceEmpty,
|
||||
} from "./helpers/new-workspace";
|
||||
import { waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
|
||||
|
||||
const LEGACY_AGENT_ID = "legacy-cwd-only-agent";
|
||||
const SERVER_ID = `srv_restart_${randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
||||
|
||||
interface RestartDaemonClient {
|
||||
connect(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
fetchWorkspaces(): Promise<{
|
||||
entries: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
workspaceDirectory: string;
|
||||
}>;
|
||||
}>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{
|
||||
agent: {
|
||||
id: string;
|
||||
workspaceId?: string;
|
||||
status: string;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RestartDaemonClientConfig {
|
||||
url: string;
|
||||
clientId: string;
|
||||
clientType: "cli";
|
||||
appVersion: string;
|
||||
webSocketFactory: NodeWebSocketFactory;
|
||||
}
|
||||
|
||||
interface SeededRestartHome {
|
||||
paseoHome: string;
|
||||
cwd: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
workspaceA: string;
|
||||
workspaceB: string;
|
||||
cleanup(): void;
|
||||
}
|
||||
|
||||
interface StartedDaemon {
|
||||
port: number;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
async function seedRestartHome(): Promise<SeededRestartHome> {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-playwright-restart-home-"));
|
||||
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-playwright-restart-cwd-"));
|
||||
const projectsDir = path.join(paseoHome, "projects");
|
||||
const agentDir = path.join(paseoHome, "agents", projectDirNameFromCwd(cwd));
|
||||
mkdirSync(projectsDir, { recursive: true });
|
||||
mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
const projectDisplayName = path.basename(cwd);
|
||||
const project = {
|
||||
projectId: `prj_restart_${randomUUID().slice(0, 8)}`,
|
||||
rootPath: cwd,
|
||||
kind: "non_git",
|
||||
displayName: projectDisplayName,
|
||||
customName: null,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
};
|
||||
const workspaceA = {
|
||||
workspaceId: "wks_restart_a",
|
||||
projectId: project.projectId,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: "Original restart workspace",
|
||||
title: null,
|
||||
branch: null,
|
||||
createdAt: "2026-03-01T00:00:00.000Z",
|
||||
updatedAt: "2026-03-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
};
|
||||
const workspaceB = {
|
||||
workspaceId: "wks_restart_b",
|
||||
projectId: project.projectId,
|
||||
cwd,
|
||||
kind: "directory",
|
||||
displayName: "Restart sibling workspace",
|
||||
title: null,
|
||||
branch: null,
|
||||
createdAt: "2026-03-02T00:00:00.000Z",
|
||||
updatedAt: "2026-03-02T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
};
|
||||
|
||||
writeFileSync(path.join(projectsDir, "projects.json"), JSON.stringify([project]));
|
||||
writeFileSync(
|
||||
path.join(projectsDir, "workspaces.json"),
|
||||
JSON.stringify([workspaceA, workspaceB]),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(agentDir, `${LEGACY_AGENT_ID}.json`),
|
||||
JSON.stringify({
|
||||
id: LEGACY_AGENT_ID,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
lastActivityAt: "2026-03-01T12:00:00.000Z",
|
||||
lastUserMessageAt: null,
|
||||
title: "Legacy cwd-only running agent",
|
||||
labels: {},
|
||||
lastStatus: "running",
|
||||
lastModeId: "default",
|
||||
config: null,
|
||||
runtimeInfo: { provider: "codex", sessionId: null },
|
||||
persistence: null,
|
||||
archivedAt: null,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
paseoHome,
|
||||
cwd,
|
||||
projectId: project.projectId,
|
||||
projectDisplayName,
|
||||
workspaceA: workspaceA.workspaceId,
|
||||
workspaceB: workspaceB.workspaceId,
|
||||
cleanup: () => {
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function projectDirNameFromCwd(cwd: string): string {
|
||||
const { root } = path.win32.parse(cwd);
|
||||
const withoutRoot = cwd.slice(root.length).replace(/[\\/]+$/, "");
|
||||
const sanitizedRoot = root.replace(/[:\\/]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
const prefix = sanitizedRoot ? `${sanitizedRoot}-` : "";
|
||||
if (!withoutRoot) {
|
||||
return sanitizedRoot || "root";
|
||||
}
|
||||
return prefix + withoutRoot.replace(/[\\/]+/g, "-");
|
||||
}
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to acquire port")));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(port: number, child: ChildProcess): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
let lastConnectionError: unknown = null;
|
||||
while (Date.now() - startedAt < 20_000) {
|
||||
if (child.exitCode !== null) {
|
||||
throw new Error(`Restart test daemon exited before listening (exit ${child.exitCode}).`);
|
||||
}
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.connect(port, "127.0.0.1", () => {
|
||||
socket.end();
|
||||
resolve();
|
||||
});
|
||||
socket.setTimeout(1000, () => {
|
||||
socket.destroy();
|
||||
reject(new Error(`Connection timed out to daemon port ${port}`));
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
lastConnectionError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Restart test daemon did not listen on ${port}. Last error: ${
|
||||
lastConnectionError instanceof Error
|
||||
? lastConnectionError.message
|
||||
: String(lastConnectionError)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function stopProcess(child: ChildProcess): Promise<void> {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
const timeout = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 5000);
|
||||
try {
|
||||
await once(child, "exit");
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function startRestartDaemon(input: {
|
||||
paseoHome: string;
|
||||
origin: string;
|
||||
}): Promise<StartedDaemon> {
|
||||
const port = await getAvailablePort();
|
||||
if (port === 6767 || String(port) === process.env.E2E_DAEMON_PORT) {
|
||||
return startRestartDaemon(input);
|
||||
}
|
||||
|
||||
const serverDir = path.resolve(__dirname, "../../server");
|
||||
const tsxBin = execSync("which tsx").toString().trim();
|
||||
const child = spawn(tsxBin, ["scripts/supervisor-entrypoint.ts", "--dev"], {
|
||||
cwd: serverDir,
|
||||
env: {
|
||||
...process.env,
|
||||
PASEO_HOME: input.paseoHome,
|
||||
PASEO_SERVER_ID: SERVER_ID,
|
||||
PASEO_LISTEN: `127.0.0.1:${port}`,
|
||||
PASEO_CORS_ORIGINS: input.origin,
|
||||
PASEO_RELAY_ENABLED: "0",
|
||||
PASEO_DICTATION_ENABLED: "0",
|
||||
PASEO_VOICE_MODE_ENABLED: "0",
|
||||
PASEO_NODE_ENV: "development",
|
||||
NODE_ENV: "development",
|
||||
},
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
detached: false,
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr?.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
stderr = stderr.split("\n").slice(-40).join("\n");
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForServer(port, child);
|
||||
} catch (error) {
|
||||
await stopProcess(child);
|
||||
throw new Error(
|
||||
`${error instanceof Error ? error.message : String(error)}\nDaemon stderr:\n${stderr}`,
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
port,
|
||||
close: () => stopProcess(child),
|
||||
};
|
||||
}
|
||||
|
||||
async function connectRestartDaemonClient(port: number): Promise<RestartDaemonClient> {
|
||||
const DaemonClient = await loadDaemonClientConstructor<
|
||||
RestartDaemonClientConfig,
|
||||
RestartDaemonClient
|
||||
>();
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${port}/ws`,
|
||||
clientId: `restart-playwright-${randomUUID()}`,
|
||||
clientType: "cli",
|
||||
appVersion: loadAppVersion(),
|
||||
webSocketFactory: createNodeWebSocketFactory(),
|
||||
});
|
||||
await client.connect();
|
||||
return client;
|
||||
}
|
||||
|
||||
function loadAppVersion(): string {
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(path.resolve(__dirname, "../package.json"), "utf8"),
|
||||
) as { version?: unknown };
|
||||
if (typeof packageJson.version !== "string" || packageJson.version.length === 0) {
|
||||
throw new Error("Missing app package version");
|
||||
}
|
||||
return packageJson.version;
|
||||
}
|
||||
|
||||
async function seedBrowserForDaemon(page: Page, input: { serverId: string; port: number }) {
|
||||
await page.route(/:(6767)\b/, (route) => route.abort());
|
||||
await page.routeWebSocket(/:(6767)\b/, async (ws) => {
|
||||
await ws.close({ code: 1008, reason: "Blocked connection to localhost:6767 during e2e." });
|
||||
});
|
||||
await page.route(
|
||||
"**/*",
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
body: "<!doctype html><html><body>storage seed</body></html>",
|
||||
});
|
||||
},
|
||||
{ times: 1 },
|
||||
);
|
||||
await page.goto("/");
|
||||
|
||||
const host = buildSeededHost({
|
||||
serverId: input.serverId,
|
||||
endpoint: `127.0.0.1:${input.port}`,
|
||||
label: "restart daemon",
|
||||
nowIso: nowIso(),
|
||||
});
|
||||
await page.evaluate(
|
||||
({ daemon, preferences }) => {
|
||||
localStorage.setItem("@paseo:e2e", "1");
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
},
|
||||
{
|
||||
daemon: host,
|
||||
preferences: {
|
||||
serverId: input.serverId,
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: { model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function parseWorkspaceIdFromPageUrl(page: Page, serverId: string): string | null {
|
||||
const pathname = new URL(page.url()).pathname;
|
||||
const match = pathname.match(
|
||||
new RegExp(`^/h/${serverId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/workspace/([^/?#]+)`),
|
||||
);
|
||||
if (!match?.[1]) return null;
|
||||
return decodeWorkspaceIdFromPathSegment(match[1]);
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowHasOnlyIndicator(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; indicator: string },
|
||||
) {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
for (const indicator of ["attention", "done", "failed", "loading", "needs_input", "running"]) {
|
||||
const locator = row.locator(`[data-testid="workspace-status-indicator-${indicator}"]`);
|
||||
if (indicator === input.indicator) {
|
||||
await expect(locator).toBeVisible({ timeout: 30_000 });
|
||||
} else {
|
||||
await expect(locator).toHaveCount(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowDoesNotShowIndicator(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; indicator: string },
|
||||
) {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`);
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(
|
||||
row.locator(`[data-testid="workspace-status-indicator-${input.indicator}"]`),
|
||||
).toHaveCount(0, { timeout: 5_000 });
|
||||
}
|
||||
|
||||
async function expectWorkspaceRowInStatusBucket(
|
||||
page: Page,
|
||||
input: { serverId: string; workspaceId: string; bucket: string },
|
||||
) {
|
||||
await page.getByTestId("sidebar-grouping-selector").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(
|
||||
page
|
||||
.getByTestId(`sidebar-status-group-rows-${input.bucket}`)
|
||||
.getByTestId(`sidebar-workspace-row-${input.serverId}:${input.workspaceId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
async function fetchLegacyAgent(client: RestartDaemonClient) {
|
||||
const agents = await client.fetchAgents({ scope: "active" });
|
||||
return agents.entries.find(hasLegacyAgentId)?.agent ?? null;
|
||||
}
|
||||
|
||||
function hasLegacyAgentId(entry: { agent: { id: string } }): boolean {
|
||||
return entry.agent.id === LEGACY_AGENT_ID;
|
||||
}
|
||||
|
||||
async function fetchWorkspaceStatuses(
|
||||
client: RestartDaemonClient,
|
||||
workspaceIds: string[],
|
||||
): Promise<Record<string, string>> {
|
||||
const workspaces = await client.fetchWorkspaces();
|
||||
const wantedWorkspaceIds = new Set(workspaceIds);
|
||||
const statuses: Record<string, string> = {};
|
||||
for (const workspace of workspaces.entries) {
|
||||
if (wantedWorkspaceIds.has(workspace.id)) {
|
||||
statuses[workspace.id] = workspace.status;
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
test.describe("Workspace model restart regressions", () => {
|
||||
test("browser-created same-cwd workspace preserves restarted agent status and migrated tab ownership", async ({
|
||||
page,
|
||||
baseURL,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
const seeded = await seedRestartHome();
|
||||
const origin = new URL(baseURL ?? "http://localhost").origin;
|
||||
const daemon = await startRestartDaemon({ paseoHome: seeded.paseoHome, origin });
|
||||
const serverId = SERVER_ID;
|
||||
const client = await connectRestartDaemonClient(daemon.port);
|
||||
|
||||
try {
|
||||
await seedBrowserForDaemon(page, { serverId, port: daemon.port });
|
||||
|
||||
await expect
|
||||
.poll(() => fetchLegacyAgent(client))
|
||||
.toMatchObject({
|
||||
id: LEGACY_AGENT_ID,
|
||||
workspaceId: seeded.workspaceA,
|
||||
status: "running",
|
||||
});
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
|
||||
await waitForSidebarHydration(page);
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceA,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceB,
|
||||
indicator: "running",
|
||||
});
|
||||
await expect
|
||||
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
|
||||
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
|
||||
|
||||
await openGlobalNewWorkspaceComposer(page);
|
||||
await selectNewWorkspaceProject(page, {
|
||||
projectKey: seeded.projectId,
|
||||
projectDisplayName: seeded.projectDisplayName,
|
||||
});
|
||||
await expectNewWorkspaceProjectSelected(page, seeded.projectDisplayName);
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
|
||||
await expect
|
||||
.poll(() => {
|
||||
const workspaceId = parseWorkspaceIdFromPageUrl(page, serverId);
|
||||
return workspaceId && workspaceId !== seeded.workspaceA ? workspaceId : null;
|
||||
})
|
||||
.not.toBeNull();
|
||||
const createdWorkspaceId = parseWorkspaceIdFromPageUrl(page, serverId);
|
||||
if (!createdWorkspaceId) {
|
||||
throw new Error(`Expected browser to navigate to created workspace, got ${page.url()}`);
|
||||
}
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
fetchWorkspaceStatuses(client, [
|
||||
seeded.workspaceA,
|
||||
seeded.workspaceB,
|
||||
createdWorkspaceId,
|
||||
]),
|
||||
)
|
||||
.toEqual({
|
||||
[seeded.workspaceA]: "running",
|
||||
[seeded.workspaceB]: "done",
|
||||
[createdWorkspaceId]: "done",
|
||||
});
|
||||
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceB,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowDoesNotShowIndicator(page, {
|
||||
serverId,
|
||||
workspaceId: createdWorkspaceId,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceA,
|
||||
bucket: "running",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
serverId,
|
||||
workspaceId: seeded.workspaceB,
|
||||
bucket: "done",
|
||||
});
|
||||
await expectWorkspaceRowInStatusBucket(page, {
|
||||
serverId,
|
||||
workspaceId: createdWorkspaceId,
|
||||
bucket: "done",
|
||||
});
|
||||
await expect
|
||||
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
|
||||
.toEqual([]);
|
||||
|
||||
await page.goto(buildHostWorkspaceRoute(serverId, seeded.workspaceA));
|
||||
await expect
|
||||
.poll(() => getVisibleWorkspaceAgentTabIds(page), { timeout: 30_000 })
|
||||
.toContain(`workspace-tab-agent_${LEGACY_AGENT_ID}`);
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close();
|
||||
seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,42 +1,67 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
|
||||
import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
import { clickNewTerminal, gotoWorkspace } from "./helpers/launcher";
|
||||
import { renameModalInput, renameModalSubmit } from "./helpers/rename";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
|
||||
async function fetchTerminalTitle(
|
||||
workspace: SeededWorkspace,
|
||||
terminalId: string,
|
||||
): Promise<string | null> {
|
||||
const result = await workspace.client.listTerminals(workspace.repoPath, undefined, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const terminal = result.terminals.find((entry) => entry.id === terminalId);
|
||||
return terminal?.title ?? null;
|
||||
}
|
||||
|
||||
async function waitForCreatedTerminalId(workspace: SeededWorkspace): Promise<string> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const result = await workspace.client.listTerminals(workspace.repoPath, undefined, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
return result.terminals.map((entry) => entry.id);
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toHaveLength(1);
|
||||
const result = await workspace.client.listTerminals(workspace.repoPath, undefined, {
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
const terminal = result.terminals[0];
|
||||
if (!terminal) {
|
||||
throw new Error("Expected one created terminal");
|
||||
}
|
||||
return terminal.id;
|
||||
}
|
||||
|
||||
test.describe("Workspace terminal tab rename", () => {
|
||||
let harness: TerminalE2EHarness;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
harness = await TerminalE2EHarness.create({ tempPrefix: "workspace-terminal-rename-" });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await harness?.cleanup();
|
||||
});
|
||||
|
||||
test("right-click rename sends terminal.rename.request and updates the tab label", async ({
|
||||
test("right-click rename persists the terminal title and updates the tab label", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({
|
||||
terminalId: String(inner.terminalId ?? ""),
|
||||
title: String(inner.title ?? ""),
|
||||
requestId: String(inner.requestId ?? ""),
|
||||
}));
|
||||
const workspace = await seedWorkspace({ repoPrefix: "workspace-terminal-rename-" });
|
||||
let terminalId: string | null = null;
|
||||
|
||||
await withTerminalInApp(page, harness, { name: "rename-target" }, async (terminal) => {
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminal.id}`).first();
|
||||
try {
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await clickNewTerminal(page);
|
||||
terminalId = await waitForCreatedTerminalId(workspace);
|
||||
|
||||
const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first();
|
||||
await expect(tab).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await tab.click({ button: "right" });
|
||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminal.id}`)).toBeVisible({
|
||||
await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminal.id}-rename`);
|
||||
const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`);
|
||||
await expect(renameItem).toBeVisible({ timeout: 10_000 });
|
||||
await renameItem.click();
|
||||
|
||||
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminal.id}`;
|
||||
const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`;
|
||||
const input = renameModalInput(page, modalPrefix);
|
||||
await expect(input).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
@@ -45,12 +70,14 @@ test.describe("Workspace terminal tab rename", () => {
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 });
|
||||
|
||||
expect(renameFrames.length).toBeGreaterThan(0);
|
||||
const lastFrame = renameFrames.at(-1)!;
|
||||
expect(lastFrame.terminalId).toBe(terminal.id);
|
||||
expect(lastFrame.title).toBe("My Renamed Terminal");
|
||||
expect(lastFrame.requestId.length).toBeGreaterThan(0);
|
||||
});
|
||||
await expect
|
||||
.poll(() => fetchTerminalTitle(workspace, terminalId!))
|
||||
.toBe("My Renamed Terminal");
|
||||
} finally {
|
||||
if (terminalId) {
|
||||
await workspace.client.killTerminal(terminalId).catch(() => undefined);
|
||||
}
|
||||
await workspace.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,7 +75,6 @@ import {
|
||||
type OpenFileDisposition,
|
||||
type WorkspaceFileOpenRequest,
|
||||
} from "@/workspace/file-open";
|
||||
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
@@ -276,13 +275,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const workspaceRoot = agent.cwd?.trim() || "";
|
||||
const workspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
|
||||
workspaceDirectory: workspaceRoot,
|
||||
});
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
workspaceId: agent.workspaceId,
|
||||
workspaceRoot,
|
||||
});
|
||||
const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({
|
||||
@@ -334,10 +329,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return;
|
||||
}
|
||||
|
||||
if (workspaceId) {
|
||||
if (agent.workspaceId) {
|
||||
navigateToPreparedWorkspaceTab({
|
||||
serverId: resolvedServerId,
|
||||
workspaceId,
|
||||
workspaceId: agent.workspaceId,
|
||||
target: createWorkspaceFileTabTarget(location),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ import { useEffect, useRef } from "react";
|
||||
import { useLocalSearchParams, usePathname, useRouter, type Href } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useResolveWorkspaceIdByCwd } from "@/stores/session-store-hooks";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
export default function HostAgentReadyRoute() {
|
||||
@@ -28,16 +27,16 @@ function HostAgentReadyRouteContent() {
|
||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const agentCwd = useSessionStore((state) => {
|
||||
const agentWorkspaceId = useSessionStore((state) => {
|
||||
if (!serverId || !agentId) {
|
||||
return null;
|
||||
}
|
||||
return state.sessions[serverId]?.agents?.get(agentId)?.cwd ?? null;
|
||||
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
||||
});
|
||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||
);
|
||||
const resolvedWorkspaceId = useResolveWorkspaceIdByCwd(serverId, agentCwd);
|
||||
const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -67,14 +66,14 @@ function HostAgentReadyRouteContent() {
|
||||
if (!serverId || !agentId) {
|
||||
return;
|
||||
}
|
||||
if (agentCwd?.trim() && !hasHydratedWorkspaces) {
|
||||
if (agentWorkspaceId && !hasHydratedWorkspaces) {
|
||||
return;
|
||||
}
|
||||
if (!client || !isConnected) {
|
||||
redirectedRef.current = true;
|
||||
router.replace(buildHostRootRoute(serverId));
|
||||
}
|
||||
}, [agentCwd, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (redirectedRef.current) {
|
||||
@@ -91,15 +90,7 @@ function HostAgentReadyRouteContent() {
|
||||
if (cancelled || redirectedRef.current) {
|
||||
return;
|
||||
}
|
||||
const cwd = result?.agent?.cwd?.trim();
|
||||
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
const workspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
if (!workspaceId && !hasHydratedWorkspaces) {
|
||||
return;
|
||||
}
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId);
|
||||
redirectedRef.current = true;
|
||||
if (workspaceId) {
|
||||
navigateToPreparedWorkspaceTab({
|
||||
@@ -124,7 +115,7 @@ function HostAgentReadyRouteContent() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentId, client, hasHydratedWorkspaces, isConnected, pathname, router, serverId]);
|
||||
}, [agentId, client, isConnected, pathname, router, serverId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -202,7 +202,7 @@ function WorkspaceStatusIndicator({
|
||||
|
||||
if (bucket === "needs_input") {
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<View style={styles.workspaceStatusDot} testID="workspace-status-indicator-needs_input">
|
||||
<ThemedCircleAlert size={14} uniProps={amberColorMapping} />
|
||||
</View>
|
||||
);
|
||||
@@ -222,7 +222,7 @@ function WorkspaceStatusIndicator({
|
||||
? EMPHASIZED_STATUS_DOT_OFFSET
|
||||
: DEFAULT_STATUS_DOT_OFFSET;
|
||||
return (
|
||||
<View style={styles.workspaceStatusDot}>
|
||||
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
|
||||
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
|
||||
{dotColorStyle ? (
|
||||
<StatusDotOverlay
|
||||
|
||||
@@ -28,11 +28,10 @@ function workspace(input?: Partial<WorkspaceDescriptor>): WorkspaceDescriptor {
|
||||
}
|
||||
|
||||
describe("workspace archive pending suppression", () => {
|
||||
it("tracks a locally pending workspace archive by id and directory", () => {
|
||||
it("tracks a locally pending workspace archive by id", () => {
|
||||
markWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -41,12 +40,6 @@ describe("workspace archive pending suppression", () => {
|
||||
workspaceId: "/repo/worktree",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
@@ -68,19 +61,34 @@ describe("workspace archive pending suppression", () => {
|
||||
markWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ workspaceDirectory: "/repo/worktree" }),
|
||||
workspace: workspace({ archivingAt: null }),
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
|
||||
});
|
||||
|
||||
it("does not suppress a same-cwd sibling whose id is not the one archived", () => {
|
||||
markWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
serverId: "server-1",
|
||||
workspace: workspace({ id: "sibling", workspaceDirectory: "/repo/worktree" }),
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
clearWorkspaceArchivePending({ serverId: "server-1", workspaceId: "/repo/worktree" });
|
||||
});
|
||||
|
||||
it("allows upserts when this client did not start the archive", () => {
|
||||
expect(
|
||||
shouldSuppressWorkspaceForLocalArchive({
|
||||
@@ -94,7 +102,6 @@ describe("workspace archive pending suppression", () => {
|
||||
markWorkspaceArchivePending({
|
||||
serverId: "server-1",
|
||||
workspaceId: "/repo/worktree",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
|
||||
interface PendingWorkspaceArchive {
|
||||
workspaceId: string;
|
||||
workspaceDirectory: string | null;
|
||||
}
|
||||
|
||||
const pendingWorkspaceArchivesByServer = new Map<string, Map<string, PendingWorkspaceArchive>>();
|
||||
const pendingWorkspaceArchivesByServer = new Map<string, Set<string>>();
|
||||
|
||||
function pendingArchiveKey(input: { serverId: string; workspaceId: string }): string {
|
||||
return `${input.serverId.trim()}::${input.workspaceId.trim()}`;
|
||||
@@ -15,7 +10,6 @@ function pendingArchiveKey(input: { serverId: string; workspaceId: string }): st
|
||||
export function markWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory?: string | null;
|
||||
}): void {
|
||||
const serverId = input.serverId.trim();
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
@@ -23,11 +17,8 @@ export function markWorkspaceArchivePending(input: {
|
||||
return;
|
||||
}
|
||||
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Map();
|
||||
archives.set(pendingArchiveKey({ serverId, workspaceId }), {
|
||||
workspaceId,
|
||||
workspaceDirectory: normalizeWorkspacePath(input.workspaceDirectory),
|
||||
});
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId) ?? new Set<string>();
|
||||
archives.add(pendingArchiveKey({ serverId, workspaceId }));
|
||||
pendingWorkspaceArchivesByServer.set(serverId, archives);
|
||||
}
|
||||
|
||||
@@ -54,34 +45,15 @@ export function clearWorkspaceArchivePending(input: {
|
||||
export function isWorkspaceArchivePending(input: {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
workspaceDirectory?: string | null;
|
||||
}): boolean {
|
||||
const serverId = input.serverId.trim();
|
||||
if (!serverId) {
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (!serverId || !workspaceId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const archives = pendingWorkspaceArchivesByServer.get(serverId);
|
||||
if (!archives) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
if (workspaceId && archives.has(pendingArchiveKey({ serverId, workspaceId }))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const workspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
|
||||
if (!workspaceDirectory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const archive of archives.values()) {
|
||||
if (archive.workspaceDirectory === workspaceDirectory) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return archives?.has(pendingArchiveKey({ serverId, workspaceId })) ?? false;
|
||||
}
|
||||
|
||||
export function shouldSuppressWorkspaceForLocalArchive(input: {
|
||||
@@ -91,6 +63,5 @@ export function shouldSuppressWorkspaceForLocalArchive(input: {
|
||||
return isWorkspaceArchivePending({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -314,7 +314,7 @@ describe("checkout-git-actions-store", () => {
|
||||
|
||||
const archive = useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd });
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId });
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cwd)).toBe(false);
|
||||
@@ -371,7 +371,9 @@ describe("checkout-git-actions-store", () => {
|
||||
appQueryClient.setQueryData(["sidebarPaseoWorktreeList", serverId, "/tmp"], listSnapshot);
|
||||
|
||||
await expect(
|
||||
useCheckoutGitActionsStore.getState().archiveWorktree({ serverId, cwd, worktreePath: cwd }),
|
||||
useCheckoutGitActionsStore
|
||||
.getState()
|
||||
.archiveWorktree({ serverId, cwd, worktreePath: cwd, workspaceId }),
|
||||
).rejects.toThrow("archive failed");
|
||||
|
||||
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(workspaceId)).toEqual(
|
||||
|
||||
@@ -13,10 +13,7 @@ import {
|
||||
clearWorkspaceArchivePending,
|
||||
markWorkspaceArchivePending,
|
||||
} from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
resolveWorkspaceIdByDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-identity";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -155,15 +152,11 @@ function isWorktreeListQuery(input: { queryKey: QueryKey; serverId: string }): b
|
||||
|
||||
function snapshotWorktreeArchiveState(input: {
|
||||
serverId: string;
|
||||
worktreePath: string;
|
||||
workspaceId: string | undefined;
|
||||
}): WorktreeArchiveSnapshot {
|
||||
const workspaces = useSessionStore.getState().sessions[input.serverId]?.workspaces;
|
||||
const workspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: input.worktreePath,
|
||||
});
|
||||
const workspaceKey = workspaceId
|
||||
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId })
|
||||
const workspaceKey = input.workspaceId
|
||||
? resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: input.workspaceId })
|
||||
: null;
|
||||
return {
|
||||
workspace: workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null,
|
||||
@@ -506,16 +499,16 @@ export const useCheckoutGitActionsStore = create<CheckoutGitActionsStoreState>()
|
||||
actionId: "archive-worktree",
|
||||
run: async () => {
|
||||
const client = resolveClient(serverId);
|
||||
const snapshot = snapshotWorktreeArchiveState({ serverId, worktreePath });
|
||||
const snapshot = snapshotWorktreeArchiveState({ serverId, workspaceId });
|
||||
// The server archive is keyed by worktreePath and must always run. The
|
||||
// optimistic client-side updates are keyed by workspace id, so they only
|
||||
// apply when the workspace is resolved in the local store.
|
||||
// apply when the caller passes the workspace id and it resolves in the
|
||||
// local store.
|
||||
const workspace = snapshot.workspace;
|
||||
if (workspace) {
|
||||
markWorkspaceArchivePending({
|
||||
serverId,
|
||||
workspaceId: workspace.id,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
removeWorktreeFromSessionStore({ serverId, workspaceId: workspace.id });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useState, useCallback, useEffect, useMemo, type ReactElement } from "react";
|
||||
import { router, type Href } from "expo-router";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type CheckoutGitActionStatus, useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
@@ -15,9 +14,8 @@ import type { CheckoutPrMergeMethod } from "@getpaseo/protocol/messages";
|
||||
import { openExternalUrl } from "@/utils/open-external-url";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
|
||||
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
|
||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
||||
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
|
||||
import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect";
|
||||
import {
|
||||
confirmRiskyWorktreeArchive,
|
||||
type WorktreeArchiveWarningLabels,
|
||||
@@ -61,6 +59,9 @@ function formatBaseRefLabel(baseRef: string | undefined, fallbackLabel: string):
|
||||
function isLastWorktreeReference(workspaces: WorkspaceDescriptor[], worktreePath: string): boolean {
|
||||
let references = 0;
|
||||
for (const candidate of workspaces) {
|
||||
// Git-fact: counting how many workspaces still reference the worktree
|
||||
// directory on disk, not attributing ownership. Same-cwd siblings keep the
|
||||
// directory alive, so the disk-deletion offer must compare directories.
|
||||
if (candidate.workspaceDirectory === worktreePath) {
|
||||
references += 1;
|
||||
}
|
||||
@@ -227,6 +228,7 @@ interface UseGitActionsResult {
|
||||
export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): UseGitActionsResult {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const [postShipArchiveSuggested, setPostShipArchiveSuggested] = useState(false);
|
||||
const [shipDefault, setShipDefault] = useState<"merge" | "pr">("pr");
|
||||
|
||||
@@ -533,27 +535,25 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
|
||||
const runArchiveWorktreeRecord = useCallback(
|
||||
(worktreePath: string, deleteWorktreeFromDisk: boolean) => {
|
||||
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
const workspaceList = Array.from(workspaces?.values() ?? []);
|
||||
const archivedWorkspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaceList,
|
||||
workspaceDirectory: worktreePath,
|
||||
});
|
||||
const redirectRoute = archivedWorkspaceId
|
||||
? buildWorkspaceArchiveRedirectRoute({
|
||||
serverId,
|
||||
archivedWorkspaceId,
|
||||
workspaces: workspaceList,
|
||||
})
|
||||
: buildHostRootRoute(serverId);
|
||||
router.replace(redirectRoute as Href);
|
||||
// These git actions only ever target the workspace the screen is showing,
|
||||
// so the redirect is sourced from the active workspace selection (the
|
||||
// screen's own route context), not from any cwd→workspace inference here.
|
||||
if (activeWorkspaceSelection) {
|
||||
redirectIfArchivingActiveWorkspace({
|
||||
serverId,
|
||||
workspaceId: activeWorkspaceSelection.workspaceId,
|
||||
activeWorkspaceSelection,
|
||||
});
|
||||
}
|
||||
// The server archive is keyed by worktreePath; the daemon owns the
|
||||
// directory→workspace resolution for archive-by-path.
|
||||
void runArchiveWorktree({ serverId, cwd, worktreePath, deleteWorktreeFromDisk }).catch(
|
||||
(err) => {
|
||||
toastActionError(err, t("workspace.git.actions.toasts.failedArchive"));
|
||||
},
|
||||
);
|
||||
},
|
||||
[cwd, runArchiveWorktree, serverId, t, toastActionError],
|
||||
[activeWorkspaceSelection, cwd, runArchiveWorktree, serverId, t, toastActionError],
|
||||
);
|
||||
|
||||
const worktreeDeletePrompt = useWorktreeDeletePrompt(runArchiveWorktreeRecord);
|
||||
@@ -567,9 +567,12 @@ export function useGitActions({ serverId, cwd, icons }: UseGitActionsInput): Use
|
||||
|
||||
const workspaces = useSessionStore.getState().sessions[serverId]?.workspaces;
|
||||
const workspaceList = Array.from(workspaces?.values() ?? []);
|
||||
const workspace = workspaceList.find(
|
||||
(candidate) => candidate.workspaceDirectory === worktreePath,
|
||||
);
|
||||
// Git-fact lookup: find the workspace backing this worktree directory to show
|
||||
// its name and diff stat in the confirmation. This reads display data by
|
||||
// directory, the same as the disk-deletion reference counting, and never
|
||||
// derives an id used to key the archive (the archive runs by path).
|
||||
const workspace =
|
||||
workspaceList.find((candidate) => candidate.workspaceDirectory === worktreePath) ?? null;
|
||||
const confirmed = await confirmRiskyWorktreeArchive(
|
||||
{
|
||||
worktreeName: workspace?.name ?? branchLabel,
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface AgentScreenAgent {
|
||||
id: string;
|
||||
status: "initializing" | "idle" | "running" | "error" | "closed";
|
||||
cwd: string;
|
||||
workspaceId?: string;
|
||||
capabilities?: AgentCapabilityFlags;
|
||||
lastError?: string | null;
|
||||
projectPlacement?: {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-f
|
||||
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
|
||||
@@ -132,15 +131,10 @@ function getRootAgentWorkspaceActivity(input: {
|
||||
workspace: WorkspaceDescriptor;
|
||||
agents: Map<string, Agent> | undefined;
|
||||
}): WorkspaceAgentActivity | null {
|
||||
const workspaceDirectory = normalizeWorkspacePath(input.workspace.workspaceDirectory);
|
||||
if (!workspaceDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let latest: WorkspaceAgentActivity | null = null;
|
||||
for (const agent of input.agents?.values() ?? []) {
|
||||
if (agent.archivedAt || agent.parentAgentId) continue;
|
||||
if (normalizeWorkspacePath(agent.cwd) !== workspaceDirectory) continue;
|
||||
if (agent.workspaceId !== input.workspace.id) continue;
|
||||
const status = deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
|
||||
@@ -254,6 +254,21 @@ describe("translation resources", () => {
|
||||
expect(en.openProject.tiles.addProject.title).toBe("Add a project");
|
||||
});
|
||||
|
||||
it("resolves the worktree delete prompt keys used by the sidebar prompt", () => {
|
||||
const strings = flattenStrings(en);
|
||||
|
||||
expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.title"]).toBe(
|
||||
"Archive workspace",
|
||||
);
|
||||
expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.message"]).toBe(
|
||||
"Also remove the worktree from disk?",
|
||||
);
|
||||
expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.keep"]).toBe(
|
||||
"Keep on disk",
|
||||
);
|
||||
expect(strings["sidebar.workspace.confirmations.deleteWorktreePrompt.delete"]).toBe("Delete");
|
||||
});
|
||||
|
||||
it("includes provider selector and pairing keys for the Batch 4D migration", () => {
|
||||
expect(en.modelSelector.title).toBe("Select provider");
|
||||
expect(en.modelSelector.favorites).toBe("Favorites");
|
||||
|
||||
@@ -811,12 +811,12 @@ export const ar: TranslationResources = {
|
||||
'إخفاء "{{workspaceName}}" من الشريط الجانبي؟\n\n لن يتم تغيير الملفات الموجودة على القرص.',
|
||||
hideConfirm: "يخفي",
|
||||
cancel: "يلغي",
|
||||
},
|
||||
deleteWorktreePrompt: {
|
||||
title: "أرشفة مساحة العمل",
|
||||
message: "هل تريد أيضًا إزالة شجرة العمل من القرص؟",
|
||||
keep: "الاحتفاظ على القرص",
|
||||
delete: "حذف",
|
||||
deleteWorktreePrompt: {
|
||||
title: "أرشفة مساحة العمل",
|
||||
message: "هل تريد أيضًا إزالة شجرة العمل من القرص؟",
|
||||
keep: "الاحتفاظ على القرص",
|
||||
delete: "حذف",
|
||||
},
|
||||
},
|
||||
rename: {
|
||||
title: "إعادة تسمية مساحة العمل",
|
||||
|
||||
@@ -817,12 +817,12 @@ export const en = {
|
||||
'Hide "{{workspaceName}}" from the sidebar?\n\nFiles on disk will not be changed.',
|
||||
hideConfirm: "Hide",
|
||||
cancel: "Cancel",
|
||||
},
|
||||
deleteWorktreePrompt: {
|
||||
title: "Archive workspace",
|
||||
message: "Also remove the worktree from disk?",
|
||||
keep: "Keep on disk",
|
||||
delete: "Delete",
|
||||
deleteWorktreePrompt: {
|
||||
title: "Archive workspace",
|
||||
message: "Also remove the worktree from disk?",
|
||||
keep: "Keep on disk",
|
||||
delete: "Delete",
|
||||
},
|
||||
},
|
||||
rename: {
|
||||
title: "Rename workspace",
|
||||
|
||||
@@ -837,12 +837,12 @@ export const es: TranslationResources = {
|
||||
'¿Ocultar "{{workspaceName}}" de la barra lateral?\n\nLos archivos en el disco no se cambiarán.',
|
||||
hideConfirm: "Esconder",
|
||||
cancel: "Cancelar",
|
||||
},
|
||||
deleteWorktreePrompt: {
|
||||
title: "Archivar espacio de trabajo",
|
||||
message: "¿También eliminar el worktree del disco?",
|
||||
keep: "Conservar en disco",
|
||||
delete: "Eliminar",
|
||||
deleteWorktreePrompt: {
|
||||
title: "Archivar espacio de trabajo",
|
||||
message: "¿También eliminar el worktree del disco?",
|
||||
keep: "Conservar en disco",
|
||||
delete: "Eliminar",
|
||||
},
|
||||
},
|
||||
rename: {
|
||||
title: "Cambiar nombre del espacio de trabajo",
|
||||
|
||||
@@ -836,12 +836,12 @@ export const fr: TranslationResources = {
|
||||
"Masquer «{{workspaceName}}» dans la barre latérale?\n\nLes fichiers sur le disque ne seront pas modifiés.",
|
||||
hideConfirm: "Cacher",
|
||||
cancel: "Annuler",
|
||||
},
|
||||
deleteWorktreePrompt: {
|
||||
title: "Archiver l'espace de travail",
|
||||
message: "Supprimer aussi le worktree du disque?",
|
||||
keep: "Conserver sur le disque",
|
||||
delete: "Supprimer",
|
||||
deleteWorktreePrompt: {
|
||||
title: "Archiver l'espace de travail",
|
||||
message: "Supprimer aussi le worktree du disque?",
|
||||
keep: "Conserver sur le disque",
|
||||
delete: "Supprimer",
|
||||
},
|
||||
},
|
||||
rename: {
|
||||
title: "Renommer l'espace de travail",
|
||||
|
||||
@@ -829,12 +829,12 @@ export const ru: TranslationResources = {
|
||||
"Скрыть «{{workspaceName}}» на боковой панели?\n\n Файлы на диске не будут изменены.",
|
||||
hideConfirm: "Скрывать",
|
||||
cancel: "Отмена",
|
||||
},
|
||||
deleteWorktreePrompt: {
|
||||
title: "Архивировать рабочее пространство",
|
||||
message: "Также удалить рабочее дерево с диска?",
|
||||
keep: "Оставить на диске",
|
||||
delete: "Удалить",
|
||||
deleteWorktreePrompt: {
|
||||
title: "Архивировать рабочее пространство",
|
||||
message: "Также удалить рабочее дерево с диска?",
|
||||
keep: "Оставить на диске",
|
||||
delete: "Удалить",
|
||||
},
|
||||
},
|
||||
rename: {
|
||||
title: "Переименовать рабочую область",
|
||||
|
||||
@@ -802,12 +802,12 @@ export const zhCN: TranslationResources = {
|
||||
hideMessage: "从侧边栏隐藏「{{workspaceName}}」?\n\n磁盘上的文件不会被更改。",
|
||||
hideConfirm: "隐藏",
|
||||
cancel: "取消",
|
||||
},
|
||||
deleteWorktreePrompt: {
|
||||
title: "归档 workspace",
|
||||
message: "同时从磁盘删除 worktree?",
|
||||
keep: "保留在磁盘上",
|
||||
delete: "删除",
|
||||
deleteWorktreePrompt: {
|
||||
title: "归档 workspace",
|
||||
message: "同时从磁盘删除 worktree?",
|
||||
keep: "保留在磁盘上",
|
||||
delete: "删除",
|
||||
},
|
||||
},
|
||||
rename: {
|
||||
title: "重命名 workspace",
|
||||
|
||||
@@ -93,6 +93,7 @@ interface ChatAgentStateShape {
|
||||
id: string | null;
|
||||
status: Agent["status"] | null;
|
||||
cwd: string | null;
|
||||
workspaceId?: string;
|
||||
capabilities?: Agent["capabilities"];
|
||||
lastError?: Agent["lastError"] | null;
|
||||
}
|
||||
@@ -136,6 +137,7 @@ function selectChatAgentState(
|
||||
id: agent.id,
|
||||
status: agent.status,
|
||||
cwd: agent.cwd,
|
||||
workspaceId: agent.workspaceId,
|
||||
capabilities: agent.capabilities,
|
||||
lastError: agent.lastError ?? null,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
@@ -156,6 +158,7 @@ function buildChatAgentFromState(
|
||||
id: state.id,
|
||||
status: state.status,
|
||||
cwd: state.cwd,
|
||||
workspaceId: state.workspaceId,
|
||||
capabilities: state.capabilities,
|
||||
lastError: state.lastError ?? null,
|
||||
projectPlacement,
|
||||
@@ -545,15 +548,13 @@ function AgentPanelBody({
|
||||
);
|
||||
const agentState = useSessionStore(
|
||||
useShallow((state) => {
|
||||
const session = state.sessions[serverId];
|
||||
const agent = agentId
|
||||
? (session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null)
|
||||
: null;
|
||||
const agent = resolveChatAgentFromSession(state, serverId, agentId);
|
||||
return {
|
||||
serverId: agent?.serverId ?? null,
|
||||
id: agent?.id ?? null,
|
||||
status: agent?.status ?? null,
|
||||
cwd: agent?.cwd ?? null,
|
||||
workspaceId: agent?.workspaceId,
|
||||
lastError: agent?.lastError ?? null,
|
||||
archivedAt: agent?.archivedAt ?? null,
|
||||
};
|
||||
@@ -646,6 +647,7 @@ function AgentPanelBody({
|
||||
id: agentState.id,
|
||||
status: agentState.status,
|
||||
cwd: agentState.cwd,
|
||||
workspaceId: agentState.workspaceId,
|
||||
lastError: agentState.lastError ?? null,
|
||||
projectPlacement,
|
||||
}
|
||||
|
||||
@@ -75,6 +75,21 @@ function resolveCheckoutRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function buildFirstAgentContext(input: {
|
||||
prompt: string;
|
||||
attachments: AgentAttachment[];
|
||||
}): { prompt?: string; attachments?: AgentAttachment[] } | undefined {
|
||||
const trimmedPrompt = input.prompt.trim();
|
||||
if (!trimmedPrompt && input.attachments.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
|
||||
attachments: input.attachments,
|
||||
};
|
||||
}
|
||||
|
||||
interface NewWorkspaceScreenProps {
|
||||
serverId: string;
|
||||
sourceDirectory?: string;
|
||||
@@ -779,6 +794,8 @@ async function createMultiplicityWorkspace(input: {
|
||||
selectedItem: PickerItem | null;
|
||||
currentBranch: string | null;
|
||||
withInitialAgent: boolean;
|
||||
prompt: string;
|
||||
attachments: AgentAttachment[];
|
||||
mergeWorkspaces: (
|
||||
serverId: string,
|
||||
workspaces: ReturnType<typeof normalizeWorkspaceDescriptor>[],
|
||||
@@ -790,6 +807,10 @@ async function createMultiplicityWorkspace(input: {
|
||||
const baseBranch = isWorktree
|
||||
? (resolveCheckoutRequest(input.selectedItem, input.currentBranch)?.refName ?? undefined)
|
||||
: undefined;
|
||||
const firstAgentContext = buildFirstAgentContext({
|
||||
prompt: input.prompt,
|
||||
attachments: input.attachments,
|
||||
});
|
||||
const payload = await input.client.createWorkspace({
|
||||
source: isWorktree
|
||||
? {
|
||||
@@ -804,6 +825,7 @@ async function createMultiplicityWorkspace(input: {
|
||||
path: input.project.iconWorkingDir,
|
||||
projectId: input.project.projectKey,
|
||||
},
|
||||
...(firstAgentContext ? { firstAgentContext } : {}),
|
||||
});
|
||||
if (payload.error || !payload.workspace) {
|
||||
throw new Error(payload.error ?? input.createFailedMessage);
|
||||
@@ -1292,21 +1314,13 @@ export function NewWorkspaceScreen({
|
||||
throw new Error("Choose a project");
|
||||
}
|
||||
const checkoutRequest = resolveCheckoutRequest(selectedItem, currentBranch);
|
||||
const trimmedPrompt = input.prompt.trim();
|
||||
const hasFirstAgentContext = trimmedPrompt.length > 0 || input.attachments.length > 0;
|
||||
const firstAgentContext = buildFirstAgentContext(input);
|
||||
|
||||
return {
|
||||
cwd: selectedProject.iconWorkingDir,
|
||||
projectId: selectedProject.projectKey,
|
||||
worktreeSlug: createNameId(),
|
||||
...(hasFirstAgentContext
|
||||
? {
|
||||
firstAgentContext: {
|
||||
...(trimmedPrompt ? { prompt: trimmedPrompt } : {}),
|
||||
...(input.attachments.length > 0 ? { attachments: input.attachments } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(firstAgentContext ? { firstAgentContext } : {}),
|
||||
...checkoutRequest,
|
||||
};
|
||||
},
|
||||
@@ -1334,6 +1348,8 @@ export function NewWorkspaceScreen({
|
||||
selectedItem,
|
||||
currentBranch,
|
||||
withInitialAgent: input.withInitialAgent,
|
||||
prompt: input.prompt,
|
||||
attachments: input.attachments,
|
||||
mergeWorkspaces,
|
||||
serverId,
|
||||
createFailedMessage: t("newWorkspace.errors.createWorktreeFailed"),
|
||||
|
||||
@@ -186,17 +186,18 @@ export function useWorkspaceTerminals(input: UseWorkspaceTerminalsInput) {
|
||||
const paneWorkspaceId = normalizedWorkspaceId || undefined;
|
||||
|
||||
const unsubscribeChanged = client.on("terminals_changed", (message) => {
|
||||
// The terminal subscription is keyed by cwd at the protocol level, so this
|
||||
// gate only routes the event to the pane watching that cwd; it is not an
|
||||
// ownership decision.
|
||||
if (message.payload.cwd !== workspaceDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Two workspaces can share a cwd, so the push can carry terminals from a
|
||||
// sibling workspace. Keep only the ones whose workspaceId matches this
|
||||
// pane; terminals without a workspaceId predate Model B and belong to
|
||||
// whichever pane is watching the cwd.
|
||||
// sibling workspace. A terminal belongs to a workspace only by its
|
||||
// workspaceId, so keep only the ones whose workspaceId matches this pane.
|
||||
const matchingTerminals = message.payload.terminals.filter(
|
||||
(terminal) =>
|
||||
terminal.workspaceId === undefined || terminal.workspaceId === paneWorkspaceId,
|
||||
(terminal) => terminal.workspaceId === paneWorkspaceId,
|
||||
);
|
||||
|
||||
queryClient.setQueryData<ListTerminalsPayload>(queryKey, (current) => ({
|
||||
|
||||
@@ -1791,7 +1791,6 @@ function WorkspaceScreenContent({
|
||||
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
||||
agentDetails: state.sessions[normalizedServerId]?.agentDetails,
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
workspaceDirectory,
|
||||
}),
|
||||
workspaceAgentVisibilityEqual,
|
||||
);
|
||||
|
||||
@@ -77,6 +77,7 @@ describe("workspace navigation", () => {
|
||||
const agent = {
|
||||
id: "agent-1",
|
||||
cwd: "/repo/workspace-a",
|
||||
workspaceId: "workspace-a",
|
||||
requiresAttention: true,
|
||||
attentionReason: "permission",
|
||||
} as unknown as Agent;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
parseHostWorkspaceRouteFromPathname,
|
||||
} from "@/utils/host-routes";
|
||||
import {
|
||||
resolveWorkspaceIdByDirectory,
|
||||
normalizeWorkspaceOpaqueId,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-identity";
|
||||
import type { ActiveWorkspaceSelection } from "@/stores/last-workspace-selection";
|
||||
@@ -76,11 +76,7 @@ export function navigateToWorkspace(
|
||||
});
|
||||
const workspaceAgents = resolvedWorkspaceId
|
||||
? Array.from(deps.getSessionAgents(serverId)).filter(
|
||||
(agent) =>
|
||||
resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: agent.cwd,
|
||||
}) === resolvedWorkspaceId,
|
||||
(agent) => normalizeWorkspaceOpaqueId(agent.workspaceId) === resolvedWorkspaceId,
|
||||
)
|
||||
: [];
|
||||
const attentionAgentId = pickAttentionAgent(workspaceAgents);
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
selectHasWorkspaces,
|
||||
selectProjectOrder,
|
||||
selectRecommendedProjectPaths,
|
||||
selectResolveWorkspaceIdByCwd,
|
||||
selectWorkspace,
|
||||
selectWorkspaceDirectory,
|
||||
selectWorkspaceExists,
|
||||
@@ -135,17 +134,6 @@ export function useHasWorkspaces(serverId: string | null): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function useResolveWorkspaceIdByCwd(
|
||||
serverId: string | null,
|
||||
cwd: string | null | undefined,
|
||||
): string | null {
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => selectResolveWorkspaceIdByCwd(state, serverId, cwd),
|
||||
workspaceEqualityFns.identity,
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
selectHasWorkspaces,
|
||||
selectProjectOrder,
|
||||
selectRecommendedProjectPaths,
|
||||
selectResolveWorkspaceIdByCwd,
|
||||
selectWorkspace,
|
||||
selectWorkspaceDirectory,
|
||||
selectWorkspaceFields,
|
||||
@@ -385,33 +384,6 @@ describe("selectHasWorkspaces", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectResolveWorkspaceIdByCwd", () => {
|
||||
it("resolves by cwd and stays stable under unrelated updates", () => {
|
||||
const workspaceA = createWorkspace({
|
||||
id: "workspace-a",
|
||||
workspaceDirectory: "/repo/a",
|
||||
});
|
||||
const workspaceB = createWorkspace({
|
||||
id: "workspace-b",
|
||||
workspaceDirectory: "/repo/b",
|
||||
});
|
||||
initializeWorkspaces([workspaceA, workspaceB]);
|
||||
|
||||
const tracked = trackSelector(
|
||||
useSessionStore,
|
||||
(state) => selectResolveWorkspaceIdByCwd(state, SERVER_ID, "/repo/a"),
|
||||
workspaceEqualityFns.identity,
|
||||
);
|
||||
const before = tracked.current;
|
||||
expect(before).toBe("workspace-a");
|
||||
|
||||
useSessionStore.getState().mergeWorkspaces(SERVER_ID, [{ ...workspaceB, status: "running" }]);
|
||||
expect(tracked.current).toBe(before);
|
||||
|
||||
tracked.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectWorkspaceStatusesForBadges", () => {
|
||||
it("tracks status changes without changing for no-ops or unrelated descriptor updates", () => {
|
||||
const workspaceA = createWorkspace({ id: "workspace-a", status: "done" });
|
||||
|
||||
@@ -5,10 +5,7 @@ import {
|
||||
type WorkspaceStructureProject,
|
||||
} from "@/projects/workspace-structure";
|
||||
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
import {
|
||||
resolveWorkspaceIdByDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
} from "@/utils/workspace-identity";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
import type { EmptyProjectDescriptor, WorkspaceDescriptor } from "../session-store";
|
||||
|
||||
export type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
|
||||
@@ -250,21 +247,6 @@ export function selectHasWorkspaces(state: SessionsSnapshot, serverId: string |
|
||||
return (state.sessions[serverId]?.workspaces?.size ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function selectResolveWorkspaceIdByCwd(
|
||||
state: SessionsSnapshot,
|
||||
serverId: string | null,
|
||||
cwd: string | null | undefined,
|
||||
): string | null {
|
||||
if (!serverId || !cwd) {
|
||||
return null;
|
||||
}
|
||||
const workspaces = state.sessions[serverId]?.workspaces;
|
||||
return resolveWorkspaceIdByDirectory({
|
||||
workspaces: workspaces?.values(),
|
||||
workspaceDirectory: cwd,
|
||||
});
|
||||
}
|
||||
|
||||
export function selectWorkspaceStatusesForBadges(
|
||||
state: SessionsSnapshot,
|
||||
): DesktopBadgeWorkspaceStatus[] {
|
||||
|
||||
@@ -56,6 +56,7 @@ const AGENT_DEFAULTS: Agent = {
|
||||
lastError: null,
|
||||
title: "Agent",
|
||||
cwd: WORKSPACE_DIRECTORY,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
model: null,
|
||||
features: undefined,
|
||||
thinkingOptionId: undefined,
|
||||
@@ -91,7 +92,7 @@ function deriveVisibilityFromSession(): WorkspaceAgentVisibility {
|
||||
const sessionAgents = useSessionStore.getState().sessions[SERVER_ID]?.agents ?? new Map();
|
||||
return deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceDirectory: WORKSPACE_DIRECTORY,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ export function navigateToAgent(input: NavigateToAgentInput): string {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
return {
|
||||
workspaces: session?.workspaces.values(),
|
||||
agentCwd: agent?.cwd,
|
||||
agentWorkspaceId: agent?.workspaceId,
|
||||
};
|
||||
},
|
||||
navigateToHostAgent: (route) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
resolveNavigateToAgent,
|
||||
type AgentNavTarget,
|
||||
@@ -11,24 +10,6 @@ const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
const AGENT_ID = "agent-1";
|
||||
|
||||
function createWorkspace(): WorkspaceDescriptor {
|
||||
return {
|
||||
id: WORKSPACE_ID,
|
||||
projectId: "project-1",
|
||||
projectDisplayName: "Project",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "worktree",
|
||||
status: "done",
|
||||
archivingAt: null,
|
||||
statusEnteredAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
interface RecordedHostNav {
|
||||
route: string;
|
||||
}
|
||||
@@ -59,10 +40,9 @@ function createFakeNavigators(target: AgentNavTarget): {
|
||||
}
|
||||
|
||||
describe("resolveNavigateToAgent", () => {
|
||||
it("opens the resolved workspace tab when the agent's cwd matches a workspace", () => {
|
||||
it("opens the workspace tab carried by the agent's workspaceId", () => {
|
||||
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
|
||||
workspaces: [createWorkspace()],
|
||||
agentCwd: "/repo/worktree",
|
||||
agentWorkspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
const route = resolveNavigateToAgent(
|
||||
@@ -83,10 +63,9 @@ describe("resolveNavigateToAgent", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("falls back to the host agent route when the workspace is unknown", () => {
|
||||
it("falls back to the host agent route when the agent has no workspaceId", () => {
|
||||
const { deps, hostNavigations, tabNavigations } = createFakeNavigators({
|
||||
workspaces: [],
|
||||
agentCwd: null,
|
||||
agentWorkspaceId: null,
|
||||
});
|
||||
|
||||
const route = resolveNavigateToAgent({ serverId: SERVER_ID, agentId: "missing-agent" }, deps);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
|
||||
import { resolveWorkspaceIdByDirectory } from "@/utils/workspace-identity";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
import type { NavigateToPreparedWorkspaceTabInput } from "@/utils/prepare-workspace-tab";
|
||||
|
||||
export interface NavigateToAgentInput {
|
||||
@@ -11,8 +10,7 @@ export interface NavigateToAgentInput {
|
||||
}
|
||||
|
||||
export interface AgentNavTarget {
|
||||
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
|
||||
agentCwd: string | null | undefined;
|
||||
agentWorkspaceId: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface NavigateToAgentDeps {
|
||||
@@ -25,14 +23,11 @@ export function resolveNavigateToAgent(
|
||||
input: NavigateToAgentInput,
|
||||
deps: NavigateToAgentDeps,
|
||||
): string {
|
||||
const { workspaces, agentCwd } = deps.readAgentNavTarget({
|
||||
const { agentWorkspaceId } = deps.readAgentNavTarget({
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
});
|
||||
const workspaceId = resolveWorkspaceIdByDirectory({
|
||||
workspaces,
|
||||
workspaceDirectory: agentCwd,
|
||||
});
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
|
||||
|
||||
if (!workspaceId) {
|
||||
const route = buildHostAgentDetailRoute(input.serverId, input.agentId);
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
resolveWorkspaceIdByDirectory,
|
||||
resolveWorkspaceMapKeyByIdentity,
|
||||
resolveWorkspaceRouteId,
|
||||
} from "./workspace-identity";
|
||||
import { resolveWorkspaceMapKeyByIdentity, resolveWorkspaceRouteId } from "./workspace-identity";
|
||||
|
||||
function createWorkspace(
|
||||
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
|
||||
@@ -38,42 +34,6 @@ describe("resolveWorkspaceRouteId", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceIdByDirectory", () => {
|
||||
it("matches workspace directories", () => {
|
||||
const workspaces = [
|
||||
createWorkspace({
|
||||
id: "workspace-1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo/.paseo/worktrees/feature",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveWorkspaceIdByDirectory({
|
||||
workspaces,
|
||||
workspaceDirectory: "/repo/.paseo/worktrees/feature",
|
||||
}),
|
||||
).toBe("workspace-1");
|
||||
});
|
||||
|
||||
it("does not match project root metadata", () => {
|
||||
const workspaces = [
|
||||
createWorkspace({
|
||||
id: "workspace-1",
|
||||
projectRootPath: "/repo",
|
||||
workspaceDirectory: "/repo/.paseo/worktrees/feature",
|
||||
}),
|
||||
];
|
||||
|
||||
expect(
|
||||
resolveWorkspaceIdByDirectory({
|
||||
workspaces,
|
||||
workspaceDirectory: "/repo",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceMapKeyByIdentity", () => {
|
||||
it("returns the existing map key when the identity already matches a key", () => {
|
||||
const workspaces = new Map<string, WorkspaceDescriptor>([
|
||||
|
||||
@@ -31,26 +31,6 @@ export function resolveWorkspaceRouteId(input: {
|
||||
return normalizeWorkspaceOpaqueId(input.routeWorkspaceId);
|
||||
}
|
||||
|
||||
// Single approved cwd→workspaceId inference site.
|
||||
// Do not add cwd-to-id inference elsewhere; prefer agent.workspaceId when the daemon provides it.
|
||||
export function resolveWorkspaceIdByDirectory(input: {
|
||||
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): string | null {
|
||||
const normalizedWorkspaceDirectory = normalizeWorkspacePath(input.workspaceDirectory);
|
||||
if (!normalizedWorkspaceDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const workspace of input.workspaces ?? []) {
|
||||
if (normalizeWorkspacePath(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) {
|
||||
return workspace.id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceMapKeyByIdentity(input: {
|
||||
workspaces: Map<string, WorkspaceDescriptor> | null | undefined;
|
||||
workspaceId: string | null | undefined;
|
||||
|
||||
@@ -57,16 +57,19 @@ function makeAgent(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const WORKSPACE_ID = "ws-1";
|
||||
|
||||
describe("workspace agent visibility", () => {
|
||||
it("keeps subagents active and known while excluding them from auto-open", () => {
|
||||
const workspaceDirectory = "/repo/worktree";
|
||||
const parent = makeAgent({
|
||||
id: "parent-agent",
|
||||
cwd: workspaceDirectory,
|
||||
cwd: "/repo/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
const child = makeAgent({
|
||||
id: "child-agent",
|
||||
cwd: workspaceDirectory,
|
||||
cwd: "/repo/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
parentAgentId: "parent-agent",
|
||||
});
|
||||
|
||||
@@ -75,7 +78,7 @@ describe("workspace agent visibility", () => {
|
||||
[parent.id, parent],
|
||||
[child.id, child],
|
||||
]),
|
||||
workspaceDirectory,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"]));
|
||||
@@ -84,17 +87,17 @@ describe("workspace agent visibility", () => {
|
||||
});
|
||||
|
||||
it("keeps archived subagents known but excludes them from active and auto-open", () => {
|
||||
const workspaceDirectory = "/repo/worktree";
|
||||
const archivedChild = makeAgent({
|
||||
id: "archived-child",
|
||||
cwd: workspaceDirectory,
|
||||
cwd: "/repo/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
parentAgentId: "parent-agent",
|
||||
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
|
||||
});
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents: new Map<string, Agent>([[archivedChild.id, archivedChild]]),
|
||||
workspaceDirectory,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set<string>());
|
||||
@@ -103,15 +106,16 @@ describe("workspace agent visibility", () => {
|
||||
});
|
||||
|
||||
it("excludes a child from auto-open even when its snapshot arrives before the parent", () => {
|
||||
const workspaceDirectory = "/repo/worktree";
|
||||
const child = makeAgent({
|
||||
id: "child-agent",
|
||||
cwd: workspaceDirectory,
|
||||
cwd: "/repo/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
parentAgentId: "parent-agent",
|
||||
});
|
||||
const parent = makeAgent({
|
||||
id: "parent-agent",
|
||||
cwd: workspaceDirectory,
|
||||
cwd: "/repo/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
@@ -119,7 +123,7 @@ describe("workspace agent visibility", () => {
|
||||
[child.id, child],
|
||||
[parent.id, parent],
|
||||
]),
|
||||
workspaceDirectory,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["child-agent", "parent-agent"]));
|
||||
@@ -128,21 +132,23 @@ describe("workspace agent visibility", () => {
|
||||
});
|
||||
|
||||
it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => {
|
||||
const workspaceDirectory = "/repo/worktree";
|
||||
const visible = makeAgent({
|
||||
id: "visible-agent",
|
||||
cwd: workspaceDirectory,
|
||||
cwd: "/repo/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
createdAt: new Date("2026-03-04T00:00:00.000Z"),
|
||||
});
|
||||
const archived = makeAgent({
|
||||
id: "archived-agent",
|
||||
cwd: workspaceDirectory,
|
||||
cwd: "/repo/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
|
||||
createdAt: new Date("2026-03-04T00:01:00.000Z"),
|
||||
});
|
||||
const otherWorkspace = makeAgent({
|
||||
id: "other-workspace-agent",
|
||||
cwd: "/repo/other",
|
||||
workspaceId: "ws-other",
|
||||
});
|
||||
|
||||
const sessionAgents = new Map<string, Agent>([
|
||||
@@ -153,7 +159,7 @@ describe("workspace agent visibility", () => {
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceDirectory,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["visible-agent"]));
|
||||
@@ -164,18 +170,22 @@ describe("workspace agent visibility", () => {
|
||||
});
|
||||
|
||||
it("treats lazy historical details as known without making them active", () => {
|
||||
const workspaceDirectory = "/repo/worktree";
|
||||
const active = makeAgent({ id: "active-agent", cwd: workspaceDirectory });
|
||||
const active = makeAgent({
|
||||
id: "active-agent",
|
||||
cwd: "/repo/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
const historicalDetail = makeAgent({
|
||||
id: "historical-agent",
|
||||
cwd: workspaceDirectory,
|
||||
cwd: "/repo/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
|
||||
});
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents: new Map([[active.id, active]]),
|
||||
agentDetails: new Map([[historicalDetail.id, historicalDetail]]),
|
||||
workspaceDirectory,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["active-agent"]));
|
||||
@@ -226,49 +236,6 @@ describe("workspace agent visibility", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches workspace agents when cwd and route workspace differ only by trailing slash", () => {
|
||||
const sessionAgents = new Map<string, Agent>([
|
||||
[
|
||||
"slash-agent",
|
||||
makeAgent({
|
||||
id: "slash-agent",
|
||||
cwd: "/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/normal-squid/",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceDirectory:
|
||||
"/Users/moboudra/dev/paseo/.dev/paseo-home/worktrees/1luy0po7/normal-squid",
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["slash-agent"]));
|
||||
expect(result.autoOpenAgentIds).toEqual(new Set(["slash-agent"]));
|
||||
expect(result.knownAgentIds.has("slash-agent")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches workspace agents using the workspace directory even when the route uses a numeric workspace id", () => {
|
||||
const sessionAgents = new Map<string, Agent>([
|
||||
[
|
||||
"recent-agent",
|
||||
makeAgent({
|
||||
id: "recent-agent",
|
||||
cwd: "/tmp/workspace-lifecycle-main",
|
||||
}),
|
||||
],
|
||||
]);
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceDirectory: "/tmp/workspace-lifecycle-main",
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["recent-agent"]));
|
||||
expect(result.autoOpenAgentIds).toEqual(new Set(["recent-agent"]));
|
||||
expect(result.knownAgentIds).toEqual(new Set(["recent-agent"]));
|
||||
});
|
||||
|
||||
it("matches agents by workspaceId regardless of cwd", () => {
|
||||
const sessionAgents = new Map<string, Agent>([
|
||||
[
|
||||
@@ -284,7 +251,6 @@ describe("workspace agent visibility", () => {
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceId: "ws-1",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["stamped-agent"]));
|
||||
@@ -306,26 +272,24 @@ describe("workspace agent visibility", () => {
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceId: "ws-1",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set<string>());
|
||||
expect(result.knownAgentIds).toEqual(new Set<string>());
|
||||
});
|
||||
|
||||
it("falls back to cwd matching for legacy agents without a workspaceId", () => {
|
||||
it("excludes agents without a workspaceId", () => {
|
||||
const sessionAgents = new Map<string, Agent>([
|
||||
["legacy-agent", makeAgent({ id: "legacy-agent", cwd: "/repo/worktree" })],
|
||||
["ownerless-agent", makeAgent({ id: "ownerless-agent", cwd: "/repo/worktree" })],
|
||||
]);
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents,
|
||||
workspaceId: "ws-1",
|
||||
workspaceDirectory: "/repo/worktree",
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["legacy-agent"]));
|
||||
expect(result.knownAgentIds).toEqual(new Set(["legacy-agent"]));
|
||||
expect(result.activeAgentIds).toEqual(new Set<string>());
|
||||
expect(result.knownAgentIds).toEqual(new Set<string>());
|
||||
});
|
||||
|
||||
it("builds the tab reconciliation snapshot without callers unpacking agent visibility", () => {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions";
|
||||
import { shouldAutoOpenAgentTab } from "@/subagents/policies";
|
||||
import { normalizeWorkspaceOpaqueId, normalizeWorkspacePath } from "@/utils/workspace-identity";
|
||||
|
||||
function normalizeWorkspaceDirectory(value: string | null | undefined): string {
|
||||
return normalizeWorkspacePath(value) ?? "";
|
||||
}
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
|
||||
export interface WorkspaceAgentVisibility {
|
||||
activeAgentIds: Set<string>;
|
||||
@@ -13,31 +9,18 @@ export interface WorkspaceAgentVisibility {
|
||||
knownAgentIds: Set<string>;
|
||||
}
|
||||
|
||||
function agentBelongsToWorkspace(input: {
|
||||
agent: Agent;
|
||||
workspaceId: string | null;
|
||||
normalizedWorkspaceDirectory: string;
|
||||
}): boolean {
|
||||
const agentWorkspaceId = normalizeWorkspaceOpaqueId(input.agent.workspaceId);
|
||||
if (agentWorkspaceId) {
|
||||
return input.workspaceId !== null && agentWorkspaceId === input.workspaceId;
|
||||
}
|
||||
// COMPAT(workspaceOwnership): legacy agents predate workspaceId stamping. Fall
|
||||
// back to the single approved cwd→id inference by comparing the agent's cwd to
|
||||
// this workspace's directory. Drop when the daemon floor always stamps workspaceId.
|
||||
return normalizeWorkspaceDirectory(input.agent.cwd) === input.normalizedWorkspaceDirectory;
|
||||
function agentBelongsToWorkspace(agent: Agent, workspaceId: string): boolean {
|
||||
return normalizeWorkspaceOpaqueId(agent.workspaceId) === workspaceId;
|
||||
}
|
||||
|
||||
export function deriveWorkspaceAgentVisibility(input: {
|
||||
sessionAgents: Map<string, Agent> | undefined;
|
||||
agentDetails?: Map<string, Agent> | undefined;
|
||||
workspaceId?: string | null | undefined;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
workspaceId: string | null | undefined;
|
||||
}): WorkspaceAgentVisibility {
|
||||
const { sessionAgents, agentDetails, workspaceDirectory } = input;
|
||||
const { sessionAgents, agentDetails } = input;
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(input.workspaceId);
|
||||
const normalizedWorkspaceDirectory = normalizeWorkspaceDirectory(workspaceDirectory);
|
||||
if ((!sessionAgents && !agentDetails) || (!workspaceId && !normalizedWorkspaceDirectory)) {
|
||||
if ((!sessionAgents && !agentDetails) || !workspaceId) {
|
||||
return {
|
||||
activeAgentIds: new Set<string>(),
|
||||
autoOpenAgentIds: new Set<string>(),
|
||||
@@ -49,7 +32,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
const autoOpenAgentIds = new Set<string>();
|
||||
const knownAgentIds = new Set<string>();
|
||||
for (const agent of sessionAgents?.values() ?? []) {
|
||||
if (!agentBelongsToWorkspace({ agent, workspaceId, normalizedWorkspaceDirectory })) {
|
||||
if (!agentBelongsToWorkspace(agent, workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
knownAgentIds.add(agent.id);
|
||||
@@ -61,7 +44,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
}
|
||||
}
|
||||
for (const agent of agentDetails?.values() ?? []) {
|
||||
if (!agentBelongsToWorkspace({ agent, workspaceId, normalizedWorkspaceDirectory })) {
|
||||
if (!agentBelongsToWorkspace(agent, workspaceId)) {
|
||||
continue;
|
||||
}
|
||||
knownAgentIds.add(agent.id);
|
||||
|
||||
@@ -32,6 +32,9 @@ export function useIsLastWorktreeReference(workspace: SidebarWorkspaceEntry): bo
|
||||
if (candidate.id === workspace.workspaceId) {
|
||||
continue;
|
||||
}
|
||||
// Git-fact: comparing directories to detect a sibling worktree reference on
|
||||
// disk, not attributing ownership. The disk-deletion decision is about the
|
||||
// backing directory, which same-cwd siblings genuinely share.
|
||||
if (normalizeWorkspacePath(candidate.workspaceDirectory) === directory) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ function target(input?: Partial<WorkspaceArchiveTarget>): WorkspaceArchiveTarget
|
||||
return {
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: base.id,
|
||||
workspaceDirectory: base.workspaceDirectory,
|
||||
...input,
|
||||
};
|
||||
}
|
||||
@@ -107,7 +106,6 @@ describe("archiveWorkspaceOptimistically", () => {
|
||||
isWorkspaceArchivePending({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: archived.id,
|
||||
workspaceDirectory: archived.workspaceDirectory,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
@@ -178,10 +176,7 @@ describe("archiveWorkspacesOptimistically", () => {
|
||||
|
||||
const failures = await archiveWorkspacesOptimistically({
|
||||
client,
|
||||
workspaces: [
|
||||
target({ workspaceId: first.id, workspaceDirectory: first.workspaceDirectory }),
|
||||
target({ workspaceId: second.id, workspaceDirectory: second.workspaceDirectory }),
|
||||
],
|
||||
workspaces: [target({ workspaceId: first.id }), target({ workspaceId: second.id })],
|
||||
});
|
||||
|
||||
expect(failures).toHaveLength(1);
|
||||
|
||||
@@ -8,7 +8,6 @@ import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
export interface WorkspaceArchiveTarget {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
workspaceDirectory?: string | null;
|
||||
}
|
||||
|
||||
interface WorkspaceArchiveClient {
|
||||
@@ -46,7 +45,6 @@ function hideWorkspaceOptimistically(
|
||||
markWorkspaceArchivePending({
|
||||
serverId: workspace.serverId,
|
||||
workspaceId: workspace.workspaceId,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
});
|
||||
useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId);
|
||||
return { workspace: snapshot };
|
||||
|
||||
Reference in New Issue
Block a user