feat: add sqlite storage and terminal ui
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);
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,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" }),
|
||||
];
|
||||
|
||||
@@ -60,8 +60,8 @@ describe("resolveWorkspaceArchiveRedirectWorkspaceId", () => {
|
||||
id: "/notes",
|
||||
projectId: "notes",
|
||||
projectRootPath: "/notes",
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
projectKind: "directory",
|
||||
workspaceKind: "checkout",
|
||||
}),
|
||||
];
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ 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;
|
||||
|
||||
@@ -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