From b5e320b91bfbb877d708e8d088bcdb7c880c3742 Mon Sep 17 00:00:00 2001 From: autoprintworks Date: Fri, 25 Sep 2026 23:04:59 +0100 Subject: [PATCH 1/3] fix(web): folded second mates stay folded after a trip through Settings The fleet tree kept its folds in the Sidebar component. Settings swaps that component out, so pressing Back reopened every second mate. After a project icon edit, the firstmate second mate's settled workers filled the list and pushed sheppi out of view. The folds now live in a small store for the app session, so they survive Settings and project edits. They are still not saved across restarts. --- apps/web/src/components/Sidebar.tsx | 15 ++--- .../components/sidebar/fleetFolds.test.tsx | 63 +++++++++++++++++++ apps/web/src/components/sidebar/fleetFolds.ts | 20 ++++++ fork-features.json | 8 ++- 4 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/sidebar/fleetFolds.test.tsx create mode 100644 apps/web/src/components/sidebar/fleetFolds.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 03f36d35d..fb4b71130 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -170,6 +170,7 @@ import { resolveParkedState, sidebarSectionFor, } from "./sidebar/fleetSidebar.logic"; +import { useFleetFolds } from "./sidebar/fleetFolds"; import { FleetTone, type FleetTheme } from "./sidebar/FleetTone"; import { resolveReadOnlyThreadModel } from "./chat/readOnlyThreadModel.logic"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; @@ -2946,16 +2947,10 @@ 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; - }); - }, []); + // Second mates fold their workers away. The folds outlive this component, + // which Settings unmounts. + const foldedFleetRepos = useFleetFolds((folds) => folds.folded); + const toggleFleetRepo = useFleetFolds((folds) => folds.toggle); const fleetRows = useMemo( () => buildFleetRows(fleetBranches, { diff --git a/apps/web/src/components/sidebar/fleetFolds.test.tsx b/apps/web/src/components/sidebar/fleetFolds.test.tsx new file mode 100644 index 000000000..eba2212d5 --- /dev/null +++ b/apps/web/src/components/sidebar/fleetFolds.test.tsx @@ -0,0 +1,63 @@ +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { useFleetFolds } from "./fleetFolds"; + +// Stands in for the sidebar: it shows two second mates' folds and toggles one. +// The project colour prop changes when the user edits the project's icon. +function SecondMateFolds(props: { readonly repos: readonly string[]; readonly color: string }) { + const { folded, toggle } = useFleetFolds(); + return ( + <> + {props.repos.map((repo) => ( + + ))} + + ); +} + +let renderer: ReactTestRenderer | undefined; + +function mount(repos: readonly string[], color: string) { + act(() => { + renderer = create(); + }); +} + +const labels = () => renderer!.root.findAllByType("button").map((button) => button.children[0]); +const toggle = (index: number) => + act(() => renderer!.root.findAllByType("button")[index]!.props.onClick()); + +afterEach(() => { + act(() => renderer?.unmount()); + renderer = undefined; +}); + +// Each test uses its own repository names: the folds live for the app session. +describe("fleet folds", () => { + it("keep a second mate folded through Settings and a project icon change", () => { + mount(["firstmate", "sheppi"], "green"); + expect(labels()).toEqual(["open", "open"]); + toggle(0); + expect(labels()).toEqual(["folded", "open"]); + + // Opening Settings unmounts the sidebar; Back mounts a new one. + act(() => renderer!.unmount()); + mount(["firstmate", "sheppi"], "green"); + expect(labels()).toEqual(["folded", "open"]); + + // The user changed a project's icon colour while in Settings. + act(() => renderer!.update()); + expect(labels()).toEqual(["folded", "open"]); + }); + + it("unfold a second mate on a second toggle", () => { + mount(["agos"], "green"); + toggle(0); + toggle(0); + expect(labels()).toEqual(["open"]); + }); +}); diff --git a/apps/web/src/components/sidebar/fleetFolds.ts b/apps/web/src/components/sidebar/fleetFolds.ts new file mode 100644 index 000000000..8ecbd0538 --- /dev/null +++ b/apps/web/src/components/sidebar/fleetFolds.ts @@ -0,0 +1,20 @@ +import { create } from "zustand"; + +/** + * Which second mates have their workers folded away, by repository. The store + * outlives the sidebar: Settings unmounts it, and coming back must not reopen + * every second mate. Folding is a view choice for this app session, so it is + * never saved or sent to the server. + */ +export const useFleetFolds = create<{ + readonly folded: ReadonlySet; + readonly toggle: (repo: string) => void; +}>((set) => ({ + folded: new Set(), + toggle: (repo) => + set(({ folded }) => { + const next = new Set(folded); + if (!next.delete(repo)) next.add(repo); + return { folded: next }; + }), +})); diff --git a/fork-features.json b/fork-features.json index c09a34266..7dc9c8123 100644 --- a/fork-features.json +++ b/fork-features.json @@ -162,12 +162,13 @@ }, { "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 folds last for the app session, through Settings and project edits. 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, fleetFolds.ts, 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.", "files": [ "apps/web/src/components/sidebar/fleetSidebar.logic.ts", "apps/web/src/components/sidebar/fleetTone.logic.ts", "apps/web/src/components/sidebar/FleetTone.tsx", + "apps/web/src/components/sidebar/fleetFolds.ts", "apps/web/src/components/FirstMateIcon.tsx", "apps/web/src/components/FleetRoleIcon.tsx", "apps/web/src/components/Sidebar.tsx", @@ -177,7 +178,8 @@ ], "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/fleetFolds.test.tsx" ], "patchesUpstream": true }, From 0f4e1113010a07c8d86c08348c49bfd92843a35a Mon Sep 17 00:00:00 2001 From: autoprintworks Date: Fri, 25 Sep 2026 23:22:16 +0100 Subject: [PATCH 2/3] fix(web): save second mate folds with the other sidebar settings Second mate folds now live in uiStateStore as fleetRepoExpandedById, next to projectExpandedById, and are saved the same way. They survive Settings, project edits and a restart, like project folds. The session store fleetFolds.ts and its test are removed. Sidebar.tsx builds the fleet rows in useFleetTreeRows. Sidebar.fleetTree.test.tsx renders that hook: a folded second mate stays folded after a remount and an icon change, and a second mate and its workers take a project's new colour at once. uiStateStore gains resetUiStateForTests. --- .../src/components/Sidebar.fleetTree.test.tsx | 143 ++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 80 ++++++---- .../components/sidebar/fleetFolds.test.tsx | 63 -------- apps/web/src/components/sidebar/fleetFolds.ts | 20 --- apps/web/src/uiStateStore.test.ts | 14 ++ apps/web/src/uiStateStore.ts | 24 +++ fork-features.json | 9 +- 7 files changed, 233 insertions(+), 120 deletions(-) create mode 100644 apps/web/src/components/Sidebar.fleetTree.test.tsx delete mode 100644 apps/web/src/components/sidebar/fleetFolds.test.tsx delete mode 100644 apps/web/src/components/sidebar/fleetFolds.ts 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 fb4b71130..f9fd7541c 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -165,12 +165,12 @@ import { buildFleetRows, buildFleetTree, FIRST_MATE_TONE_CLASS, + type FleetBranch, FLEET_ROLE_WORDS, type FleetParked, resolveParkedState, sidebarSectionFor, } from "./sidebar/fleetSidebar.logic"; -import { useFleetFolds } from "./sidebar/fleetFolds"; import { FleetTone, type FleetTheme } from "./sidebar/FleetTone"; import { resolveReadOnlyThreadModel } from "./chat/readOnlyThreadModel.logic"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; @@ -2422,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); @@ -2947,38 +2992,7 @@ export default function Sidebar() { }; }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); - // Second mates fold their workers away. The folds outlive this component, - // which Settings unmounts. - const foldedFleetRepos = useFleetFolds((folds) => folds.folded); - const toggleFleetRepo = useFleetFolds((folds) => folds.toggle); - 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/components/sidebar/fleetFolds.test.tsx b/apps/web/src/components/sidebar/fleetFolds.test.tsx deleted file mode 100644 index eba2212d5..000000000 --- a/apps/web/src/components/sidebar/fleetFolds.test.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { act } from "react"; -import { create, type ReactTestRenderer } from "react-test-renderer"; -import { afterEach, describe, expect, it } from "vite-plus/test"; - -import { useFleetFolds } from "./fleetFolds"; - -// Stands in for the sidebar: it shows two second mates' folds and toggles one. -// The project colour prop changes when the user edits the project's icon. -function SecondMateFolds(props: { readonly repos: readonly string[]; readonly color: string }) { - const { folded, toggle } = useFleetFolds(); - return ( - <> - {props.repos.map((repo) => ( - - ))} - - ); -} - -let renderer: ReactTestRenderer | undefined; - -function mount(repos: readonly string[], color: string) { - act(() => { - renderer = create(); - }); -} - -const labels = () => renderer!.root.findAllByType("button").map((button) => button.children[0]); -const toggle = (index: number) => - act(() => renderer!.root.findAllByType("button")[index]!.props.onClick()); - -afterEach(() => { - act(() => renderer?.unmount()); - renderer = undefined; -}); - -// Each test uses its own repository names: the folds live for the app session. -describe("fleet folds", () => { - it("keep a second mate folded through Settings and a project icon change", () => { - mount(["firstmate", "sheppi"], "green"); - expect(labels()).toEqual(["open", "open"]); - toggle(0); - expect(labels()).toEqual(["folded", "open"]); - - // Opening Settings unmounts the sidebar; Back mounts a new one. - act(() => renderer!.unmount()); - mount(["firstmate", "sheppi"], "green"); - expect(labels()).toEqual(["folded", "open"]); - - // The user changed a project's icon colour while in Settings. - act(() => renderer!.update()); - expect(labels()).toEqual(["folded", "open"]); - }); - - it("unfold a second mate on a second toggle", () => { - mount(["agos"], "green"); - toggle(0); - toggle(0); - expect(labels()).toEqual(["open"]); - }); -}); diff --git a/apps/web/src/components/sidebar/fleetFolds.ts b/apps/web/src/components/sidebar/fleetFolds.ts deleted file mode 100644 index 8ecbd0538..000000000 --- a/apps/web/src/components/sidebar/fleetFolds.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { create } from "zustand"; - -/** - * Which second mates have their workers folded away, by repository. The store - * outlives the sidebar: Settings unmounts it, and coming back must not reopen - * every second mate. Folding is a view choice for this app session, so it is - * never saved or sent to the server. - */ -export const useFleetFolds = create<{ - readonly folded: ReadonlySet; - readonly toggle: (repo: string) => void; -}>((set) => ({ - folded: new Set(), - toggle: (repo) => - set(({ folded }) => { - const next = new Set(folded); - if (!next.delete(repo)) next.add(repo); - return { folded: next }; - }), -})); 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..20fa79daa 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( }; } +export 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 7dc9c8123..536f46939 100644 --- a/fork-features.json +++ b/fork-features.json @@ -162,24 +162,25 @@ }, { "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; the folds last for the app session, through Settings and project edits. 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, fleetFolds.ts, 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", "apps/web/src/components/sidebar/FleetTone.tsx", - "apps/web/src/components/sidebar/fleetFolds.ts", "apps/web/src/components/FirstMateIcon.tsx", "apps/web/src/components/FleetRoleIcon.tsx", "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/fleetFolds.test.tsx" + "apps/web/src/components/Sidebar.fleetTree.test.tsx" ], "patchesUpstream": true }, From 8920f61d5549de5ea52766746413888fb20812d0 Mon Sep 17 00:00:00 2001 From: autoprintworks Date: Fri, 25 Sep 2026 23:43:46 +0100 Subject: [PATCH 3/3] fix(web): keep setFleetRepoExpanded private to uiStateStore Only the store action uses it, as with setPullRequestMergeMethod, so the unused code check no longer flags it. --- apps/web/src/uiStateStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 20fa79daa..7a53b398b 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -386,7 +386,7 @@ export function setProjectExpanded( }; } -export function setFleetRepoExpanded(state: UiState, repo: string, expanded: boolean): UiState { +function setFleetRepoExpanded(state: UiState, repo: string, expanded: boolean): UiState { if ((state.fleetRepoExpandedById[repo] ?? true) === expanded) { return state; }