WIP: workspace execution refactor + notification routing + setup store updates
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"> = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user