diff --git a/apps/web/src/components/Sidebar.fleetTree.test.tsx b/apps/web/src/components/Sidebar.fleetTree.test.tsx new file mode 100644 index 000000000..8778ef567 --- /dev/null +++ b/apps/web/src/components/Sidebar.fleetTree.test.tsx @@ -0,0 +1,143 @@ +import type { + EnvironmentProject, + EnvironmentThreadShell, +} from "@t3tools/client-runtime/state/models"; +import { + EnvironmentId, + OrchestrationProjectShell, + OrchestrationThreadShell, + type ProjectIconColor, +} from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { act, useEffect } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; + +import { resetUiStateForTests } from "../uiStateStore"; +import { useFleetTreeRows } from "./Sidebar"; +import { buildFleetTree, type FleetParked } from "./sidebar/fleetSidebar.logic"; + +const ENVIRONMENT = EnvironmentId.make("environment-1"); +const decodeThread = Schema.decodeUnknownSync(OrchestrationThreadShell); +const decodeProject = Schema.decodeUnknownSync(OrchestrationProjectShell); + +const thread = ( + id: string, + projectId: string, + fleetRole: "second-mate" | "worker", + fleetRepo: string, + createdAt: string, +): EnvironmentThreadShell => ({ + ...decodeThread({ + id, + projectId, + title: id, + modelSelection: { instanceId: "claudeAgent", model: "claude-opus-5-5" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt, + updatedAt: createdAt, + fleetRole, + fleetRepo, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }), + environmentId: ENVIRONMENT, +}); + +const project = (id: string, color: ProjectIconColor): EnvironmentProject => ({ + ...decodeProject({ + id, + title: id, + workspaceRoot: `C:\\work\\${id}`, + defaultModelSelection: null, + projectIcon: { kind: "lucide", name: "folder-code", color }, + scripts: [], + createdAt: "2026-09-25T10:00:00.000Z", + updatedAt: "2026-09-25T10:00:00.000Z", + }), + environmentId: ENVIRONMENT, +}); + +// Two second mates, as in the report: firstmate with many workers, sheppi with one. +const THREADS = [ + thread("firstmate-mate", "firstmate", "second-mate", "firstmate", "2026-09-25T10:00:00.000Z"), + thread("firstmate-worker-1", "firstmate", "worker", "firstmate", "2026-09-25T10:01:00.000Z"), + thread("firstmate-worker-2", "firstmate", "worker", "firstmate", "2026-09-25T10:02:00.000Z"), + thread("sheppi-mate", "sheppi", "second-mate", "sheppi", "2026-09-25T10:03:00.000Z"), + thread("sheppi-worker", "sheppi", "worker", "sheppi", "2026-09-25T10:04:00.000Z"), +]; +const BRANCHES = buildFleetTree(THREADS, { parkedState: () => null }).branches; +const NOT_PARKED: ReadonlyMap = new Map(); + +const projectsWithSheppi = (color: ProjectIconColor) => + new Map( + [project("firstmate", "violet"), project("sheppi", color)].map((entry) => [ + `${entry.environmentId}:${entry.id}`, + entry, + ]), + ); + +type FleetTreeRows = ReturnType; +let rows: FleetTreeRows = []; +let renderer: ReactTestRenderer | null = null; + +function FleetTree(props: { readonly projectByKey: ReadonlyMap }) { + const next = useFleetTreeRows(BRANCHES, NOT_PARKED, props.projectByKey); + useEffect(() => { + rows = next; + }, [next]); + return null; +} + +function mountSidebar(projectByKey: ReadonlyMap) { + act(() => { + renderer = create(); + }); +} + +function unmountSidebar() { + act(() => renderer?.unmount()); + renderer = null; +} + +const shownIds = () => rows.map((row) => row.thread.id); +const rowFor = (id: string) => rows.find((row) => row.thread.id === id); +const iconColorOf = (id: string) => { + const theme = rowFor(id)?.placement.theme; + return theme?.kind === "project" ? theme.project?.projectIcon : undefined; +}; + +beforeEach(resetUiStateForTests); +afterEach(unmountSidebar); + +describe("sidebar fleet tree", () => { + it("keeps a folded second mate folded through Settings and a project icon change", () => { + mountSidebar(projectsWithSheppi("green")); + act(() => rowFor("firstmate-mate")?.placement.fold?.onToggle()); + expect(shownIds()).toEqual(["firstmate-mate", "sheppi-mate", "sheppi-worker"]); + + // Settings unmounts the sidebar. The user changes sheppi's icon colour there, then goes Back. + unmountSidebar(); + mountSidebar(projectsWithSheppi("blue")); + + expect(shownIds()).toEqual(["firstmate-mate", "sheppi-mate", "sheppi-worker"]); + expect(rowFor("firstmate-mate")?.placement.fold?.expanded).toBe(false); + }); + + it("gives a second mate and its workers the project's new colour at once", () => { + mountSidebar(projectsWithSheppi("green")); + expect(iconColorOf("sheppi-mate")).toMatchObject({ color: "green" }); + + // The sidebar stays open while another client changes the icon. + act(() => renderer?.update()); + + expect(iconColorOf("sheppi-mate")).toMatchObject({ color: "blue" }); + expect(iconColorOf("sheppi-worker")).toMatchObject({ color: "blue" }); + }); +}); diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 03f36d35d..f9fd7541c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -165,6 +165,7 @@ import { buildFleetRows, buildFleetTree, FIRST_MATE_TONE_CLASS, + type FleetBranch, FLEET_ROLE_WORDS, type FleetParked, resolveParkedState, @@ -2421,6 +2422,51 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { ); }); +/** + * The fleet tree as sidebar rows. Each second mate's fold is a saved sidebar + * setting, like a project's, and each row's colour comes from the project as + * it is now. + */ +export function useFleetTreeRows( + fleetBranches: readonly FleetBranch[], + fleetParked: ReadonlyMap, + projectByKey: ReadonlyMap, +) { + const fleetRepoExpandedById = useUiStateStore((store) => store.fleetRepoExpandedById); + const setFleetRepoExpanded = useUiStateStore((store) => store.setFleetRepoExpanded); + return useMemo( + () => + buildFleetRows(fleetBranches, { + isFolded: (repo) => fleetRepoExpandedById[repo] === false, + parkedState: (thread) => fleetParked.get(thread) ?? null, + }).map((row) => ({ + thread: row.thread, + // A parked fleet row draws as today's settled or snoozed row, so it + // keeps its un-settle or wake control where it stands. + section: row.parked ?? ("active" as const), + placement: { + depth: row.depth, + theme: { + kind: "project", + project: projectByKey.get(`${row.theme.environmentId}:${row.theme.projectId}`) ?? null, + repo: row.theme.fleetRepo ?? "", + }, + quiet: row.quiet, + parked: row.parked, + lifecycle: row.thread.fleetRole === "second-mate" ? "undo-only" : "default", + pin: row.pin, + fold: row.fold + ? { + expanded: row.fold.expanded, + onToggle: () => setFleetRepoExpanded(row.fold!.repo, !row.fold!.expanded), + } + : undefined, + } satisfies FleetRowPlacement, + })), + [fleetBranches, fleetParked, fleetRepoExpandedById, projectByKey, setFleetRepoExpanded], + ); +} + export default function Sidebar() { const projects = useProjects(); const projectOrder = useUiStateStore((store) => store.projectOrder); @@ -2946,44 +2992,7 @@ export default function Sidebar() { }; }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); - // Second mates fold their workers away. Folding is a view choice, so it - // lives here rather than on the server. - const [foldedFleetRepos, setFoldedFleetRepos] = useState>(() => new Set()); - const toggleFleetRepo = useCallback((repo: string) => { - setFoldedFleetRepos((previous) => { - const next = new Set(previous); - if (!next.delete(repo)) next.add(repo); - return next; - }); - }, []); - const fleetRows = useMemo( - () => - buildFleetRows(fleetBranches, { - isFolded: (repo) => foldedFleetRepos.has(repo), - parkedState: (thread) => fleetParked.get(thread) ?? null, - }).map((row) => ({ - thread: row.thread, - // A parked fleet row draws as today's settled or snoozed row, so it - // keeps its un-settle or wake control where it stands. - section: row.parked ?? ("active" as const), - placement: { - depth: row.depth, - theme: { - kind: "project", - project: projectByKey.get(`${row.theme.environmentId}:${row.theme.projectId}`) ?? null, - repo: row.theme.fleetRepo ?? "", - }, - quiet: row.quiet, - parked: row.parked, - lifecycle: row.thread.fleetRole === "second-mate" ? "undo-only" : "default", - pin: row.pin, - fold: row.fold - ? { expanded: row.fold.expanded, onToggle: () => toggleFleetRepo(row.fold!.repo) } - : undefined, - } satisfies FleetRowPlacement, - })), - [fleetBranches, fleetParked, foldedFleetRepos, projectByKey, toggleFleetRepo], - ); + const fleetRows = useFleetTreeRows(fleetBranches, fleetParked, projectByKey); const fleetThreads = useMemo(() => fleetRows.map((row) => row.thread), [fleetRows]); // Every thread in the fleet tree, folded ones included. const fleetTreeThreadKeys = useMemo( diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 4d68a3231..cd3474413 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -21,6 +21,7 @@ import { function makeUiState(overrides: Partial = {}): UiState { return { projectExpandedById: {}, + fleetRepoExpandedById: {}, projectOrder: [], sidebarProjectScopeKey: null, threadLastVisitedAtById: {}, @@ -196,6 +197,7 @@ describe("parsePersistedState", () => { projectExpandedById: { logical: false, }, + fleetRepoExpandedById: {}, projectOrder: ["physical-b", "physical-a"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", @@ -318,6 +320,7 @@ describe("uiStateStore persistence", () => { projectExpandedById: { logical: false, }, + fleetRepoExpandedById: {}, projectOrder: ["physical-b", "physical-a"], threadLastVisitedAtById: { "environment:thread-1": "2026-02-25T12:35:00.000Z", @@ -350,6 +353,17 @@ describe("uiStateStore persistence", () => { ); }); + it("restores folded second mates across reloads", () => { + persistState(makeUiState({ fleetRepoExpandedById: { firstmate: false } })); + + const persisted = JSON.parse( + localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}", + ) as PersistedUiState; + + expect(parsePersistedState(persisted).fleetRepoExpandedById).toEqual({ firstmate: false }); + expect(parsePersistedState({}).fleetRepoExpandedById).toEqual({}); + }); + it("drops the temporary expanded-only migration fallback when rewriting state", () => { const migrated = parsePersistedState({ expandedProjectCwds: ["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/repo/a"], diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index e82c86f26..7a53b398b 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -21,6 +21,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [ export interface PersistedUiState { projectExpandedById?: Record; + fleetRepoExpandedById?: Record; projectOrder?: string[]; threadLastVisitedAtById?: Record; collapsedProjectCwds?: string[]; @@ -35,6 +36,9 @@ export interface PersistedUiState { export interface UiProjectState { projectExpandedById: Record; + // Whether each second mate in the fleet tree shows its workers, by fleet + // repository. A repository with no entry shows them. + fleetRepoExpandedById: Record; projectOrder: string[]; // Logical project key the sidebar list is scoped to, or null for "all // projects". Lives here so routes that unmount the sidebar (Settings) @@ -60,6 +64,7 @@ export interface UiState const initialState: UiState = { projectExpandedById: {}, + fleetRepoExpandedById: {}, projectOrder: [], sidebarProjectScopeKey: null, threadLastVisitedAtById: {}, @@ -147,6 +152,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState { return { projectExpandedById, + fleetRepoExpandedById: sanitizeBooleanRecord(parsed.fleetRepoExpandedById), projectOrder, threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById), threadChangedFilesExpandedById: @@ -225,6 +231,7 @@ export function persistState(state: UiState): void { PERSISTED_STATE_KEY, JSON.stringify({ projectExpandedById, + fleetRepoExpandedById: state.fleetRepoExpandedById, projectOrder: state.projectOrder, threadLastVisitedAtById: state.threadLastVisitedAtById, defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey, @@ -379,6 +386,16 @@ export function setProjectExpanded( }; } +function setFleetRepoExpanded(state: UiState, repo: string, expanded: boolean): UiState { + if ((state.fleetRepoExpandedById[repo] ?? true) === expanded) { + return state; + } + return { + ...state, + fleetRepoExpandedById: { ...state.fleetRepoExpandedById, [repo]: expanded }, + }; +} + export function reorderProjects( state: UiState, currentProjectOrder: readonly string[], @@ -431,6 +448,7 @@ interface UiStateStore extends UiState { setSidebarProjectScopeKey: (projectKey: string | null) => void; setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void; setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void; + setFleetRepoExpanded: (repo: string, expanded: boolean) => void; reorderProjects: ( currentProjectOrder: readonly string[], draggedProjectIds: readonly string[], @@ -453,6 +471,8 @@ export const useUiStateStore = create((set) => ({ setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)), setProjectExpanded: (projectIds, expanded) => set((state) => setProjectExpanded(state, projectIds, expanded)), + setFleetRepoExpanded: (repo, expanded) => + set((state) => setFleetRepoExpanded(state, repo, expanded)), reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) => set((state) => reorderProjects(state, currentProjectOrder, draggedProjectIds, targetProjectIds), @@ -461,6 +481,10 @@ export const useUiStateStore = create((set) => ({ useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state)); +export function resetUiStateForTests(): void { + useUiStateStore.setState(initialState); +} + if (typeof window !== "undefined" && typeof window.addEventListener === "function") { window.addEventListener("beforeunload", () => { debouncedPersistState.flush(); diff --git a/fork-features.json b/fork-features.json index c09a34266..536f46939 100644 --- a/fork-features.json +++ b/fork-features.json @@ -162,8 +162,8 @@ }, { "name": "fm-fleet-sidebar-tree", - "description": "The sidebar shows First Mate's fleet as a tree above the other threads. First Mate always holds the top, above a divider: the pinned First Mate, or else the newest live one, with any others under it; with none live, a Start First Mate row stands there instead. Second mates follow in repository-name order and never move with activity; each worker is indented under the second mate that shares its fleetRepo, and a second mate can fold its workers away. A settled or snoozed worker leaves the tree and lists in the Settled or Snoozed section with the other threads, and un-settling or waking it puts it back under its second mate. A settled second mate leaves the tree for Settled with all its workers, each listed by its own state, and un-settling it brings it and its live workers back. A snoozed second mate stays a full row in the tree, dimmed, with its wake control, and a pinned second mate keeps its unpin. One rule, resolveParkedState, decides parking for both the tree and the sections, and a drag out of Settled or Snoozed lands a worker straight in the tree. A compact row in Settled or Snoozed always shows its title and the whole role word, and leaves the model and thinking level to the hover card. Fleet rows name their role on the model line in their own colour, then the model and reasoning level, with a ship, sailboat or pickaxe beside the project icon. A second mate's colour, and its workers' quieter one, comes from its project icon as the sidebar shows it: a badge's own colour, or an image logo's main colour read once from the image, with only its lightness set for each theme. Only an icon with no usable colour falls back to a stable colour from the repository name. First Mate keeps its pink.", - "keep": "fleetSidebar.logic.ts, fleetTone.logic.ts, FleetTone.tsx, their tests, FirstMateIcon.tsx and FleetRoleIcon.tsx are ours whole. In Sidebar.tsx the fleet tree rows, the parkedStateOf helper that calls resolveParkedState and feeds both buildFleetTree and the section loop through sidebarSectionFor, the fleetTreeThreadKeys hold on a drag that lands in the tree, the compact row that keeps the whole role word and drops the model badges, the FleetRowPlacement prop and onPin on SidebarThreadRow, the role line, the fold arrow, the First Mate slot with its Start First Mate row and divider survive; partitionFirstMateThreads survives in Sidebar.logic.ts and its tests in Sidebar.logic.test.ts; the First Mate threads section of docs/user/thread-sidebar.md is ours. Everything else in those files takes upstream.", + "description": "The sidebar shows First Mate's fleet as a tree above the other threads. First Mate always holds the top, above a divider: the pinned First Mate, or else the newest live one, with any others under it; with none live, a Start First Mate row stands there instead. Second mates follow in repository-name order and never move with activity; each worker is indented under the second mate that shares its fleetRepo, and a second mate can fold its workers away; the fold is saved with the other sidebar settings, like a project fold, so it survives Settings, project edits and a restart. A settled or snoozed worker leaves the tree and lists in the Settled or Snoozed section with the other threads, and un-settling or waking it puts it back under its second mate. A settled second mate leaves the tree for Settled with all its workers, each listed by its own state, and un-settling it brings it and its live workers back. A snoozed second mate stays a full row in the tree, dimmed, with its wake control, and a pinned second mate keeps its unpin. One rule, resolveParkedState, decides parking for both the tree and the sections, and a drag out of Settled or Snoozed lands a worker straight in the tree. A compact row in Settled or Snoozed always shows its title and the whole role word, and leaves the model and thinking level to the hover card. Fleet rows name their role on the model line in their own colour, then the model and reasoning level, with a ship, sailboat or pickaxe beside the project icon. A second mate's colour, and its workers' quieter one, comes from its project icon as the sidebar shows it: a badge's own colour, or an image logo's main colour read once from the image, with only its lightness set for each theme. Only an icon with no usable colour falls back to a stable colour from the repository name. First Mate keeps its pink.", + "keep": "fleetSidebar.logic.ts, fleetTone.logic.ts, FleetTone.tsx, their tests, Sidebar.fleetTree.test.tsx, FirstMateIcon.tsx and FleetRoleIcon.tsx are ours whole. In Sidebar.tsx the fleet tree rows built by useFleetTreeRows, the parkedStateOf helper that calls resolveParkedState and feeds both buildFleetTree and the section loop through sidebarSectionFor, the fleetTreeThreadKeys hold on a drag that lands in the tree, the compact row that keeps the whole role word and drops the model badges, the FleetRowPlacement prop and onPin on SidebarThreadRow, the role line, the fold arrow, the First Mate slot with its Start First Mate row and divider survive; partitionFirstMateThreads survives in Sidebar.logic.ts and its tests in Sidebar.logic.test.ts; the First Mate threads section of docs/user/thread-sidebar.md is ours; in uiStateStore.ts the fleetRepoExpandedById field, its load and save lines, setFleetRepoExpanded and resetUiStateForTests survive, and in uiStateStore.test.ts the fleetRepoExpandedById lines and the folded second mates test survive. Everything else in those files takes upstream.", "files": [ "apps/web/src/components/sidebar/fleetSidebar.logic.ts", "apps/web/src/components/sidebar/fleetTone.logic.ts", @@ -173,11 +173,14 @@ "apps/web/src/components/Sidebar.tsx", "apps/web/src/components/Sidebar.logic.ts", "apps/web/src/components/Sidebar.logic.test.ts", + "apps/web/src/uiStateStore.ts", + "apps/web/src/uiStateStore.test.ts", "docs/user/thread-sidebar.md" ], "test": [ "apps/web/src/components/sidebar/fleetSidebar.logic.test.ts", - "apps/web/src/components/sidebar/fleetTone.logic.test.ts" + "apps/web/src/components/sidebar/fleetTone.logic.test.ts", + "apps/web/src/components/Sidebar.fleetTree.test.tsx" ], "patchesUpstream": true },