WIP: workspace execution refactor + notification routing + setup store updates

This commit is contained in:
Mohamed Boudra
2026-04-01 21:28:07 +07:00
parent d8243bfb72
commit a7d5ab2497
29 changed files with 1064 additions and 197 deletions
+190 -7
View File
@@ -2,6 +2,7 @@ import path from "node:path";
import { randomUUID } from "node:crypto";
import { pathToFileURL } from "node:url";
import { expect, type Page } from "@playwright/test";
import { parseHostWorkspaceRouteFromPathname } from "../../src/utils/host-routes";
import { gotoAppShell } from "./app";
type WorkspaceSetupProgressPayload = {
@@ -20,10 +21,52 @@ type WorkspaceSetupDaemonClient = {
close(): Promise<void>;
openProject(
cwd: string,
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>;
): Promise<{
workspace: {
id: number;
name: string;
workspaceDirectory: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
createPaseoWorktree(
input: { cwd: string; worktreeSlug?: string },
): Promise<{ workspace: { id: string; name: string } | null; error: string | null }>;
): Promise<{
workspace: {
id: number;
name: string;
workspaceDirectory: string;
projectRootPath: string;
} | null;
error: string | null;
}>;
fetchWorkspaces(): Promise<{
entries: Array<{
id: number;
name: string;
workspaceDirectory: string;
projectRootPath: string;
}>;
}>;
fetchAgents(): Promise<{
entries: Array<{
agent: { id: string; cwd: string; workspaceId?: string | null };
}>;
}>;
fetchAgent(
agentId: string,
): Promise<{
agent: { id: string; cwd: string } | null;
project: unknown | null;
} | null>;
listTerminals(
cwd: string,
): Promise<{
cwd?: string;
terminals: Array<{ id: string; cwd: string; name: string }>;
error?: string | null;
}>;
subscribeRawMessages(handler: (message: WorkspaceSetupRawMessage) => void): () => void;
};
export type { WorkspaceSetupDaemonClient, WorkspaceSetupProgressPayload };
@@ -80,7 +123,12 @@ export function projectNameFromPath(repoPath: string): string {
export async function openHomeWithProject(page: Page, repoPath: string): Promise<void> {
await gotoAppShell(page);
await expect(createWorkspaceButton(page, repoPath)).toBeVisible({ timeout: 30_000 });
await expect(
page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: projectNameFromPath(repoPath) })
.first(),
).toBeVisible({ timeout: 30_000 });
}
function createWorkspaceButton(page: Page, repoPath: string) {
@@ -90,14 +138,98 @@ function createWorkspaceButton(page: Page, repoPath: string) {
}
async function revealWorkspaceButton(page: Page, repoPath: string): Promise<void> {
await page.getByTestId(`sidebar-project-row-${repoPath}`).hover();
await page
.locator('[data-testid^="sidebar-project-row-"]')
.filter({ hasText: projectNameFromPath(repoPath) })
.first()
.hover();
}
export async function createWorkspaceFromSidebar(page: Page, repoPath: string): Promise<void> {
const button = createWorkspaceButton(page, repoPath);
await revealWorkspaceButton(page, repoPath);
await expect(createWorkspaceButton(page, repoPath)).toBeEnabled({ timeout: 30_000 });
await createWorkspaceButton(page, repoPath).click();
await expect(button).toBeVisible({ timeout: 30_000 });
await expect(button).toBeEnabled({ timeout: 30_000 });
await button.click();
await expect(page).toHaveURL(/\/workspace\//, { timeout: 30_000 });
await expect(page.getByTestId("workspace-setup-dialog")).toBeVisible({ timeout: 30_000 });
}
export async function getCurrentWorkspaceIdFromRoute(page: Page): Promise<string> {
await expect
.poll(
() => parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null,
{ timeout: 30_000 },
)
.not.toBeNull();
const workspaceId =
parseHostWorkspaceRouteFromPathname(new URL(page.url()).pathname)?.workspaceId ?? null;
if (!workspaceId) {
throw new Error(`Expected a workspace route but found ${page.url()}`);
}
return workspaceId;
}
function workspaceSetupDialog(page: Page) {
return page.getByTestId("workspace-setup-dialog");
}
export async function createChatAgentFromWorkspaceSetup(
page: Page,
input: { message: string },
): Promise<void> {
const dialog = workspaceSetupDialog(page);
await dialog.getByRole("button", { name: /Chat Agent/i }).click();
const messageInput = dialog.getByRole("textbox", { name: "Message agent..." }).first();
await expect(messageInput).toBeVisible({ timeout: 15_000 });
await messageInput.fill(input.message);
await dialog.getByRole("button", { name: "Send message" }).click();
}
export async function createTerminalAgentFromWorkspaceSetup(
page: Page,
input: { providerLabel: string; prompt?: string },
): Promise<void> {
const dialog = workspaceSetupDialog(page);
await dialog.getByRole("button", { name: /Terminal Agent/i }).click();
const providerButton = dialog.getByRole("button", { name: new RegExp(`^${input.providerLabel}$`, "i") }).first();
await expect(providerButton).toBeVisible({ timeout: 15_000 });
await providerButton.click();
if (input.prompt) {
const promptInput = dialog.getByPlaceholder("Optional").first();
await expect(promptInput).toBeVisible({ timeout: 15_000 });
await promptInput.fill(input.prompt);
}
await dialog.getByRole("button", { name: "Launch" }).click();
}
export async function createStandaloneTerminalFromWorkspaceSetup(page: Page): Promise<void> {
await workspaceSetupDialog(page)
.getByRole("button", { name: /^Terminal Create the workspace/i })
.click();
}
export async function waitForWorkspaceSetupDialogToClose(page: Page, timeoutMs = 45_000): Promise<void> {
const dialog = workspaceSetupDialog(page);
try {
await expect(dialog).toHaveCount(0, { timeout: timeoutMs });
} catch (error) {
const dialogText = (await dialog.textContent().catch(() => null))?.replace(/\s+/g, " ").trim();
throw new Error(
dialogText
? `Workspace setup dialog stayed open. Visible text: ${dialogText}`
: `Workspace setup dialog did not close within ${timeoutMs}ms`,
{ cause: error },
);
}
}
export async function expectSetupPanel(page: Page): Promise<void> {
@@ -133,7 +265,58 @@ export async function createWorkspaceThroughDaemon(
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to create workspace for ${input.cwd}`);
}
return result.workspace;
return {
id: String(result.workspace.id),
name: result.workspace.name,
};
}
export async function findWorktreeWorkspaceForProject(
client: WorkspaceSetupDaemonClient,
repoPath: string,
): Promise<{
id: string;
name: string;
projectRootPath: string;
workspaceDirectory: string;
}> {
const payload = await client.fetchWorkspaces();
const workspace =
payload.entries.find(
(entry) =>
entry.projectRootPath === repoPath && entry.workspaceDirectory !== repoPath,
) ?? null;
if (!workspace) {
throw new Error(`Failed to find created worktree workspace for ${repoPath}`);
}
return {
id: String(workspace.id),
name: workspace.name,
projectRootPath: workspace.projectRootPath,
workspaceDirectory: workspace.workspaceDirectory,
};
}
export async function fetchWorkspaceById(
client: WorkspaceSetupDaemonClient,
workspaceId: string,
): Promise<{
id: number;
name: string;
workspaceDirectory: string;
projectRootPath: string;
}> {
const parsedWorkspaceId = Number(workspaceId);
if (!Number.isInteger(parsedWorkspaceId)) {
throw new Error(`Workspace id is not numeric: ${workspaceId}`);
}
const payload = await client.fetchWorkspaces();
const workspace = payload.entries.find((entry) => entry.id === parsedWorkspaceId) ?? null;
if (!workspace) {
throw new Error(`Workspace not found: ${workspaceId}`);
}
return workspace;
}
export async function waitForWorkspaceSetupProgress(
+4 -1
View File
@@ -50,7 +50,10 @@ async function openProjectViaDaemon(
if (!result.workspace || result.error) {
throw new Error(result.error ?? `Failed to open project ${cwd}`);
}
return result.workspace;
return {
id: String(result.workspace.id),
name: result.workspace.name,
};
}
async function openWorkspaceFromSidebar(page: import("@playwright/test").Page, workspaceId: string) {
@@ -0,0 +1,171 @@
import { existsSync } from "node:fs";
import { expect, test } from "./fixtures";
import { createTempGitRepo } from "./helpers/workspace";
import {
connectWorkspaceSetupClient,
createChatAgentFromWorkspaceSetup,
createStandaloneTerminalFromWorkspaceSetup,
createTerminalAgentFromWorkspaceSetup,
createWorkspaceFromSidebar,
findWorktreeWorkspaceForProject,
openHomeWithProject,
type WorkspaceSetupDaemonClient,
} from "./helpers/workspace-setup";
async function openWorkspaceSetupDialogFromSidebar(
page: import("@playwright/test").Page,
repoPath: string,
): Promise<void> {
await openHomeWithProject(page, repoPath);
await createWorkspaceFromSidebar(page, repoPath);
}
async function expectCreatedWorkspaceRoute(
client: WorkspaceSetupDaemonClient,
originalProjectPath: string,
) {
await expect
.poll(
async () => {
try {
return await findWorktreeWorkspaceForProject(client, originalProjectPath);
} catch {
return null;
}
},
{ timeout: 30_000 },
)
.not.toBeNull();
const workspace = await findWorktreeWorkspaceForProject(client, originalProjectPath);
expect(workspace.workspaceDirectory).not.toBe(originalProjectPath);
expect(existsSync(workspace.workspaceDirectory)).toBe(true);
return workspace;
}
async function waitForNewWorkspaceAgent(
client: WorkspaceSetupDaemonClient,
expectedWorkspaceDirectory: string,
agentIdsBefore: Set<string>,
) {
await expect
.poll(
async () => {
const payload = await client.fetchAgents();
return (
payload.entries.find(
(entry) =>
!agentIdsBefore.has(entry.agent.id) &&
entry.agent.cwd === expectedWorkspaceDirectory,
)?.agent ?? null
);
},
{ timeout: 30_000 },
)
.not.toBeNull();
const payload = await client.fetchAgents();
const agent =
payload.entries.find(
(entry) =>
!agentIdsBefore.has(entry.agent.id) && entry.agent.cwd === expectedWorkspaceDirectory,
)?.agent ?? null;
if (!agent) {
throw new Error(`Expected a new agent for workspace ${expectedWorkspaceDirectory}`);
}
return agent;
}
test.describe("Workspace setup runtime authority", () => {
test.describe.configure({ retries: 1 });
test("first chat agent attaches to the created workspace", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-setup-chat-");
try {
await client.openProject(repo.path);
await openWorkspaceSetupDialogFromSidebar(page, repo.path);
const agentIdsBefore = new Set((await client.fetchAgents()).entries.map((entry) => entry.agent.id));
await createChatAgentFromWorkspaceSetup(page, {
message: `workspace-setup-chat-${Date.now()}`,
});
const workspace = await expectCreatedWorkspaceRoute(client, repo.path);
const agent = await waitForNewWorkspaceAgent(
client,
workspace.workspaceDirectory,
agentIdsBefore,
);
expect(agent.cwd).toBe(workspace.workspaceDirectory);
expect(agent.cwd).not.toBe(repo.path);
} finally {
await client.close();
await repo.cleanup();
}
});
test("first terminal agent attaches to the created workspace", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-setup-terminal-agent-");
try {
await client.openProject(repo.path);
await openWorkspaceSetupDialogFromSidebar(page, repo.path);
const agentIdsBefore = new Set((await client.fetchAgents()).entries.map((entry) => entry.agent.id));
await createTerminalAgentFromWorkspaceSetup(page, {
providerLabel: "Claude",
prompt: `workspace-setup-terminal-agent-${Date.now()}`,
});
const workspace = await expectCreatedWorkspaceRoute(client, repo.path);
const agent = await waitForNewWorkspaceAgent(
client,
workspace.workspaceDirectory,
agentIdsBefore,
);
expect(agent.cwd).toBe(workspace.workspaceDirectory);
expect(agent.cwd).not.toBe(repo.path);
} finally {
await client.close();
await repo.cleanup();
}
});
test("first terminal attaches to the created workspace", async ({ page }) => {
test.setTimeout(90_000);
const client = await connectWorkspaceSetupClient();
const repo = await createTempGitRepo("workspace-setup-terminal-");
try {
await client.openProject(repo.path);
await openWorkspaceSetupDialogFromSidebar(page, repo.path);
await createStandaloneTerminalFromWorkspaceSetup(page);
const workspace = await expectCreatedWorkspaceRoute(client, repo.path);
await expect
.poll(
async () =>
(await client.listTerminals(workspace.workspaceDirectory)).terminals.length > 0,
{ timeout: 30_000 },
)
.toBe(true);
expect(
(await client.listTerminals(repo.path)).terminals.length,
).toBe(0);
} finally {
await client.close();
await repo.cleanup();
}
});
});
@@ -3,7 +3,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildHostRootRoute } from "@/utils/host-routes";
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
export default function HostAgentReadyRoute() {
@@ -33,9 +33,9 @@ export default function HostAgentReadyRoute() {
if (!serverId || !agentId) {
return null;
}
return resolveHydratedWorkspaceId({
return resolveWorkspaceIdByExecutionDirectory({
workspaces: state.sessions[serverId]?.workspaces?.values(),
path: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
workspaceDirectory: state.sessions[serverId]?.agents?.get(agentId)?.cwd,
});
});
@@ -93,9 +93,9 @@ export default function HostAgentReadyRoute() {
return;
}
const cwd = result?.agent?.cwd?.trim();
const workspaceId = resolveHydratedWorkspaceId({
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: sessionWorkspaces?.values(),
path: cwd,
workspaceDirectory: cwd,
});
if (!workspaceId && !hasHydratedWorkspaces) {
return;
+3 -3
View File
@@ -8,7 +8,7 @@ import {
buildHostRootRoute,
buildHostWorkspaceOpenRoute,
} from "@/utils/host-routes";
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
const HOST_ROOT_REDIRECT_DELAY_MS = 300;
@@ -66,9 +66,9 @@ export default function HostIndexRoute() {
});
const primaryAgent = visibleAgents[0];
const primaryAgentWorkspaceId = resolveHydratedWorkspaceId({
const primaryAgentWorkspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: sessionWorkspaces?.values(),
path: primaryAgent?.cwd,
workspaceDirectory: primaryAgent?.cwd,
});
if (primaryAgent && primaryAgentWorkspaceId) {
router.replace(
+3 -3
View File
@@ -18,7 +18,7 @@ import { useSessionStore } from "@/stores/session-store";
import { Archive, SquareTerminal } from "lucide-react-native";
import { getProviderIcon } from "@/components/provider-icons";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
interface AgentListProps {
@@ -244,9 +244,9 @@ export function AgentList({
const serverId = agent.serverId;
const agentId = agent.id;
const workspaceId = resolveHydratedWorkspaceId({
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[serverId]?.workspaces?.values(),
path: agent.cwd,
workspaceDirectory: agent.cwd,
});
onAgentSelect?.();
@@ -63,7 +63,7 @@ import { createMarkdownStyles } from "@/styles/markdown-styles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { getMarkdownListMarker } from "@/utils/markdown-list";
import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
@@ -133,10 +133,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const workspaceRoot = agent.cwd?.trim() || "";
const workspacePath = agent.projectPlacement?.checkout?.cwd?.trim() || workspaceRoot;
const workspaceId = resolveHydratedWorkspaceId({
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[resolvedServerId]?.workspaces?.values(),
path: workspacePath,
workspaceDirectory: workspaceRoot,
});
const { requestDirectoryListing } = useFileExplorerActions({
serverId: resolvedServerId,
@@ -86,6 +86,10 @@ import { useSessionStore } from "@/stores/session-store";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation";
import { openExternalUrl } from "@/utils/open-external-url";
import {
requireWorkspaceExecutionDirectory,
resolveWorkspaceExecutionDirectory,
} from "@/utils/workspace-execution";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -674,8 +678,8 @@ function ProjectHeaderRow({
onWorkspacePress?.();
beginWorkspaceSetup({
serverId,
projectPath: project.iconWorkingDir,
projectName: displayName,
sourceDirectory: project.iconWorkingDir,
displayName,
creationMethod: "create_worktree",
navigationMethod: "navigate",
});
@@ -815,10 +819,13 @@ function WorkspaceRowInner({
const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false);
const isMobile = Platform.OS !== "web";
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const prHint = useWorkspacePrHint({
serverId: workspace.serverId,
cwd: workspace.projectRootPath ?? "",
enabled: workspace.projectKind === "git" && Boolean(workspace.projectRootPath),
cwd: workspaceDirectory ?? "",
enabled: workspace.projectKind === "git" && Boolean(workspaceDirectory),
});
const interaction = useLongPressDragInteraction({
drag,
@@ -982,12 +989,17 @@ function WorkspaceRowWithMenu({
(state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES,
);
const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false);
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: workspace.workspaceDirectory,
});
const archiveStatus = useCheckoutGitActionsStore((state) =>
state.getStatus({
serverId: workspace.serverId,
cwd: workspace.workspaceDirectory ?? workspace.projectRootPath ?? "",
actionId: "archive-worktree",
}),
workspaceDirectory
? state.getStatus({
serverId: workspace.serverId,
cwd: workspaceDirectory,
actionId: "archive-worktree",
})
: "idle",
);
const isWorktree = workspace.workspaceKind === "worktree";
const isArchiving = isWorktree ? archiveStatus === "pending" : isArchivingWorkspace;
@@ -1025,7 +1037,17 @@ function WorkspaceRowWithMenu({
if (!confirmed) {
return;
}
const workspaceDirectory = workspace.workspaceDirectory ?? workspace.projectRootPath;
let workspaceDirectory: string;
try {
workspaceDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
if (!workspaceDirectory) {
toast.error("Workspace path not available");
return;
@@ -1050,7 +1072,6 @@ function WorkspaceRowWithMenu({
redirectAfterArchive,
toast,
workspace.name,
workspace.projectRootPath,
workspace.workspaceDirectory,
workspace.serverId,
workspace.workspaceId,
@@ -1102,14 +1123,19 @@ function WorkspaceRowWithMenu({
]);
const handleCopyPath = useCallback(() => {
const workspaceDirectory = workspace.workspaceDirectory ?? workspace.projectRootPath;
if (!workspaceDirectory) {
toast.error("Workspace path not available");
let workspaceDirectory: string;
try {
workspaceDirectory = requireWorkspaceExecutionDirectory({
workspaceId: workspace.workspaceId,
workspaceDirectory: workspace.workspaceDirectory,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : "Workspace path not available");
return;
}
void Clipboard.setStringAsync(workspaceDirectory);
toast.copied("Path copied");
}, [toast, workspace.projectRootPath, workspace.workspaceDirectory]);
}, [toast, workspace.workspaceDirectory, workspace.workspaceId]);
const handleCopyBranchName = useCallback(() => {
void Clipboard.setStringAsync(workspace.name);
@@ -17,6 +17,10 @@ import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { encodeImages } from "@/utils/encode-images";
import { toErrorMessage } from "@/utils/error-messages";
import {
requireWorkspaceExecutionAuthority,
requireWorkspaceRecordId,
} from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import type { MessagePayload } from "./message-input";
@@ -41,19 +45,21 @@ export function WorkspaceSetupDialog() {
);
const serverId = pendingWorkspaceSetup?.serverId ?? "";
const projectPath = pendingWorkspaceSetup?.projectPath ?? "";
const projectName = pendingWorkspaceSetup?.projectName?.trim() ?? "";
const sourceDirectory = pendingWorkspaceSetup?.sourceDirectory ?? "";
const displayName = pendingWorkspaceSetup?.displayName?.trim() ?? "";
const workspace = createdWorkspace;
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const chatDraft = useAgentInputDraft({
draftKey: `workspace-setup:${serverId}:${projectPath}`,
draftKey: `workspace-setup:${serverId}:${sourceDirectory}`,
composer: {
initialServerId: serverId || null,
initialValues: projectPath ? { workingDir: projectPath } : undefined,
initialValues: workspace?.workspaceDirectory
? { workingDir: workspace.workspaceDirectory }
: undefined,
isVisible: pendingWorkspaceSetup !== null,
onlineServerIds: isConnected && serverId ? [serverId] : [],
lockedWorkingDir: workspace?.projectRootPath ?? projectPath,
lockedWorkingDir: workspace?.workspaceDirectory || undefined,
},
});
const composerState = chatDraft.composerState;
@@ -70,7 +76,7 @@ export function WorkspaceSetupDialog() {
setErrorMessage(null);
setCreatedWorkspace(null);
setPendingAction(null);
}, [pendingWorkspaceSetup?.creationMethod, projectPath, serverId]);
}, [pendingWorkspaceSetup?.creationMethod, serverId, sourceDirectory]);
const handleClose = useCallback(() => {
clearWorkspaceSetup();
@@ -116,10 +122,10 @@ export function WorkspaceSetupDialog() {
const payload =
pendingWorkspaceSetup.creationMethod === "create_worktree"
? await connectedClient.createPaseoWorktree({
cwd: pendingWorkspaceSetup.projectPath,
cwd: pendingWorkspaceSetup.sourceDirectory,
worktreeSlug: createNameId(),
})
: await connectedClient.openProject(pendingWorkspaceSetup.projectPath);
: await connectedClient.openProject(pendingWorkspaceSetup.sourceDirectory);
if (payload.error || !payload.workspace) {
throw new Error(
@@ -149,13 +155,13 @@ export function WorkspaceSetupDialog() {
const current = useWorkspaceSetupStore.getState().pendingWorkspaceSetup;
return (
current?.serverId === pendingWorkspaceSetup?.serverId &&
current?.projectPath === pendingWorkspaceSetup?.projectPath &&
current?.sourceDirectory === pendingWorkspaceSetup?.sourceDirectory &&
current?.creationMethod === pendingWorkspaceSetup?.creationMethod
);
}, [
pendingWorkspaceSetup?.creationMethod,
pendingWorkspaceSetup?.projectPath,
pendingWorkspaceSetup?.serverId,
pendingWorkspaceSetup?.sourceDirectory,
]);
const handleCreateChatAgent = useCallback(
@@ -170,10 +176,11 @@ export function WorkspaceSetupDialog() {
}
const encodedImages = await encodeImages(images);
const workspaceDirectory = workspace.projectRootPath ?? projectPath;
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspaceDirectory,
workspaceId: requireWorkspaceRecordId(workspace.id),
...(composerState.modeOptions.length > 0 && composerState.selectedMode !== ""
? { modeId: composerState.selectedMode }
: {}),
@@ -227,10 +234,11 @@ export function WorkspaceSetupDialog() {
throw new Error("Workspace setup composer state is required");
}
const workspaceDirectory = workspace.projectRootPath ?? projectPath;
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
const agent = await connectedClient.createAgent({
provider: composerState.selectedProvider,
cwd: workspaceDirectory,
workspaceId: requireWorkspaceRecordId(workspace.id),
terminal: true,
...(terminalPrompt.trim() ? { initialPrompt: terminalPrompt.trim() } : {}),
});
@@ -274,11 +282,7 @@ export function WorkspaceSetupDialog() {
setErrorMessage(null);
const workspace = await ensureWorkspace();
const connectedClient = withConnectedClient();
const workspaceDirectory = workspace.projectRootPath ?? projectPath;
if (!workspaceDirectory) {
throw new Error("Workspace directory not found");
}
const workspaceDirectory = requireWorkspaceExecutionAuthority({ workspace }).workspaceDirectory;
const payload = await connectedClient.createTerminal(workspaceDirectory);
if (payload.error || !payload.terminal) {
@@ -304,12 +308,12 @@ export function WorkspaceSetupDialog() {
const workspaceTitle =
workspace?.name ||
workspace?.projectDisplayName ||
projectName ||
projectPath.split(/[\\/]/).filter(Boolean).pop() ||
projectPath;
const workspacePath = workspace?.projectRootPath || projectPath;
displayName ||
sourceDirectory.split(/[\\/]/).filter(Boolean).pop() ||
sourceDirectory;
const workspacePath = workspace?.workspaceDirectory || "Workspace will be created before launch.";
if (!pendingWorkspaceSetup || !projectPath) {
if (!pendingWorkspaceSetup || !sourceDirectory) {
return null;
}
@@ -378,7 +382,7 @@ export function WorkspaceSetupDialog() {
</Text>
<View style={styles.composerCard}>
<Composer
agentId={`workspace-setup:${serverId}:${projectPath}`}
agentId={`workspace-setup:${serverId}:${sourceDirectory}`}
serverId={serverId}
isInputActive={true}
onSubmitMessage={handleCreateChatAgent}
+3 -3
View File
@@ -23,7 +23,7 @@ import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keybo
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { getIsDesktop } from "@/constants/layout";
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { focusWithRetries } from "@/utils/web-focus";
@@ -221,9 +221,9 @@ export function useCommandCenter() {
// Don't restore focus back to the prior element after we navigate.
clearCommandCenterFocusRestoreElement();
setOpen(false);
const workspaceId = resolveHydratedWorkspaceId({
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[agent.serverId]?.workspaces?.values(),
path: agent.cwd,
workspaceDirectory: agent.cwd,
});
if (!workspaceId) {
router.navigate(buildHostAgentDetailRoute(agent.serverId, agent.id) as any);
+23 -1
View File
@@ -1,8 +1,11 @@
import { Text, View } from "react-native";
import { FileText } from "lucide-react-native";
import invariant from "tiny-invariant";
import { FilePane } from "@/components/file-pane";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelRegistration } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import { resolveWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
const fileName = target.path.split("/").filter(Boolean).pop() ?? target.path;
@@ -17,8 +20,27 @@ function useFilePanelDescriptor(target: { kind: "file"; path: string }) {
function FilePanel() {
const { serverId, workspaceId, target } = usePaneContext();
const authority = useSessionStore((state) =>
resolveWorkspaceExecutionAuthority({
workspaces: state.sessions[serverId]?.workspaces,
workspaceId,
}),
);
invariant(target.kind === "file", "FilePanel requires file target");
return <FilePane serverId={serverId} workspaceRoot={workspaceId} filePath={target.path} />;
if (!authority) {
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
<Text>Workspace execution directory not found.</Text>
</View>
);
}
return (
<FilePane
serverId={serverId}
workspaceRoot={authority.workspaceDirectory}
filePath={target.path}
/>
);
}
export const filePanelRegistration: PanelRegistration<"file"> = {
+28 -7
View File
@@ -19,6 +19,10 @@ import { useProviderRecency } from "@/stores/provider-recency-store";
import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { toErrorMessage } from "@/utils/error-messages";
import {
getWorkspaceExecutionAuthority,
requireWorkspaceRecordId,
} from "@/utils/workspace-execution";
const MAX_VISIBLE_PROVIDER_TILES = 4;
@@ -36,11 +40,11 @@ function LauncherPanel() {
const { serverId, workspaceId, target, retargetCurrentTab, isPaneFocused } = usePaneContext();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const workspaceDirectory = useSessionStore(
(state) =>
state.sessions[serverId]?.workspaces.get(workspaceId)?.workspaceDirectory ??
state.sessions[serverId]?.workspaces.get(workspaceId)?.projectRootPath,
);
const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId });
const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
const { providers, recordUsage } = useProviderRecency();
const setAgents = useSessionStore((state) => state.setAgents);
const [pendingAction, setPendingAction] = useState<string | null>(null);
@@ -62,6 +66,11 @@ function LauncherPanel() {
setErrorMessage(!workspaceDirectory ? "Workspace directory not found" : "Host is not connected");
return;
}
if (!workspaceAuthority.ok) {
setErrorMessage(workspaceAuthority.message);
return;
}
const persistedWorkspaceId = requireWorkspaceRecordId(workspaceAuthority.authority.workspaceId);
setPendingAction(providerId);
setErrorMessage(null);
@@ -70,6 +79,7 @@ function LauncherPanel() {
const agent = await client.createAgent({
provider: providerId,
cwd: workspaceDirectory,
workspaceId: persistedWorkspaceId,
terminal: true,
});
recordUsage(providerId);
@@ -87,7 +97,16 @@ function LauncherPanel() {
setPendingAction((current) => (current === providerId ? null : current));
}
},
[client, isConnected, recordUsage, retargetCurrentTab, serverId, setAgents, workspaceDirectory],
[
client,
isConnected,
recordUsage,
retargetCurrentTab,
serverId,
setAgents,
workspaceAuthority,
workspaceDirectory,
],
);
const openDraftTab = useCallback(() => {
@@ -131,7 +150,9 @@ function LauncherPanel() {
return (
<View style={styles.container}>
<View style={[styles.content, styles.loadingContent]}>
<ActivityIndicator />
<Text style={styles.errorText}>
{workspaceAuthority.ok ? "Workspace execution directory not found." : workspaceAuthority.message}
</Text>
</View>
</View>
);
+29 -15
View File
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { Terminal } from "lucide-react-native";
import { View } from "react-native";
import { Text, View } from "react-native";
import { useIsFocused } from "@react-navigation/native";
import invariant from "tiny-invariant";
import type { ListTerminalsResponse } from "@server/shared/messages";
@@ -8,6 +8,7 @@ import { TerminalPane } from "@/components/terminal-pane";
import { usePaneContext } from "@/panels/pane-context";
import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import { getWorkspaceExecutionAuthority } from "@/utils/workspace-execution";
type ListTerminalsPayload = ListTerminalsResponse["payload"];
@@ -24,18 +25,22 @@ function useTerminalPanelDescriptor(
context: { serverId: string; workspaceId: string },
): PanelDescriptor {
const client = useSessionStore((state) => state.sessions[context.serverId]?.client ?? null);
const workspaceDirectory = useSessionStore(
(state) =>
state.sessions[context.serverId]?.workspaces.get(context.workspaceId)?.workspaceDirectory ??
state.sessions[context.serverId]?.workspaces.get(context.workspaceId)?.projectRootPath ??
null,
);
const workspaces = useSessionStore((state) => state.sessions[context.serverId]?.workspaces);
const workspaceAuthority = getWorkspaceExecutionAuthority({
workspaces,
workspaceId: context.workspaceId,
});
const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
const terminalsQuery = useQuery({
queryKey: ["terminals", context.serverId, workspaceDirectory] as const,
enabled: Boolean(client && workspaceDirectory),
queryFn: async (): Promise<ListTerminalsPayload> => {
if (!client || !workspaceDirectory) {
return { cwd: workspaceDirectory ?? "", terminals: [], requestId: "missing-client" };
throw new Error(
workspaceAuthority.ok ? "Workspace execution directory not found" : workspaceAuthority.message,
);
}
return client.listTerminals(workspaceDirectory);
},
@@ -56,18 +61,27 @@ function useTerminalPanelDescriptor(
function TerminalPanel() {
const isFocused = useIsFocused();
const { serverId, workspaceId, target, isPaneFocused } = usePaneContext();
const workspaceDirectory = useSessionStore(
(state) =>
state.sessions[serverId]?.workspaces.get(workspaceId)?.workspaceDirectory ??
state.sessions[serverId]?.workspaces.get(workspaceId)?.projectRootPath ??
null,
);
const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId });
const workspaceDirectory = workspaceAuthority.ok
? workspaceAuthority.authority.workspaceDirectory
: null;
invariant(target.kind === "terminal", "TerminalPanel requires terminal target");
if (!isFocused || !workspaceDirectory) {
if (!isFocused) {
return <View style={{ flex: 1 }} />;
}
if (!workspaceDirectory) {
return (
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
<Text>
{workspaceAuthority.ok ? "Workspace execution directory not found." : workspaceAuthority.message}
</Text>
</View>
);
}
return (
<TerminalPane
serverId={serverId}
@@ -50,7 +50,7 @@ import type {
AgentSessionConfig,
} from "@server/server/agent/agent-sdk-types";
import { AGENT_PROVIDER_DEFINITIONS } from "@server/server/agent/provider-manifest";
import { resolveHydratedWorkspaceId } from "@/utils/resolve-hydrated-workspace-id";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useDesktopDragHandlers } from "@/utils/desktop-window";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -634,6 +634,16 @@ function DraftAgentScreenContent({
isGit: isAttachWorktree && selectedWorktreePath ? true : checkout?.isGit === true,
};
}, [selectedServerId, explorerCwd, isAttachWorktree, selectedWorktreePath, checkout?.isGit]);
const draftExplorerWorkspaceId = useSessionStore(
useCallback(
(state) =>
resolveWorkspaceIdByExecutionDirectory({
workspaces: selectedServerId ? state.sessions[selectedServerId]?.workspaces?.values() : null,
workspaceDirectory: explorerCwd,
}),
[explorerCwd, selectedServerId],
),
);
const canOpenExplorer = draftExplorerCheckout !== null;
const openExplorerForDraftCheckout = useCallback(() => {
if (!draftExplorerCheckout) {
@@ -910,9 +920,9 @@ function DraftAgentScreenContent({
const createdWorkingDir = typeof result.cwd === "string" ? result.cwd.trim() : "";
const configuredWorkingDir = config.cwd.trim();
const workspaceId = resolveHydratedWorkspaceId({
const workspaceId = resolveWorkspaceIdByExecutionDirectory({
workspaces: useSessionStore.getState().sessions[selectedServerId]?.workspaces?.values(),
path: createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir,
workspaceDirectory: createdWorkingDir.length > 0 ? createdWorkingDir : configuredWorkingDir,
});
return {
@@ -1218,7 +1228,7 @@ function DraftAgentScreenContent({
{!isMobile && isExplorerOpen && explorerServerId && draftExplorerCheckout ? (
<ExplorerSidebar
serverId={explorerServerId}
workspaceId={draftExplorerCheckout.cwd}
workspaceId={draftExplorerWorkspaceId}
workspaceRoot={draftExplorerCheckout.cwd}
isGit={explorerIsGit}
/>
@@ -1241,7 +1251,7 @@ function DraftAgentScreenContent({
{isMobile && explorerServerId && draftExplorerCheckout ? (
<ExplorerSidebar
serverId={explorerServerId}
workspaceId={draftExplorerCheckout.cwd}
workspaceId={draftExplorerWorkspaceId}
workspaceRoot={draftExplorerCheckout.cwd}
isGit={explorerIsGit}
/>
@@ -13,6 +13,10 @@ import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-dr
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { type Agent, useSessionStore } from "@/stores/session-store";
import { encodeImages } from "@/utils/encode-images";
import {
getWorkspaceExecutionAuthority,
requireWorkspaceRecordId,
} from "@/utils/workspace-execution";
import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus";
import type { AgentCapabilityFlags } from "@server/server/agent/agent-sdk-types";
import type { AgentSnapshotPayload } from "@server/shared/messages";
@@ -49,11 +53,10 @@ export function WorkspaceDraftAgentTab({
}: WorkspaceDraftAgentTabProps) {
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const workspaceDirectory = useSessionStore(
(state) =>
state.sessions[serverId]?.workspaces.get(workspaceId)?.workspaceDirectory ??
state.sessions[serverId]?.workspaces.get(workspaceId)?.projectRootPath,
);
const workspaces = useSessionStore((state) => state.sessions[serverId]?.workspaces);
const workspaceAuthority = getWorkspaceExecutionAuthority({ workspaces, workspaceId });
const workspaceExecutionAuthority = workspaceAuthority.ok ? workspaceAuthority.authority : null;
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null;
const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const draftStoreKey = useMemo(
() =>
@@ -69,10 +72,10 @@ export function WorkspaceDraftAgentTab({
draftKey: draftStoreKey,
composer: {
initialServerId: serverId,
initialValues: { workingDir: workspaceDirectory },
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined,
isVisible: true,
onlineServerIds: isConnected ? [serverId] : [],
lockedWorkingDir: workspaceDirectory,
lockedWorkingDir: workspaceDirectory ?? undefined,
},
},
);
@@ -152,6 +155,7 @@ export function WorkspaceDraftAgentTab({
},
createRequest: async ({ attempt, text, images }) => {
invariant(workspaceDirectory, "Workspace directory is required");
invariant(workspaceExecutionAuthority, "Workspace authority is required");
if (!client) {
throw new Error("Host is not connected");
}
@@ -169,6 +173,7 @@ export function WorkspaceDraftAgentTab({
const imagesData = await encodeImages(images);
const result = await client.createAgent({
config,
workspaceId: requireWorkspaceRecordId(workspaceExecutionAuthority.workspaceId),
...(text ? { initialPrompt: text } : {}),
clientMessageId: attempt.clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
@@ -59,7 +59,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
import {
normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual,
@@ -77,6 +76,10 @@ import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useStableEvent } from "@/hooks/use-stable-event";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
import {
resolveWorkspaceExecutionAuthority,
resolveWorkspaceRouteId,
} from "@/utils/workspace-execution";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
@@ -554,23 +557,13 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const isFocusModeEnabled = usePanelStore((state) => state.desktop.focusModeEnabled);
const normalizedServerId = trimNonEmpty(decodeSegment(serverId)) ?? "";
const rawWorkspaceIdentifier =
normalizeWorkspaceIdentity(decodeWorkspaceIdFromPathSegment(workspaceId)) ?? "";
// Resolve the workspace ID: first try direct map key, then fall back to path-based lookup.
// This lets URLs that encode a filesystem path (e.g. from deep links or older bookmarks)
// resolve correctly even though the map is keyed by numeric DB IDs.
const normalizedWorkspaceId = useSessionStore((state) => {
if (!normalizedServerId || !rawWorkspaceIdentifier) return "";
const workspaces = state.sessions[normalizedServerId]?.workspaces;
if (!workspaces) return rawWorkspaceIdentifier;
if (workspaces.has(rawWorkspaceIdentifier)) return rawWorkspaceIdentifier;
for (const [id, ws] of workspaces.entries()) {
if (normalizeWorkspaceIdentity(ws.workspaceDirectory) === rawWorkspaceIdentifier) return id;
if (normalizeWorkspaceIdentity(ws.projectRootPath) === rawWorkspaceIdentifier) return id;
}
return rawWorkspaceIdentifier;
});
const normalizedWorkspaceId =
resolveWorkspaceRouteId({
routeWorkspaceId: decodeWorkspaceIdFromPathSegment(workspaceId),
}) ?? "";
const sessionWorkspaces = useSessionStore(
(state) => state.sessions[normalizedServerId]?.workspaces,
);
const workspaceTerminalScopeKey =
normalizedServerId && normalizedWorkspaceId
@@ -583,11 +576,17 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
const queryClient = useQueryClient();
const client = useHostRuntimeClient(normalizedServerId);
const isConnected = useHostRuntimeIsConnected(normalizedServerId);
const workspaceDescriptor = useSessionStore(
(state) => state.sessions[normalizedServerId]?.workspaces.get(normalizedWorkspaceId) ?? null,
const workspaceDescriptor = sessionWorkspaces?.get(normalizedWorkspaceId) ?? null;
const workspaceAuthority = useMemo(
() =>
resolveWorkspaceExecutionAuthority({
workspaces: sessionWorkspaces,
workspaceId: normalizedWorkspaceId,
}),
[normalizedWorkspaceId, sessionWorkspaces],
);
const workspaceDirectory =
workspaceDescriptor?.workspaceDirectory ?? workspaceDescriptor?.projectRootPath ?? null;
const workspaceDirectory = workspaceAuthority?.workspaceDirectory ?? null;
const isMissingWorkspaceExecutionAuthority = Boolean(workspaceDescriptor && !workspaceAuthority);
const workspaceAgentVisibility = useStoreWithEqualityFn(
useSessionStore,
@@ -633,7 +632,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
terminals: current?.terminals ?? [],
terminal: createdTerminal,
});
const cwd = current?.cwd ?? workspaceDirectory ?? undefined;
const cwd = current?.cwd ?? workspaceDirectory;
return {
...(cwd ? { cwd } : {}),
terminals: nextTerminals,
@@ -706,7 +705,10 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
}, [client, isConnected, queryClient, terminalsQueryKey, workspaceDirectory]);
const checkoutQuery = useQuery({
queryKey: checkoutStatusQueryKey(normalizedServerId, workspaceDirectory ?? ""),
queryKey: checkoutStatusQueryKey(
normalizedServerId,
workspaceDirectory ?? `missing-workspace-directory:${normalizedWorkspaceId}`,
),
enabled:
Boolean(client && isConnected) &&
Boolean(workspaceDirectory),
@@ -1749,6 +1751,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
<View style={styles.emptyState}>
<ActivityIndicator color={theme.colors.foregroundMuted} />
</View>
) : isMissingWorkspaceExecutionAuthority ? (
<View style={styles.emptyState}>
<Text style={styles.emptyStateText}>
Workspace execution directory is missing. Reload workspace data before opening tabs.
</Text>
</View>
) : !activeTabDescriptor ? (
!hasHydratedAgents ? (
<View style={styles.emptyState}>
@@ -1966,10 +1974,12 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
<View style={styles.headerRight}>
{!isMobile && isGitCheckout ? (
<>
<WorkspaceGitActions
serverId={normalizedServerId}
cwd={workspaceDirectory ?? ""}
/>
{workspaceDirectory ? (
<WorkspaceGitActions
serverId={normalizedServerId}
cwd={workspaceDirectory}
/>
) : null}
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>
<Pressable
@@ -2148,13 +2158,15 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
</View>
{(!isFocusModeEnabled || isMobile) && (
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory ?? ""}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
workspaceDirectory ? (
<ExplorerSidebar
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
workspaceRoot={workspaceDirectory}
isGit={isGitCheckout}
onOpenFile={handleOpenFileFromExplorer}
/>
) : null
)}
</View>
</View>
@@ -6,19 +6,21 @@ describe("workspace-setup-store", () => {
useWorkspaceSetupStore.setState({ pendingWorkspaceSetup: null });
});
it("tracks deferred project setup by path instead of a created workspace", () => {
it("tracks deferred workspace setup by source directory and optional workspace id", () => {
useWorkspaceSetupStore.getState().beginWorkspaceSetup({
serverId: "server-1",
projectPath: "/Users/test/project",
projectName: "project",
sourceDirectory: "/Users/test/project",
sourceWorkspaceId: "42",
displayName: "project",
creationMethod: "open_project",
navigationMethod: "replace",
});
expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toEqual({
serverId: "server-1",
projectPath: "/Users/test/project",
projectName: "project",
sourceDirectory: "/Users/test/project",
sourceWorkspaceId: "42",
displayName: "project",
creationMethod: "open_project",
navigationMethod: "replace",
});
@@ -27,7 +29,7 @@ describe("workspace-setup-store", () => {
it("clears pending setup state", () => {
useWorkspaceSetupStore.getState().beginWorkspaceSetup({
serverId: "server-1",
projectPath: "/Users/test/project",
sourceDirectory: "/Users/test/project",
creationMethod: "create_worktree",
navigationMethod: "navigate",
});
@@ -5,8 +5,9 @@ export type WorkspaceCreationMethod = "open_project" | "create_worktree";
export interface PendingWorkspaceSetup {
serverId: string;
projectPath: string;
projectName?: string;
sourceDirectory: string;
sourceWorkspaceId?: string;
displayName?: string;
creationMethod: WorkspaceCreationMethod;
navigationMethod: WorkspaceSetupNavigationMethod;
}
@@ -28,6 +28,20 @@ describe("resolveNotificationTarget", () => {
workspaceId: null,
});
});
it("does not treat cwd as a workspace id alias", () => {
expect(
resolveNotificationTarget({
serverId: "srv-1",
agentId: "agent-1",
cwd: "/tmp/repo",
}),
).toEqual({
serverId: "srv-1",
agentId: "agent-1",
workspaceId: null,
});
});
});
describe("buildNotificationRoute", () => {
@@ -23,7 +23,7 @@ export function resolveNotificationTarget(data: NotificationData): {
return {
serverId: readNonEmptyString(data, "serverId"),
agentId: readNonEmptyString(data, "agentId"),
workspaceId: readNonEmptyString(data, "workspaceId") ?? readNonEmptyString(data, "cwd"),
workspaceId: readNonEmptyString(data, "workspaceId"),
};
}
@@ -1,26 +0,0 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
export function resolveHydratedWorkspaceId(input: {
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
path: string | null | undefined;
}): string | null {
const normalizedPath = normalizeWorkspaceIdentity(input.path);
if (!normalizedPath) {
return null;
}
for (const workspace of input.workspaces ?? []) {
if (normalizeWorkspaceIdentity(workspace.id) === normalizedPath) {
return workspace.id;
}
if (normalizeWorkspaceIdentity(workspace.workspaceDirectory) === normalizedPath) {
return workspace.id;
}
if (normalizeWorkspaceIdentity(workspace.projectRootPath) === normalizedPath) {
return workspace.id;
}
}
return null;
}
@@ -17,7 +17,7 @@ function workspace(
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "worktree",
name: input.name ?? input.id,
status: input.status ?? "done",
status: input.status ?? "running",
activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null,
};
@@ -38,7 +38,7 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
).toBe("/repo");
});
it("falls back to the project root path when the root checkout is not in the visible workspace list", () => {
it("falls back to the host root route when no sibling workspace target exists", () => {
const workspaces = [
workspace({
id: "/repo/.paseo/worktrees/feature",
@@ -48,11 +48,12 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
];
expect(
resolveWorkspaceArchiveRedirectWorkspaceId({
buildWorkspaceArchiveRedirectRoute({
serverId: "server-1",
archivedWorkspaceId: "/repo/.paseo/worktrees/feature",
workspaces,
}),
).toBe("/repo");
).toBe("/h/server-1");
});
it("falls back to the host root route when no alternate workspace target exists", () => {
@@ -1,20 +1,14 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { buildHostRootRoute, buildHostWorkspaceRoute } from "@/utils/host-routes";
function trimNonEmpty(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
import { resolveWorkspaceRouteId } from "@/utils/workspace-execution";
export function resolveWorkspaceArchiveRedirectWorkspaceId(input: {
archivedWorkspaceId: string;
workspaces: Iterable<WorkspaceDescriptor>;
}): string | null {
const archivedWorkspaceId = trimNonEmpty(input.archivedWorkspaceId);
const archivedWorkspaceId = resolveWorkspaceRouteId({
routeWorkspaceId: input.archivedWorkspaceId,
});
if (!archivedWorkspaceId) {
return null;
}
@@ -38,11 +32,6 @@ export function resolveWorkspaceArchiveRedirectWorkspaceId(input: {
return rootCheckoutWorkspace.id;
}
const fallbackProjectRootPath = trimNonEmpty(archivedWorkspace.projectRootPath);
if (fallbackProjectRootPath && fallbackProjectRootPath !== archivedWorkspace.id) {
return fallbackProjectRootPath;
}
const siblingWorkspace =
sameProjectWorkspaces.find((workspace) => workspace.id !== archivedWorkspace.id) ?? null;
return siblingWorkspace?.id ?? null;
@@ -0,0 +1,135 @@
import { describe, expect, it } from "vitest";
import type { WorkspaceDescriptor } from "@/stores/session-store";
import {
getWorkspaceExecutionAuthority,
requireWorkspaceExecutionAuthority,
resolveWorkspaceIdByExecutionDirectory,
resolveWorkspaceRouteId,
} from "./workspace-execution";
function createWorkspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project",
projectRootPath: input.projectRootPath ?? "/repo",
workspaceDirectory: input.workspaceDirectory ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "checkout",
name: input.name ?? "main",
status: input.status ?? "running",
activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null,
};
}
describe("resolveWorkspaceRouteId", () => {
it("normalizes route workspace ids", () => {
expect(resolveWorkspaceRouteId({ routeWorkspaceId: " /tmp/repo/ " })).toBe("/tmp/repo");
});
it("returns null for empty values", () => {
expect(resolveWorkspaceRouteId({ routeWorkspaceId: " " })).toBeNull();
});
});
describe("resolveWorkspaceIdByExecutionDirectory", () => {
it("matches workspace directories", () => {
const workspaces = [
createWorkspace({
id: "workspace-1",
projectRootPath: "/repo",
workspaceDirectory: "/repo/.paseo/worktrees/feature",
}),
];
expect(
resolveWorkspaceIdByExecutionDirectory({
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(
resolveWorkspaceIdByExecutionDirectory({
workspaces,
workspaceDirectory: "/repo",
}),
).toBeNull();
});
});
describe("workspace execution authority", () => {
it("returns an explicit failure when workspace id is missing", () => {
expect(
getWorkspaceExecutionAuthority({
workspaces: new Map(),
workspaceId: null,
}),
).toEqual({
ok: false,
reason: "workspace_id_missing",
message: "Workspace id is required.",
});
});
it("returns an explicit failure when workspace directory is missing", () => {
const workspaces = new Map<string, WorkspaceDescriptor>([
[
"workspace-1",
createWorkspace({
id: "workspace-1",
workspaceDirectory: " ",
projectRootPath: "/repo",
}),
],
]);
expect(
getWorkspaceExecutionAuthority({
workspaces,
workspaceId: "workspace-1",
}),
).toEqual({
ok: false,
reason: "workspace_directory_missing",
message: "Workspace directory is missing for workspace workspace-1",
});
});
it("never falls back to project root metadata", () => {
const workspaces = new Map<string, WorkspaceDescriptor>([
[
"workspace-1",
createWorkspace({
id: "workspace-1",
projectRootPath: "/repo",
workspaceDirectory: "/repo/.paseo/worktrees/feature",
}),
],
]);
expect(
requireWorkspaceExecutionAuthority({
workspaces,
workspaceId: "workspace-1",
}),
).toEqual({
workspaceId: "workspace-1",
workspaceDirectory: "/repo/.paseo/worktrees/feature",
workspace: workspaces.get("workspace-1"),
});
});
});
@@ -0,0 +1,177 @@
import type { WorkspaceDescriptor } from "@/stores/session-store";
import { normalizeWorkspaceIdentity } from "@/utils/workspace-identity";
export type WorkspaceAuthorityResult = {
workspaceId: string;
workspaceDirectory: string;
workspace: WorkspaceDescriptor;
};
export type WorkspaceExecutionAuthorityFailureReason =
| "workspace_id_missing"
| "workspace_missing"
| "workspace_directory_missing";
export type WorkspaceExecutionAuthorityResult =
| { ok: true; authority: WorkspaceAuthorityResult }
| {
ok: false;
reason: WorkspaceExecutionAuthorityFailureReason;
message: string;
};
export function resolveWorkspaceRouteId(input: {
routeWorkspaceId: string | null | undefined;
}): string | null {
return normalizeWorkspaceIdentity(input.routeWorkspaceId);
}
export function resolveWorkspaceIdByExecutionDirectory(input: {
workspaces: Iterable<WorkspaceDescriptor> | null | undefined;
workspaceDirectory: string | null | undefined;
}): string | null {
const normalizedWorkspaceDirectory = normalizeWorkspaceIdentity(input.workspaceDirectory);
if (!normalizedWorkspaceDirectory) {
return null;
}
for (const workspace of input.workspaces ?? []) {
if (normalizeWorkspaceIdentity(workspace.workspaceDirectory) === normalizedWorkspaceDirectory) {
return workspace.id;
}
}
return null;
}
export function getWorkspaceExecutionAuthority(
input:
| {
workspace: WorkspaceDescriptor | null | undefined;
}
| {
workspaces: Map<string, WorkspaceDescriptor> | undefined;
workspaceId: string | null | undefined;
},
): WorkspaceExecutionAuthorityResult {
const workspace =
"workspace" in input
? input.workspace
: (() => {
const normalizedWorkspaceId = normalizeWorkspaceIdentity(input.workspaceId);
if (!normalizedWorkspaceId) {
return null;
}
return input.workspaces?.get(normalizedWorkspaceId) ?? null;
})();
if ("workspaces" in input) {
const normalizedWorkspaceId = normalizeWorkspaceIdentity(input.workspaceId);
if (!normalizedWorkspaceId) {
return {
ok: false,
reason: "workspace_id_missing",
message: "Workspace id is required.",
};
}
}
if (!workspace) {
return {
ok: false,
reason: "workspace_missing",
message:
"workspaces" in input
? `Workspace not found: ${String(input.workspaceId ?? "")}`
: "Workspace not found.",
};
}
const workspaceDirectory = normalizeWorkspaceIdentity(workspace.workspaceDirectory);
if (!workspaceDirectory) {
return {
ok: false,
reason: "workspace_directory_missing",
message: `Workspace directory is missing for workspace ${workspace.id}`,
};
}
return {
ok: true,
authority: {
workspaceId: workspace.id,
workspaceDirectory,
workspace,
},
};
}
export function requireWorkspaceExecutionAuthority(
input:
| {
workspace: WorkspaceDescriptor | null | undefined;
}
| {
workspaces: Map<string, WorkspaceDescriptor> | undefined;
workspaceId: string | null | undefined;
},
): WorkspaceAuthorityResult {
const result = getWorkspaceExecutionAuthority(input);
if (!result.ok) {
throw new Error(result.message);
}
return result.authority;
}
export function requireWorkspaceRecordId(workspaceId: string): number {
const normalizedWorkspaceId = normalizeWorkspaceIdentity(workspaceId);
if (!normalizedWorkspaceId) {
throw new Error("Workspace ID is required");
}
const parsedWorkspaceId = Number(normalizedWorkspaceId);
if (!Number.isInteger(parsedWorkspaceId)) {
throw new Error(`Workspace ID is not a persisted record ID: ${workspaceId}`);
}
return parsedWorkspaceId;
}
export function resolveWorkspaceExecutionDirectory(input: {
workspaceDirectory: string | null | undefined;
}): string | null {
return normalizeWorkspaceIdentity(input.workspaceDirectory);
}
export function requireWorkspaceExecutionDirectory(input: {
workspaceId?: string;
workspaceDirectory: string | null | undefined;
}): string {
const workspaceDirectory = resolveWorkspaceExecutionDirectory({
workspaceDirectory: input.workspaceDirectory,
});
if (!workspaceDirectory) {
throw new Error(
input.workspaceId
? `Workspace directory is missing for workspace ${input.workspaceId}`
: "Workspace directory is missing.",
);
}
return workspaceDirectory;
}
export function resolveWorkspaceExecutionAuthority(
input:
| {
workspace: WorkspaceDescriptor | null | undefined;
}
| {
workspaces: Map<string, WorkspaceDescriptor> | undefined;
workspaceId: string | null | undefined;
},
): WorkspaceAuthorityResult | null {
const result = getWorkspaceExecutionAuthority(input);
return result.ok ? result.authority : null;
}
export const parseWorkspaceRecordId = requireWorkspaceRecordId;
@@ -180,6 +180,7 @@ export type CreateAgentRequestOptions = {
config?: AgentSessionConfig;
provider?: AgentProvider;
cwd?: string;
workspaceId?: number;
initialPrompt?: string;
clientMessageId?: string;
outputSchema?: Record<string, unknown>;
@@ -1383,6 +1384,7 @@ export class DaemonClient {
type: "create_agent_request",
requestId,
config,
...(typeof options.workspaceId === "number" ? { workspaceId: options.workspaceId } : {}),
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
@@ -3633,6 +3635,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
config,
provider,
cwd,
workspaceId: _workspaceId,
initialPrompt: _initialPrompt,
images: _images,
git: _git,
+1 -1
View File
@@ -2522,7 +2522,7 @@ export class Session {
);
await this.forwardAgentUpdate(snapshot);
if (sessionConfig.terminal) {
void this.emitInitialTerminalsChangedSnapshot(sessionConfig.cwd);
void this.emitInitialTerminalsChangedSnapshot(resolvedWorkspace.directory);
}
if (requestId) {
@@ -630,6 +630,108 @@ describe("workspace aggregation", () => {
});
});
test("create_agent_request uses workspaceId as the execution authority", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
seedProject({
projects,
id: 5,
directory: "/tmp/repo",
displayName: "repo",
kind: "git",
});
seedWorkspace({
workspaces,
id: 50,
projectId: 5,
directory: "/tmp/repo/.paseo/worktrees/feature",
displayName: "feature",
kind: "worktree",
});
const createdAgent = makeAgent({
id: "agent-1",
cwd: "/tmp/repo/.paseo/worktrees/feature",
status: "idle",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const createAgent = vi.fn(async () => createdAgent as any);
(session as any).agentManager = {
createAgent,
getAgent: vi.fn(() => createdAgent as any),
};
(session as any).forwardAgentUpdate = vi.fn(async () => undefined);
(session as any).getAgentPayloadById = vi.fn(async () => createdAgent);
(session as any).buildAgentSessionConfig = vi.fn(async (config: any) => ({
sessionConfig: config,
worktreeConfig: null,
}));
await (session as any).handleCreateAgentRequest({
type: "create_agent_request",
requestId: "req-create-agent",
workspaceId: 50,
config: {
provider: "codex",
cwd: "/tmp/repo",
modeId: "default",
},
labels: {},
});
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: "/tmp/repo/.paseo/worktrees/feature",
}),
undefined,
expect.objectContaining({
workspaceId: 50,
}),
);
const response = emitted.find((message) => message.type === "status") as any;
expect(response?.payload).toMatchObject({
status: "agent_created",
requestId: "req-create-agent",
agent: {
cwd: "/tmp/repo/.paseo/worktrees/feature",
},
});
});
test("create_agent_request fails for an unknown workspaceId", async () => {
const { session, emitted } = createSessionForWorkspaceTests();
const createAgent = vi.fn();
(session as any).agentManager = {
createAgent,
getAgent: vi.fn(() => null),
};
(session as any).buildAgentSessionConfig = vi.fn(async (config: any) => ({
sessionConfig: config,
worktreeConfig: null,
}));
await (session as any).handleCreateAgentRequest({
type: "create_agent_request",
requestId: "req-create-agent-fail",
workspaceId: 999,
config: {
provider: "codex",
cwd: "/tmp/repo",
modeId: "default",
},
labels: {},
});
expect(createAgent).not.toHaveBeenCalled();
const response = emitted.find((message) => message.type === "status") as any;
expect(response?.payload).toMatchObject({
status: "agent_create_failed",
requestId: "req-create-agent-fail",
});
expect((response?.payload as any)?.error).toContain("Workspace not found: 999");
});
test("open_project_request creates git projects with GitHub owner/repo and branch names", async () => {
const { session, emitted, projects, workspaces } = createSessionForWorkspaceTests();
const { tempDir, repoDir } = createTempGitRepo({
@@ -573,6 +573,13 @@ export async function handleCreatePaseoWorktreeRequest(
worktreePath,
branchName: normalizedSlug,
});
await createAgentWorktree({
cwd: repoRoot,
branchName: normalizedSlug,
baseBranch,
worktreeSlug: normalizedSlug,
paseoHome: dependencies.paseoHome,
});
const descriptor = await dependencies.describeWorkspaceRecord(workspace);
dependencies.emit({
type: "create_paseo_worktree_response",
@@ -622,14 +629,6 @@ export async function createPaseoWorktreeInBackground(
let setupTerminalId: string | null = null;
try {
await createAgentWorktree({
cwd: options.repoRoot,
branchName: options.slug,
baseBranch: options.baseBranch,
worktreeSlug: options.slug,
paseoHome: dependencies.paseoHome,
});
const setupCommands = getWorktreeSetupCommands(options.worktreePath);
if (setupCommands.length > 0 && dependencies.terminalManager) {
const runtimeEnv = await resolveWorktreeRuntimeEnv({