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
This commit is contained in:
@@ -1,3 +1,13 @@
|
||||
export function agentHistoryQueryKey(serverId: string | null) {
|
||||
return ["agentHistory", serverId] as const;
|
||||
}
|
||||
|
||||
const ALL_AGENT_HISTORY_QUERY_ROOT = ["allAgentHistory"] as const;
|
||||
|
||||
export function allAgentHistoryQueryRootKey() {
|
||||
return ALL_AGENT_HISTORY_QUERY_ROOT;
|
||||
}
|
||||
|
||||
export function allAgentHistoryQueryKey(serverIds: readonly string[]) {
|
||||
return [...ALL_AGENT_HISTORY_QUERY_ROOT, ...[...serverIds].sort()] as const;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,21 @@ export interface OpenProjectFailure {
|
||||
}
|
||||
|
||||
export type OpenProjectResult = OpenProjectSuccess | OpenProjectFailure;
|
||||
export type OpenProjectFailureReason = "directory_not_found" | "open_failed";
|
||||
|
||||
export function getOpenProjectFailureReason(
|
||||
result: OpenProjectResult,
|
||||
): OpenProjectFailureReason | null {
|
||||
if (result.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.errorCode === "directory_not_found") {
|
||||
return "directory_not_found";
|
||||
}
|
||||
|
||||
return "open_failed";
|
||||
}
|
||||
|
||||
export interface OpenProjectDirectlyInput {
|
||||
serverId: string;
|
||||
|
||||
@@ -15,6 +15,7 @@ function ws(
|
||||
serverId: input.serverId ?? "srv",
|
||||
workspaceId: input.workspaceId ?? input.workspaceKey.split(":")[1] ?? "ws",
|
||||
projectKey: input.projectKey ?? "proj",
|
||||
projectName: input.projectName ?? "Project",
|
||||
projectRootPath: input.projectRootPath,
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
|
||||
import type { SidebarStatusWorkspacePlacement } from "@/hooks/sidebar-workspaces-view-model";
|
||||
|
||||
export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
|
||||
export type StatusBucket = SidebarStatusWorkspacePlacement["statusBucket"];
|
||||
|
||||
export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [
|
||||
"needs_input",
|
||||
@@ -21,14 +21,14 @@ export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
|
||||
export interface StatusGroup {
|
||||
bucket: StatusBucket;
|
||||
label: string;
|
||||
rows: SidebarWorkspaceEntry[];
|
||||
rows: SidebarStatusWorkspacePlacement[];
|
||||
}
|
||||
|
||||
export function buildStatusGroups(
|
||||
workspaces: SidebarWorkspaceEntry[],
|
||||
workspaces: SidebarStatusWorkspacePlacement[],
|
||||
projectNamesByKey: Map<string, string>,
|
||||
): StatusGroup[] {
|
||||
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
|
||||
const bucketRows = new Map<StatusBucket, SidebarStatusWorkspacePlacement[]>();
|
||||
|
||||
for (const ws of workspaces) {
|
||||
const bucket: StatusBucket = ws.statusBucket;
|
||||
@@ -54,8 +54,8 @@ export function buildStatusGroups(
|
||||
}
|
||||
|
||||
function compareStatusRows(
|
||||
a: SidebarWorkspaceEntry,
|
||||
b: SidebarWorkspaceEntry,
|
||||
a: SidebarStatusWorkspacePlacement,
|
||||
b: SidebarStatusWorkspacePlacement,
|
||||
projectNamesByKey: Map<string, string>,
|
||||
): number {
|
||||
const aTime = a.statusEnteredAt?.getTime() ?? null;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
import {
|
||||
appendMissingOrderKeys,
|
||||
applyStoredOrdering,
|
||||
buildSidebarStatusWorkspacePlacements,
|
||||
buildSidebarWorkspacePlacementModel,
|
||||
buildSidebarProjectsFromStructure,
|
||||
computeSidebarOrderUpdates,
|
||||
deriveSidebarLoadingState,
|
||||
@@ -23,12 +26,20 @@ function project(input: {
|
||||
projectKind?: WorkspaceStructureProject["projectKind"];
|
||||
iconWorkingDir?: string;
|
||||
workspaceKeys: string[];
|
||||
hosts?: WorkspaceStructureProject["hosts"];
|
||||
}): WorkspaceStructureProject {
|
||||
return {
|
||||
projectKey: input.projectKey,
|
||||
projectName: input.projectName ?? input.projectKey,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||
hosts: input.hosts ?? [
|
||||
{
|
||||
serverId: "srv",
|
||||
iconWorkingDir: input.iconWorkingDir ?? input.projectKey,
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
workspaceKeys: input.workspaceKeys,
|
||||
};
|
||||
}
|
||||
@@ -36,10 +47,8 @@ function project(input: {
|
||||
function sidebarProject(input: {
|
||||
projectKey: string;
|
||||
workspaceKeys: string[];
|
||||
serverId?: string;
|
||||
}): SidebarProjectEntry {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
serverId: input.serverId ?? "srv",
|
||||
projects: [project({ projectKey: input.projectKey, workspaceKeys: input.workspaceKeys })],
|
||||
});
|
||||
const result = projects[0];
|
||||
@@ -49,6 +58,31 @@ function sidebarProject(input: {
|
||||
return result;
|
||||
}
|
||||
|
||||
function workspace(input: {
|
||||
id: string;
|
||||
name: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
status?: WorkspaceDescriptor["status"];
|
||||
statusEnteredAt?: Date | null;
|
||||
}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: input.id,
|
||||
projectId: input.projectId,
|
||||
projectDisplayName: input.projectDisplayName,
|
||||
projectRootPath: `/repo/${input.projectId}`,
|
||||
workspaceDirectory: `/repo/${input.projectId}/${input.id}`,
|
||||
projectKind: "git",
|
||||
workspaceKind: input.name === "main" ? "local_checkout" : "worktree",
|
||||
name: input.name,
|
||||
status: input.status ?? "done",
|
||||
statusEnteredAt: input.statusEnteredAt ?? null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("applyStoredOrdering", () => {
|
||||
it("keeps unknown items on the baseline while applying stored order", () => {
|
||||
const result = applyStoredOrdering({
|
||||
@@ -107,7 +141,6 @@ describe("appendMissingOrderKeys", () => {
|
||||
describe("buildSidebarProjectsFromStructure", () => {
|
||||
it("creates structural workspace rows from ordered workspace keys", () => {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
serverId: "srv",
|
||||
projects: [
|
||||
project({
|
||||
projectKey: "project-1",
|
||||
@@ -131,7 +164,6 @@ describe("buildSidebarProjectsFromStructure", () => {
|
||||
|
||||
it("preserves the structure hook project order", () => {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
serverId: "srv",
|
||||
projects: [
|
||||
project({ projectKey: "project-b", workspaceKeys: ["ws-b"] }),
|
||||
project({ projectKey: "project-a", workspaceKeys: ["ws-a"] }),
|
||||
@@ -143,15 +175,126 @@ describe("buildSidebarProjectsFromStructure", () => {
|
||||
|
||||
it("preserves the structure hook workspace order", () => {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
serverId: "srv",
|
||||
projects: [project({ projectKey: "project-1", workspaceKeys: ["feature", "main"] })],
|
||||
});
|
||||
|
||||
expect(projects[0]?.workspaces.map((workspace) => workspace.workspaceId)).toEqual([
|
||||
expect(projects[0]?.workspaces.map((placement) => placement.workspaceId)).toEqual([
|
||||
"feature",
|
||||
"main",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves workspace keys by known host prefix when server ids contain colons", () => {
|
||||
const projects = buildSidebarProjectsFromStructure({
|
||||
projects: [
|
||||
project({
|
||||
projectKey: "project-1",
|
||||
hosts: [
|
||||
{
|
||||
serverId: "relay:paseo-host",
|
||||
iconWorkingDir: "/repo/project-1",
|
||||
canCreateWorktree: true,
|
||||
},
|
||||
],
|
||||
workspaceKeys: ["relay:paseo-host:ws-main"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(projects[0]?.workspaces[0]).toMatchObject({
|
||||
workspaceKey: "relay:paseo-host:ws-main",
|
||||
serverId: "relay:paseo-host",
|
||||
workspaceId: "ws-main",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared sidebar workspace model", () => {
|
||||
it("feeds project placement and status grouping from the same cross-host workspace identities", () => {
|
||||
const model = buildSidebarWorkspacePlacementModel({
|
||||
projects: [
|
||||
project({
|
||||
projectKey: "getpaseo/paseo",
|
||||
projectName: "getpaseo/paseo",
|
||||
iconWorkingDir: "/repo/getpaseo/paseo",
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
|
||||
],
|
||||
workspaceKeys: ["host-a:main", "host-b:feature"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
const statusRows = buildSidebarStatusWorkspacePlacements({
|
||||
placements: model.workspaces,
|
||||
sessions: [
|
||||
{
|
||||
serverId: "host-a",
|
||||
workspaces: new Map([
|
||||
[
|
||||
"main",
|
||||
workspace({
|
||||
id: "main",
|
||||
name: "main",
|
||||
projectId: "getpaseo/paseo",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
status: "done",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
},
|
||||
{
|
||||
serverId: "host-b",
|
||||
workspaces: new Map([
|
||||
[
|
||||
"feature",
|
||||
workspace({
|
||||
id: "feature",
|
||||
name: "feature/status-flow",
|
||||
projectId: "getpaseo/paseo",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
status: "running",
|
||||
statusEnteredAt: new Date("2026-06-10T00:00:00.000Z"),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(model.workspaces.map((entry) => entry.workspaceKey)).toEqual([
|
||||
"host-a:main",
|
||||
"host-b:feature",
|
||||
]);
|
||||
expect(model.projects).toEqual([
|
||||
expect.objectContaining({
|
||||
projectKey: "getpaseo/paseo",
|
||||
hosts: [
|
||||
{ serverId: "host-a", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
|
||||
{ serverId: "host-b", iconWorkingDir: "/repo/getpaseo/paseo", canCreateWorktree: true },
|
||||
],
|
||||
workspaces: [
|
||||
expect.objectContaining({
|
||||
workspaceKey: "host-a:main",
|
||||
serverId: "host-a",
|
||||
name: "main",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
workspaceKey: "host-b:feature",
|
||||
serverId: "host-b",
|
||||
name: "feature",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(statusRows.map((entry) => [entry.workspaceKey, entry.statusBucket, entry.name])).toEqual(
|
||||
[
|
||||
["host-a:main", "done", "main"],
|
||||
["host-b:feature", "running", "feature/status-flow"],
|
||||
],
|
||||
);
|
||||
expect(model.projectNamesByKey).toEqual(new Map([["getpaseo/paseo", "getpaseo/paseo"]]));
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeSidebarOrderUpdates", () => {
|
||||
@@ -206,8 +349,8 @@ describe("deriveSidebarLoadingState", () => {
|
||||
expect(
|
||||
deriveSidebarLoadingState({
|
||||
isActive: true,
|
||||
serverId: "srv",
|
||||
hasHydratedWorkspaces: false,
|
||||
serverIds: ["srv"],
|
||||
hydratedServerIds: [],
|
||||
hasProjects: false,
|
||||
}),
|
||||
).toEqual({ isLoading: true, isInitialLoad: true, isRevalidating: false });
|
||||
@@ -217,8 +360,8 @@ describe("deriveSidebarLoadingState", () => {
|
||||
expect(
|
||||
deriveSidebarLoadingState({
|
||||
isActive: true,
|
||||
serverId: "srv",
|
||||
hasHydratedWorkspaces: false,
|
||||
serverIds: ["srv"],
|
||||
hydratedServerIds: [],
|
||||
hasProjects: true,
|
||||
}),
|
||||
).toEqual({ isLoading: true, isInitialLoad: false, isRevalidating: false });
|
||||
@@ -228,8 +371,8 @@ describe("deriveSidebarLoadingState", () => {
|
||||
expect(
|
||||
deriveSidebarLoadingState({
|
||||
isActive: true,
|
||||
serverId: "srv",
|
||||
hasHydratedWorkspaces: true,
|
||||
serverIds: ["srv"],
|
||||
hydratedServerIds: ["srv"],
|
||||
hasProjects: true,
|
||||
}),
|
||||
).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false });
|
||||
@@ -239,8 +382,8 @@ describe("deriveSidebarLoadingState", () => {
|
||||
expect(
|
||||
deriveSidebarLoadingState({
|
||||
isActive: false,
|
||||
serverId: "srv",
|
||||
hasHydratedWorkspaces: false,
|
||||
serverIds: ["srv"],
|
||||
hydratedServerIds: [],
|
||||
hasProjects: false,
|
||||
}),
|
||||
).toEqual({ isLoading: false, isInitialLoad: false, isRevalidating: false });
|
||||
|
||||
@@ -1,38 +1,44 @@
|
||||
import type { PrHint } from "@/git/use-pr-status-query";
|
||||
import {
|
||||
canCreateWorktreeForProjectKind,
|
||||
type HostProjectListItem,
|
||||
} from "@/projects/host-project-model";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
|
||||
import type { PrHint } from "@/git/pr-hint";
|
||||
import { selectPrHintFromStatus } from "@/git/pr-hint";
|
||||
import { type HostProjectListItem } from "@/projects/host-project-model";
|
||||
import type { PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type {
|
||||
WorkspaceStructureHostPlacement,
|
||||
WorkspaceStructureProject,
|
||||
} from "@/projects/workspace-structure";
|
||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity";
|
||||
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
|
||||
function workspaceNameFromDirectory(directory: string): string {
|
||||
const trimmed = directory.trim().replace(/[\\/]+$/g, "");
|
||||
const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
|
||||
return separator >= 0 ? trimmed.slice(separator + 1) : trimmed;
|
||||
}
|
||||
|
||||
export type SidebarStateBucket = WorkspaceDescriptor["status"];
|
||||
|
||||
export interface SidebarWorkspaceEntry {
|
||||
export interface SidebarWorkspacePlacement {
|
||||
workspaceKey: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
projectRootPath?: string;
|
||||
workspaceDirectory?: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface SidebarStatusWorkspacePlacement extends SidebarWorkspacePlacement {
|
||||
statusBucket: SidebarStateBucket;
|
||||
statusEnteredAt: Date | null;
|
||||
}
|
||||
|
||||
export interface SidebarWorkspaceEntry extends SidebarStatusWorkspacePlacement {
|
||||
// Raw user-set title (null when the name is derived from branch/directory).
|
||||
// Prefills the rename input and signals whether a reset is available.
|
||||
title: string | null;
|
||||
// Checkout branch (null when not a git checkout or detached HEAD).
|
||||
currentBranch: string | null;
|
||||
statusBucket: SidebarStateBucket;
|
||||
statusEnteredAt: Date | null;
|
||||
archivingAt: string | null;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
prHint: PrHint | null;
|
||||
@@ -47,52 +53,278 @@ export interface SidebarProjectEntry {
|
||||
projectName: string;
|
||||
projectKind: WorkspaceDescriptor["projectKind"];
|
||||
iconWorkingDir: string;
|
||||
canCreateWorktree: boolean;
|
||||
workspaces: SidebarWorkspaceEntry[];
|
||||
hosts: WorkspaceStructureHostPlacement[];
|
||||
workspaces: SidebarWorkspacePlacement[];
|
||||
}
|
||||
|
||||
export interface SidebarWorkspacePlacementModel {
|
||||
workspaces: SidebarWorkspacePlacement[];
|
||||
projects: SidebarProjectEntry[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
}
|
||||
|
||||
export interface SidebarStatusWorkspaceSession {
|
||||
serverId: string;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
agents?: Map<string, Agent>;
|
||||
}
|
||||
|
||||
interface EffectiveWorkspaceStatus {
|
||||
status: WorkspaceDescriptor["status"];
|
||||
enteredAt: Date | null;
|
||||
}
|
||||
|
||||
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
|
||||
|
||||
function projectNameForWorkspace(workspace: WorkspaceDescriptor, projectKey: string): string {
|
||||
return (
|
||||
workspace.projectCustomName ??
|
||||
workspace.projectDisplayName ??
|
||||
projectDisplayNameFromProjectId(projectKey)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeCurrentBranch(currentBranch: string | null | undefined): string | null {
|
||||
if (!currentBranch) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = currentBranch.trim();
|
||||
return trimmed.length === 0 || trimmed === "HEAD" ? null : trimmed;
|
||||
}
|
||||
|
||||
export function createSidebarWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
agents?: Map<string, Agent>;
|
||||
}): SidebarWorkspaceEntry {
|
||||
const projectKey = input.workspace.project?.projectKey ?? input.workspace.projectId;
|
||||
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
projectKey,
|
||||
projectName: projectNameForWorkspace(input.workspace, projectKey),
|
||||
projectRootPath: input.workspace.projectRootPath,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
projectKind: input.workspace.projectKind,
|
||||
workspaceKind: input.workspace.workspaceKind,
|
||||
name: input.workspace.name,
|
||||
title: input.workspace.title ?? null,
|
||||
currentBranch: normalizeCurrentBranch(input.workspace.gitRuntime?.currentBranch),
|
||||
statusBucket: effectiveStatus.status,
|
||||
statusEnteredAt: effectiveStatus.enteredAt,
|
||||
archivingAt: input.workspace.archivingAt,
|
||||
diffStat: input.workspace.diffStat,
|
||||
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
|
||||
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
|
||||
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
|
||||
scripts: input.workspace.scripts,
|
||||
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
|
||||
};
|
||||
}
|
||||
|
||||
function deriveEffectiveWorkspaceStatus(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
agents?: Map<string, Agent>;
|
||||
}): EffectiveWorkspaceStatus {
|
||||
if (input.workspace.status !== "done") {
|
||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||
}
|
||||
|
||||
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
});
|
||||
if (pendingStartedAt) {
|
||||
return { status: "running", enteredAt: pendingStartedAt };
|
||||
}
|
||||
|
||||
const rootAgentActivity = getRootAgentWorkspaceActivity({
|
||||
workspace: input.workspace,
|
||||
agents: input.agents,
|
||||
});
|
||||
if (rootAgentActivity && rootAgentActivity.status !== "done") {
|
||||
return rootAgentActivity;
|
||||
}
|
||||
|
||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||
}
|
||||
|
||||
function getPendingInitialAgentCreateStartedAt(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
pendingCreateAttempts: Record<string, PendingCreateAttempt> | undefined;
|
||||
}): Date | null {
|
||||
let latestStartedAt: Date | null = null;
|
||||
for (const pending of Object.values(input.pendingCreateAttempts ?? {})) {
|
||||
if (pending.serverId !== input.serverId) continue;
|
||||
if (pending.workspaceId !== input.workspaceId) continue;
|
||||
if (pending.lifecycle === "abandoned") continue;
|
||||
const startedAt = new Date(pending.timestamp);
|
||||
if (!latestStartedAt || startedAt > latestStartedAt) {
|
||||
latestStartedAt = startedAt;
|
||||
}
|
||||
}
|
||||
return latestStartedAt;
|
||||
}
|
||||
|
||||
function getRootAgentWorkspaceActivity(input: {
|
||||
workspace: WorkspaceDescriptor;
|
||||
agents?: Map<string, Agent>;
|
||||
}): WorkspaceAgentActivity | null {
|
||||
let latest: WorkspaceAgentActivity | null = null;
|
||||
for (const agent of input.agents?.values() ?? []) {
|
||||
if (agent.archivedAt || agent.parentAgentId) continue;
|
||||
if (agent.workspaceId !== input.workspace.id) continue;
|
||||
const status = deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
});
|
||||
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
|
||||
if (!latest || enteredAt > (latest.enteredAt ?? new Date(0))) {
|
||||
latest = { status, enteredAt };
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function buildSidebarWorkspacePlacementModel(input: {
|
||||
projects: readonly HostProjectListItem[];
|
||||
}): SidebarWorkspacePlacementModel {
|
||||
const projects = buildSidebarProjectsFromHostProjects({ projects: input.projects });
|
||||
return {
|
||||
projects,
|
||||
workspaces: projects.flatMap((project) => project.workspaces),
|
||||
projectNamesByKey: new Map(
|
||||
projects.map((project) => [project.projectKey, project.projectName]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function createStructuralWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
project: HostProjectListItem;
|
||||
workspaceId: string;
|
||||
}): SidebarWorkspaceEntry {
|
||||
workspaceKey: string;
|
||||
}): SidebarWorkspacePlacement {
|
||||
const identity = resolveStructuralWorkspaceIdentity({
|
||||
project: input.project,
|
||||
workspaceKey: input.workspaceKey,
|
||||
});
|
||||
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspaceId}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspaceId,
|
||||
workspaceKey: identity.workspaceKey,
|
||||
serverId: identity.serverId,
|
||||
workspaceId: identity.workspaceId,
|
||||
projectKey: input.project.projectKey,
|
||||
projectName: input.project.projectName,
|
||||
projectRootPath: input.project.iconWorkingDir,
|
||||
workspaceDirectory: undefined,
|
||||
projectKind: input.project.projectKind,
|
||||
workspaceKind: "checkout",
|
||||
name: workspaceNameFromDirectory(input.project.iconWorkingDir) || input.workspaceId,
|
||||
title: null,
|
||||
currentBranch: null,
|
||||
statusBucket: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
prHint: null,
|
||||
archiveHasUncommittedChanges: null,
|
||||
archiveUnpushedCommitCount: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
name: identity.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarProjectsFromStructure(input: {
|
||||
function resolveStructuralWorkspaceIdentity(input: {
|
||||
project: HostProjectListItem;
|
||||
workspaceKey: string;
|
||||
}): {
|
||||
workspaceKey: string;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
} {
|
||||
const hostsByLongestPrefix = [...input.project.hosts].sort(
|
||||
(left, right) => right.serverId.length - left.serverId.length,
|
||||
);
|
||||
|
||||
for (const host of hostsByLongestPrefix) {
|
||||
const prefix = `${host.serverId}:`;
|
||||
if (!input.workspaceKey.startsWith(prefix)) continue;
|
||||
const workspaceId = input.workspaceKey.slice(prefix.length);
|
||||
if (!workspaceId) continue;
|
||||
return {
|
||||
workspaceKey: input.workspaceKey,
|
||||
serverId: host.serverId,
|
||||
workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
const separatorIndex = input.workspaceKey.indexOf(":");
|
||||
if (separatorIndex > 0) {
|
||||
return {
|
||||
workspaceKey: input.workspaceKey,
|
||||
serverId: input.workspaceKey.slice(0, separatorIndex),
|
||||
workspaceId: input.workspaceKey.slice(separatorIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
const serverId = input.project.hosts[0]?.serverId ?? input.workspaceKey;
|
||||
return {
|
||||
workspaceKey: `${serverId}:${input.workspaceKey}`,
|
||||
serverId,
|
||||
workspaceId: input.workspaceKey,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSidebarStatusWorkspacePlacements(input: {
|
||||
placements: readonly SidebarWorkspacePlacement[];
|
||||
sessions: SidebarStatusWorkspaceSession[];
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
}): SidebarStatusWorkspacePlacement[] {
|
||||
if (input.placements.length === 0 || input.sessions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sessionByServerId = new Map(input.sessions.map((session) => [session.serverId, session]));
|
||||
const rows: SidebarStatusWorkspacePlacement[] = [];
|
||||
|
||||
for (const placement of input.placements) {
|
||||
const session = sessionByServerId.get(placement.serverId);
|
||||
if (!session) continue;
|
||||
const workspaceKey = resolveWorkspaceMapKeyByIdentity({
|
||||
workspaces: session.workspaces,
|
||||
workspaceId: placement.workspaceId,
|
||||
});
|
||||
const workspace = workspaceKey ? session.workspaces.get(workspaceKey) : null;
|
||||
if (!workspace) continue;
|
||||
|
||||
const effectiveStatus = deriveEffectiveWorkspaceStatus({
|
||||
serverId: placement.serverId,
|
||||
workspace,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
agents: session.agents,
|
||||
});
|
||||
|
||||
rows.push({
|
||||
...placement,
|
||||
name: workspace.name,
|
||||
workspaceDirectory: workspace.workspaceDirectory,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
statusBucket: effectiveStatus.status,
|
||||
statusEnteredAt: effectiveStatus.enteredAt,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function buildSidebarProjectsFromStructure(input: {
|
||||
projects: WorkspaceStructureProject[];
|
||||
}): SidebarProjectEntry[] {
|
||||
return buildSidebarProjectsFromHostProjects({
|
||||
projects: input.projects.map((project) => ({
|
||||
serverId: input.serverId,
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
projectKind: project.projectKind,
|
||||
iconWorkingDir: project.iconWorkingDir,
|
||||
hosts: project.hosts,
|
||||
workspaceKeys: project.workspaceKeys,
|
||||
canCreateWorktree: canCreateWorktreeForProjectKind(project.projectKind),
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -109,12 +341,11 @@ export function buildSidebarProjectsFromHostProjects(input: {
|
||||
projectName: project.projectName,
|
||||
projectKind: project.projectKind,
|
||||
iconWorkingDir: project.iconWorkingDir,
|
||||
canCreateWorktree: project.canCreateWorktree,
|
||||
workspaces: project.workspaceKeys.map((workspaceId) =>
|
||||
hosts: project.hosts,
|
||||
workspaces: project.workspaceKeys.map((workspaceKey) =>
|
||||
createStructuralWorkspaceEntry({
|
||||
serverId: project.serverId,
|
||||
project,
|
||||
workspaceId,
|
||||
workspaceKey,
|
||||
}),
|
||||
),
|
||||
}));
|
||||
@@ -227,11 +458,14 @@ export interface SidebarLoadingState {
|
||||
|
||||
export function deriveSidebarLoadingState(input: {
|
||||
isActive: boolean;
|
||||
serverId: string | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
serverIds: string[];
|
||||
hydratedServerIds: string[];
|
||||
hasProjects: boolean;
|
||||
}): SidebarLoadingState {
|
||||
const isLoading = input.isActive && Boolean(input.serverId) && !input.hasHydratedWorkspaces;
|
||||
const hasRegisteredHosts = input.serverIds.length > 0;
|
||||
const allHydrated =
|
||||
input.serverIds.length > 0 && input.serverIds.length === input.hydratedServerIds.length;
|
||||
const isLoading = input.isActive && hasRegisteredHosts && !allHydrated;
|
||||
const isInitialLoad = isLoading && !input.hasProjects;
|
||||
return { isLoading, isInitialLoad, isRevalidating: false };
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { usePathname } from "expo-router";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { resolveActiveHost } from "@/utils/active-host";
|
||||
|
||||
export function useActiveHost() {
|
||||
const pathname = usePathname();
|
||||
const hosts = useHosts();
|
||||
|
||||
return useMemo(() => resolveActiveHost({ hosts, pathname }), [hosts, pathname]);
|
||||
}
|
||||
|
||||
export function useActiveServerId(): string | null {
|
||||
return useActiveHost()?.serverId ?? null;
|
||||
}
|
||||
@@ -1,10 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
DaemonClient,
|
||||
FetchAgentHistoryEntry,
|
||||
FetchAgentHistoryOptions,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import { type AgentHistoryClient, fetchAgentHistoryPage } from "./use-agent-history";
|
||||
import type { AgentHistoryClient, AgentHistoryHost } from "./use-agent-history";
|
||||
import { allAgentHistoryQueryKey } from "./agent-history-query-key";
|
||||
|
||||
(
|
||||
globalThis as unknown as {
|
||||
__DEV__: boolean;
|
||||
}
|
||||
).__DEV__ = false;
|
||||
|
||||
type UseAgentHistoryModule = typeof import("./use-agent-history");
|
||||
|
||||
let fetchAgentHistoryBatch: UseAgentHistoryModule["fetchAgentHistoryBatch"];
|
||||
let fetchAgentHistoryPage: UseAgentHistoryModule["fetchAgentHistoryPage"];
|
||||
|
||||
beforeAll(async () => {
|
||||
const module = await import("./use-agent-history");
|
||||
fetchAgentHistoryBatch = module.fetchAgentHistoryBatch;
|
||||
fetchAgentHistoryPage = module.fetchAgentHistoryPage;
|
||||
});
|
||||
|
||||
type FetchAgentHistory = DaemonClient["fetchAgentHistory"];
|
||||
type FetchAgentHistoryResult = Awaited<ReturnType<FetchAgentHistory>>;
|
||||
@@ -30,6 +48,17 @@ function createClient(pages: FetchAgentHistoryResult[]): FakeAgentHistoryClient
|
||||
};
|
||||
}
|
||||
|
||||
function createFailingClient(): FakeAgentHistoryClient {
|
||||
const calls: FetchAgentHistoryOptions[] = [];
|
||||
return {
|
||||
calls,
|
||||
fetchAgentHistory: async (options) => {
|
||||
calls.push(options ?? {});
|
||||
throw new Error("Host history failed");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function historyPayload(input: {
|
||||
entries: FetchAgentHistoryEntry[];
|
||||
hasMore?: boolean;
|
||||
@@ -105,6 +134,12 @@ function historyEntry(input: {
|
||||
}
|
||||
|
||||
describe("fetchAgentHistoryPage", () => {
|
||||
it("builds the all-host query key independent of host order", () => {
|
||||
expect(allAgentHistoryQueryKey(["server-b", "server-a"])).toEqual(
|
||||
allAgentHistoryQueryKey(["server-a", "server-b"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("requests the first page with the default limit and updated_at descending sort", async () => {
|
||||
const client = createClient([
|
||||
historyPayload({
|
||||
@@ -206,4 +241,134 @@ describe("fetchAgentHistoryPage", () => {
|
||||
|
||||
expect(page.agents[0]?.archivedAt).toEqual(new Date("2026-04-01T10:05:00.000Z"));
|
||||
});
|
||||
|
||||
it("fetches and sorts history across hosts with host labels", async () => {
|
||||
const serverAClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "older-a",
|
||||
cwd: "/repo/a",
|
||||
updatedAt: "2026-04-01T10:00:00.000Z",
|
||||
title: "Older A",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const serverBClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "newer-b",
|
||||
cwd: "/repo/b",
|
||||
updatedAt: "2026-04-02T10:00:00.000Z",
|
||||
title: "Newer B",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await fetchAgentHistoryBatch({
|
||||
hosts: [
|
||||
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
|
||||
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
|
||||
] satisfies AgentHistoryHost[],
|
||||
cursorByServerId: null,
|
||||
});
|
||||
|
||||
expect(page.agents.map((agent) => `${agent.serverLabel}:${agent.id}`)).toEqual([
|
||||
"Linux box:newer-b",
|
||||
"MacBook:older-a",
|
||||
]);
|
||||
});
|
||||
|
||||
it("fetches only hosts with a cursor when loading the next all-host page", async () => {
|
||||
const serverAClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "next-a",
|
||||
cwd: "/repo/a",
|
||||
updatedAt: "2026-04-01T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
const serverBClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "next-b",
|
||||
cwd: "/repo/b",
|
||||
updatedAt: "2026-04-02T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await fetchAgentHistoryBatch({
|
||||
hosts: [
|
||||
{ serverId: "server-a", serverLabel: "MacBook", client: serverAClient },
|
||||
{ serverId: "server-b", serverLabel: "Linux box", client: serverBClient },
|
||||
] satisfies AgentHistoryHost[],
|
||||
cursorByServerId: { "server-b": "cursor-b" },
|
||||
});
|
||||
|
||||
expect(page.agents.map((agent) => agent.id)).toEqual(["next-b"]);
|
||||
expect(serverAClient.calls).toEqual([]);
|
||||
expect(serverBClient.calls).toEqual([
|
||||
{
|
||||
sort: [{ key: "updated_at", direction: "desc" }],
|
||||
page: { limit: 200, cursor: "cursor-b" },
|
||||
} satisfies FetchAgentHistoryOptions,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps fulfilled host history when another host fails", async () => {
|
||||
const failedClient = createFailingClient();
|
||||
const healthyClient = createClient([
|
||||
historyPayload({
|
||||
entries: [
|
||||
historyEntry({
|
||||
id: "healthy-history",
|
||||
cwd: "/repo/healthy",
|
||||
updatedAt: "2026-04-02T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
hasMore: true,
|
||||
nextCursor: "healthy-cursor",
|
||||
}),
|
||||
]);
|
||||
|
||||
const page = await fetchAgentHistoryBatch({
|
||||
hosts: [
|
||||
{ serverId: "failed-host", serverLabel: "Failed", client: failedClient },
|
||||
{ serverId: "healthy-host", serverLabel: "Healthy", client: healthyClient },
|
||||
] satisfies AgentHistoryHost[],
|
||||
cursorByServerId: null,
|
||||
});
|
||||
|
||||
expect(page.agents.map((agent) => `${agent.serverLabel}:${agent.id}`)).toEqual([
|
||||
"Healthy:healthy-history",
|
||||
]);
|
||||
expect(page.pageInfoByServerId).toEqual({
|
||||
"healthy-host": {
|
||||
nextCursor: "healthy-cursor",
|
||||
prevCursor: null,
|
||||
hasMore: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when every requested host history fetch fails", async () => {
|
||||
await expect(
|
||||
fetchAgentHistoryBatch({
|
||||
hosts: [
|
||||
{ serverId: "failed-a", serverLabel: "Failed A", client: createFailingClient() },
|
||||
{ serverId: "failed-b", serverLabel: "Failed B", client: createFailingClient() },
|
||||
] satisfies AgentHistoryHost[],
|
||||
cursorByServerId: null,
|
||||
}),
|
||||
).rejects.toThrow("No connected hosts could load agent history");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,26 +4,28 @@ import type {
|
||||
FetchAgentHistoryPageInfo,
|
||||
} from "@getpaseo/client/internal/daemon-client";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
|
||||
import { agentHistoryQueryKey } from "./agent-history-query-key";
|
||||
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
|
||||
|
||||
const AGENT_HISTORY_PAGE_LIMIT = 200;
|
||||
const AGENT_HISTORY_SORT: NonNullable<FetchAgentHistoryOptions["sort"]> = [
|
||||
{ key: "updated_at", direction: "desc" },
|
||||
];
|
||||
const AGENT_HISTORY_ALL_HOSTS_FAILED_MESSAGE = "No connected hosts could load agent history";
|
||||
|
||||
export interface AgentHistoryResult {
|
||||
agents: AggregatedAgent[];
|
||||
isLoading: boolean;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
isError: boolean;
|
||||
hasMore: boolean;
|
||||
isLoadingMore: boolean;
|
||||
refreshAll: () => void;
|
||||
refreshAll: () => Promise<void>;
|
||||
loadMore: () => void;
|
||||
}
|
||||
|
||||
@@ -34,6 +36,19 @@ export interface AgentHistoryPage {
|
||||
|
||||
export type AgentHistoryClient = Pick<DaemonClient, "fetchAgentHistory">;
|
||||
|
||||
export interface AgentHistoryHost {
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
client: AgentHistoryClient;
|
||||
}
|
||||
|
||||
interface AgentHistoryBatchPage {
|
||||
agents: AggregatedAgent[];
|
||||
pageInfoByServerId: Record<string, FetchAgentHistoryPageInfo>;
|
||||
}
|
||||
|
||||
type AgentHistoryCursorByServerId = Record<string, string | null>;
|
||||
|
||||
export async function fetchAgentHistoryPage(input: {
|
||||
client: AgentHistoryClient;
|
||||
serverId: string;
|
||||
@@ -75,70 +90,167 @@ export async function fetchAgentHistoryPage(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function sortByLatestActivity(agents: AggregatedAgent[]): AggregatedAgent[] {
|
||||
return [...agents].sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime());
|
||||
}
|
||||
|
||||
function getNextAgentHistoryPageParam(
|
||||
page: AgentHistoryBatchPage,
|
||||
): AgentHistoryCursorByServerId | null {
|
||||
const cursorByServerId: AgentHistoryCursorByServerId = {};
|
||||
for (const [serverId, pageInfo] of Object.entries(page.pageInfoByServerId)) {
|
||||
if (pageInfo.hasMore && pageInfo.nextCursor) {
|
||||
cursorByServerId[serverId] = pageInfo.nextCursor;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(cursorByServerId).length > 0 ? cursorByServerId : null;
|
||||
}
|
||||
|
||||
export async function fetchAgentHistoryBatch(input: {
|
||||
hosts: readonly AgentHistoryHost[];
|
||||
cursorByServerId: AgentHistoryCursorByServerId | null;
|
||||
}): Promise<AgentHistoryBatchPage> {
|
||||
const cursorByServerId = input.cursorByServerId ?? {};
|
||||
const hasCursorFilter = Object.keys(cursorByServerId).length > 0;
|
||||
const hostsToFetch = hasCursorFilter
|
||||
? input.hosts.filter((host) => Object.hasOwn(cursorByServerId, host.serverId))
|
||||
: input.hosts;
|
||||
|
||||
const settledPages = await Promise.allSettled(
|
||||
hostsToFetch.map(async (host) => {
|
||||
const page = await fetchAgentHistoryPage({
|
||||
client: host.client,
|
||||
serverId: host.serverId,
|
||||
cursor: cursorByServerId[host.serverId] ?? null,
|
||||
});
|
||||
return { host, page };
|
||||
}),
|
||||
);
|
||||
const pages = settledPages.flatMap((result) =>
|
||||
result.status === "fulfilled" ? [result.value] : [],
|
||||
);
|
||||
if (pages.length === 0) {
|
||||
throw new Error(AGENT_HISTORY_ALL_HOSTS_FAILED_MESSAGE);
|
||||
}
|
||||
|
||||
const agents = pages.flatMap(({ host, page }) =>
|
||||
page.agents.map((agent) => Object.assign({}, agent, { serverLabel: host.serverLabel })),
|
||||
);
|
||||
const pageInfoByServerId = Object.fromEntries(
|
||||
pages.map(({ host, page }) => [host.serverId, page.pageInfo]),
|
||||
);
|
||||
|
||||
return {
|
||||
agents: sortByLatestActivity(agents),
|
||||
pageInfoByServerId,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAgentHistory(options: {
|
||||
serverId?: string | null;
|
||||
enabled?: boolean;
|
||||
}): AgentHistoryResult {
|
||||
const { t } = useTranslation();
|
||||
const daemons = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
const runtimeVersion = useSyncExternalStore(
|
||||
(onStoreChange) => runtime.subscribeAll(onStoreChange),
|
||||
() => runtime.getVersion(),
|
||||
() => runtime.getVersion(),
|
||||
);
|
||||
const serverId = useMemo(() => {
|
||||
const value = options.serverId;
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}, [options.serverId]);
|
||||
const enabled = options.enabled ?? true;
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const queryKey = useMemo(() => agentHistoryQueryKey(serverId), [serverId]);
|
||||
const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
|
||||
const targetHosts = useMemo(() => {
|
||||
void runtimeVersion;
|
||||
const serverLabelById = new Map(daemons.map((daemon) => [daemon.serverId, daemon.label]));
|
||||
const serverIds = serverId ? [serverId] : daemons.map((daemon) => daemon.serverId);
|
||||
const hosts: AgentHistoryHost[] = [];
|
||||
|
||||
for (const targetServerId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(targetServerId);
|
||||
const client = runtime.getClient(targetServerId);
|
||||
if (!client || !isHostRuntimeConnected(snapshot)) {
|
||||
continue;
|
||||
}
|
||||
hosts.push({
|
||||
serverId: targetServerId,
|
||||
serverLabel: serverLabelById.get(targetServerId) ?? targetServerId,
|
||||
client,
|
||||
});
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}, [daemons, runtime, runtimeVersion, serverId]);
|
||||
const targetServerIds = useMemo(() => targetHosts.map((host) => host.serverId), [targetHosts]);
|
||||
const queryKey = useMemo(
|
||||
() => (serverId ? agentHistoryQueryKey(serverId) : allAgentHistoryQueryKey(targetServerIds)),
|
||||
[serverId, targetServerIds],
|
||||
);
|
||||
const serverLabelById = useMemo(
|
||||
() => new Map(daemons.map((daemon) => [daemon.serverId, daemon.label])),
|
||||
[daemons],
|
||||
);
|
||||
|
||||
const historyQuery = useInfiniteQuery<
|
||||
AgentHistoryPage,
|
||||
AgentHistoryBatchPage,
|
||||
Error,
|
||||
{ pages: AgentHistoryPage[] },
|
||||
ReturnType<typeof agentHistoryQueryKey>,
|
||||
string | null
|
||||
{ pages: AgentHistoryBatchPage[] },
|
||||
readonly unknown[],
|
||||
AgentHistoryCursorByServerId | null
|
||||
>({
|
||||
queryKey,
|
||||
enabled: Boolean(enabled && serverId && client && isConnected),
|
||||
enabled: Boolean(enabled && targetHosts.length > 0),
|
||||
staleTime: 30_000,
|
||||
initialPageParam: null as string | null,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.pageInfo.hasMore ? lastPage.pageInfo.nextCursor : null,
|
||||
initialPageParam: null,
|
||||
getNextPageParam: getNextAgentHistoryPageParam,
|
||||
queryFn: async ({ pageParam }) => {
|
||||
if (!serverId || !client) {
|
||||
if (targetHosts.length === 0) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
return fetchAgentHistoryPage({ client, serverId, cursor: pageParam });
|
||||
return fetchAgentHistoryBatch({
|
||||
hosts: targetHosts,
|
||||
cursorByServerId: pageParam,
|
||||
});
|
||||
},
|
||||
});
|
||||
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isLoading, refetch } =
|
||||
historyQuery;
|
||||
const {
|
||||
data,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isError,
|
||||
isFetching,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = historyQuery;
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!serverId || !client || !isConnected) {
|
||||
const refreshAll = useCallback(async () => {
|
||||
if (!enabled || targetHosts.length === 0) {
|
||||
return;
|
||||
}
|
||||
void refetch();
|
||||
}, [client, isConnected, refetch, serverId]);
|
||||
await refetch();
|
||||
}, [enabled, refetch, targetHosts.length]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!serverId || !client || !isConnected || !hasNextPage || isFetchingNextPage) {
|
||||
if (!enabled || targetHosts.length === 0 || !hasNextPage || isFetchingNextPage) {
|
||||
return;
|
||||
}
|
||||
void fetchNextPage();
|
||||
}, [client, fetchNextPage, hasNextPage, isConnected, isFetchingNextPage, serverId]);
|
||||
}, [enabled, fetchNextPage, hasNextPage, isFetchingNextPage, targetHosts.length]);
|
||||
|
||||
const agents = useMemo(
|
||||
() =>
|
||||
(data?.pages ?? [])
|
||||
.flatMap((page) => page.agents)
|
||||
.map((agent) =>
|
||||
Object.assign({}, agent, {
|
||||
serverLabel: serverLabel ?? agent.serverLabel,
|
||||
}),
|
||||
),
|
||||
[data?.pages, serverLabel],
|
||||
);
|
||||
const agents = useMemo(() => {
|
||||
const historyAgents = (data?.pages ?? []).flatMap((page) => page.agents);
|
||||
const labelledAgents = historyAgents.map((agent) =>
|
||||
Object.assign({}, agent, {
|
||||
serverLabel: serverLabelById.get(agent.serverId) ?? agent.serverLabel,
|
||||
}),
|
||||
);
|
||||
return sortByLatestActivity(labelledAgents);
|
||||
}, [data?.pages, serverLabelById]);
|
||||
const isInitialLoad = isLoading && agents.length === 0;
|
||||
const isRevalidating = isFetching && !isFetchingNextPage && agents.length > 0;
|
||||
|
||||
@@ -147,6 +259,7 @@ export function useAgentHistory(options: {
|
||||
isLoading,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
isError,
|
||||
hasMore: hasNextPage,
|
||||
isLoadingMore: isFetchingNextPage,
|
||||
refreshAll,
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-all-agents-list";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
|
||||
|
||||
const AGENT_DEFAULTS: Agent = {
|
||||
serverId: "server-1",
|
||||
id: "agent-1",
|
||||
provider: "codex",
|
||||
status: "idle",
|
||||
createdAt: AGENT_TIMESTAMP,
|
||||
updatedAt: AGENT_TIMESTAMP,
|
||||
lastUserMessageAt: null,
|
||||
lastActivityAt: AGENT_TIMESTAMP,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
persistence: null,
|
||||
runtimeInfo: undefined,
|
||||
lastUsage: undefined,
|
||||
lastError: null,
|
||||
title: "Agent",
|
||||
cwd: "/tmp/project",
|
||||
model: null,
|
||||
thinkingOptionId: undefined,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: null,
|
||||
parentAgentId: null,
|
||||
labels: {},
|
||||
projectPlacement: null,
|
||||
};
|
||||
|
||||
function makeAgent(input?: Partial<Agent>): Agent {
|
||||
return { ...AGENT_DEFAULTS, ...input };
|
||||
}
|
||||
|
||||
describe("useAllAgentsList", () => {
|
||||
it("excludes archived agents by default", () => {
|
||||
const visibleAgent = makeAgent({ id: "visible" });
|
||||
const archivedAgent = makeAgent({
|
||||
id: "archived",
|
||||
archivedAt: new Date("2026-03-08T11:00:00.000Z"),
|
||||
});
|
||||
|
||||
const result = __private__.buildAllAgentsList({
|
||||
agents: [visibleAgent, archivedAgent],
|
||||
serverId: "server-1",
|
||||
serverLabel: "Local",
|
||||
includeArchived: false,
|
||||
});
|
||||
|
||||
expect(result.map((agent) => agent.id)).toEqual(["visible"]);
|
||||
});
|
||||
|
||||
it("includes archived agents when requested", () => {
|
||||
const visibleAgent = makeAgent({ id: "visible" });
|
||||
const archivedAgent = makeAgent({
|
||||
id: "archived",
|
||||
archivedAt: new Date("2026-03-08T11:00:00.000Z"),
|
||||
});
|
||||
|
||||
const result = __private__.buildAllAgentsList({
|
||||
agents: [visibleAgent, archivedAgent],
|
||||
serverId: "server-1",
|
||||
serverLabel: "Local",
|
||||
includeArchived: true,
|
||||
});
|
||||
|
||||
expect(result.map((agent) => agent.id)).toEqual(["visible", "archived"]);
|
||||
expect(result[1]?.archivedAt).toEqual(archivedAgent.archivedAt);
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useHosts } from "@/runtime/host-runtime";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
useHostRuntimeConnectionStatus,
|
||||
useHostRuntimeIsDirectoryLoading,
|
||||
} from "@/runtime/host-runtime";
|
||||
import type { AggregatedAgent, AggregatedAgentsResult } from "@/hooks/use-aggregated-agents";
|
||||
|
||||
function toAggregatedAgent(params: {
|
||||
source: Agent;
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
}): AggregatedAgent {
|
||||
const source = params.source;
|
||||
return {
|
||||
id: source.id,
|
||||
serverId: params.serverId,
|
||||
serverLabel: params.serverLabel,
|
||||
title: source.title ?? null,
|
||||
status: source.status,
|
||||
lastActivityAt: source.lastActivityAt,
|
||||
cwd: source.cwd,
|
||||
provider: source.provider,
|
||||
pendingPermissionCount: source.pendingPermissions.length,
|
||||
requiresAttention: source.requiresAttention,
|
||||
attentionReason: source.attentionReason,
|
||||
attentionTimestamp: source.attentionTimestamp ?? null,
|
||||
archivedAt: source.archivedAt ?? null,
|
||||
createdAt: source.createdAt,
|
||||
labels: source.labels,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAllAgentsList(params: {
|
||||
agents: Iterable<Agent>;
|
||||
serverId: string;
|
||||
serverLabel: string;
|
||||
includeArchived: boolean;
|
||||
}): AggregatedAgent[] {
|
||||
const list: AggregatedAgent[] = [];
|
||||
|
||||
for (const agent of params.agents) {
|
||||
const aggregated = toAggregatedAgent({
|
||||
source: agent,
|
||||
serverId: params.serverId,
|
||||
serverLabel: params.serverLabel,
|
||||
});
|
||||
if (!params.includeArchived && aggregated.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
list.push(aggregated);
|
||||
}
|
||||
|
||||
list.sort((left, right) => {
|
||||
const leftRunning = left.status === "running";
|
||||
const rightRunning = right.status === "running";
|
||||
if (leftRunning && !rightRunning) {
|
||||
return -1;
|
||||
}
|
||||
if (!leftRunning && rightRunning) {
|
||||
return 1;
|
||||
}
|
||||
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||
});
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
export function useAllAgentsList(options?: {
|
||||
serverId?: string | null;
|
||||
includeArchived?: boolean;
|
||||
}): AggregatedAgentsResult {
|
||||
const daemons = useHosts();
|
||||
const runtime = getHostRuntimeStore();
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
const value = options?.serverId;
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}, [options?.serverId]);
|
||||
const includeArchived = options?.includeArchived ?? false;
|
||||
|
||||
const liveAgents = useSessionStore((state) =>
|
||||
serverId ? (state.sessions[serverId]?.agents ?? null) : null,
|
||||
);
|
||||
const connectionStatus = useHostRuntimeConnectionStatus(serverId ?? "");
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!serverId || connectionStatus !== "online") {
|
||||
return;
|
||||
}
|
||||
void runtime.refreshAgentDirectory({ serverId }).catch(() => undefined);
|
||||
}, [runtime, serverId, connectionStatus]);
|
||||
|
||||
const agents = useMemo(() => {
|
||||
if (!serverId || !liveAgents) {
|
||||
return [];
|
||||
}
|
||||
const serverLabel = daemons.find((daemon) => daemon.serverId === serverId)?.label ?? serverId;
|
||||
return buildAllAgentsList({
|
||||
agents: liveAgents.values(),
|
||||
serverId,
|
||||
serverLabel,
|
||||
includeArchived,
|
||||
});
|
||||
}, [daemons, includeArchived, liveAgents, serverId]);
|
||||
|
||||
const isDirectoryLoading = useHostRuntimeIsDirectoryLoading(serverId ?? "");
|
||||
const isInitialLoad = isDirectoryLoading && agents.length === 0;
|
||||
const isRevalidating = isDirectoryLoading && agents.length > 0;
|
||||
|
||||
return {
|
||||
agents,
|
||||
isLoading: isDirectoryLoading,
|
||||
isInitialLoad,
|
||||
isRevalidating,
|
||||
refreshAll,
|
||||
};
|
||||
}
|
||||
|
||||
export const __private__ = {
|
||||
buildAllAgentsList,
|
||||
toAggregatedAgent,
|
||||
};
|
||||
@@ -3,7 +3,7 @@ import { QueryClient } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { agentHistoryQueryKey } from "./agent-history-query-key";
|
||||
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
|
||||
import {
|
||||
applyArchivedAgentCloseResults,
|
||||
isAgentArchiving,
|
||||
@@ -144,6 +144,17 @@ describe("useArchiveAgent", () => {
|
||||
],
|
||||
pageParams: [null],
|
||||
});
|
||||
queryClient.setQueryData(allAgentHistoryQueryKey(["server-a", "server-b"]), {
|
||||
pages: [
|
||||
{
|
||||
agents: [
|
||||
{ id: "agent-1", serverId: "server-a", archivedAt: null },
|
||||
{ id: "agent-1", serverId: "server-b", archivedAt: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
pageParams: [null],
|
||||
});
|
||||
|
||||
applyArchivedAgentCloseResults({
|
||||
queryClient,
|
||||
@@ -174,6 +185,24 @@ describe("useArchiveAgent", () => {
|
||||
],
|
||||
pageParams: [null],
|
||||
});
|
||||
expect(
|
||||
queryClient.getQueryState(allAgentHistoryQueryKey(["server-a", "server-b"]))?.isInvalidated,
|
||||
).toBe(true);
|
||||
expect(queryClient.getQueryData(allAgentHistoryQueryKey(["server-a", "server-b"]))).toEqual({
|
||||
pages: [
|
||||
{
|
||||
agents: [
|
||||
{
|
||||
id: "agent-1",
|
||||
serverId: "server-a",
|
||||
archivedAt: new Date("2026-04-01T04:00:00.000Z"),
|
||||
},
|
||||
{ id: "agent-1", serverId: "server-b", archivedAt: null },
|
||||
],
|
||||
},
|
||||
],
|
||||
pageParams: [null],
|
||||
});
|
||||
});
|
||||
|
||||
it("can apply archived agent close results without invalidating cached lists", () => {
|
||||
@@ -194,6 +223,10 @@ describe("useArchiveAgent", () => {
|
||||
],
|
||||
pageParams: [null],
|
||||
});
|
||||
queryClient.setQueryData(allAgentHistoryQueryKey(["server-a"]), {
|
||||
pages: [{ agents: [{ id: "agent-1", serverId: "server-a", archivedAt: null }] }],
|
||||
pageParams: [null],
|
||||
});
|
||||
|
||||
applyArchivedAgentCloseResults({
|
||||
queryClient,
|
||||
@@ -205,6 +238,9 @@ describe("useArchiveAgent", () => {
|
||||
expect(queryClient.getQueryState(["sidebarAgentsList", "server-a"])?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(["allAgents", "server-a"])?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(agentHistoryQueryKey("server-a"))?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(allAgentHistoryQueryKey(["server-a"]))?.isInvalidated).toBe(
|
||||
false,
|
||||
);
|
||||
expect(queryClient.getQueryData(agentHistoryQueryKey("server-a"))).toEqual({
|
||||
pages: [
|
||||
{
|
||||
@@ -213,5 +249,19 @@ describe("useArchiveAgent", () => {
|
||||
],
|
||||
pageParams: [null],
|
||||
});
|
||||
expect(queryClient.getQueryData(allAgentHistoryQueryKey(["server-a"]))).toEqual({
|
||||
pages: [
|
||||
{
|
||||
agents: [
|
||||
{
|
||||
id: "agent-1",
|
||||
serverId: "server-a",
|
||||
archivedAt: new Date("2026-04-01T04:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
pageParams: [null],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
} from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { agentHistoryQueryKey } from "./agent-history-query-key";
|
||||
import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "./agent-history-query-key";
|
||||
|
||||
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
|
||||
const EMPTY_PENDING_ARCHIVE_AGENT_IDS = new Set<string>();
|
||||
@@ -29,6 +35,7 @@ export interface AgentsListQueryData {
|
||||
|
||||
export interface AgentHistoryQueryAgent {
|
||||
id?: string | null;
|
||||
serverId?: string | null;
|
||||
archivedAt?: Date | null;
|
||||
}
|
||||
|
||||
@@ -175,7 +182,10 @@ export function markAgentArchivedInHistoryPayload<T extends AgentHistoryQueryDat
|
||||
|
||||
let pageChanged = false;
|
||||
const agents = page.agents.map((agent) => {
|
||||
if (agent.id !== input.agentId) {
|
||||
if (
|
||||
agent.id !== input.agentId ||
|
||||
(agent.serverId != null && agent.serverId !== input.serverId)
|
||||
) {
|
||||
return agent;
|
||||
}
|
||||
pageChanged = true;
|
||||
@@ -200,6 +210,10 @@ export function markAgentArchivedInHistoryCache(
|
||||
agentHistoryQueryKey(input.serverId),
|
||||
(current) => markAgentArchivedInHistoryPayload(current, input),
|
||||
);
|
||||
queryClient.setQueriesData<AgentHistoryQueryData | undefined>(
|
||||
{ queryKey: allAgentHistoryQueryRootKey() },
|
||||
(current) => markAgentArchivedInHistoryPayload(current, input),
|
||||
);
|
||||
}
|
||||
|
||||
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
|
||||
@@ -218,6 +232,7 @@ interface ArchivedAgentListCacheSnapshot {
|
||||
sidebarAgentsList: AgentsListQueryData | undefined;
|
||||
allAgents: AgentsListQueryData | undefined;
|
||||
agentHistory: AgentHistoryQueryData | undefined;
|
||||
allAgentHistory: Array<[QueryKey, AgentHistoryQueryData | undefined]>;
|
||||
}
|
||||
|
||||
interface ArchiveAgentMutationContext {
|
||||
@@ -268,6 +283,9 @@ function getArchivedAgentListCacheSnapshot(
|
||||
agentHistory: queryClient.getQueryData<AgentHistoryQueryData | undefined>(
|
||||
agentHistoryQueryKey(serverId),
|
||||
),
|
||||
allAgentHistory: queryClient.getQueriesData<AgentHistoryQueryData | undefined>({
|
||||
queryKey: allAgentHistoryQueryRootKey(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,6 +313,9 @@ function restoreArchivedAgentListCacheSnapshot(
|
||||
);
|
||||
restoreCachedQuerySnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
|
||||
restoreCachedQuerySnapshot(queryClient, agentHistoryQueryKey(serverId), snapshot.agentHistory);
|
||||
for (const [queryKey, querySnapshot] of snapshot.allAgentHistory) {
|
||||
restoreCachedQuerySnapshot(queryClient, queryKey, querySnapshot);
|
||||
}
|
||||
}
|
||||
|
||||
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
|
||||
@@ -360,6 +381,9 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
|
||||
void input.queryClient.invalidateQueries({
|
||||
queryKey: agentHistoryQueryKey(input.serverId),
|
||||
});
|
||||
void input.queryClient.invalidateQueries({
|
||||
queryKey: allAgentHistoryQueryRootKey(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +473,9 @@ export function useArchiveAgent() {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: agentHistoryQueryKey(input.serverId),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: allAgentHistoryQueryRootKey(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@ import { router, usePathname, type Href } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useAllAgentsList } from "@/hooks/use-all-agents-list";
|
||||
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useOpenProjectPicker } from "@/hooks/use-open-project-picker";
|
||||
import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import { buildHostOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import { buildOpenProjectRoute, buildSettingsRoute } from "@/utils/host-routes";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { chordStringToShortcutKeys } from "@/keyboard/shortcut-string";
|
||||
import { getBindingIdForAction, getDefaultKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
@@ -20,7 +19,6 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
|
||||
const EMPTY_AGENTS: AggregatedAgent[] = [];
|
||||
@@ -139,7 +137,6 @@ function resolveActionShortcutKeys(
|
||||
export function useCommandCenter() {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
const routeActiveServerId = useActiveServerId();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const open = useKeyboardShortcutsStore((s) => s.commandCenterOpen);
|
||||
const setOpen = useKeyboardShortcutsStore((s) => s.setCommandCenterOpen);
|
||||
@@ -153,11 +150,7 @@ export function useCommandCenter() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const activeServerId = open ? routeActiveServerId : null;
|
||||
|
||||
const { agents } = useAllAgentsList({
|
||||
serverId: activeServerId,
|
||||
});
|
||||
const { agents } = useAggregatedAgents();
|
||||
|
||||
const agentResults = useMemo(() => {
|
||||
if (!open || agents.length === 0) {
|
||||
@@ -172,10 +165,7 @@ export function useCommandCenter() {
|
||||
return buildSettingsRoute();
|
||||
}, []);
|
||||
|
||||
const homeRoute = useMemo<Href | undefined>(() => {
|
||||
if (!routeActiveServerId) return undefined;
|
||||
return buildHostOpenProjectRoute(routeActiveServerId) as Href;
|
||||
}, [routeActiveServerId]);
|
||||
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute() as Href, []);
|
||||
|
||||
const actionItems = useMemo(() => {
|
||||
if (!open) {
|
||||
@@ -239,7 +229,7 @@ export function useCommandCenter() {
|
||||
[pathname, setOpen],
|
||||
);
|
||||
|
||||
const openProjectPicker = useOpenProjectPicker(activeServerId);
|
||||
const openProjectPicker = useOpenProjectPicker();
|
||||
|
||||
const handleSelectAction = useCallback(
|
||||
(action: CommandCenterActionItem) => {
|
||||
|
||||
@@ -23,7 +23,6 @@ import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-over
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
import {
|
||||
type ActiveWorkspaceSelection,
|
||||
navigateToLastWorkspace,
|
||||
@@ -55,8 +54,7 @@ export function useKeyboardShortcuts({
|
||||
step: 0,
|
||||
timeoutId: null,
|
||||
});
|
||||
const activeServerId = useActiveServerId();
|
||||
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
|
||||
const openProjectPickerAction = useOpenProjectPicker();
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
|
||||
|
||||
|
||||
@@ -1,30 +1,15 @@
|
||||
import { useCallback } from "react";
|
||||
import { pickDirectory } from "@/desktop/pick-directory";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useIsLocalDaemon } from "./use-is-local-daemon";
|
||||
import { useOpenProject } from "./use-open-project";
|
||||
import { useHostChooser } from "@/hosts/host-chooser";
|
||||
import { useProjectPickerStore } from "@/stores/project-picker-store";
|
||||
|
||||
export function useOpenProjectPicker(serverId: string | null): () => Promise<void> {
|
||||
const normalizedServerId = serverId?.trim() ?? "";
|
||||
const isLocalDaemon = useIsLocalDaemon(normalizedServerId);
|
||||
const setProjectPickerOpen = useKeyboardShortcutsStore((state) => state.setProjectPickerOpen);
|
||||
const openProject = useOpenProject(serverId);
|
||||
export function useOpenProjectPicker(): () => void {
|
||||
const chooseHost = useHostChooser();
|
||||
const openProjectPicker = useProjectPickerStore((state) => state.open);
|
||||
|
||||
return useCallback(async () => {
|
||||
if (!normalizedServerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLocalDaemon) {
|
||||
setProjectPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const path = await pickDirectory();
|
||||
if (path === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await openProject(path);
|
||||
}, [isLocalDaemon, normalizedServerId, openProject, setProjectPickerOpen]);
|
||||
return useCallback(() => {
|
||||
chooseHost({
|
||||
title: "Choose host",
|
||||
onChooseHost: openProjectPicker,
|
||||
});
|
||||
}, [chooseHost, openProjectPicker]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { openProjectDirectly } from "@/hooks/open-project";
|
||||
import { getOpenProjectFailureReason, openProjectDirectly } from "@/hooks/open-project";
|
||||
import type { EmptyProjectDescriptor as ProjectWithoutWorkspacesDescriptor } from "@/stores/session-store";
|
||||
|
||||
const SERVER_ID = "server-1";
|
||||
@@ -132,3 +132,21 @@ describe("openProjectDirectly", () => {
|
||||
expect(session.hydrated).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOpenProjectFailureReason", () => {
|
||||
it("keeps the known directory-not-found failure reason", () => {
|
||||
expect(
|
||||
getOpenProjectFailureReason({
|
||||
ok: false,
|
||||
errorCode: "directory_not_found",
|
||||
error: "Directory not found: /missing",
|
||||
}),
|
||||
).toBe("directory_not_found");
|
||||
});
|
||||
|
||||
it("uses the generic failure reason for untyped project-open failures", () => {
|
||||
expect(getOpenProjectFailureReason({ ok: false, errorCode: null, error: "boom" })).toBe(
|
||||
"open_failed",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ function workspace(projectKey: string, workspaceId: string): SidebarWorkspaceEnt
|
||||
serverId: "srv",
|
||||
workspaceId,
|
||||
projectKey,
|
||||
projectName: projectKey,
|
||||
projectRootPath: `/repo/${projectKey}`,
|
||||
workspaceDirectory: `/repo/${projectKey}/${workspaceId}`,
|
||||
projectKind: "git",
|
||||
@@ -43,7 +44,7 @@ function project(projectKey: string): SidebarProjectEntry {
|
||||
projectName: projectKey,
|
||||
projectKind: "git",
|
||||
iconWorkingDir: `/repo/${projectKey}`,
|
||||
canCreateWorktree: true,
|
||||
hosts: [],
|
||||
workspaces: [workspace(projectKey, `${projectKey}-main`)],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { shallow } from "zustand/shallow";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import { useSessionStore, type Agent, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { selectWorkspace, workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
|
||||
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { selectPrHintFromStatus } from "@/git/use-pr-status-query";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
import { fetchAllWorkspaceDescriptors } from "@/projects/workspace-fetching";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
|
||||
import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import { useSidebarViewStore } from "@/stores/sidebar-view-store";
|
||||
import { shouldSuppressWorkspaceForLocalArchive } from "@/contexts/session-workspace-upserts";
|
||||
import {
|
||||
buildSidebarProjectsFromHostProjects,
|
||||
buildSidebarWorkspacePlacementModel,
|
||||
computeSidebarOrderUpdates,
|
||||
createSidebarWorkspaceEntry,
|
||||
deriveSidebarLoadingState,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
type SidebarWorkspacePlacement,
|
||||
} from "./sidebar-workspaces-view-model";
|
||||
|
||||
export {
|
||||
@@ -24,131 +26,21 @@ export {
|
||||
applyStoredOrdering,
|
||||
buildSidebarProjectsFromHostProjects,
|
||||
buildSidebarProjectsFromStructure,
|
||||
buildSidebarStatusWorkspacePlacements,
|
||||
buildSidebarWorkspacePlacementModel,
|
||||
computeSidebarOrderUpdates,
|
||||
createSidebarWorkspaceEntry,
|
||||
deriveSidebarLoadingState,
|
||||
type SidebarLoadingState,
|
||||
type SidebarOrderUpdates,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
type SidebarWorkspacePlacement,
|
||||
type SidebarWorkspacePlacementModel,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarStateBucket,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "./sidebar-workspaces-view-model";
|
||||
|
||||
function normalizeCurrentBranch(currentBranch: string | null | undefined): string | null {
|
||||
if (!currentBranch) {
|
||||
return null;
|
||||
}
|
||||
const trimmed = currentBranch.trim();
|
||||
return trimmed.length === 0 || trimmed === "HEAD" ? null : trimmed;
|
||||
}
|
||||
|
||||
export function createSidebarWorkspaceEntry(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
agents?: Map<string, Agent>;
|
||||
}): SidebarWorkspaceEntry {
|
||||
const effectiveStatus = deriveEffectiveWorkspaceStatus(input);
|
||||
return {
|
||||
workspaceKey: `${input.serverId}:${input.workspace.id}`,
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
projectKey: input.workspace.project?.projectKey ?? input.workspace.projectId,
|
||||
projectRootPath: input.workspace.projectRootPath,
|
||||
workspaceDirectory: input.workspace.workspaceDirectory,
|
||||
projectKind: input.workspace.projectKind,
|
||||
workspaceKind: input.workspace.workspaceKind,
|
||||
name: input.workspace.name,
|
||||
title: input.workspace.title ?? null,
|
||||
currentBranch: normalizeCurrentBranch(input.workspace.gitRuntime?.currentBranch),
|
||||
statusBucket: effectiveStatus.status,
|
||||
statusEnteredAt: effectiveStatus.enteredAt,
|
||||
archivingAt: input.workspace.archivingAt,
|
||||
diffStat: input.workspace.diffStat,
|
||||
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
|
||||
archiveHasUncommittedChanges: input.workspace.gitRuntime?.isDirty ?? null,
|
||||
archiveUnpushedCommitCount: input.workspace.gitRuntime?.aheadOfOrigin ?? null,
|
||||
scripts: input.workspace.scripts,
|
||||
hasRunningScripts: input.workspace.scripts.some((script) => script.lifecycle === "running"),
|
||||
};
|
||||
}
|
||||
|
||||
interface EffectiveWorkspaceStatus {
|
||||
status: WorkspaceDescriptor["status"];
|
||||
enteredAt: Date | null;
|
||||
}
|
||||
|
||||
interface WorkspaceAgentActivity extends EffectiveWorkspaceStatus {}
|
||||
|
||||
function deriveEffectiveWorkspaceStatus(input: {
|
||||
serverId: string;
|
||||
workspace: WorkspaceDescriptor;
|
||||
pendingCreateAttempts?: Record<string, PendingCreateAttempt>;
|
||||
agents?: Map<string, Agent>;
|
||||
}): EffectiveWorkspaceStatus {
|
||||
if (input.workspace.status !== "done") {
|
||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||
}
|
||||
|
||||
const pendingStartedAt = getPendingInitialAgentCreateStartedAt({
|
||||
serverId: input.serverId,
|
||||
workspaceId: input.workspace.id,
|
||||
pendingCreateAttempts: input.pendingCreateAttempts,
|
||||
});
|
||||
if (pendingStartedAt) {
|
||||
return { status: "running", enteredAt: pendingStartedAt };
|
||||
}
|
||||
|
||||
const rootAgentActivity = getRootAgentWorkspaceActivity({
|
||||
workspace: input.workspace,
|
||||
agents: input.agents,
|
||||
});
|
||||
if (rootAgentActivity && rootAgentActivity.status !== "done") {
|
||||
return rootAgentActivity;
|
||||
}
|
||||
|
||||
return { status: input.workspace.status, enteredAt: input.workspace.statusEnteredAt };
|
||||
}
|
||||
|
||||
function getPendingInitialAgentCreateStartedAt(input: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
pendingCreateAttempts: Record<string, PendingCreateAttempt> | undefined;
|
||||
}): Date | null {
|
||||
let latestStartedAt: Date | null = null;
|
||||
for (const pending of Object.values(input.pendingCreateAttempts ?? {})) {
|
||||
if (pending.serverId !== input.serverId) continue;
|
||||
if (pending.workspaceId !== input.workspaceId) continue;
|
||||
if (pending.lifecycle === "abandoned") continue;
|
||||
const startedAt = new Date(pending.timestamp);
|
||||
if (!latestStartedAt || startedAt > latestStartedAt) {
|
||||
latestStartedAt = startedAt;
|
||||
}
|
||||
}
|
||||
return latestStartedAt;
|
||||
}
|
||||
|
||||
function getRootAgentWorkspaceActivity(input: {
|
||||
workspace: WorkspaceDescriptor;
|
||||
agents: Map<string, Agent> | undefined;
|
||||
}): WorkspaceAgentActivity | null {
|
||||
let latest: WorkspaceAgentActivity | null = null;
|
||||
for (const agent of input.agents?.values() ?? []) {
|
||||
if (agent.archivedAt || agent.parentAgentId) continue;
|
||||
if (agent.workspaceId !== input.workspace.id) continue;
|
||||
const status = deriveSidebarStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions.length,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason,
|
||||
});
|
||||
const enteredAt = agent.attentionTimestamp ?? agent.updatedAt;
|
||||
if (!latest || enteredAt > (latest.enteredAt ?? new Date(0))) {
|
||||
latest = { status, enteredAt };
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
export function useSidebarWorkspaceEntry(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
@@ -182,9 +74,13 @@ export function useSidebarWorkspaceEntry(
|
||||
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
const EMPTY_WORKSPACES: SidebarWorkspacePlacement[] = [];
|
||||
const EMPTY_PROJECT_NAMES = new Map<string, string>();
|
||||
|
||||
export interface SidebarWorkspacesListResult {
|
||||
workspacePlacements: SidebarWorkspacePlacement[];
|
||||
projects: SidebarProjectEntry[];
|
||||
projectNamesByKey: Map<string, string>;
|
||||
isLoading: boolean;
|
||||
isInitialLoad: boolean;
|
||||
isRevalidating: boolean;
|
||||
@@ -192,121 +88,122 @@ export interface SidebarWorkspacesListResult {
|
||||
}
|
||||
|
||||
export function useSidebarWorkspacesList(options?: {
|
||||
serverId?: string | null;
|
||||
hostFilter?: string | null;
|
||||
enabled?: boolean;
|
||||
}): SidebarWorkspacesListResult {
|
||||
const runtime = getHostRuntimeStore();
|
||||
const allHosts = useHosts();
|
||||
const hostRegistryLoaded = useHostRegistryLoaded();
|
||||
const allServerIds = useMemo(() => allHosts.map((h) => h.serverId), [allHosts]);
|
||||
|
||||
const serverId = useMemo(() => {
|
||||
const value = options?.serverId;
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}, [options?.serverId]);
|
||||
const isActive = Boolean(serverId) && options?.enabled !== false;
|
||||
const persistedProjectOrder = useSidebarOrderStore((state) =>
|
||||
isActive && serverId ? (state.projectOrderByServerId[serverId] ?? EMPTY_ORDER) : EMPTY_ORDER,
|
||||
);
|
||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
||||
isActive && serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
||||
);
|
||||
const hostProjects = useHostProjects(isActive ? serverId : null);
|
||||
const storeHostFilter = useSidebarViewStore((state) => state.hostFilter);
|
||||
const hostFilter = options?.hostFilter ?? storeHostFilter;
|
||||
const reconcileHostFilter = useSidebarViewStore((state) => state.reconcileHostFilter);
|
||||
const hasHostFilterMatch = hostFilter ? allServerIds.includes(hostFilter) : false;
|
||||
const effectiveHostFilter =
|
||||
hostFilter && (!hostRegistryLoaded || hasHostFilterMatch) ? hostFilter : null;
|
||||
const isActive = options?.enabled !== false;
|
||||
|
||||
const connectionStatus = useSyncExternalStore(
|
||||
(onStoreChange) =>
|
||||
isActive && serverId ? runtime.subscribe(serverId, onStoreChange) : () => {},
|
||||
() => {
|
||||
if (!isActive || !serverId) {
|
||||
return "idle";
|
||||
}
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
return snapshot?.connectionStatus ?? "idle";
|
||||
},
|
||||
() => {
|
||||
if (!isActive || !serverId) {
|
||||
return "idle";
|
||||
}
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
return snapshot?.connectionStatus ?? "idle";
|
||||
},
|
||||
);
|
||||
|
||||
const projects = useMemo(() => {
|
||||
if (!serverId || hostProjects.length === 0) {
|
||||
return EMPTY_PROJECTS;
|
||||
const serverIds = useMemo(() => {
|
||||
if (effectiveHostFilter) {
|
||||
return allServerIds.filter((id) => id === effectiveHostFilter);
|
||||
}
|
||||
return buildSidebarProjectsFromHostProjects({
|
||||
projects: hostProjects,
|
||||
});
|
||||
}, [hostProjects, serverId]);
|
||||
return allServerIds;
|
||||
}, [allServerIds, effectiveHostFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) {
|
||||
if (!hostRegistryLoaded) {
|
||||
return;
|
||||
}
|
||||
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId]);
|
||||
reconcileHostFilter(allServerIds);
|
||||
}, [allServerIds, hostRegistryLoaded, reconcileHostFilter]);
|
||||
|
||||
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
|
||||
|
||||
const hydratedServerIds = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => serverIds.filter((id) => state.sessions[id]?.hasHydratedWorkspaces ?? false),
|
||||
shallow,
|
||||
);
|
||||
|
||||
const hostProjects = useHostProjects(serverIds);
|
||||
|
||||
const sidebarModel = useMemo(
|
||||
() =>
|
||||
buildSidebarWorkspacePlacementModel({
|
||||
projects: hostProjects,
|
||||
}),
|
||||
[hostProjects],
|
||||
);
|
||||
|
||||
const projects = sidebarModel.projects.length > 0 ? sidebarModel.projects : EMPTY_PROJECTS;
|
||||
const workspacePlacements =
|
||||
sidebarModel.workspaces.length > 0 ? sidebarModel.workspaces : EMPTY_WORKSPACES;
|
||||
const projectNamesByKey =
|
||||
sidebarModel.projectNamesByKey.size > 0 ? sidebarModel.projectNamesByKey : EMPTY_PROJECT_NAMES;
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const orderStore = useSidebarOrderStore.getState();
|
||||
const updates = computeSidebarOrderUpdates({
|
||||
projects,
|
||||
persistedProjectOrder,
|
||||
getWorkspaceOrder: (projectKey) => orderStore.getWorkspaceOrder(serverId, projectKey),
|
||||
getWorkspaceOrder: (projectKey) =>
|
||||
orderStore.workspaceOrderByProject[projectKey] ?? EMPTY_ORDER,
|
||||
});
|
||||
|
||||
if (updates.projectOrder) {
|
||||
orderStore.setProjectOrder(serverId, updates.projectOrder);
|
||||
orderStore.setProjectOrder(updates.projectOrder);
|
||||
}
|
||||
for (const { projectKey, order } of updates.workspaceOrders) {
|
||||
orderStore.setWorkspaceOrder(serverId, projectKey, order);
|
||||
orderStore.setWorkspaceOrder(projectKey, order);
|
||||
}
|
||||
}, [persistedProjectOrder, projects, serverId]);
|
||||
}, [persistedProjectOrder, projects]);
|
||||
|
||||
const refreshAll = useCallback(() => {
|
||||
if (!isActive || !serverId || connectionStatus !== "online") {
|
||||
return;
|
||||
}
|
||||
const client = runtime.getClient(serverId);
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
void (async () => {
|
||||
const next = new Map<string, WorkspaceDescriptor>();
|
||||
try {
|
||||
const workspaces = await fetchAllWorkspaceDescriptors({
|
||||
client,
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
});
|
||||
for (const workspace of workspaces) {
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
continue;
|
||||
if (!isActive) return;
|
||||
for (const serverId of serverIds) {
|
||||
const snapshot = runtime.getSnapshot(serverId);
|
||||
if (snapshot?.connectionStatus !== "online") continue;
|
||||
const client = runtime.getClient(serverId);
|
||||
if (!client) continue;
|
||||
void (async () => {
|
||||
const next = new Map<string, WorkspaceDescriptor>();
|
||||
try {
|
||||
const workspaces = await fetchAllWorkspaceDescriptors({
|
||||
client,
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
});
|
||||
for (const workspace of workspaces) {
|
||||
if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) {
|
||||
continue;
|
||||
}
|
||||
next.set(workspace.id, workspace);
|
||||
}
|
||||
next.set(workspace.id, workspace);
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(serverId, next);
|
||||
store.setHasHydratedWorkspaces(serverId, true);
|
||||
} catch (error) {
|
||||
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
||||
serverId,
|
||||
error,
|
||||
});
|
||||
// ignore explicit refresh failures; hook keeps existing data
|
||||
}
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(serverId, next);
|
||||
store.setHasHydratedWorkspaces(serverId, true);
|
||||
} catch (error) {
|
||||
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
||||
serverId,
|
||||
error,
|
||||
});
|
||||
// ignore explicit refresh failures; hook keeps existing data
|
||||
}
|
||||
})();
|
||||
}, [connectionStatus, isActive, runtime, serverId]);
|
||||
})();
|
||||
}
|
||||
}, [isActive, runtime, serverIds]);
|
||||
|
||||
const loadingState = deriveSidebarLoadingState({
|
||||
isActive,
|
||||
serverId,
|
||||
hasHydratedWorkspaces,
|
||||
serverIds,
|
||||
hydratedServerIds,
|
||||
hasProjects: projects.length > 0,
|
||||
});
|
||||
|
||||
return {
|
||||
workspacePlacements,
|
||||
projects,
|
||||
projectNamesByKey,
|
||||
...loadingState,
|
||||
refreshAll,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
areStatusModeSessionsEqual,
|
||||
selectStatusModeSessions,
|
||||
type StatusModeSession,
|
||||
} from "./use-status-mode-workspaces";
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function workspaceMap(): Map<string, WorkspaceDescriptor> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function agentMap(): Map<string, Agent> {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
function statusSession(input?: Partial<Omit<StatusModeSession, "serverId">>) {
|
||||
return {
|
||||
workspaces: input?.workspaces ?? workspaceMap(),
|
||||
agents: input?.agents ?? agentMap(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("status mode session selection", () => {
|
||||
it("selects only sessions needed by visible placements", () => {
|
||||
const hostA = statusSession();
|
||||
const hostB = statusSession();
|
||||
const unusedHost = statusSession();
|
||||
|
||||
expect(
|
||||
selectStatusModeSessions(
|
||||
{
|
||||
"host-a": hostA,
|
||||
"host-b": hostB,
|
||||
unused: unusedHost,
|
||||
},
|
||||
["host-b", "missing", "host-a"],
|
||||
),
|
||||
).toEqual([
|
||||
{ serverId: "host-b", workspaces: hostB.workspaces, agents: hostB.agents },
|
||||
{ serverId: "host-a", workspaces: hostA.workspaces, agents: hostA.agents },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps selector output equal when only wrapper objects change", () => {
|
||||
const workspaces = workspaceMap();
|
||||
const agents = agentMap();
|
||||
|
||||
const previous = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
|
||||
"host-a",
|
||||
]);
|
||||
const next = selectStatusModeSessions({ "host-a": statusSession({ workspaces, agents }) }, [
|
||||
"host-a",
|
||||
]);
|
||||
|
||||
expect(previous).not.toBe(next);
|
||||
expect(areStatusModeSessionsEqual(previous, next)).toBe(true);
|
||||
});
|
||||
|
||||
it("detects workspace or agent map changes for selected hosts", () => {
|
||||
const agents = agentMap();
|
||||
const previous = selectStatusModeSessions(
|
||||
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
|
||||
["host-a"],
|
||||
);
|
||||
const next = selectStatusModeSessions(
|
||||
{ "host-a": statusSession({ agents, workspaces: workspaceMap() }) },
|
||||
["host-a"],
|
||||
);
|
||||
|
||||
expect(areStatusModeSessionsEqual(previous, next)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,63 +1,100 @@
|
||||
import { useMemo } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
import {
|
||||
createSidebarWorkspaceEntry,
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
buildSidebarStatusWorkspacePlacements,
|
||||
type SidebarStatusWorkspacePlacement,
|
||||
type SidebarWorkspacePlacement,
|
||||
} from "./use-sidebar-workspaces-list";
|
||||
|
||||
const EMPTY_WORKSPACES: SidebarWorkspaceEntry[] = [];
|
||||
const EMPTY_WORKSPACES: SidebarStatusWorkspacePlacement[] = [];
|
||||
const EMPTY_STATUS_SESSIONS: StatusModeSession[] = [];
|
||||
const EMPTY_PENDING_CREATE_ATTEMPTS: ReturnType<
|
||||
typeof useCreateFlowStore.getState
|
||||
>["pendingByDraftId"] = {};
|
||||
|
||||
export function useStatusModeWorkspaceEntries(input: {
|
||||
serverId: string | null;
|
||||
projects: SidebarProjectEntry[];
|
||||
}): SidebarWorkspaceEntry[] {
|
||||
const workspaces = useSessionStore((state) =>
|
||||
input.serverId ? state.sessions[input.serverId]?.workspaces : undefined,
|
||||
interface StatusModeSessionSource {
|
||||
workspaces: SessionState["workspaces"];
|
||||
agents: SessionState["agents"];
|
||||
}
|
||||
|
||||
export interface StatusModeSession {
|
||||
serverId: string;
|
||||
workspaces: SessionState["workspaces"];
|
||||
agents: SessionState["agents"];
|
||||
}
|
||||
|
||||
export function selectStatusModeSessions(
|
||||
sessions: Record<string, StatusModeSessionSource | undefined>,
|
||||
serverIds: readonly string[],
|
||||
): StatusModeSession[] {
|
||||
const statusSessions: StatusModeSession[] = [];
|
||||
for (const serverId of serverIds) {
|
||||
const session = sessions[serverId];
|
||||
if (!session) {
|
||||
continue;
|
||||
}
|
||||
statusSessions.push({
|
||||
serverId,
|
||||
workspaces: session.workspaces,
|
||||
agents: session.agents,
|
||||
});
|
||||
}
|
||||
return statusSessions;
|
||||
}
|
||||
|
||||
export function areStatusModeSessionsEqual(
|
||||
left: readonly StatusModeSession[],
|
||||
right: readonly StatusModeSession[],
|
||||
): boolean {
|
||||
if (left.length !== right.length) {
|
||||
return false;
|
||||
}
|
||||
for (let index = 0; index < left.length; index += 1) {
|
||||
const leftSession = left[index];
|
||||
const rightSession = right[index];
|
||||
if (
|
||||
!leftSession ||
|
||||
!rightSession ||
|
||||
leftSession.serverId !== rightSession.serverId ||
|
||||
leftSession.workspaces !== rightSession.workspaces ||
|
||||
leftSession.agents !== rightSession.agents
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function useStatusModeWorkspacePlacements(input: {
|
||||
placements: SidebarWorkspacePlacement[];
|
||||
enabled?: boolean;
|
||||
}): SidebarStatusWorkspacePlacement[] {
|
||||
const isEnabled = input.enabled !== false && input.placements.length > 0;
|
||||
const serverIds = useMemo(
|
||||
() => Array.from(new Set(input.placements.map((placement) => placement.serverId))),
|
||||
[input.placements],
|
||||
);
|
||||
const agents = useSessionStore((state) =>
|
||||
input.serverId ? state.sessions[input.serverId]?.agents : undefined,
|
||||
const statusSessions = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) =>
|
||||
isEnabled ? selectStatusModeSessions(state.sessions, serverIds) : EMPTY_STATUS_SESSIONS,
|
||||
areStatusModeSessionsEqual,
|
||||
);
|
||||
const pendingCreateAttempts = useCreateFlowStore((state) =>
|
||||
isEnabled ? state.pendingByDraftId : EMPTY_PENDING_CREATE_ATTEMPTS,
|
||||
);
|
||||
const pendingCreateAttempts = useCreateFlowStore((state) => state.pendingByDraftId);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!input.serverId || input.projects.length === 0 || !workspaces) {
|
||||
if (!isEnabled) {
|
||||
return EMPTY_WORKSPACES;
|
||||
}
|
||||
|
||||
const entries: SidebarWorkspaceEntry[] = [];
|
||||
for (const placedWorkspace of input.projects.flatMap((project) => project.workspaces)) {
|
||||
const workspace = workspaces.get(placedWorkspace.workspaceId);
|
||||
entries.push(
|
||||
workspace
|
||||
? createSidebarWorkspaceEntry({
|
||||
serverId: input.serverId,
|
||||
workspace,
|
||||
pendingCreateAttempts,
|
||||
agents,
|
||||
})
|
||||
: placedWorkspace,
|
||||
);
|
||||
}
|
||||
return entries;
|
||||
}, [agents, input.projects, input.serverId, pendingCreateAttempts, workspaces]);
|
||||
}
|
||||
|
||||
export function useProjectNamesMap(serverId: string | null): Map<string, string> {
|
||||
const workspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
if (!serverId || !workspaces) return map;
|
||||
for (const workspace of workspaces.values()) {
|
||||
const key = workspace.project?.projectKey ?? workspace.projectId;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, workspace.projectCustomName ?? workspace.projectDisplayName);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [serverId, workspaces]);
|
||||
return buildSidebarStatusWorkspacePlacements({
|
||||
placements: input.placements,
|
||||
sessions: statusSessions,
|
||||
pendingCreateAttempts,
|
||||
});
|
||||
}, [input.placements, isEnabled, pendingCreateAttempts, statusSessions]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user