Merge branch 'investigate/portless' into dev

# Conflicts:
#	packages/app/e2e/helpers/workspace-setup.ts
#	packages/app/src/components/sidebar-workspace-list.tsx
#	packages/app/src/contexts/session-context.tsx
#	packages/app/src/hooks/use-sidebar-workspaces-list.test.ts
#	packages/app/src/screens/workspace/workspace-tab-menu.ts
#	packages/app/src/stores/workspace-setup-store.ts
#	packages/app/src/stores/workspace-tabs-store.ts
#	packages/app/src/utils/sidebar-project-row-model.test.ts
#	packages/app/src/utils/sidebar-shortcuts.test.ts
#	packages/app/src/utils/workspace-tab-identity.ts
#	packages/server/src/server/bootstrap.ts
#	packages/server/src/server/worktree-session.ts
This commit is contained in:
Mohamed Boudra
2026-04-01 22:06:39 +07:00
52 changed files with 5855 additions and 354 deletions
@@ -0,0 +1,124 @@
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@server/client/daemon-client";
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
import {
normalizeWorkspaceDescriptor,
useSessionStore,
type WorkspaceDescriptor,
} from "./session-store";
function workspace(
input: Partial<WorkspaceDescriptor> & Pick<WorkspaceDescriptor, "id">,
): WorkspaceDescriptor {
return {
id: input.id,
projectId: input.projectId ?? "project-1",
projectDisplayName: input.projectDisplayName ?? "Project 1",
projectRootPath: input.projectRootPath ?? "/repo",
projectKind: input.projectKind ?? "git",
workspaceKind: input.workspaceKind ?? "local_checkout",
name: input.name ?? "main",
status: input.status ?? "done",
activityAt: input.activityAt ?? null,
diffStat: input.diffStat ?? null,
services: input.services ?? [],
};
}
afterEach(() => {
useSessionStore.getState().clearSession("test-server");
});
describe("normalizeWorkspaceDescriptor", () => {
it("normalizes workspace services and invalid activity timestamps", () => {
const services = [
{
serviceName: "web",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running" as const,
},
];
const workspace = normalizeWorkspaceDescriptor({
id: "/repo/main",
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "running",
activityAt: "not-a-date",
diffStat: null,
services,
});
expect(workspace.activityAt).toBeNull();
expect(workspace.services).toEqual([
{
serviceName: "web",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
},
]);
expect(workspace.services).not.toBe(services);
});
it("defaults missing services to an empty array", () => {
const payload = {
id: "/repo/main",
projectId: "project-1",
projectDisplayName: "Project 1",
projectRootPath: "/repo",
projectKind: "git",
workspaceKind: "local_checkout",
name: "main",
status: "done",
activityAt: null,
diffStat: null,
} as WorkspaceDescriptorPayload;
const workspace = normalizeWorkspaceDescriptor(payload);
expect(workspace.services).toEqual([]);
});
});
describe("mergeWorkspaces", () => {
it("preserves services on merged workspace entries", () => {
const store = useSessionStore.getState();
store.initializeSession("test-server", null as unknown as DaemonClient);
store.setWorkspaces(
"test-server",
new Map([["/repo/main", workspace({ id: "/repo/main", services: [] })]]),
);
store.mergeWorkspaces("test-server", [
workspace({
id: "/repo/main",
services: [
{
serviceName: "web",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
},
],
}),
]);
expect(store.getSession("test-server")?.workspaces.get("/repo/main")?.services).toEqual([
{
serviceName: "web",
hostname: "main.web.localhost",
port: 3000,
url: "http://main.web.localhost:6767",
status: "running",
},
]);
});
});
+2
View File
@@ -125,6 +125,7 @@ export interface WorkspaceDescriptor {
status: WorkspaceDescriptorPayload["status"];
activityAt: Date | null;
diffStat: { additions: number; deletions: number } | null;
services: WorkspaceDescriptorPayload["services"];
}
export function normalizeWorkspaceDescriptor(
@@ -143,6 +144,7 @@ export function normalizeWorkspaceDescriptor(
status: payload.status,
activityAt: activityAt && !Number.isNaN(activityAt.getTime()) ? activityAt : null,
diffStat: payload.diffStat ?? null,
services: (payload.services ?? []).map((s) => ({ ...s })),
};
}
@@ -1,4 +1,6 @@
import type { SessionOutboundMessage } from "@server/shared/messages";
import { create } from "zustand";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
export type WorkspaceSetupNavigationMethod = "navigate" | "replace";
export type WorkspaceCreationMethod = "open_project" | "create_worktree";
@@ -12,18 +14,81 @@ export interface PendingWorkspaceSetup {
navigationMethod: WorkspaceSetupNavigationMethod;
}
export type WorkspaceSetupProgressPayload = Extract<
SessionOutboundMessage,
{ type: "workspace_setup_progress" }
>["payload"];
export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload {
updatedAt: number;
}
interface WorkspaceSetupStoreState {
pendingWorkspaceSetup: PendingWorkspaceSetup | null;
snapshots: Record<string, WorkspaceSetupSnapshot>;
beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void;
clearWorkspaceSetup: () => void;
upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void;
removeWorkspace: (input: { serverId: string; workspaceId: string }) => void;
clearServer: (serverId: string) => void;
}
function buildWorkspaceSetupKey(input: {
serverId: string;
workspaceId: string;
}): string | null {
return buildWorkspaceTabPersistenceKey(input);
}
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) => ({
pendingWorkspaceSetup: null,
snapshots: {},
beginWorkspaceSetup: (value) => {
set({ pendingWorkspaceSetup: value });
},
clearWorkspaceSetup: () => {
set({ pendingWorkspaceSetup: null });
},
upsertProgress: ({ serverId, payload }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId: payload.workspaceId });
if (!key) {
return;
}
set((state) => ({
snapshots: {
...state.snapshots,
[key]: {
...payload,
updatedAt: Date.now(),
},
},
}));
},
removeWorkspace: ({ serverId, workspaceId }) => {
const key = buildWorkspaceSetupKey({ serverId, workspaceId });
if (!key) {
return;
}
set((state) => {
if (!(key in state.snapshots)) {
return state;
}
const next = { ...state.snapshots };
delete next[key];
return { snapshots: next };
});
},
clearServer: (serverId) => {
set((state) => {
const nextEntries = Object.entries(state.snapshots).filter(
([key]) => !key.startsWith(`${serverId}:`),
);
if (nextEntries.length === Object.keys(state.snapshots).length) {
return state;
}
return { snapshots: Object.fromEntries(nextEntries) };
});
},
}));
@@ -235,4 +235,19 @@ describe("workspace-tabs-store retargetTab", () => {
expect(reopenedFileTabId).toBe(fileTabId);
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(fileTabId);
});
it("builds a deterministic setup tab keyed by workspace id", () => {
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });
expect(key).toBeTruthy();
const workspaceKey = key as string;
const tabId = useWorkspaceTabsStore.getState().openOrFocusTab({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
target: { kind: "setup", workspaceId: WORKSPACE_ID },
});
expect(tabId).toBe(`setup_${WORKSPACE_ID}`);
expect(useWorkspaceTabsStore.getState().focusedTabIdByWorkspace[workspaceKey]).toBe(tabId);
});
});
@@ -13,7 +13,8 @@ export type WorkspaceTabTarget =
| { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "file"; path: string }
| { kind: "launcher"; launcherId: string };
| { kind: "launcher"; launcherId: string }
| { kind: "setup"; workspaceId: string };
export type WorkspaceTab = {
tabId: string;