feat: add sqlite storage and terminal ui

This commit is contained in:
Mohamed Boudra
2026-03-29 23:20:18 +07:00
parent e5fd61f131
commit 7d02e24d84
189 changed files with 17868 additions and 4663 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);
@@ -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 } : {}),
};
}
@@ -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;