Group and clear workspace status in the sidebar (#1317)
* Add status grouping to the workspace sidebar * Mark workspaces as read from the sidebar * Clean package builds before release checks * Fix sidebar status follow-up checks
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SidebarWorkspaceEntry } from "./sidebar-workspaces-view-model";
|
||||
import {
|
||||
buildStatusGroups,
|
||||
buildStatusShortcutIndex,
|
||||
STATUS_BUCKET_LABELS,
|
||||
STATUS_BUCKET_ORDER,
|
||||
type StatusGroup,
|
||||
} from "./sidebar-status-view-model";
|
||||
|
||||
function ws(
|
||||
input: Partial<SidebarWorkspaceEntry> & { workspaceKey: string },
|
||||
): SidebarWorkspaceEntry {
|
||||
return {
|
||||
serverId: input.serverId ?? "srv",
|
||||
workspaceId: input.workspaceId ?? input.workspaceKey.split(":")[1] ?? "ws",
|
||||
projectKey: input.projectKey ?? "proj",
|
||||
projectRootPath: input.projectRootPath,
|
||||
workspaceDirectory: input.workspaceDirectory,
|
||||
projectKind: input.projectKind ?? "git",
|
||||
workspaceKind: input.workspaceKind ?? "worktree",
|
||||
name: input.name ?? "main",
|
||||
statusBucket: input.statusBucket ?? "done",
|
||||
statusEnteredAt: input.statusEnteredAt ?? null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
prHint: null,
|
||||
archiveHasUncommittedChanges: null,
|
||||
archiveUnpushedCommitCount: null,
|
||||
scripts: [],
|
||||
hasRunningScripts: false,
|
||||
workspaceKey: input.workspaceKey,
|
||||
};
|
||||
}
|
||||
|
||||
function d(iso: string): Date {
|
||||
return new Date(iso);
|
||||
}
|
||||
|
||||
const emptyProjectNames = new Map<string, string>();
|
||||
|
||||
describe("buildStatusGroups", () => {
|
||||
it("groups workspaces by status bucket in fixed order", () => {
|
||||
const workspaces = [
|
||||
ws({ workspaceKey: "srv:done-ws", statusBucket: "done", name: "done-ws" }),
|
||||
ws({
|
||||
workspaceKey: "srv:needs-input-ws",
|
||||
statusBucket: "needs_input",
|
||||
name: "needs-input-ws",
|
||||
}),
|
||||
ws({ workspaceKey: "srv:running-ws", statusBucket: "running", name: "running-ws" }),
|
||||
];
|
||||
|
||||
const groups = buildStatusGroups(workspaces, emptyProjectNames);
|
||||
|
||||
expect(groups.map((g) => g.bucket)).toEqual(["needs_input", "running", "done"]);
|
||||
expect(groups[0]?.label).toBe("Needs input");
|
||||
expect(groups[1]?.label).toBe("Working");
|
||||
expect(groups[2]?.label).toBe("Done");
|
||||
});
|
||||
|
||||
it("omits empty buckets", () => {
|
||||
const workspaces = [
|
||||
ws({ workspaceKey: "srv:a", statusBucket: "done" }),
|
||||
ws({ workspaceKey: "srv:b", statusBucket: "running" }),
|
||||
];
|
||||
|
||||
const groups = buildStatusGroups(workspaces, emptyProjectNames);
|
||||
|
||||
expect(groups.map((g) => g.bucket)).toEqual(["running", "done"]);
|
||||
});
|
||||
|
||||
it("sorts by statusEnteredAt desc within a bucket", () => {
|
||||
const workspaces = [
|
||||
ws({
|
||||
workspaceKey: "srv:old",
|
||||
statusBucket: "done",
|
||||
statusEnteredAt: d("2026-01-01T00:00:00Z"),
|
||||
}),
|
||||
ws({
|
||||
workspaceKey: "srv:new",
|
||||
statusBucket: "done",
|
||||
statusEnteredAt: d("2026-06-01T00:00:00Z"),
|
||||
}),
|
||||
ws({
|
||||
workspaceKey: "srv:mid",
|
||||
statusBucket: "done",
|
||||
statusEnteredAt: d("2026-03-01T00:00:00Z"),
|
||||
}),
|
||||
];
|
||||
|
||||
const groups = buildStatusGroups(workspaces, emptyProjectNames);
|
||||
|
||||
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:new", "srv:mid", "srv:old"]);
|
||||
});
|
||||
|
||||
it("sorts null timestamps last within a bucket", () => {
|
||||
const workspaces = [
|
||||
ws({ workspaceKey: "srv:null-a", statusBucket: "done", statusEnteredAt: null }),
|
||||
ws({
|
||||
workspaceKey: "srv:ts",
|
||||
statusBucket: "done",
|
||||
statusEnteredAt: d("2026-01-01T00:00:00Z"),
|
||||
}),
|
||||
ws({ workspaceKey: "srv:null-b", statusBucket: "done", statusEnteredAt: null }),
|
||||
];
|
||||
|
||||
const groups = buildStatusGroups(workspaces, emptyProjectNames);
|
||||
|
||||
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual([
|
||||
"srv:ts",
|
||||
"srv:null-a",
|
||||
"srv:null-b",
|
||||
]);
|
||||
});
|
||||
|
||||
it("tie-breaks by project name, then workspace name, then workspaceKey", () => {
|
||||
const projectNames = new Map<string, string>([
|
||||
["proj-b", "Beta"],
|
||||
["proj-a", "Alpha"],
|
||||
]);
|
||||
|
||||
const workspaces = [
|
||||
ws({ workspaceKey: "srv:1", statusBucket: "done", projectKey: "proj-b", name: "zebra" }),
|
||||
ws({ workspaceKey: "srv:2", statusBucket: "done", projectKey: "proj-a", name: "alpha" }),
|
||||
ws({ workspaceKey: "srv:3", statusBucket: "done", projectKey: "proj-a", name: "alpha" }),
|
||||
];
|
||||
|
||||
const groups = buildStatusGroups(workspaces, projectNames);
|
||||
|
||||
expect(groups[0]?.rows.map((r) => r.workspaceKey)).toEqual(["srv:2", "srv:3", "srv:1"]);
|
||||
});
|
||||
|
||||
it("returns empty array for no workspaces", () => {
|
||||
const groups = buildStatusGroups([], emptyProjectNames);
|
||||
expect(groups).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses hydrated workspace entries with real status, not structural placeholders", () => {
|
||||
const workspaces = [
|
||||
ws({
|
||||
workspaceKey: "srv:ni",
|
||||
statusBucket: "needs_input",
|
||||
statusEnteredAt: d("2026-01-01T00:00:00Z"),
|
||||
}),
|
||||
ws({
|
||||
workspaceKey: "srv:fail",
|
||||
statusBucket: "failed",
|
||||
statusEnteredAt: d("2026-01-01T00:00:00Z"),
|
||||
}),
|
||||
ws({
|
||||
workspaceKey: "srv:att",
|
||||
statusBucket: "attention",
|
||||
statusEnteredAt: d("2026-01-01T00:00:00Z"),
|
||||
}),
|
||||
ws({
|
||||
workspaceKey: "srv:run",
|
||||
statusBucket: "running",
|
||||
statusEnteredAt: d("2026-01-01T00:00:00Z"),
|
||||
}),
|
||||
ws({ workspaceKey: "srv:dn", statusBucket: "done", statusEnteredAt: null }),
|
||||
];
|
||||
|
||||
const groups = buildStatusGroups(workspaces, emptyProjectNames);
|
||||
|
||||
expect(groups.map((g) => g.bucket)).toEqual(STATUS_BUCKET_ORDER);
|
||||
expect(groups.map((g) => g.label)).toEqual(
|
||||
STATUS_BUCKET_ORDER.map((b) => STATUS_BUCKET_LABELS[b]),
|
||||
);
|
||||
// Each group has exactly one row with the matching bucket
|
||||
for (const group of groups) {
|
||||
expect(group.rows).toHaveLength(1);
|
||||
expect(group.rows[0]?.statusBucket).toBe(group.bucket);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStatusShortcutIndex", () => {
|
||||
it("assigns sequential numbers in status visual order", () => {
|
||||
const groups: StatusGroup[] = [
|
||||
{ bucket: "needs_input", label: "Needs input", rows: [ws({ workspaceKey: "srv:ni" })] },
|
||||
{
|
||||
bucket: "running",
|
||||
label: "Working",
|
||||
rows: [ws({ workspaceKey: "srv:run" }), ws({ workspaceKey: "srv:run2" })],
|
||||
},
|
||||
{ bucket: "done", label: "Done", rows: [ws({ workspaceKey: "srv:dn" })] },
|
||||
];
|
||||
|
||||
const index = buildStatusShortcutIndex(groups);
|
||||
|
||||
expect(index.get("srv:ni")).toBe(1);
|
||||
expect(index.get("srv:run")).toBe(2);
|
||||
expect(index.get("srv:run2")).toBe(3);
|
||||
expect(index.get("srv:dn")).toBe(4);
|
||||
});
|
||||
|
||||
it("stops at 9 shortcuts", () => {
|
||||
const rows = Array.from({ length: 12 }, (_, i) => ws({ workspaceKey: `srv:ws${i}` }));
|
||||
const groups: StatusGroup[] = [{ bucket: "done", label: "Done", rows }];
|
||||
|
||||
const index = buildStatusShortcutIndex(groups);
|
||||
|
||||
expect(index.size).toBe(9);
|
||||
expect(index.has("srv:ws8")).toBe(true);
|
||||
expect(index.has("srv:ws9")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns empty map for empty groups", () => {
|
||||
const index = buildStatusShortcutIndex([]);
|
||||
expect(index.size).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/sidebar-workspaces-view-model";
|
||||
|
||||
export type StatusBucket = SidebarWorkspaceEntry["statusBucket"];
|
||||
|
||||
export const STATUS_BUCKET_ORDER: readonly StatusBucket[] = [
|
||||
"needs_input",
|
||||
"failed",
|
||||
"attention",
|
||||
"running",
|
||||
"done",
|
||||
] as const;
|
||||
|
||||
export const STATUS_BUCKET_LABELS: Record<StatusBucket, string> = {
|
||||
needs_input: "Needs input",
|
||||
failed: "Failed",
|
||||
attention: "Ready to review",
|
||||
running: "Working",
|
||||
done: "Done",
|
||||
};
|
||||
|
||||
export interface StatusGroup {
|
||||
bucket: StatusBucket;
|
||||
label: string;
|
||||
rows: SidebarWorkspaceEntry[];
|
||||
}
|
||||
|
||||
export function buildStatusGroups(
|
||||
workspaces: SidebarWorkspaceEntry[],
|
||||
projectNamesByKey: Map<string, string>,
|
||||
): StatusGroup[] {
|
||||
const bucketRows = new Map<StatusBucket, SidebarWorkspaceEntry[]>();
|
||||
|
||||
for (const ws of workspaces) {
|
||||
const bucket: StatusBucket = ws.statusBucket;
|
||||
let rows = bucketRows.get(bucket);
|
||||
if (!rows) {
|
||||
rows = [];
|
||||
bucketRows.set(bucket, rows);
|
||||
}
|
||||
rows.push(ws);
|
||||
}
|
||||
|
||||
const groups: StatusGroup[] = [];
|
||||
|
||||
for (const bucket of STATUS_BUCKET_ORDER) {
|
||||
const rows = bucketRows.get(bucket);
|
||||
if (!rows || rows.length === 0) continue;
|
||||
|
||||
rows.sort((a, b) => compareStatusRows(a, b, projectNamesByKey));
|
||||
groups.push({ bucket, label: STATUS_BUCKET_LABELS[bucket], rows });
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function compareStatusRows(
|
||||
a: SidebarWorkspaceEntry,
|
||||
b: SidebarWorkspaceEntry,
|
||||
projectNamesByKey: Map<string, string>,
|
||||
): number {
|
||||
const aTime = a.statusEnteredAt?.getTime() ?? null;
|
||||
const bTime = b.statusEnteredAt?.getTime() ?? null;
|
||||
|
||||
if (aTime !== null && bTime !== null) {
|
||||
if (aTime !== bTime) return bTime - aTime;
|
||||
} else if (aTime !== null) {
|
||||
return -1;
|
||||
} else if (bTime !== null) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const aProject = projectNamesByKey.get(a.projectKey) ?? "";
|
||||
const bProject = projectNamesByKey.get(b.projectKey) ?? "";
|
||||
const projectCmp = aProject.localeCompare(bProject);
|
||||
if (projectCmp !== 0) return projectCmp;
|
||||
|
||||
const nameCmp = a.name.localeCompare(b.name);
|
||||
if (nameCmp !== 0) return nameCmp;
|
||||
|
||||
return a.workspaceKey.localeCompare(b.workspaceKey);
|
||||
}
|
||||
|
||||
export function buildStatusShortcutIndex(groups: StatusGroup[]): Map<string, number> {
|
||||
const index = new Map<string, number>();
|
||||
let shortcutNumber = 1;
|
||||
for (const group of groups) {
|
||||
for (const row of group.rows) {
|
||||
if (shortcutNumber > 9) return index;
|
||||
index.set(row.workspaceKey, shortcutNumber);
|
||||
shortcutNumber += 1;
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export interface SidebarWorkspaceEntry {
|
||||
workspaceKind: WorkspaceDescriptor["workspaceKind"];
|
||||
name: string;
|
||||
statusBucket: SidebarStateBucket;
|
||||
statusEnteredAt: Date | null;
|
||||
archivingAt: string | null;
|
||||
diffStat: { additions: number; deletions: number } | null;
|
||||
prHint: PrHint | null;
|
||||
@@ -50,6 +51,7 @@ function createStructuralWorkspaceEntry(input: {
|
||||
workspaceKind: "checkout",
|
||||
name: input.workspaceId,
|
||||
statusBucket: "done",
|
||||
statusEnteredAt: null,
|
||||
archivingAt: null,
|
||||
diffStat: null,
|
||||
prHint: null,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
export interface ClearWorkspaceAttentionController {
|
||||
hasClearableAttention: boolean;
|
||||
clearAttention: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useClearWorkspaceAttention({
|
||||
serverId,
|
||||
workspaceId,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
}): ClearWorkspaceAttentionController {
|
||||
const hasClearableAttention = useSessionStore((state) => {
|
||||
const workspace = state.sessions[serverId]?.workspaces.get(workspaceId);
|
||||
return workspace?.status === "attention" || workspace?.status === "failed";
|
||||
});
|
||||
|
||||
const clearAttention = useCallback(async () => {
|
||||
if (!hasClearableAttention) {
|
||||
return;
|
||||
}
|
||||
const client = getHostRuntimeStore().getClient(serverId);
|
||||
if (!client) {
|
||||
throw new Error("Host is not connected");
|
||||
}
|
||||
await client.clearWorkspaceAttention(workspaceId);
|
||||
}, [hasClearableAttention, serverId, workspaceId]);
|
||||
|
||||
return useMemo(
|
||||
() => ({ hasClearableAttention, clearAttention }),
|
||||
[clearAttention, hasClearableAttention],
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import { useActiveServerId } from "@/hooks/use-active-server-id";
|
||||
import {
|
||||
type ActiveWorkspaceSelection,
|
||||
navigateToLastWorkspace,
|
||||
useActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
@@ -56,6 +57,13 @@ export function useKeyboardShortcuts({
|
||||
const activeServerId = useActiveServerId();
|
||||
const openProjectPickerAction = useOpenProjectPicker(activeServerId);
|
||||
const activeWorkspaceSelection = useActiveWorkspaceSelection();
|
||||
const keyboardWorkspaceSelectionRef = useRef<ActiveWorkspaceSelection | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeWorkspaceSelection) {
|
||||
keyboardWorkspaceSelectionRef.current = activeWorkspaceSelection;
|
||||
}
|
||||
}, [activeWorkspaceSelection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
@@ -95,6 +103,10 @@ export function useKeyboardShortcuts({
|
||||
case "dispatch":
|
||||
return keyboardActionDispatcher.dispatch(action.action);
|
||||
case "navigate-workspace":
|
||||
keyboardWorkspaceSelectionRef.current = {
|
||||
serverId: action.serverId,
|
||||
workspaceId: action.workspaceId,
|
||||
};
|
||||
navigateToWorkspace(action.serverId, action.workspaceId, { currentPathname: pathname });
|
||||
return true;
|
||||
case "navigate-last-workspace":
|
||||
@@ -197,7 +209,8 @@ export function useKeyboardShortcuts({
|
||||
pathname,
|
||||
isMobile,
|
||||
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
|
||||
navigationActiveWorkspace: activeWorkspaceSelection,
|
||||
navigationActiveWorkspace:
|
||||
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
|
||||
commandCenterOpen: store.commandCenterOpen,
|
||||
shortcutsDialogOpen: store.shortcutsDialogOpen,
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ function buildWorkspacePayload() {
|
||||
name: "project",
|
||||
archivingAt: null,
|
||||
status: "done" as const,
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
|
||||
@@ -84,6 +84,7 @@ function workspace(input: {
|
||||
name: input.id,
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: null,
|
||||
diffStat: null,
|
||||
scripts: [],
|
||||
|
||||
@@ -46,6 +46,7 @@ export function createSidebarWorkspaceEntry(input: {
|
||||
workspaceKind: input.workspace.workspaceKind,
|
||||
name: input.workspace.name,
|
||||
statusBucket: input.workspace.status,
|
||||
statusEnteredAt: input.workspace.statusEnteredAt,
|
||||
archivingAt: input.workspace.archivingAt,
|
||||
diffStat: input.workspace.diffStat,
|
||||
prHint: selectPrHintFromStatus(input.workspace.githubRuntime?.pullRequest),
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from "react";
|
||||
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import {
|
||||
createSidebarWorkspaceEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "./use-sidebar-workspaces-list";
|
||||
|
||||
export function useHydratedWorkspaceEntries(serverId: string | null): SidebarWorkspaceEntry[] {
|
||||
const workspaces = useSessionStore((state) =>
|
||||
serverId ? state.sessions[serverId]?.workspaces : undefined,
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (!serverId || !workspaces || workspaces.size === 0) return [];
|
||||
return Array.from(workspaces.values()).map((workspace: WorkspaceDescriptor) =>
|
||||
createSidebarWorkspaceEntry({ serverId, workspace }),
|
||||
);
|
||||
}, [serverId, 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user