Merge branch 'storage-terminal-ui-dev' into dev
# Conflicts: # nix/package.nix # packages/app/src/app/_layout.tsx # packages/app/src/components/sidebar-workspace-list.tsx # packages/app/src/hooks/use-command-center.ts # packages/app/src/screens/agent/draft-agent-screen.tsx # packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx # packages/app/src/screens/workspace/workspace-screen.tsx # packages/server/src/server/session.ts # packages/server/src/server/session.workspaces.test.ts # packages/server/src/terminal/terminal.test.ts # packages/server/src/terminal/terminal.ts
This commit is contained in:
@@ -31,6 +31,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
serverId,
|
||||
id: snapshot.id,
|
||||
provider: snapshot.provider,
|
||||
terminal: snapshot.terminal === true,
|
||||
status: snapshot.status as AgentLifecycleStatus,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
@@ -44,6 +45,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
runtimeInfo: snapshot.runtimeInfo,
|
||||
lastUsage: snapshot.lastUsage,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
terminalExit: snapshot.terminalExit ?? null,
|
||||
title: snapshot.title ?? null,
|
||||
cwd: snapshot.cwd,
|
||||
model: snapshot.model ?? null,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildHostAgentDetailRoute,
|
||||
buildHostRootRoute,
|
||||
buildHostWorkspaceOpenRoute,
|
||||
buildHostWorkspaceRoute,
|
||||
decodeFilePathFromPathSegment,
|
||||
decodeWorkspaceIdFromPathSegment,
|
||||
@@ -89,4 +90,10 @@ describe("workspace route parsing", () => {
|
||||
"/h/local/workspace/L3RtcC9yZXBv?open=agent%3Aagent-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("builds workspace routes with a one-shot open intent", () => {
|
||||
expect(buildHostWorkspaceOpenRoute("local", "/tmp/repo", "draft:new")).toBe(
|
||||
"/h/local/workspace/L3RtcC9yZXBv?open=draft%3Anew",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -281,6 +281,19 @@ export function buildHostWorkspaceRoute(serverId: string, workspaceId: string):
|
||||
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}`;
|
||||
}
|
||||
|
||||
export function buildHostWorkspaceOpenRoute(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
openIntent: string,
|
||||
): string {
|
||||
const base = buildHostWorkspaceRoute(serverId, workspaceId);
|
||||
const normalizedOpenIntent = trimNonEmpty(openIntent);
|
||||
if (base === "/" || !normalizedOpenIntent) {
|
||||
return base;
|
||||
}
|
||||
return `${base}?open=${encodeURIComponent(normalizedOpenIntent)}`;
|
||||
}
|
||||
|
||||
export function buildHostAgentDetailRoute(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
@@ -292,11 +305,7 @@ export function buildHostAgentDetailRoute(
|
||||
if (!normalizedAgentId) {
|
||||
return "/";
|
||||
}
|
||||
const base = buildHostWorkspaceRoute(serverId, normalizedWorkspaceId);
|
||||
if (base === "/") {
|
||||
return "/";
|
||||
}
|
||||
return `${base}?open=${encodeURIComponent(`agent:${normalizedAgentId}`)}`;
|
||||
return buildHostWorkspaceOpenRoute(serverId, normalizedWorkspaceId, `agent:${normalizedAgentId}`);
|
||||
}
|
||||
const normalizedServerId = trimNonEmpty(serverId);
|
||||
const normalizedAgentId = trimNonEmpty(agentId);
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,8 @@ function workspace(overrides: Partial<SidebarWorkspaceEntry> = {}): SidebarWorks
|
||||
workspaceKey: "srv:/repo",
|
||||
serverId: "srv",
|
||||
workspaceId: "/repo",
|
||||
workspaceKind: "directory",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "paseo",
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
@@ -41,13 +42,13 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
it("flattens non-git projects with one workspace into a direct workspace row model", () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
workspaceId: "/repo/non-git",
|
||||
workspaceKind: "directory",
|
||||
workspaceKind: "checkout",
|
||||
statusBucket: "running",
|
||||
});
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "non_git",
|
||||
projectKind: "directory",
|
||||
workspaces: [flattenedWorkspace],
|
||||
}),
|
||||
collapsed: false,
|
||||
@@ -70,7 +71,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "non_git",
|
||||
projectKind: "directory",
|
||||
workspaces: [flattenedWorkspace],
|
||||
}),
|
||||
collapsed: false,
|
||||
@@ -87,25 +88,23 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("flattens git projects with a single workspace and keeps the new worktree action", () => {
|
||||
const flattenedWorkspace = workspace({
|
||||
it("keeps single-workspace git projects as sections with the new worktree action", () => {
|
||||
const onlyWorkspace = workspace({
|
||||
workspaceId: "/repo/main",
|
||||
workspaceKind: "local_checkout",
|
||||
workspaceKind: "checkout",
|
||||
});
|
||||
|
||||
const result = buildSidebarProjectRowModel({
|
||||
project: project({
|
||||
projectKind: "git",
|
||||
workspaces: [flattenedWorkspace],
|
||||
workspaces: [onlyWorkspace],
|
||||
}),
|
||||
collapsed: true,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "workspace_link",
|
||||
workspace: flattenedWorkspace,
|
||||
selected: false,
|
||||
chevron: null,
|
||||
kind: "project_section",
|
||||
chevron: "expand",
|
||||
trailingAction: "new_worktree",
|
||||
});
|
||||
});
|
||||
@@ -115,7 +114,7 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
project: project({
|
||||
projectKind: "git",
|
||||
workspaces: [
|
||||
workspace({ workspaceId: "/repo/main", workspaceKind: "local_checkout" }),
|
||||
workspace({ workspaceId: "/repo/main", workspaceKind: "checkout" }),
|
||||
workspace({ workspaceId: "/repo/feature", workspaceKind: "worktree" }),
|
||||
],
|
||||
}),
|
||||
@@ -131,12 +130,12 @@ describe("buildSidebarProjectRowModel", () => {
|
||||
});
|
||||
|
||||
describe("isSidebarProjectFlattened", () => {
|
||||
it("returns true for single-workspace projects regardless of kind", () => {
|
||||
it("returns true only for single-workspace directory projects", () => {
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: "git", workspaces: [workspace()] })),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
expect(
|
||||
isSidebarProjectFlattened(project({ projectKind: "non_git", workspaces: [workspace()] })),
|
||||
isSidebarProjectFlattened(project({ projectKind: "directory", workspaces: [workspace()] })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ function workspace(serverId: string, cwd: string): SidebarWorkspaceEntry {
|
||||
workspaceKey: `${serverId}:${cwd}`,
|
||||
serverId,
|
||||
workspaceId: cwd,
|
||||
workspaceKind: "local_checkout",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: cwd,
|
||||
activityAt: null,
|
||||
statusBucket: "done",
|
||||
@@ -76,7 +77,7 @@ describe("buildSidebarShortcutModel", () => {
|
||||
expect(model.shortcutTargets[8]).toEqual({ serverId: "s", workspaceId: "/repo/w9" });
|
||||
});
|
||||
|
||||
it("ignores collapsed state for flattened single-workspace projects", () => {
|
||||
it("respects collapsed state for single-workspace git projects", () => {
|
||||
const projects = [project("p1", [workspace("s1", "/repo/main")])];
|
||||
|
||||
const model = buildSidebarShortcutModel({
|
||||
@@ -84,7 +85,7 @@ describe("buildSidebarShortcutModel", () => {
|
||||
collapsedProjectKeys: new Set<string>(["p1"]),
|
||||
});
|
||||
|
||||
expect(model.visibleTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
|
||||
expect(model.shortcutTargets).toEqual([{ serverId: "s1", workspaceId: "/repo/main" }]);
|
||||
expect(model.visibleTargets).toEqual([]);
|
||||
expect(model.shortcutTargets).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,4 +50,18 @@ describe("terminal-list", () => {
|
||||
{ id: "term-2", name: "Renamed Terminal" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves terminal titles from create responses", () => {
|
||||
const result = upsertTerminalListEntry({
|
||||
terminals: [],
|
||||
terminal: {
|
||||
id: "term-3",
|
||||
name: "Terminal 3",
|
||||
title: "Build Output",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual([{ id: "term-3", name: "Terminal 3", title: "Build Output" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ function toTerminalListEntry(input: { terminal: CreatedTerminal }): TerminalList
|
||||
return {
|
||||
id: input.terminal.id,
|
||||
name: input.terminal.name,
|
||||
...(input.terminal.title ? { title: input.terminal.title } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ function workspace(
|
||||
projectId: input.projectId ?? "project-1",
|
||||
projectDisplayName: input.projectDisplayName ?? "Project",
|
||||
projectRootPath: input.projectRootPath ?? "/repo",
|
||||
workspaceDirectory: input.workspaceDirectory ?? input.projectRootPath ?? "/repo",
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "worktree",
|
||||
name: input.name ?? input.id,
|
||||
status: input.status ?? "done",
|
||||
status: input.status ?? "running",
|
||||
activityAt: input.activityAt ?? null,
|
||||
diffStat: input.diffStat ?? null,
|
||||
};
|
||||
@@ -25,7 +26,7 @@ function workspace(
|
||||
describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
||||
it("redirects an archived worktree to the visible local checkout for the same project", () => {
|
||||
const workspaces = [
|
||||
workspace({ id: "/repo", workspaceKind: "local_checkout", name: "main" }),
|
||||
workspace({ id: "/repo", workspaceKind: "checkout", name: "main" }),
|
||||
workspace({ id: "/repo/.paseo/worktrees/feature", name: "feature" }),
|
||||
];
|
||||
|
||||
@@ -37,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",
|
||||
@@ -47,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", () => {
|
||||
@@ -60,8 +62,8 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
||||
id: "/notes",
|
||||
projectId: "notes",
|
||||
projectRootPath: "/notes",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
projectKind: "directory",
|
||||
workspaceKind: "checkout",
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -32,17 +26,12 @@ export function resolveWorkspaceArchiveRedirectWorkspaceId(input: {
|
||||
const rootCheckoutWorkspace =
|
||||
sameProjectWorkspaces.find(
|
||||
(workspace) =>
|
||||
workspace.workspaceKind === "local_checkout" && workspace.id !== archivedWorkspace.id,
|
||||
workspace.workspaceKind === "checkout" && workspace.id !== archivedWorkspace.id,
|
||||
) ?? null;
|
||||
if (rootCheckoutWorkspace) {
|
||||
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;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@react-native-async-storage/async-storage", () => {
|
||||
const storage = new Map<string, string>();
|
||||
return {
|
||||
default: {
|
||||
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn(async (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
}),
|
||||
removeItem: vi.fn(async (key: string) => {
|
||||
storage.delete(key);
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import {
|
||||
buildTerminalAgentReopenKey,
|
||||
useTerminalAgentReopenStore,
|
||||
} from "@/stores/terminal-agent-reopen-store";
|
||||
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
const WORKSPACE_ID = "/repo/worktree";
|
||||
const AGENT_ID = "agent-1";
|
||||
|
||||
describe("prepareWorkspaceTab", () => {
|
||||
beforeEach(() => {
|
||||
useWorkspaceLayoutStore.setState({
|
||||
layoutByWorkspace: {},
|
||||
splitSizesByWorkspace: {},
|
||||
pinnedAgentIdsByWorkspace: {},
|
||||
});
|
||||
useTerminalAgentReopenStore.setState({
|
||||
reopenIntentVersionByAgentKey: {},
|
||||
requestReopen: useTerminalAgentReopenStore.getState().requestReopen,
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes a reopen intent when requested for an agent tab", () => {
|
||||
const route = prepareWorkspaceTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: AGENT_ID },
|
||||
requestReopen: true,
|
||||
});
|
||||
|
||||
const reopenKey = buildTerminalAgentReopenKey({ serverId: SERVER_ID, agentId: AGENT_ID });
|
||||
expect(reopenKey).toBeTruthy();
|
||||
expect(route).toBe("/h/server-1/workspace/L3JlcG8vd29ya3RyZWU");
|
||||
expect(
|
||||
useTerminalAgentReopenStore.getState().reopenIntentVersionByAgentKey[reopenKey as string],
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("does not publish a reopen intent unless explicitly requested", () => {
|
||||
prepareWorkspaceTab({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
target: { kind: "agent", agentId: AGENT_ID },
|
||||
});
|
||||
|
||||
const reopenKey = buildTerminalAgentReopenKey({ serverId: SERVER_ID, agentId: AGENT_ID });
|
||||
expect(reopenKey).toBeTruthy();
|
||||
expect(
|
||||
useTerminalAgentReopenStore.getState().reopenIntentVersionByAgentKey[reopenKey as string],
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { router } from "expo-router";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { generateDraftId } from "@/stores/draft-keys";
|
||||
import { useTerminalAgentReopenStore } from "@/stores/terminal-agent-reopen-store";
|
||||
import {
|
||||
buildWorkspaceTabPersistenceKey,
|
||||
type WorkspaceTabTarget,
|
||||
@@ -11,6 +13,11 @@ interface PrepareWorkspaceTabInput {
|
||||
workspaceId: string;
|
||||
target: WorkspaceTabTarget;
|
||||
pin?: boolean;
|
||||
requestReopen?: boolean;
|
||||
}
|
||||
|
||||
interface NavigateToPreparedWorkspaceTabInput extends PrepareWorkspaceTabInput {
|
||||
navigationMethod?: "navigate" | "replace";
|
||||
}
|
||||
|
||||
function getPreparedTarget(target: WorkspaceTabTarget): WorkspaceTabTarget {
|
||||
@@ -38,5 +45,22 @@ export function prepareWorkspaceTab(input: PrepareWorkspaceTabInput): string {
|
||||
useWorkspaceLayoutStore.getState().pinAgent(key, target.agentId);
|
||||
}
|
||||
|
||||
if (input.requestReopen && target.kind === "agent") {
|
||||
useTerminalAgentReopenStore.getState().requestReopen({
|
||||
serverId: input.serverId,
|
||||
agentId: target.agentId,
|
||||
});
|
||||
}
|
||||
|
||||
return buildHostWorkspaceRoute(input.serverId, input.workspaceId);
|
||||
}
|
||||
|
||||
export function navigateToPreparedWorkspaceTab(input: NavigateToPreparedWorkspaceTabInput): string {
|
||||
const route = prepareWorkspaceTab(input);
|
||||
if (input.navigationMethod === "replace") {
|
||||
router.replace(route as any);
|
||||
} else {
|
||||
router.navigate(route as any);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ export function normalizeWorkspaceTabTarget(
|
||||
const path = trimNonEmpty(value.path);
|
||||
return path ? { kind: "file", path: path.replace(/\\/g, "/") } : null;
|
||||
}
|
||||
if (value.kind === "launcher") {
|
||||
const launcherId = trimNonEmpty(value.launcherId);
|
||||
return launcherId ? { kind: "launcher", launcherId } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -44,6 +48,10 @@ export function workspaceTabTargetsEqual(
|
||||
if (left.kind === "file" && right.kind === "file") {
|
||||
return left.path === right.path;
|
||||
}
|
||||
if (left.kind === "launcher" && right.kind === "launcher") {
|
||||
// Launcher tabs are intentionally always unique, even when reopened repeatedly.
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -57,9 +65,19 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
|
||||
if (target.kind === "terminal") {
|
||||
return `terminal_${target.terminalId}`;
|
||||
}
|
||||
if (target.kind === "launcher") {
|
||||
return `launcher_${target.launcherId}`;
|
||||
}
|
||||
return `file_${target.path}`;
|
||||
}
|
||||
|
||||
export function createLauncherId(): string {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
|
||||
Reference in New Issue
Block a user