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:
Mohamed Boudra
2026-04-01 21:51:29 +07:00
217 changed files with 21075 additions and 4943 deletions
@@ -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,
+6
View File
@@ -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",
);
});
});
+14 -5
View File
@@ -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" }]);
});
});
+1
View File
@@ -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;