Improve startup provider snapshots and dev harness
This commit is contained in:
@@ -88,6 +88,25 @@ describe("workspace agent visibility", () => {
|
||||
expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false);
|
||||
});
|
||||
|
||||
it("treats lazy historical details as known without making them active", () => {
|
||||
const workspaceDirectory = "/repo/worktree";
|
||||
const active = makeAgent({ id: "active-agent", cwd: workspaceDirectory });
|
||||
const historicalDetail = makeAgent({
|
||||
id: "historical-agent",
|
||||
cwd: workspaceDirectory,
|
||||
archivedAt: new Date("2026-03-04T00:01:00.000Z"),
|
||||
});
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents: new Map([[active.id, active]]),
|
||||
agentDetails: new Map([[historicalDetail.id, historicalDetail]]),
|
||||
workspaceDirectory,
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["active-agent"]));
|
||||
expect(result.knownAgentIds).toEqual(new Set(["active-agent", "historical-agent"]));
|
||||
});
|
||||
|
||||
it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => {
|
||||
const knownAgentIds = new Set(["archived-agent"]);
|
||||
const activeAgentIds = new Set<string>();
|
||||
|
||||
@@ -12,11 +12,12 @@ export interface WorkspaceAgentVisibility {
|
||||
|
||||
export function deriveWorkspaceAgentVisibility(input: {
|
||||
sessionAgents: Map<string, Agent> | undefined;
|
||||
agentDetails?: Map<string, Agent> | undefined;
|
||||
workspaceDirectory: string | null | undefined;
|
||||
}): WorkspaceAgentVisibility {
|
||||
const { sessionAgents, workspaceDirectory } = input;
|
||||
const { sessionAgents, agentDetails, workspaceDirectory } = input;
|
||||
const normalizedWorkspaceDirectory = normalizeWorkspaceId(workspaceDirectory);
|
||||
if (!sessionAgents || !normalizedWorkspaceDirectory) {
|
||||
if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) {
|
||||
return {
|
||||
activeAgentIds: new Set<string>(),
|
||||
knownAgentIds: new Set<string>(),
|
||||
@@ -25,7 +26,7 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
|
||||
const activeAgentIds = new Set<string>();
|
||||
const knownAgentIds = new Set<string>();
|
||||
for (const agent of sessionAgents.values()) {
|
||||
for (const agent of sessionAgents?.values() ?? []) {
|
||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
continue;
|
||||
}
|
||||
@@ -34,6 +35,12 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
activeAgentIds.add(agent.id);
|
||||
}
|
||||
}
|
||||
for (const agent of agentDetails?.values() ?? []) {
|
||||
if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) {
|
||||
continue;
|
||||
}
|
||||
knownAgentIds.add(agent.id);
|
||||
}
|
||||
|
||||
return { activeAgentIds, knownAgentIds };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
export type WorkspaceHeaderCheckoutState =
|
||||
| { kind: "pending" }
|
||||
| { kind: "error" }
|
||||
| { kind: "ready"; checkout: { isGit: boolean; currentBranch: string | null } };
|
||||
|
||||
type WorkspaceHeaderRenderState =
|
||||
| { kind: "skeleton" }
|
||||
| {
|
||||
kind: "ready";
|
||||
title: string;
|
||||
subtitle: string;
|
||||
shouldShowSubtitle: boolean;
|
||||
isGitCheckout: boolean;
|
||||
currentBranchName: string | null;
|
||||
};
|
||||
|
||||
function trimNonEmpty(value: string | null | undefined): string | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function areHeaderLabelsEquivalent(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
): boolean {
|
||||
const normalizedA = trimNonEmpty(a)?.toLocaleLowerCase();
|
||||
const normalizedB = trimNonEmpty(b)?.toLocaleLowerCase();
|
||||
if (!normalizedA || !normalizedB) {
|
||||
return false;
|
||||
}
|
||||
return normalizedA === normalizedB;
|
||||
}
|
||||
|
||||
export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }): {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
@@ -10,6 +46,35 @@ export function resolveWorkspaceHeader(input: { workspace: WorkspaceDescriptor }
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveWorkspaceHeaderRenderState(input: {
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
checkoutState: WorkspaceHeaderCheckoutState;
|
||||
}): WorkspaceHeaderRenderState {
|
||||
if (!input.workspace) {
|
||||
return { kind: "skeleton" };
|
||||
}
|
||||
|
||||
if (input.checkoutState.kind === "pending" && input.workspace.projectKind === "git") {
|
||||
return { kind: "skeleton" };
|
||||
}
|
||||
|
||||
const header = resolveWorkspaceHeader({ workspace: input.workspace });
|
||||
const checkout = input.checkoutState.kind === "ready" ? input.checkoutState.checkout : null;
|
||||
const currentBranchName =
|
||||
checkout?.isGit && checkout.currentBranch !== "HEAD"
|
||||
? trimNonEmpty(checkout.currentBranch)
|
||||
: null;
|
||||
|
||||
return {
|
||||
kind: "ready",
|
||||
title: header.title,
|
||||
subtitle: header.subtitle,
|
||||
shouldShowSubtitle: !areHeaderLabelsEquivalent(header.title, header.subtitle),
|
||||
isGitCheckout: checkout?.isGit ?? false,
|
||||
currentBranchName,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRenderMissingWorkspaceDescriptor(input: {
|
||||
workspace: WorkspaceDescriptor | null;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
|
||||
@@ -96,8 +96,9 @@ import {
|
||||
import { buildWorkspaceTabMenuEntries } from "@/screens/workspace/workspace-tab-menu";
|
||||
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
|
||||
import {
|
||||
resolveWorkspaceHeader,
|
||||
resolveWorkspaceHeaderRenderState,
|
||||
shouldRenderMissingWorkspaceDescriptor,
|
||||
type WorkspaceHeaderCheckoutState,
|
||||
} from "@/screens/workspace/workspace-header-source";
|
||||
import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
@@ -153,18 +154,6 @@ function decodeSegment(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function areHeaderLabelsEquivalent(
|
||||
a: string | null | undefined,
|
||||
b: string | null | undefined,
|
||||
): boolean {
|
||||
const normalizedA = trimNonEmpty(a)?.toLocaleLowerCase();
|
||||
const normalizedB = trimNonEmpty(b)?.toLocaleLowerCase();
|
||||
if (!normalizedA || !normalizedB) {
|
||||
return false;
|
||||
}
|
||||
return normalizedA === normalizedB;
|
||||
}
|
||||
|
||||
function getFallbackTabOptionLabel(tab: WorkspaceTabDescriptor): string {
|
||||
if (tab.target.kind === "draft") {
|
||||
return "New Agent";
|
||||
@@ -658,9 +647,8 @@ function WorkspaceScreenContent({
|
||||
workspaceDescriptor && !workspaceExecutionAuthority,
|
||||
);
|
||||
|
||||
// Warm the server-side provider snapshot for this workspace cwd so the model
|
||||
// picker is ready when opened. Consumers share the same query cache key.
|
||||
useProvidersSnapshot(normalizedServerId, workspaceDirectory, {
|
||||
// Warm the global provider snapshot so the model picker is ready when opened.
|
||||
useProvidersSnapshot(normalizedServerId, {
|
||||
enabled: isRouteFocused,
|
||||
});
|
||||
const [pendingTerminalCreateInput, setPendingTerminalCreateInput] = useState<{
|
||||
@@ -675,6 +663,7 @@ function WorkspaceScreenContent({
|
||||
(state) =>
|
||||
deriveWorkspaceAgentVisibility({
|
||||
sessionAgents: state.sessions[normalizedServerId]?.agents,
|
||||
agentDetails: state.sessions[normalizedServerId]?.agentDetails,
|
||||
workspaceDirectory,
|
||||
}),
|
||||
workspaceAgentVisibilityEqual,
|
||||
@@ -879,21 +868,36 @@ function WorkspaceScreenContent({
|
||||
pendingTerminalCreateInput,
|
||||
toast,
|
||||
]);
|
||||
const workspaceHeader = workspaceDescriptor
|
||||
? resolveWorkspaceHeader({ workspace: workspaceDescriptor })
|
||||
: null;
|
||||
const isWorkspaceHeaderLoading = workspaceHeader === null || isCheckoutStatusLoading;
|
||||
const workspaceHeaderTitle = workspaceHeader?.title ?? "";
|
||||
const workspaceHeaderSubtitle = workspaceHeader?.subtitle ?? "";
|
||||
const shouldShowWorkspaceHeaderSubtitle = !areHeaderLabelsEquivalent(
|
||||
workspaceHeaderTitle,
|
||||
workspaceHeaderSubtitle,
|
||||
);
|
||||
|
||||
const isGitCheckout = checkoutQuery.data?.isGit ?? false;
|
||||
let workspaceHeaderCheckoutState: WorkspaceHeaderCheckoutState;
|
||||
if (isCheckoutStatusLoading) {
|
||||
workspaceHeaderCheckoutState = { kind: "pending" };
|
||||
} else if (checkoutQuery.isError || !checkoutQuery.data) {
|
||||
workspaceHeaderCheckoutState = { kind: "error" };
|
||||
} else {
|
||||
workspaceHeaderCheckoutState = {
|
||||
kind: "ready",
|
||||
checkout: {
|
||||
isGit: checkoutQuery.data.isGit,
|
||||
currentBranch: checkoutQuery.data.currentBranch,
|
||||
},
|
||||
};
|
||||
}
|
||||
const workspaceHeaderRenderState = resolveWorkspaceHeaderRenderState({
|
||||
workspace: workspaceDescriptor,
|
||||
checkoutState: workspaceHeaderCheckoutState,
|
||||
});
|
||||
const isWorkspaceHeaderLoading = workspaceHeaderRenderState.kind === "skeleton";
|
||||
const workspaceHeaderTitle =
|
||||
workspaceHeaderRenderState.kind === "ready" ? workspaceHeaderRenderState.title : "";
|
||||
const workspaceHeaderSubtitle =
|
||||
workspaceHeaderRenderState.kind === "ready" ? workspaceHeaderRenderState.subtitle : "";
|
||||
const shouldShowWorkspaceHeaderSubtitle =
|
||||
workspaceHeaderRenderState.kind === "ready" && workspaceHeaderRenderState.shouldShowSubtitle;
|
||||
const isGitCheckout =
|
||||
workspaceHeaderRenderState.kind === "ready" ? workspaceHeaderRenderState.isGitCheckout : false;
|
||||
const currentBranchName =
|
||||
checkoutQuery.data?.isGit && checkoutQuery.data.currentBranch !== "HEAD"
|
||||
? trimNonEmpty(checkoutQuery.data.currentBranch)
|
||||
workspaceHeaderRenderState.kind === "ready"
|
||||
? workspaceHeaderRenderState.currentBranchName
|
||||
: null;
|
||||
|
||||
const isExplorerOpen = usePanelStore((state) =>
|
||||
|
||||
@@ -6,26 +6,32 @@ vi.hoisted(() => {
|
||||
|
||||
import {
|
||||
resolveWorkspaceHeader,
|
||||
resolveWorkspaceHeaderRenderState,
|
||||
shouldRenderMissingWorkspaceDescriptor,
|
||||
} from "./workspace-header-source";
|
||||
import { createSidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
|
||||
function createWorkspaceDescriptor(input: Partial<WorkspaceDescriptor> = {}): WorkspaceDescriptor {
|
||||
return {
|
||||
id: "/repo/main",
|
||||
projectId: "remote:github.com/getpaseo/paseo",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
projectRootPath: "/repo/main",
|
||||
workspaceDirectory: "/repo/main",
|
||||
projectKind: "git",
|
||||
workspaceKind: "local_checkout",
|
||||
name: "feat/workspace-sot",
|
||||
status: "running",
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
...input,
|
||||
};
|
||||
}
|
||||
|
||||
describe("workspace source of truth consumption", () => {
|
||||
it("uses the same descriptor name in header and sidebar row", () => {
|
||||
const workspace: WorkspaceDescriptor = {
|
||||
id: "/repo/main",
|
||||
projectId: "remote:github.com/getpaseo/paseo",
|
||||
projectDisplayName: "getpaseo/paseo",
|
||||
projectRootPath: "/repo/main",
|
||||
workspaceDirectory: "/repo/main",
|
||||
projectKind: "git",
|
||||
workspaceKind: "checkout",
|
||||
name: "feat/workspace-sot",
|
||||
status: "running",
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
};
|
||||
const workspace = createWorkspaceDescriptor();
|
||||
|
||||
const header = resolveWorkspaceHeader({ workspace });
|
||||
const sidebarWorkspace = createSidebarWorkspaceEntry({
|
||||
@@ -39,6 +45,104 @@ describe("workspace source of truth consumption", () => {
|
||||
expect(sidebarWorkspace.statusBucket).toBe("running");
|
||||
});
|
||||
|
||||
it("keeps the header skeleton while the workspace descriptor is missing", () => {
|
||||
expect(
|
||||
resolveWorkspaceHeaderRenderState({
|
||||
workspace: null,
|
||||
checkoutState: { kind: "pending" },
|
||||
}),
|
||||
).toEqual({ kind: "skeleton" });
|
||||
});
|
||||
|
||||
it("keeps git workspace headers skeletoned until checkout status resolves", () => {
|
||||
expect(
|
||||
resolveWorkspaceHeaderRenderState({
|
||||
workspace: createWorkspaceDescriptor({ projectKind: "git" }),
|
||||
checkoutState: { kind: "pending" },
|
||||
}),
|
||||
).toEqual({ kind: "skeleton" });
|
||||
});
|
||||
|
||||
it("renders known non-git workspace identity while checkout status is pending", () => {
|
||||
expect(
|
||||
resolveWorkspaceHeaderRenderState({
|
||||
workspace: createWorkspaceDescriptor({
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "notes",
|
||||
projectDisplayName: "Local folders",
|
||||
}),
|
||||
checkoutState: { kind: "pending" },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "ready",
|
||||
title: "notes",
|
||||
subtitle: "Local folders",
|
||||
shouldShowSubtitle: true,
|
||||
isGitCheckout: false,
|
||||
currentBranchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders git checkout headers with branch affordance after checkout status resolves", () => {
|
||||
expect(
|
||||
resolveWorkspaceHeaderRenderState({
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
checkoutState: {
|
||||
kind: "ready",
|
||||
checkout: { isGit: true, currentBranch: "feat/workspace-sot" },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "ready",
|
||||
title: "feat/workspace-sot",
|
||||
subtitle: "getpaseo/paseo",
|
||||
shouldShowSubtitle: true,
|
||||
isGitCheckout: true,
|
||||
currentBranchName: "feat/workspace-sot",
|
||||
});
|
||||
});
|
||||
|
||||
it("renders non-git checkout headers without branch affordance after checkout status resolves", () => {
|
||||
expect(
|
||||
resolveWorkspaceHeaderRenderState({
|
||||
workspace: createWorkspaceDescriptor({
|
||||
projectKind: "non_git",
|
||||
workspaceKind: "directory",
|
||||
name: "notes",
|
||||
projectDisplayName: "notes",
|
||||
}),
|
||||
checkoutState: {
|
||||
kind: "ready",
|
||||
checkout: { isGit: false, currentBranch: null },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "ready",
|
||||
title: "notes",
|
||||
subtitle: "notes",
|
||||
shouldShowSubtitle: false,
|
||||
isGitCheckout: false,
|
||||
currentBranchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders descriptor identity after checkout status errors", () => {
|
||||
expect(
|
||||
resolveWorkspaceHeaderRenderState({
|
||||
workspace: createWorkspaceDescriptor(),
|
||||
checkoutState: { kind: "error" },
|
||||
}),
|
||||
).toEqual({
|
||||
kind: "ready",
|
||||
title: "feat/workspace-sot",
|
||||
subtitle: "getpaseo/paseo",
|
||||
shouldShowSubtitle: true,
|
||||
isGitCheckout: false,
|
||||
currentBranchName: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders explicit missing state only after workspace hydration", () => {
|
||||
expect(
|
||||
shouldRenderMissingWorkspaceDescriptor({
|
||||
|
||||
Reference in New Issue
Block a user