diff --git a/cmd/sgai/webapp/src/lib/workspace-sort.ts b/cmd/sgai/webapp/src/lib/workspace-sort.ts new file mode 100644 index 0000000..8579336 --- /dev/null +++ b/cmd/sgai/webapp/src/lib/workspace-sort.ts @@ -0,0 +1,22 @@ +const naturalWorkspaceLabelCollator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: "base", +}); + +export function sortByVisibleLabel( + items: readonly T[], + getLabel: (item: T) => string, + getKey: (item: T) => string, +): T[] { + return items + .map((item) => ({ item, label: getLabel(item), key: getKey(item) })) + .sort((left, right) => { + const labelComparison = naturalWorkspaceLabelCollator.compare(left.label, right.label); + if (labelComparison !== 0) { + return labelComparison; + } + + return naturalWorkspaceLabelCollator.compare(left.key, right.key); + }) + .map(({ item }) => item); +} diff --git a/cmd/sgai/webapp/src/pages/Dashboard.tsx b/cmd/sgai/webapp/src/pages/Dashboard.tsx index 9f9d97d..d1b58f6 100644 --- a/cmd/sgai/webapp/src/pages/Dashboard.tsx +++ b/cmd/sgai/webapp/src/pages/Dashboard.tsx @@ -35,15 +35,11 @@ import { isSameWorkspace, resolveWorkspaceByName, } from "@/lib/workspace-identity"; +import { sortByVisibleLabel } from "@/lib/workspace-sort"; type ForkEntry = NonNullable[number]; type WorkspaceLabelSource = Pick & Partial>; -const naturalWorkspaceLabelCollator = new Intl.Collator(undefined, { - numeric: true, - sensitivity: "base", -}); - function workspaceToForkEntry(ws: ApiWorkspaceEntry): ForkEntry { return { name: ws.name, @@ -72,24 +68,6 @@ function getOrphanPinnedForkDisplayLabel( return `${rootLabel}/${forkLabel}`; } -function sortByVisibleLabel( - items: readonly T[], - getLabel: (item: T) => string, - getKey: (item: T) => string, -): T[] { - return items - .map((item) => ({ item, label: getLabel(item), key: getKey(item) })) - .sort((left, right) => { - const labelComparison = naturalWorkspaceLabelCollator.compare(left.label, right.label); - if (labelComparison !== 0) { - return labelComparison; - } - - return naturalWorkspaceLabelCollator.compare(left.key, right.key); - }) - .map(({ item }) => item); -} - function getWorkspaceLabelSource( workspace: WorkspaceLabelSource, workspaceLookup: Map, diff --git a/cmd/sgai/webapp/src/pages/tabs/ForksTab.tsx b/cmd/sgai/webapp/src/pages/tabs/ForksTab.tsx index 2c6aadf..336ea77 100644 --- a/cmd/sgai/webapp/src/pages/tabs/ForksTab.tsx +++ b/cmd/sgai/webapp/src/pages/tabs/ForksTab.tsx @@ -20,6 +20,7 @@ import { getWorkspaceDisplayLabel, resolveWorkspaceByName, } from "@/lib/workspace-identity"; +import { sortByVisibleLabel } from "@/lib/workspace-sort"; import { useAdhocRun } from "@/hooks/useAdhocRun"; import type { ApiForkEntry, ApiActionEntry, ApiWorkspaceEntry } from "@/types"; @@ -385,6 +386,23 @@ export function ForksTab({ workspaceName, actions, actionConfigError, onActionCl return map; }, [allWorkspaces]); + const sortedForks = useMemo(() => { + const rawForks = workspace?.forks ?? []; + return sortByVisibleLabel( + rawForks, + (fork) => { + const forkWs = forkWorkspaceLookup.get(fork.dir); + const labelSource = { + ...(forkWs ?? fork), + title: fork.title || forkWs?.title || "", + computedTitle: fork.computedTitle || forkWs?.computedTitle || "", + }; + return getWorkspaceDisplayLabel(labelSource, workspaceNameDisambiguators); + }, + (fork) => fork.dir, + ); + }, [workspace?.forks, forkWorkspaceLookup, workspaceNameDisambiguators]); + if (fetchStatus === "fetching" && !workspace) return ; if (!workspace) { @@ -398,7 +416,7 @@ export function ForksTab({ workspaceName, actions, actionConfigError, onActionCl return null; } - const forks = workspace.forks ?? []; + const forks = sortedForks; const hasActionBar = Boolean((actions && actions.length > 0) || actionConfigError?.trim()); const workspaceLabel = getWorkspaceDisplayLabel(workspace, workspaceNameDisambiguators); diff --git a/cmd/sgai/webapp/src/pages/tabs/__tests__/ForksTab.test.tsx b/cmd/sgai/webapp/src/pages/tabs/__tests__/ForksTab.test.tsx index 9dcb2dd..24ab882 100644 --- a/cmd/sgai/webapp/src/pages/tabs/__tests__/ForksTab.test.tsx +++ b/cmd/sgai/webapp/src/pages/tabs/__tests__/ForksTab.test.tsx @@ -473,6 +473,47 @@ describe("ForksTab", () => { expect(screen.getByRole("button", { name: /^Delete$/ })).toBeTruthy(); }); + it("renders forks sorted by visible label matching left-tree order", () => { + factoryState.workspaces = [ + createMockWorkspace({ + forks: [ + { + name: "workspace-1-fork-c", + dir: "/path/to/workspace-1-fork-c", + running: false, + needsInput: false, + inProgress: false, + pinned: false, + title: "Charlie Fork", + }, + { + name: "workspace-1-fork-a", + dir: "/path/to/workspace-1-fork-a", + running: false, + needsInput: false, + inProgress: false, + pinned: false, + title: "Alpha Fork", + }, + { + name: "workspace-1-fork-b", + dir: "/path/to/workspace-1-fork-b", + running: false, + needsInput: false, + inProgress: false, + pinned: false, + title: "Bravo Fork", + }, + ], + }), + ]; + + render(forksTabTestView()); + + const forkLabels = screen.getAllByText(/^(Alpha|Bravo|Charlie) Fork$/).map((el) => el.textContent); + expect(forkLabels).toEqual(["Alpha Fork", "Bravo Fork", "Charlie Fork"]); + }); + it("hides running fork row actions when backend policy marks them hidden", async () => { factoryState.workspaces = [ createMockWorkspace({