Files
paseo/packages/app/src/projects/host-project-model.ts
T
Mohamed BoudraandGitHub a49c658d1f Merge sidebar workspaces across all connected hosts (#1538)
* Merge workspaces across all hosts in the sidebar

Replace per-host sidebar sections with a single merged project list.
Projects that exist on multiple hosts (matched by projectKey) are
collapsed into one row. Host identity is surfaced via:

- A status-only footer pill showing online/offline host counts
- Hover cards with a mandatory host row on every workspace entry
- Subtitles showing the host name when a project spans >1 host
- A host filter in the grouping dropdown (not a sidebar switcher)

Order and group-mode stores migrated from per-server to global keys
with zustand persist migrations. New-workspace screen now shows a
merged project list with a host selector combobox.

Key files:
- workspace-structure.ts: merge algorithm by projectKey
- sidebar-view-store.ts / sidebar-order-store.ts: global state
- sidebar-workspace-list.tsx: context-based subtitle injection
- new-workspace-screen.tsx: host selector in footer row

* Add host filtering to the merged sidebar

The sidebar now merges workspaces from every connected host, so a single
global "active host" no longer fits. Host selection becomes explicit per
action via a host chooser, and agent history pages across all hosts at
once. Removes the active-host / active-server-id model and the
all-agents-list hook that fed it.

* Fix rebase fallout: translate archive host error, update stale workspace-key test

archiveWorkspacesOptimistically used a raw "Host is not connected" string
that the i18n completeness test forbids; route it through i18n.t with the
existing hostDisconnected key. The selectors memoization test asserted
un-prefixed workspace keys, but merged-sidebar keys are serverId-prefixed.

* Fix sidebar review fallout

* Address sidebar review followups

* Fix sidebar workspace identity migration

* Address sidebar review followups

* Handle partial host history failures

* Fix merged sidebar project removal and E2E drift

* Restore history routes and project removal shape

* Remove sidebar host context plumbing

* Tighten sidebar status and host filter state

* Keep new workspace project selection host-compatible

* Migrate sidebar view storage

* Streamline host selection flows

* Update all-host history on archive

* Fix empty project sidebar action

Code regression: the main merge dropped the empty-project New workspace child row while keeping the empty project persistence contract. Restore the row using the project host target so the sidebar stays aligned with the no-host-selection model.

* Fix Japanese shortcut translation key

Stale-base integration: main added the Japanese locale after this branch added cycleAgentMode to the English shortcut help map. Merging main exposed the missing ja key in CI typecheck and resources.test.

* Fix Playwright speech teardown race
2026-06-26 12:16:49 +08:00

194 lines
5.6 KiB
TypeScript

import type { WorkspaceDescriptor } from "@/stores/session-store";
import type {
WorkspaceStructureHostPlacement,
WorkspaceStructureProject,
} from "@/projects/workspace-structure";
export interface HostProjectListItem {
projectKey: string;
projectName: string;
projectKind: WorkspaceDescriptor["projectKind"];
iconWorkingDir: string;
hosts: WorkspaceStructureHostPlacement[];
workspaceKeys: string[];
}
export interface HostProjectRouteContext {
serverId: string;
projectId?: string;
displayName?: string;
sourceDirectory?: string;
}
function trimOptional(value: string | undefined): string | undefined {
const trimmed = value?.trim() ?? "";
return trimmed.length > 0 ? trimmed : undefined;
}
export function canCreateWorktreeForProjectKind(
projectKind: WorkspaceDescriptor["projectKind"],
): boolean {
return projectKind === "git";
}
export function buildHostProjectList(input: {
projects: readonly WorkspaceStructureProject[];
}): HostProjectListItem[] {
return input.projects.map((project) => ({
projectKey: project.projectKey,
projectName: project.projectName,
projectKind: project.projectKind,
iconWorkingDir: project.iconWorkingDir,
hosts: project.hosts,
workspaceKeys: project.workspaceKeys,
}));
}
export function hostProjectFromRoute(route: HostProjectRouteContext): HostProjectListItem | null {
const projectKey = trimOptional(route.projectId);
const iconWorkingDir = trimOptional(route.sourceDirectory);
if (!projectKey || !iconWorkingDir) {
return null;
}
return {
projectKey,
projectName: trimOptional(route.displayName) ?? projectKey,
projectKind: "git",
iconWorkingDir,
hosts: [
{
serverId: route.serverId,
iconWorkingDir,
canCreateWorktree: true,
},
],
workspaceKeys: [],
};
}
export function hostProjectFromWorkspace(input: {
serverId: string;
workspace: WorkspaceDescriptor | null;
}): HostProjectListItem | null {
if (!input.workspace) {
return null;
}
const projectKey = input.workspace.projectId.trim();
const iconWorkingDir = input.workspace.projectRootPath.trim();
if (!projectKey || !iconWorkingDir) {
return null;
}
const canCreate = canCreateWorktreeForProjectKind(input.workspace.projectKind);
return {
projectKey,
projectName: input.workspace.projectDisplayName || projectKey,
projectKind: input.workspace.projectKind,
iconWorkingDir,
hosts: [
{
serverId: input.serverId,
iconWorkingDir,
canCreateWorktree: canCreate,
},
],
workspaceKeys: [`${input.serverId}:${input.workspace.id}`],
};
}
function projectCanCreateWorktree(project: HostProjectListItem): boolean {
return project.hosts.some((h) => h.canCreateWorktree);
}
export function getHostProjectSourceDirectory(
project: HostProjectListItem,
serverId: string,
): string | null {
return project.hosts.find((host) => host.serverId === serverId)?.iconWorkingDir ?? null;
}
export function canCreateWorkspaceForHostProject(input: {
project: HostProjectListItem;
serverId: string;
allowAllProjects: boolean;
}): boolean {
const host = input.project.hosts.find((candidate) => candidate.serverId === input.serverId);
if (!host) {
return false;
}
return input.allowAllProjects || host.canCreateWorktree;
}
export function filterWorkspaceProjectsForHost(input: {
projects: readonly HostProjectListItem[];
serverId: string;
allowAllProjects: boolean;
}): HostProjectListItem[] {
return input.projects.filter((project) =>
canCreateWorkspaceForHostProject({
project,
serverId: input.serverId,
allowAllProjects: input.allowAllProjects,
}),
);
}
export function resolveInitialWorkspaceProject(input: {
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
projects: readonly HostProjectListItem[];
serverId: string;
allowAllProjects: boolean;
}): HostProjectListItem | null {
const candidates = [input.routeProject, input.lastActiveProject];
for (const candidate of candidates) {
if (!candidate) {
continue;
}
const hydratedProject =
input.projects.find((project) => project.projectKey === candidate.projectKey) ?? candidate;
if (
canCreateWorkspaceForHostProject({
project: hydratedProject,
serverId: input.serverId,
allowAllProjects: input.allowAllProjects,
})
) {
return hydratedProject;
}
}
return input.projects[0] ?? null;
}
export function resolveInitialWorktreeProject(input: {
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
projects: readonly HostProjectListItem[];
}): HostProjectListItem | null {
if (input.routeProject && projectCanCreateWorktree(input.routeProject)) {
return input.routeProject;
}
if (input.lastActiveProject && projectCanCreateWorktree(input.lastActiveProject)) {
return input.lastActiveProject;
}
return input.projects.find((project) => projectCanCreateWorktree(project)) ?? null;
}
export function resolveSelectedHostProject(input: {
selectedProjectKey: string | null;
projects: readonly HostProjectListItem[];
routeProject: HostProjectListItem | null;
lastActiveProject: HostProjectListItem | null;
}): HostProjectListItem | null {
const selectedProjectKey = input.selectedProjectKey?.trim() ?? "";
if (!selectedProjectKey) {
return null;
}
return (
input.projects.find((project) => project.projectKey === selectedProjectKey) ??
(input.routeProject?.projectKey === selectedProjectKey ? input.routeProject : null) ??
(input.lastActiveProject?.projectKey === selectedProjectKey ? input.lastActiveProject : null)
);
}