From 558aa48784e64eb3b0a91dcd5ae4eaec627af717 Mon Sep 17 00:00:00 2001 From: autoprintworks Date: Thu, 24 Sep 2026 23:49:20 +0100 Subject: [PATCH 1/4] feat(web): show First Mate's fleet as a tree in the sidebar Fleet threads were scattered among ordinary threads. A second mate moved whenever its activity changed. Nothing tied a worker to its second mate. The roles showed only as small grey labels. The sidebar now shows the fleet as a tree above the other threads. - A pinned First Mate sits at the top, above a divider. Unpinned, it lists among the other threads. The pin is today's pin. - Second mates follow in repository-name order. Their place never changes with activity, and they cannot be settled, snoozed or pinned out of it. - Each worker is indented under the second mate that shares its fleetRepo. First Mate already sends that key for both, so no new field is needed. - An arrow beside a second mate folds its workers away. - A worker that is settled or snoozed moves to the usual shelf. Each fleet row keeps today's row shape. Its model line reads First Mate, Second mate or Worker in its own colour, then the model and the reasoning level. A ship, sailboat or pickaxe sits beside the project icon in the same colour. First Mate is pink. Each second mate gets one of four cool colours from its repository name, so the colour stays the same across reloads. Workers take their second mate's colour, faded. Fleet rows leave the branch to the tooltip. --- apps/web/src/components/FirstMateIcon.tsx | 25 +- apps/web/src/components/Sidebar.logic.test.ts | 27 +- apps/web/src/components/Sidebar.logic.ts | 29 +- apps/web/src/components/Sidebar.tsx | 259 +++++++++++++++--- .../sidebar/fleetSidebar.logic.test.ts | 160 +++++++++++ .../components/sidebar/fleetSidebar.logic.ts | 137 +++++++++ docs/user/thread-sidebar.md | 17 +- fork-features.json | 17 +- 8 files changed, 613 insertions(+), 58 deletions(-) create mode 100644 apps/web/src/components/sidebar/fleetSidebar.logic.test.ts create mode 100644 apps/web/src/components/sidebar/fleetSidebar.logic.ts diff --git a/apps/web/src/components/FirstMateIcon.tsx b/apps/web/src/components/FirstMateIcon.tsx index fb761e0b9..37ffa6f87 100644 --- a/apps/web/src/components/FirstMateIcon.tsx +++ b/apps/web/src/components/FirstMateIcon.tsx @@ -1,14 +1,31 @@ -import { ShipWheelIcon, type LucideProps } from "lucide-react"; +import type { FleetRole } from "@t3tools/contracts"; +import { PickaxeIcon, SailboatIcon, ShipIcon, type LucideProps } from "lucide-react"; import { cn } from "~/lib/utils"; -/** First Mate's mark: a pink ship's wheel, shown wherever other rows show a project. */ +/** First Mate's mark: a pink ship. */ export function FirstMateIcon({ className, ...props }: LucideProps) { return ( - ); } + +const FLEET_ROLE_ICONS = { + "first-mate": ShipIcon, + "second-mate": SailboatIcon, + worker: PickaxeIcon, +} as const; + +/** + * A fleet thread's role mark, drawn beside its project icon: a ship for First + * Mate, a sailboat for a second mate, a pickaxe for a worker. The caller gives + * it the thread's text colour. + */ +export function FleetRoleIcon({ role, className, ...props }: LucideProps & { role: FleetRole }) { + const Icon = FLEET_ROLE_ICONS[role]; + return ; +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 4c509a263..be557cbfe 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2586,24 +2586,39 @@ describe("partitionFirstMateThreads", () => { readonly fleetRole?: "first-mate" | "second-mate" | "worker"; readonly fleetRepo?: string | null; readonly archivedAt?: string | null; + readonly pinnedAt?: string | null; } = {}, - ) => ({ id, archivedAt: null, ...fleet }); + ) => ({ id, archivedAt: null, pinnedAt: null, ...fleet }); - it("puts the live First Mate in its own slot, outside every section", () => { - const firstMate = thread("fm", { fleetRole: "first-mate", fleetRepo: null }); + it("puts a pinned First Mate in its own slot, outside every section", () => { + const firstMate = thread("fm", { + fleetRole: "first-mate", + fleetRepo: null, + pinnedAt: "2026-09-24T00:00:00Z", + }); const secondMate = thread("2m", { fleetRole: "second-mate", fleetRepo: "t3code" }); const worker = thread("w", { fleetRole: "worker", fleetRepo: "t3code" }); const plain = thread("plain"); - const { slot, rest } = partitionFirstMateThreads([secondMate, firstMate, worker, plain]); + const { slot, rest, live } = partitionFirstMateThreads([secondMate, firstMate, worker, plain]); expect(slot).toEqual([firstMate]); - // Second mates and workers stay with ordinary threads, under their project. + expect(live).toEqual([firstMate]); + // Second mates and workers pass on for the fleet tree to place. expect(rest).toEqual([secondMate, worker, plain]); }); + it("lists an unpinned First Mate with the other threads, and still counts it live", () => { + const firstMate = thread("fm", { fleetRole: "first-mate" }); + const { slot, rest, live } = partitionFirstMateThreads([thread("plain"), firstMate]); + expect(slot).toEqual([]); + expect(rest).toEqual([thread("plain"), firstMate]); + expect(live).toEqual([firstMate]); + }); + it("leaves the slot empty once First Mate is archived", () => { const archived = thread("fm", { fleetRole: "first-mate", archivedAt: "2026-09-23T00:00:00Z" }); - const { slot, rest } = partitionFirstMateThreads([archived, thread("plain")]); + const { slot, rest, live } = partitionFirstMateThreads([archived, thread("plain")]); expect(slot).toEqual([]); + expect(live).toEqual([]); expect(rest.map((entry) => entry.id)).toEqual(["plain"]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 4cd0ac72e..cd27d6ec0 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -904,22 +904,31 @@ export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3t const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet = new Set(); /** - * Split the First Mate thread out of the sidebar's thread list. First Mate - * belongs to no project, so it sits in its own slot above every section and - * ignores the project scope: `slot` holds the live First Mate threads (the - * server keeps that to one per environment) and `rest` holds every other - * thread, for the pinned, active, snoozed, and settled sections to share. + * Split the First Mate thread out of the sidebar's thread list. A pinned First + * Mate sits in its own slot above every section and ignores the project scope: + * `slot` holds it (the server keeps one live First Mate per environment). An + * unpinned one lists in `rest` with every other thread, for the pinned, active, + * snoozed, and settled sections to share. `live` holds every live First Mate, + * pinned or not, so the sidebar knows whether one exists. */ export function partitionFirstMateThreads< - T extends FleetThreadFields & { readonly archivedAt: string | null }, ->(threads: readonly T[]): { readonly slot: T[]; readonly rest: T[] } { + T extends FleetThreadFields & { + readonly archivedAt: string | null; + readonly pinnedAt?: string | null | undefined; + }, +>(threads: readonly T[]): { readonly slot: T[]; readonly rest: T[]; readonly live: T[] } { const slot: T[] = []; const rest: T[] = []; + const live: T[] = []; for (const thread of threads) { - if (!isFirstMateThread(thread)) rest.push(thread); - else if (thread.archivedAt === null) slot.push(thread); + if (!isFirstMateThread(thread)) { + rest.push(thread); + } else if (thread.archivedAt === null) { + live.push(thread); + (thread.pinnedAt != null ? slot : rest).push(thread); + } } - return { slot, rest }; + return { slot, rest, live }; } /** diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0b7a1c250..9a4b521bd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -53,6 +53,7 @@ import { BotIcon, CheckIcon, ChevronDownIcon, + ChevronRightIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, @@ -158,7 +159,15 @@ import type { SidebarThreadSummary } from "../types"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { FirstMateIcon } from "./FirstMateIcon"; +import { FirstMateIcon, FleetRoleIcon } from "./FirstMateIcon"; +import { + assignSecondMateTones, + buildFleetTree, + FIRST_MATE_TONE, + fleetRoleWord, + SECOND_MATE_TONES, +} from "./sidebar/fleetSidebar.logic"; +import { resolveReadOnlyThreadModel } from "./chat/readOnlyThreadModel.logic"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { @@ -973,9 +982,24 @@ const dropVerbBadge: Record = { ), }; +const FIRST_MATE_PLACEMENT: FleetRowPlacement = { depth: 0, tone: FIRST_MATE_TONE, quiet: false }; + +/** Where a fleet row sits in the fleet tree, and the colour it wears. */ +interface FleetRowPlacement { + readonly depth: 0 | 1; + /** Text colour classes: First Mate's pink or its second mate's colour. */ + readonly tone: string; + /** A worker: its second mate's colour, faded, at regular weight. */ + readonly quiet: boolean; + readonly fold?: { readonly expanded: boolean; readonly onToggle: () => void } | undefined; +} + const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; + // A row in the fleet: First Mate, a second mate, or a worker under its + // second mate. Absent for ordinary threads and for a worker parked on a shelf. + fleet?: FleetRowPlacement | undefined; // Slim rows are either settled (action: un-settle) or merely quiet // (seen Ready threads — action: settle). variantAction: "settle" | "unsettle" | "unsnooze"; @@ -1214,8 +1238,20 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread.modelSelection.model, selectedModel ? getTriggerDisplayModelLabel(selectedModel) : null, ); - const isFirstMate = isFirstMateThread(thread); - const roleLabel = isFirstMate ? null : fleetRoleLabel(thread); + const fleet = props.fleet; + const fleetRole = fleet ? (thread.fleetRole ?? null) : null; + // A fleet row names its role on the model line, so the top line keeps + // today's project icon and name, and First Mate reads like any other row. + const isFirstMate = fleet === undefined && isFirstMateThread(thread); + const roleLabel = isFirstMate || fleet ? null : fleetRoleLabel(thread); + const fleetThinkingLabel = fleet + ? resolveReadOnlyThreadModel({ + selection: thread.modelSelection, + models: providerEntry?.models ?? [], + }).thinkingLabel + : null; + // A worker wears its second mate's colour, quieter. + const fleetToneClassName = fleet ? cn(fleet.tone, fleet.quiet && "opacity-70") : undefined; const displayTitle = threadDisplayTitle(thread); // The local environment is "this machine" and needs no marker; every other @@ -1599,7 +1635,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // A read-only thread cannot be opened to see what is driving it, so both row // variants carry the model and the thinking level at rest, not on hover. const readOnlyModelBadges = - thread.readOnly === true ? ( + thread.readOnly === true && fleet === undefined ? ( + {/* The fold arrow sits in the gutter left of the project icon, so a + second mate lines up with the rows around it. */} + {fleet?.fold ? ( + + ) : null} ) : null} + {fleetRole ? ( + + ) : null} {/* A second mate's label already names its repository, so it stands in for the project name rather than crowding it. */} {props.projectDisplayName && @@ -1975,8 +2039,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
{/* Always the branch. The plan step used to take this slot while working, but it truncated to a half-sentence and dropped the - branch, so the row lost its most stable identifier. */} - {thread.branch ? ( + branch, so the row lost its most stable identifier. A fleet row + gives the line to its role, and keeps the branch in the tooltip. */} + {thread.branch && fleet === undefined ? ( <> @@ -1992,7 +2057,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { data-testid={`sidebar-model-${thread.id}`} className="shrink-0 whitespace-nowrap text-muted-foreground/70" > + {fleetRole ? ( + <> + + {fleetRoleWord(thread)} + + {" \u00b7 "} + + ) : null} {modelLabel} + {fleetThinkingLabel ? ` \u00b7 ` + fleetThinkingLabel : null} ) : null} @@ -2596,6 +2670,8 @@ export default function Sidebar() { } | null>(null); const { firstMateThreads, + hasLiveFirstMate, + fleetBranches, pinnedThreads, draggableThreadKeys, activeReorderableThreadKeys, @@ -2610,14 +2686,26 @@ export default function Sidebar() { // memo exactly at the next wake boundary. void snoozeWakeTick; const preciseNow = new Date().toISOString(); - // First Mate takes its own slot above every section, whatever the scope. - const { slot: firstMate, rest } = partitionFirstMateThreads(threads); - const visible = rest.filter( + // A pinned First Mate takes its own slot above every section. First Mate + // ignores the project scope wherever it sits. + const { slot: firstMate, rest, live } = partitionFirstMateThreads(threads); + const inScope = rest.filter( (thread) => thread.archivedAt === null && - (scopedProjectKeys === null || + (isFirstMateThread(thread) || + scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); + // Second mates and their workers leave the sections for the fleet tree. A + // settled or snoozed worker stays on the shelves like any other thread. + const fleetTree = buildFleetTree(inScope, (thread) => { + const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; + return ( + (capabilities?.threadSnooze === true && effectiveSnoozed(thread, { now: preciseNow })) || + (capabilities?.threadSettlement === true && thread.settledOverride === "settled") + ); + }); + const visible = fleetTree.rest; const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; @@ -2676,6 +2764,8 @@ export default function Sidebar() { const sortedActive = sortThreadsForSidebar(active); return { firstMateThreads: firstMate, + hasLiveFirstMate: live.length > 0, + fleetBranches: fleetTree.branches, pinnedThreads: optimisticDrop?.section !== "pinned" || optimisticDrop.order === null ? sortedPinned @@ -2705,6 +2795,57 @@ 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; + }); + }, []); + // The fleet tree as rows: each second mate in its colour, then its workers + // indented under it unless folded. + const fleetRows = useMemo(() => { + const tones = assignSecondMateTones( + fleetBranches.flatMap((branch) => (branch.secondMate ? [branch.repo] : [])), + ); + const rows: { + readonly thread: EnvironmentThreadShell; + readonly placement: FleetRowPlacement; + }[] = []; + for (const branch of fleetBranches) { + const toneIndex = tones.get(branch.repo); + const tone = + toneIndex === undefined ? "text-muted-foreground" : SECOND_MATE_TONES[toneIndex]!; + const folded = foldedFleetRepos.has(branch.repo); + if (branch.secondMate) { + rows.push({ + thread: branch.secondMate, + placement: { + depth: 0, + tone, + quiet: false, + fold: + branch.workers.length > 0 + ? { expanded: !folded, onToggle: () => toggleFleetRepo(branch.repo) } + : undefined, + }, + }); + } + if (folded) continue; + for (const worker of branch.workers) { + rows.push({ + thread: worker, + placement: { depth: branch.secondMate ? 1 : 0, tone, quiet: true }, + }); + } + } + return rows; + }, [fleetBranches, foldedFleetRepos, toggleFleetRepo]); + const fleetThreads = useMemo(() => fleetRows.map((row) => row.thread), [fleetRows]); + const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0); @@ -2712,12 +2853,13 @@ export default function Sidebar() { const searchableThreads = useMemo( () => [ ...firstMateThreads, + ...fleetThreads, ...pinnedThreads, ...activeThreads, ...snoozedThreads, ...settledThreads, ], - [activeThreads, firstMateThreads, pinnedThreads, settledThreads, snoozedThreads], + [activeThreads, firstMateThreads, fleetThreads, pinnedThreads, settledThreads, snoozedThreads], ); const searchEnvironmentIds = useMemo( () => @@ -2856,12 +2998,20 @@ export default function Sidebar() { const orderedThreads = useMemo( () => [ ...firstMateThreads, + ...fleetThreads, ...pinnedThreads, ...activeThreads, ...visibleSnoozedThreads, ...renderedSettledThreads, ], - [firstMateThreads, pinnedThreads, activeThreads, visibleSnoozedThreads, renderedSettledThreads], + [ + firstMateThreads, + fleetThreads, + pinnedThreads, + activeThreads, + visibleSnoozedThreads, + renderedSettledThreads, + ], ); const orderedThreadKeys = useMemo( () => @@ -4759,7 +4909,12 @@ export default function Sidebar() { // The First Mate slot: always a pinned card, and none of // the lifecycle moves that would take it out of the slot. firstMateSlot = false, + fleet?: FleetRowPlacement, ) => { + // A second mate keeps its place in the fleet tree: no + // settling, snoozing or pinning it out of there. + const fixedInFleet = + fleet !== undefined && thread.fleetRole === "second-mate"; const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); @@ -4776,6 +4931,7 @@ export default function Sidebar() { key={`${threadKey}:${rowVariant}`} thread={thread} variant={rowVariant} + fleet={fleet} // Snoozed rows wake, settled rows un-settle, and cards settle. variantAction={ section === "snoozed" @@ -4786,20 +4942,22 @@ export default function Sidebar() { } settlementSupported={ !firstMateSlot && + !fixedInFleet && serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSettlement === true } snoozeSupported={ !firstMateSlot && + !fixedInFleet && serverConfigs.get(thread.environmentId)?.environment.capabilities .threadSnooze === true } pinningSupported={ - !firstMateSlot && + !fixedInFleet && serverConfigs.get(thread.environmentId)?.environment.capabilities .threadPinning === true } - isPinned={firstMateSlot || thread.pinnedAt != null} + isPinned={thread.pinnedAt != null} sortable={sortable} dropVerb={ dragState?.activeKey === threadKey @@ -4869,6 +5027,7 @@ export default function Sidebar() { const renderThreadRow = ( thread: EnvironmentThreadShell, section: SidebarSection, + fleet?: FleetRowPlacement, ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), @@ -4883,30 +5042,55 @@ export default function Sidebar() { optimisticDrop !== null } > - {(bag) => renderThreadRowInner(thread, section, bag)} + {(bag) => renderThreadRowInner(thread, section, bag, false, fleet)} ); }; const from = dragState?.activeSection ?? null; const items: ReactNode[] = [ ...(firstMateThreads.length > 0 - ? firstMateThreads.map((thread) => - renderThreadRowInner(thread, "pinned", undefined, true), - ) - : [ -
  • - -
  • , - ]), + ? [ + ...firstMateThreads.map((thread) => + renderThreadRowInner( + thread, + "pinned", + undefined, + true, + FIRST_MATE_PLACEMENT, + ), + ), + // Sets the pinned First Mate apart from every row below. +
  • , + ] + : hasLiveFirstMate + ? [] + : [ +
  • + +
  • , + ]), + ...fleetRows.map((row) => + renderThreadRowInner( + row.thread, + "active", + undefined, + false, + row.placement, + ), + ), ({ + id, + fleetRole: null, + fleetRepo: null, + createdAt: "2026-09-24T10:00:00Z", + updatedAt: "2026-09-24T10:00:00Z", + archivedAt: null, + pinnedAt: null, + settled: false, + ...fields, +}); + +const ids = (threads: ReadonlyArray<{ readonly id: string }>) => threads.map((entry) => entry.id); + +describe("buildFleetTree", () => { + it("puts each worker under the second mate that shares its fleetRepo", () => { + const { branches } = buildFleetTree([ + thread("w-t3", { fleetRole: "worker", fleetRepo: "t3code" }), + thread("sm-fm", { fleetRole: "second-mate", fleetRepo: "firstmate" }), + thread("w-fm", { fleetRole: "worker", fleetRepo: "firstmate" }), + thread("sm-t3", { fleetRole: "second-mate", fleetRepo: "t3code" }), + ]); + expect(branches.map((branch) => [branch.secondMate?.id, ids(branch.workers)])).toEqual([ + ["sm-fm", ["w-fm"]], + ["sm-t3", ["w-t3"]], + ]); + }); + + it("keeps a second mate and its workers in place when their activity changes", () => { + const fleet = (busy: string) => [ + thread("sm-lavish", { + fleetRole: "second-mate", + fleetRepo: "lavish-axi", + updatedAt: busy === "sm-lavish" ? "2026-09-24T12:00:00Z" : "2026-09-24T10:00:00Z", + }), + thread("sm-t3", { + fleetRole: "second-mate", + fleetRepo: "t3code", + updatedAt: busy === "sm-t3" ? "2026-09-24T12:00:00Z" : "2026-09-24T10:00:00Z", + }), + thread("w-old", { + fleetRole: "worker", + fleetRepo: "t3code", + createdAt: "2026-09-24T09:00:00Z", + updatedAt: busy === "w-old" ? "2026-09-24T12:00:00Z" : "2026-09-24T10:00:00Z", + }), + thread("w-new", { + fleetRole: "worker", + fleetRepo: "t3code", + createdAt: "2026-09-24T09:30:00Z", + }), + ]; + const order = (busy: string) => + buildFleetTree(fleet(busy)).branches.flatMap((branch) => [ + branch.secondMate?.id, + ...ids(branch.workers), + ]); + const expected = ["sm-lavish", "sm-t3", "w-old", "w-new"]; + expect(order("none")).toEqual(expected); + expect(order("sm-t3")).toEqual(expected); + expect(order("sm-lavish")).toEqual(expected); + expect(order("w-old")).toEqual(expected); + }); + + it("passes ordinary threads through unchanged and in their order", () => { + const plain = [thread("b"), thread("a"), thread("c")]; + const { branches, rest } = buildFleetTree([ + plain[0]!, + thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), + plain[1]!, + plain[2]!, + ]); + expect(rest).toEqual(plain); + expect(branches).toHaveLength(1); + }); + + it("keeps a settled second mate in the tree and sends a settled worker to the shelves", () => { + const secondMate = thread("sm", { + fleetRole: "second-mate", + fleetRepo: "t3code", + settled: true, + }); + const worker = thread("w", { fleetRole: "worker", fleetRepo: "t3code", settled: true }); + const { branches, rest } = buildFleetTree([secondMate, worker], (entry) => entry.settled); + expect(branches.map((branch) => branch.secondMate?.id)).toEqual(["sm"]); + expect(branches[0]!.workers).toEqual([]); + expect(rest).toEqual([worker]); + }); + + it("lists workers without a second mate after every crewed repository", () => { + const { branches } = buildFleetTree([ + thread("w-orphan", { fleetRole: "worker", fleetRepo: "aaa" }), + thread("sm", { fleetRole: "second-mate", fleetRepo: "zzz" }), + ]); + expect(branches.map((branch) => [branch.repo, branch.secondMate?.id ?? null])).toEqual([ + ["zzz", "sm"], + ["aaa", null], + ]); + }); +}); + +describe("pinned First Mate", () => { + it("stays at the top, above the fleet tree and every other thread", () => { + const threads = [ + thread("plain"), + thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), + thread("fm", { fleetRole: "first-mate", pinnedAt: "2026-09-24T08:00:00Z" }), + ]; + const { slot, rest } = partitionFirstMateThreads(threads); + expect(ids(slot)).toEqual(["fm"]); + expect(ids(rest)).not.toContain("fm"); + }); +}); + +describe("assignSecondMateTones", () => { + it("gives the same repository the same colour whatever order the fleet arrives in", () => { + const repos = ["t3code", "firstmate", "lavish-axi"]; + const first = assignSecondMateTones(repos); + const again = assignSecondMateTones(repos.toReversed()); + for (const repo of repos) expect(again.get(repo)).toBe(first.get(repo)); + }); + + it("gives every second mate its own colour up to the palette size", () => { + const repos = Array.from({ length: SECOND_MATE_TONES.length }, (_, index) => `repo-${index}`); + const tones = assignSecondMateTones(repos); + expect(new Set(tones.values()).size).toBe(repos.length); + }); +}); + +describe("fleetRoleWord", () => { + it("names each fleet role and nothing for an ordinary thread", () => { + expect(fleetRoleWord({ fleetRole: "first-mate" })).toBe("First Mate"); + expect(fleetRoleWord({ fleetRole: "second-mate" })).toBe("Second mate"); + expect(fleetRoleWord({ fleetRole: "worker" })).toBe("Worker"); + expect(fleetRoleWord({ fleetRole: null })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/sidebar/fleetSidebar.logic.ts b/apps/web/src/components/sidebar/fleetSidebar.logic.ts new file mode 100644 index 000000000..d71deb4b8 --- /dev/null +++ b/apps/web/src/components/sidebar/fleetSidebar.logic.ts @@ -0,0 +1,137 @@ +import type { FleetThreadFields } from "@t3tools/client-runtime/fleet-threads"; + +/** + * How the sidebar arranges and colours First Mate's fleet. A worker belongs to + * the second mate whose `fleetRepo` it shares: First Mate sends the same + * repository key for both, and nothing else links them. + */ + +/** One second mate and the workers it launched. */ +export interface FleetBranch { + readonly repo: string; + /** Null when workers run in a repository that has no live second mate. */ + readonly secondMate: T | null; + readonly workers: readonly T[]; +} + +type TreeThread = FleetThreadFields & { + readonly id: string; + readonly archivedAt: string | null; + readonly createdAt: string; +}; + +/** + * Pull second mates and their workers out of the thread list into a tree. + * Branches sort by repository name and workers by launch order, never by + * activity, so no fleet row moves when a thread gets busy. A second mate stays + * in the tree whatever its settled or snoozed state; a `parked` worker (settled + * or snoozed) leaves for the ordinary shelves like any thread. Every other + * thread passes through to `rest` in its original order. + */ +export function buildFleetTree( + threads: readonly T[], + parked: (thread: T) => boolean = () => false, +): { readonly branches: FleetBranch[]; readonly rest: T[] } { + const byRepo = new Map(); + const rest: T[] = []; + for (const thread of threads) { + const inTree = + thread.archivedAt === null && + (thread.fleetRole === "second-mate" || (thread.fleetRole === "worker" && !parked(thread))); + if (!inTree) { + rest.push(thread); + continue; + } + const repo = thread.fleetRepo ?? ""; + const branch = byRepo.get(repo) ?? { secondMate: null, workers: [] }; + byRepo.set(repo, branch); + if (thread.fleetRole === "worker") { + branch.workers.push(thread); + } else if (branch.secondMate === null) { + branch.secondMate = thread; + } else { + // One second mate per repository; the newer one leads, an older one + // lists as an ordinary thread rather than vanishing. + const [older, newer] = + thread.createdAt > branch.secondMate.createdAt + ? [branch.secondMate, thread] + : [thread, branch.secondMate]; + rest.push(older); + branch.secondMate = newer; + } + } + const byLaunch = (left: T, right: T) => + left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); + const branches = [...byRepo.entries()] + .map(([repo, branch]) => ({ + repo, + secondMate: branch.secondMate, + workers: branch.workers.toSorted(byLaunch), + })) + .toSorted( + (left, right) => + // Workers with no second mate sort after every crewed repository. + Number(left.secondMate === null) - Number(right.secondMate === null) || + left.repo.localeCompare(right.repo), + ); + return { branches, rest }; +} + +/** First Mate's text colour. Pink is First Mate's alone. */ +export const FIRST_MATE_TONE = "text-pink-600 dark:text-pink-400"; + +/** + * Second mate text colours: a cool set that avoids the status colours (amber + * approval, red failure, green done), four hues far enough apart to tell at a + * glance, in shades picked to read on the sidebar in both light and dark. + */ +export const SECOND_MATE_TONES = [ + "text-sky-700 dark:text-sky-300", + "text-teal-700 dark:text-teal-300", + "text-indigo-600 dark:text-indigo-300", + "text-violet-600 dark:text-violet-300", +] as const; + +function hashRepo(repo: string): number { + // FNV-1a: small, fast, and the same on every client. + let hash = 0x811c9dc5; + for (let index = 0; index < repo.length; index += 1) { + hash ^= repo.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +/** + * The colour index of each second mate's repository. A repository starts at + * the slot its name hashes to and moves on to the next free slot, visiting + * repositories in name order, so the same fleet always gets the same colours + * and adding a repository rarely moves anyone else's. Up to the palette size, + * every second mate gets its own colour. + */ +export function assignSecondMateTones(repos: readonly string[]): ReadonlyMap { + const size = SECOND_MATE_TONES.length; + const assigned = new Map(); + const used = new Set(); + for (const repo of [...new Set(repos)].toSorted()) { + let slot = hashRepo(repo) % size; + for (let tries = 0; tries < size && used.has(slot); tries += 1) slot = (slot + 1) % size; + used.add(slot); + assigned.set(repo, slot); + } + return assigned; +} + +/** The role word a fleet row shows before its model. */ +export function fleetRoleWord(thread: FleetThreadFields): string | null { + switch (thread.fleetRole) { + case "first-mate": + return "First Mate"; + case "second-mate": + return "Second mate"; + case "worker": + return "Worker"; + default: + return null; + } +} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index b8d3a2fe8..f5fe77c1f 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -86,10 +86,19 @@ their default order until the server is updated. ## First Mate threads -On web and desktop, the First Mate thread sits in its own slot at the top of the sidebar, -outside every project, with a pink ship's wheel. It stays pinned there and never settles -automatically, even when a pull request linked to it merges. Second mate and worker threads -sit with their project and carry a **Second mate** or **Worker** label. +On web and desktop, the sidebar arranges First Mate's fleet as a small tree above your other +threads. Each fleet row says what it is on its model line, **First Mate**, **Second mate** or +**Worker**, in its own colour, followed by the model and its reasoning level, with a ship, +sailboat or pickaxe beside the project icon. + +- **First Mate** is pink. Pin it to keep it at the top of the sidebar, above a divider. Unpinned, + it lists among your other threads. It never settles automatically, even when a pull request + linked to it merges. +- **Second mates** follow, one per repository, in repository-name order. Each keeps its own colour + and its place, whatever it is doing, and never settles away. +- **Workers** sit indented under the second mate for their repository, in that second mate's + colour. Use the arrow beside a second mate to hide or show its workers. A worker you settle or + snooze moves to the usual shelf. When no First Mate thread is live, for example after you archive it, choose **New First Mate** at the top of the sidebar to start one. It runs in the folder set in **Settings → General → diff --git a/fork-features.json b/fork-features.json index d26ccbae2..e6e9addd2 100644 --- a/fork-features.json +++ b/fork-features.json @@ -146,7 +146,7 @@ }, { "name": "fm-fleet-thread-roles", - "description": "Fleet threads carry a role. The First Mate thread gets its own sidebar slot and never auto-settles; second mates and workers are labelled under their project.", + "description": "Fleet threads carry a role. The First Mate thread never auto-settles and, pinned, has its own sidebar slot; fm-fleet-sidebar-tree arranges second mates and workers.", "keep": "fleetThreads.ts, useStartFirstMateThread.ts and fork migration 8 are ours whole, and the fleetRole and fleetRepo fields, the First Mate sidebar slot, the settlement exception and the single First Mate invariant survive in the files listed; everything else in those files follows upstream.", "files": [ "packages/contracts/src/orchestration.ts", @@ -160,6 +160,21 @@ "test": "apps/server/src/orchestration/decider.fleetRole.test.ts", "patchesUpstream": true }, + { + "name": "fm-fleet-sidebar-tree", + "description": "The sidebar shows First Mate's fleet as a tree above the other threads. A pinned First Mate sits at the top above a divider; second mates follow in repository-name order and never move or settle away; each worker is indented under the second mate that shares its fleetRepo, and a second mate can fold its workers away. 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 comes from its repository, so it stays the same across reloads.", + "keep": "fleetSidebar.logic.ts, its test and FirstMateIcon.tsx are ours whole. In Sidebar.tsx the fleet tree rows, the FleetRowPlacement prop on SidebarThreadRow, the role line, the fold arrow and the First Mate divider survive; partitionFirstMateThreads and its pinned slot survive 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/FirstMateIcon.tsx", + "apps/web/src/components/Sidebar.tsx", + "apps/web/src/components/Sidebar.logic.ts", + "apps/web/src/components/Sidebar.logic.test.ts", + "docs/user/thread-sidebar.md" + ], + "test": "apps/web/src/components/sidebar/fleetSidebar.logic.test.ts", + "patchesUpstream": true + }, { "name": "fm-fleet-thread-read-only-clear", "description": "The fleet can clear read-only on a second mate's thread with thread.read-only.clear, so the user can type into it. Only the fleet issuer may send it, and only on a fleet-owned, unarchived thread whose fleetRole is second-mate. It records a thread.read-only-cleared event that sets readOnly to false on the thread and its shell row, so it survives a restart and clients update. Nothing makes a thread read-only again. The server advertises fleetThreadReadOnlyClear in its environment capabilities.", From d97accbd0ecbfebed28f65fcea86e4633763969e Mon Sep 17 00:00:00 2001 From: autoprintworks Date: Fri, 25 Sep 2026 00:12:37 +0100 Subject: [PATCH 2/4] fix(web): First Mate always holds the top of the sidebar First Mate now sits at the top whether or not it is pinned. The pin marks the main First Mate when an environment list has several; the others list under it. With none live, a Start First Mate row replaces the old New First Mate button. First Mate rows offer Pin on hover, and the row menu no longer offers settle or snooze for First Mate, since the top slot has no way back from either. Settled and snoozed workers stay under their second mate as dimmed rows with their un-settle or wake control. A second mate that was already settled, snoozed or pinned keeps the control that undoes it. The second mate palette grows to six colours, and the row layout moves into buildFleetRows so the sidebar only renders it. --- apps/web/src/components/FirstMateIcon.tsx | 24 +- apps/web/src/components/FleetRoleIcon.tsx | 20 + apps/web/src/components/Sidebar.logic.test.ts | 25 +- apps/web/src/components/Sidebar.logic.ts | 34 +- apps/web/src/components/Sidebar.tsx | 359 ++++++++++-------- .../components/settings/SettingsPanels.tsx | 2 +- .../sidebar/fleetSidebar.logic.test.ts | 166 +++++--- .../components/sidebar/fleetSidebar.logic.ts | 205 +++++++--- .../hooks/useStartFirstMateThread.logic.ts | 2 +- docs/user/thread-sidebar.md | 35 +- fork-features.json | 5 +- 11 files changed, 534 insertions(+), 343 deletions(-) create mode 100644 apps/web/src/components/FleetRoleIcon.tsx diff --git a/apps/web/src/components/FirstMateIcon.tsx b/apps/web/src/components/FirstMateIcon.tsx index 37ffa6f87..a66e39ea5 100644 --- a/apps/web/src/components/FirstMateIcon.tsx +++ b/apps/web/src/components/FirstMateIcon.tsx @@ -1,31 +1,15 @@ -import type { FleetRole } from "@t3tools/contracts"; -import { PickaxeIcon, SailboatIcon, ShipIcon, type LucideProps } from "lucide-react"; +import { ShipIcon, type LucideProps } from "lucide-react"; import { cn } from "~/lib/utils"; +import { FLEET_TONE_CLASSES } from "./sidebar/fleetSidebar.logic"; -/** First Mate's mark: a pink ship. */ +/** First Mate's mark: a ship in First Mate's colour. */ export function FirstMateIcon({ className, ...props }: LucideProps) { return ( ); } - -const FLEET_ROLE_ICONS = { - "first-mate": ShipIcon, - "second-mate": SailboatIcon, - worker: PickaxeIcon, -} as const; - -/** - * A fleet thread's role mark, drawn beside its project icon: a ship for First - * Mate, a sailboat for a second mate, a pickaxe for a worker. The caller gives - * it the thread's text colour. - */ -export function FleetRoleIcon({ role, className, ...props }: LucideProps & { role: FleetRole }) { - const Icon = FLEET_ROLE_ICONS[role]; - return ; -} diff --git a/apps/web/src/components/FleetRoleIcon.tsx b/apps/web/src/components/FleetRoleIcon.tsx new file mode 100644 index 000000000..5a6465ece --- /dev/null +++ b/apps/web/src/components/FleetRoleIcon.tsx @@ -0,0 +1,20 @@ +import type { FleetRole } from "@t3tools/contracts"; +import { PickaxeIcon, SailboatIcon, ShipIcon, type LucideProps } from "lucide-react"; + +import { cn } from "~/lib/utils"; + +const FLEET_ROLE_ICONS = { + "first-mate": ShipIcon, + "second-mate": SailboatIcon, + worker: PickaxeIcon, +} as const; + +/** + * A fleet thread's role mark, drawn beside its project icon: a ship for First + * Mate, a sailboat for a second mate, a pickaxe for a worker. The caller gives + * it the thread's text colour. + */ +export function FleetRoleIcon({ role, className, ...props }: LucideProps & { role: FleetRole }) { + const Icon = FLEET_ROLE_ICONS[role]; + return ; +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index be557cbfe..7fe2a269c 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -2586,39 +2586,24 @@ describe("partitionFirstMateThreads", () => { readonly fleetRole?: "first-mate" | "second-mate" | "worker"; readonly fleetRepo?: string | null; readonly archivedAt?: string | null; - readonly pinnedAt?: string | null; } = {}, - ) => ({ id, archivedAt: null, pinnedAt: null, ...fleet }); + ) => ({ id, archivedAt: null, createdAt: "2026-09-24T00:00:00Z", pinnedAt: null, ...fleet }); - it("puts a pinned First Mate in its own slot, outside every section", () => { - const firstMate = thread("fm", { - fleetRole: "first-mate", - fleetRepo: null, - pinnedAt: "2026-09-24T00:00:00Z", - }); + it("puts the live First Mate in its own slot, outside every section", () => { + const firstMate = thread("fm", { fleetRole: "first-mate", fleetRepo: null }); const secondMate = thread("2m", { fleetRole: "second-mate", fleetRepo: "t3code" }); const worker = thread("w", { fleetRole: "worker", fleetRepo: "t3code" }); const plain = thread("plain"); - const { slot, rest, live } = partitionFirstMateThreads([secondMate, firstMate, worker, plain]); + const { slot, rest } = partitionFirstMateThreads([secondMate, firstMate, worker, plain]); expect(slot).toEqual([firstMate]); - expect(live).toEqual([firstMate]); // Second mates and workers pass on for the fleet tree to place. expect(rest).toEqual([secondMate, worker, plain]); }); - it("lists an unpinned First Mate with the other threads, and still counts it live", () => { - const firstMate = thread("fm", { fleetRole: "first-mate" }); - const { slot, rest, live } = partitionFirstMateThreads([thread("plain"), firstMate]); - expect(slot).toEqual([]); - expect(rest).toEqual([thread("plain"), firstMate]); - expect(live).toEqual([firstMate]); - }); - it("leaves the slot empty once First Mate is archived", () => { const archived = thread("fm", { fleetRole: "first-mate", archivedAt: "2026-09-23T00:00:00Z" }); - const { slot, rest, live } = partitionFirstMateThreads([archived, thread("plain")]); + const { slot, rest } = partitionFirstMateThreads([archived, thread("plain")]); expect(slot).toEqual([]); - expect(live).toEqual([]); expect(rest.map((entry) => entry.id)).toEqual(["plain"]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index cd27d6ec0..c0ed2b3c3 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -14,6 +14,7 @@ import { effectiveSnoozed, type ThreadSnoozeShell, } from "@t3tools/client-runtime/state/thread-settled"; +import { planFirstMateSlot } from "./sidebar/fleetSidebar.logic"; import { isFirstMateThread, threadDisplayTitle, @@ -904,31 +905,26 @@ export { sortPinnedThreadsByOrderKey as sortPinnedThreadsForSidebar } from "@t3t const EMPTY_CONTENT_MATCH_KEYS: ReadonlySet = new Set(); /** - * Split the First Mate thread out of the sidebar's thread list. A pinned First - * Mate sits in its own slot above every section and ignores the project scope: - * `slot` holds it (the server keeps one live First Mate per environment). An - * unpinned one lists in `rest` with every other thread, for the pinned, active, - * snoozed, and settled sections to share. `live` holds every live First Mate, - * pinned or not, so the sidebar knows whether one exists. + * Split the First Mate threads out of the sidebar's thread list. First Mate + * always sits in its own slot above every section and ignores the project + * scope: `slot` holds the live First Mate threads, the main one first (see + * planFirstMateSlot), and `rest` holds every other thread, for the pinned, + * active, snoozed, and settled sections to share. An archived First Mate + * leaves both. */ export function partitionFirstMateThreads< T extends FleetThreadFields & { + readonly id: string; readonly archivedAt: string | null; + readonly createdAt: string; readonly pinnedAt?: string | null | undefined; }, ->(threads: readonly T[]): { readonly slot: T[]; readonly rest: T[]; readonly live: T[] } { - const slot: T[] = []; - const rest: T[] = []; - const live: T[] = []; - for (const thread of threads) { - if (!isFirstMateThread(thread)) { - rest.push(thread); - } else if (thread.archivedAt === null) { - live.push(thread); - (thread.pinnedAt != null ? slot : rest).push(thread); - } - } - return { slot, rest, live }; +>(threads: readonly T[]): { readonly slot: T[]; readonly rest: T[] } { + const plan = planFirstMateSlot(threads); + return { + slot: plan.kind === "first-mates" ? [plan.main, ...plan.others] : [], + rest: threads.filter((thread) => !isFirstMateThread(thread)), + }; } /** diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9a4b521bd..491a7a213 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -159,13 +159,15 @@ import type { SidebarThreadSummary } from "../types"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { FirstMateIcon, FleetRoleIcon } from "./FirstMateIcon"; +import { FirstMateIcon } from "./FirstMateIcon"; +import { FleetRoleIcon } from "./FleetRoleIcon"; import { - assignSecondMateTones, + buildFleetRows, buildFleetTree, - FIRST_MATE_TONE, - fleetRoleWord, - SECOND_MATE_TONES, + FLEET_ROLE_WORDS, + FLEET_TONE_CLASSES, + type FleetParked, + type FleetTone, } from "./sidebar/fleetSidebar.logic"; import { resolveReadOnlyThreadModel } from "./chat/readOnlyThreadModel.logic"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; @@ -982,23 +984,35 @@ const dropVerbBadge: Record = { ), }; -const FIRST_MATE_PLACEMENT: FleetRowPlacement = { depth: 0, tone: FIRST_MATE_TONE, quiet: false }; - -/** Where a fleet row sits in the fleet tree, and the colour it wears. */ +/** + * Where a fleet row sits, the colour it wears, and which row controls it keeps. + * A second mate's place is fixed: it keeps only the way back out of a settle, + * snooze or pin it already has. First Mate is never settled or snoozed, and + * its pin, which marks the main First Mate, is offered on the row. + */ interface FleetRowPlacement { readonly depth: 0 | 1; - /** Text colour classes: First Mate's pink or its second mate's colour. */ - readonly tone: string; + readonly tone: FleetTone; /** A worker: its second mate's colour, faded, at regular weight. */ readonly quiet: boolean; + readonly lifecycle: "none" | "undo-only" | "default"; + readonly pin: "none" | "unpin-only" | "default" | "toggle"; readonly fold?: { readonly expanded: boolean; readonly onToggle: () => void } | undefined; } +const FIRST_MATE_PLACEMENT: FleetRowPlacement = { + depth: 0, + tone: "first-mate", + quiet: false, + lifecycle: "none", + pin: "toggle", +}; + const SidebarThreadRow = memo(function SidebarThreadRow(props: { thread: SidebarThreadSummary; variant: "card" | "slim"; // A row in the fleet: First Mate, a second mate, or a worker under its - // second mate. Absent for ordinary threads and for a worker parked on a shelf. + // second mate. Absent for ordinary threads. fleet?: FleetRowPlacement | undefined; // Slim rows are either settled (action: un-settle) or merely quiet // (seen Ready threads — action: settle). @@ -1050,6 +1064,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onSnooze: (threadRef: ScopedThreadRef, preset: Pick) => void; onUnsnooze: (threadRef: ScopedThreadRef) => void; onUnpin: (threadRef: ScopedThreadRef) => void; + // Present where the row offers its own pin on hover (First Mate rows). + onPin?: ((threadRef: ScopedThreadRef) => void) | undefined; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; /** * External files dropped onto this row. The row highlights while the drag @@ -1074,6 +1090,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onUnsettle, onUnsnooze, onUnpin, + onPin, openPullRequestsInRightPanel, renamingTitle, thread, @@ -1251,7 +1268,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }).thinkingLabel : null; // A worker wears its second mate's colour, quieter. - const fleetToneClassName = fleet ? cn(fleet.tone, fleet.quiet && "opacity-70") : undefined; + const fleetToneClassName = fleet + ? cn(FLEET_TONE_CLASSES[fleet.tone], fleet.quiet && "opacity-70") + : undefined; const displayTitle = threadDisplayTitle(thread); // The local environment is "this machine" and needs no marker; every other @@ -1398,6 +1417,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }, [onUnpin, threadRef], ); + const handlePinClick = useCallback( + (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + onPin?.(threadRef); + }, + [onPin, threadRef], + ); const handleSnoozePreset = useCallback( (preset: Pick) => { onSnooze(threadRef, preset); @@ -1606,6 +1633,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { ) : null; const showPin = props.isPinned && (!sortable?.isDragging || (props.dragOverPinned && props.dropVerb === null)); + const showPinButton = + onPin !== undefined && props.pinningSupported && !props.isPinned && !sortable?.isDragging; const pinIndicator = showPin ? ( props.pinningSupported && !sortable?.isDragging ? ( @@ -1642,6 +1671,30 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> ) : null; + // The fold arrow sits in the gutter left of the project icon, so a second + // mate lines up with the rows around it. + const foldButton = fleet?.fold ? ( + + ) : null; + if (variant === "slim") { return (
  • + {foldButton} {props.project ? : null} + {/* A parked fleet row keeps its role mark, so it still reads as + part of the tree it sits in. */} + {fleetRole ? : null} {draftIndicator} {title} {roleBadge} @@ -1811,29 +1870,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { fleet?.depth === 1 && "pl-4", )} > - {/* The fold arrow sits in the gutter left of the project icon, so a - second mate lines up with the rows around it. */} - {fleet?.fold ? ( - - ) : null} + {foldButton} - {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? ( + {props.settlementSupported || + showSnoozeButton || + hasUnsentDraft || + showPinButton ? ( Discard draft ) : null} + {showPinButton ? ( + + + } + > + + Pin + + Pin as the main First Mate + + ) : null} {showSnoozeButton ? ( - {fleetRoleWord(thread)} + {FLEET_ROLE_WORDS[fleetRole]} {" \u00b7 "} @@ -2670,8 +2728,8 @@ export default function Sidebar() { } | null>(null); const { firstMateThreads, - hasLiveFirstMate, fleetBranches, + fleetParked, pinnedThreads, draggableThreadKeys, activeReorderableThreadKeys, @@ -2686,25 +2744,34 @@ export default function Sidebar() { // memo exactly at the next wake boundary. void snoozeWakeTick; const preciseNow = new Date().toISOString(); - // A pinned First Mate takes its own slot above every section. First Mate - // ignores the project scope wherever it sits. - const { slot: firstMate, rest, live } = partitionFirstMateThreads(threads); + // First Mate takes its own slot above every section and ignores the + // project scope. + const { slot: firstMate, rest } = partitionFirstMateThreads(threads); const inScope = rest.filter( (thread) => thread.archivedAt === null && - (isFirstMateThread(thread) || - scopedProjectKeys === null || + (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); - // Second mates and their workers leave the sections for the fleet tree. A - // settled or snoozed worker stays on the shelves like any other thread. - const fleetTree = buildFleetTree(inScope, (thread) => { - const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; - return ( - (capabilities?.threadSnooze === true && effectiveSnoozed(thread, { now: preciseNow })) || - (capabilities?.threadSettlement === true && thread.settledOverride === "settled") - ); - }); + // Second mates and their workers leave the sections for the fleet tree, + // settled and snoozed ones included: they park in place. + const fleetTree = buildFleetTree(inScope); + const parked = new Map(); + for (const branch of fleetTree.branches) { + for (const thread of branch.secondMate + ? [branch.secondMate, ...branch.workers] + : branch.workers) { + const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; + if (capabilities?.threadSnooze === true && effectiveSnoozed(thread, { now: preciseNow })) { + parked.set(thread, "snoozed"); + } else if ( + capabilities?.threadSettlement === true && + thread.settledOverride === "settled" + ) { + parked.set(thread, "settled"); + } + } + } const visible = fleetTree.rest; const pinned: EnvironmentThreadShell[] = []; const active: EnvironmentThreadShell[] = []; @@ -2764,8 +2831,8 @@ export default function Sidebar() { const sortedActive = sortThreadsForSidebar(active); return { firstMateThreads: firstMate, - hasLiveFirstMate: live.length > 0, fleetBranches: fleetTree.branches, + fleetParked: parked, pinnedThreads: optimisticDrop?.section !== "pinned" || optimisticDrop.order === null ? sortedPinned @@ -2805,45 +2872,29 @@ export default function Sidebar() { return next; }); }, []); - // The fleet tree as rows: each second mate in its colour, then its workers - // indented under it unless folded. - const fleetRows = useMemo(() => { - const tones = assignSecondMateTones( - fleetBranches.flatMap((branch) => (branch.secondMate ? [branch.repo] : [])), - ); - const rows: { - readonly thread: EnvironmentThreadShell; - readonly placement: FleetRowPlacement; - }[] = []; - for (const branch of fleetBranches) { - const toneIndex = tones.get(branch.repo); - const tone = - toneIndex === undefined ? "text-muted-foreground" : SECOND_MATE_TONES[toneIndex]!; - const folded = foldedFleetRepos.has(branch.repo); - if (branch.secondMate) { - rows.push({ - thread: branch.secondMate, - placement: { - depth: 0, - tone, - quiet: false, - fold: - branch.workers.length > 0 - ? { expanded: !folded, onToggle: () => toggleFleetRepo(branch.repo) } - : undefined, - }, - }); - } - if (folded) continue; - for (const worker of branch.workers) { - rows.push({ - thread: worker, - placement: { depth: branch.secondMate ? 1 : 0, tone, quiet: true }, - }); - } - } - return rows; - }, [fleetBranches, foldedFleetRepos, toggleFleetRepo]); + 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, + tone: row.tone, + quiet: row.quiet, + 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, toggleFleetRepo], + ); const fleetThreads = useMemo(() => fleetRows.map((row) => row.thread), [fleetRows]); const threadSearchInputRef = useRef(null); @@ -4279,11 +4330,15 @@ export default function Sidebar() { null; // Un-settle pins the thread active until real activity clears the pin. // Environments without - // the settlement capability get no lifecycle items at all. + // the settlement capability get no lifecycle items at all. First Mate + // holds the top slot, which has no settled or snoozed look to undo. + const isFirstMate = isFirstMateThread(thread); const supportsSettlement = + !isFirstMate && serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === - true; + true; const supportsSnooze = + !isFirstMate && serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; const supportsPinning = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinning === true; @@ -4906,15 +4961,12 @@ export default function Sidebar() { thread: EnvironmentThreadShell, section: SidebarSection, sortable?: SortableThreadRowBag, - // The First Mate slot: always a pinned card, and none of - // the lifecycle moves that would take it out of the slot. - firstMateSlot = false, fleet?: FleetRowPlacement, ) => { - // A second mate keeps its place in the fleet tree: no - // settling, snoozing or pinning it out of there. - const fixedInFleet = - fleet !== undefined && thread.fleetRole === "second-mate"; + const capabilities = serverConfigs.get(thread.environmentId)?.environment + .capabilities; + const lifecycle = fleet?.lifecycle ?? "default"; + const pin = fleet?.pin ?? "default"; const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); @@ -4941,23 +4993,20 @@ export default function Sidebar() { : "settle" } settlementSupported={ - !firstMateSlot && - !fixedInFleet && - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadSettlement === true + capabilities?.threadSettlement === true && + (lifecycle === "default" || + (lifecycle === "undo-only" && section === "settled")) } snoozeSupported={ - !firstMateSlot && - !fixedInFleet && - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadSnooze === true + capabilities?.threadSnooze === true && + (lifecycle === "default" || + (lifecycle === "undo-only" && section === "snoozed")) } pinningSupported={ - !fixedInFleet && - serverConfigs.get(thread.environmentId)?.environment.capabilities - .threadPinning === true + capabilities?.threadPinning === true && pin !== "none" } isPinned={thread.pinnedAt != null} + onPin={pin === "toggle" ? attemptPin : undefined} sortable={sortable} dropVerb={ dragState?.activeKey === threadKey @@ -5027,7 +5076,6 @@ export default function Sidebar() { const renderThreadRow = ( thread: EnvironmentThreadShell, section: SidebarSection, - fleet?: FleetRowPlacement, ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), @@ -5042,54 +5090,49 @@ export default function Sidebar() { optimisticDrop !== null } > - {(bag) => renderThreadRowInner(thread, section, bag, false, fleet)} + {(bag) => renderThreadRowInner(thread, section, bag)} ); }; const from = dragState?.activeSection ?? null; const items: ReactNode[] = [ + // First Mate always holds the top: the main one, any + // others under it, or a row to start one. ...(firstMateThreads.length > 0 - ? [ - ...firstMateThreads.map((thread) => - renderThreadRowInner( - thread, - "pinned", - undefined, - true, - FIRST_MATE_PLACEMENT, - ), + ? firstMateThreads.map((thread) => + renderThreadRowInner( + thread, + "pinned", + undefined, + FIRST_MATE_PLACEMENT, ), - // Sets the pinned First Mate apart from every row below. -
  • , - ] - : hasLiveFirstMate - ? [] - : [ -
  • - -
  • , - ]), + ) + : [ +
  • + +
  • , + ]), + // Sets First Mate apart from every row below. +
  • , ...fleetRows.map((row) => - renderThreadRowInner( - row.thread, - "active", - undefined, - false, - row.placement, - ), + renderThreadRowInner(row.thread, row.section, undefined, row.placement), ), ({ id, @@ -29,12 +30,64 @@ const thread = ( updatedAt: "2026-09-24T10:00:00Z", archivedAt: null, pinnedAt: null, - settled: false, + parked: null, ...fields, }); +type TestThread = ReturnType; const ids = (threads: ReadonlyArray<{ readonly id: string }>) => threads.map((entry) => entry.id); +const rowsFor = (threads: readonly TestThread[], folded: readonly string[] = []) => + buildFleetRows(buildFleetTree(threads).branches, { + isFolded: (repo) => folded.includes(repo), + parkedState: (entry) => entry.parked, + }); + +describe("First Mate at the top", () => { + it("keeps First Mate in the top slot whether or not it is pinned", () => { + const unpinned = thread("fm", { fleetRole: "first-mate" }); + const { slot, rest } = partitionFirstMateThreads([thread("plain"), unpinned]); + expect(ids(slot)).toEqual(["fm"]); + expect(ids(rest)).toEqual(["plain"]); + }); + + it("offers Start First Mate when no First Mate thread is live", () => { + const archived = thread("fm", { fleetRole: "first-mate", archivedAt: "2026-09-24T11:00:00Z" }); + expect(planFirstMateSlot([archived, thread("plain")])).toEqual({ kind: "start" }); + }); + + it("puts the pinned First Mate on top and lists the others under it", () => { + const older = thread("fm-old", { fleetRole: "first-mate", createdAt: "2026-09-20T00:00:00Z" }); + const pinned = thread("fm-pinned", { + fleetRole: "first-mate", + createdAt: "2026-09-21T00:00:00Z", + pinnedAt: "2026-09-22T00:00:00Z", + }); + const newest = thread("fm-new", { fleetRole: "first-mate", createdAt: "2026-09-23T00:00:00Z" }); + const plan = planFirstMateSlot([older, newest, pinned]); + expect(plan.kind === "first-mates" ? [plan.main.id, ...ids(plan.others)] : plan).toEqual([ + "fm-pinned", + "fm-new", + "fm-old", + ]); + }); + + it("puts the most recent First Mate on top when none is pinned", () => { + const older = thread("fm-old", { fleetRole: "first-mate", createdAt: "2026-09-20T00:00:00Z" }); + const newest = thread("fm-new", { fleetRole: "first-mate", createdAt: "2026-09-23T00:00:00Z" }); + const plan = planFirstMateSlot([older, newest]); + expect(plan.kind === "first-mates" ? [plan.main.id, ...ids(plan.others)] : plan).toEqual([ + "fm-new", + "fm-old", + ]); + }); + + it("lets a First Mate row be pinned and unpinned from the row", () => { + const plan = planFirstMateSlot([thread("fm", { fleetRole: "first-mate" })]); + expect(plan.kind === "first-mates" ? plan.pin : null).toBe("toggle"); + }); +}); + describe("buildFleetTree", () => { it("puts each worker under the second mate that shares its fleetRepo", () => { const { branches } = buildFleetTree([ @@ -73,65 +126,75 @@ describe("buildFleetTree", () => { createdAt: "2026-09-24T09:30:00Z", }), ]; - const order = (busy: string) => - buildFleetTree(fleet(busy)).branches.flatMap((branch) => [ - branch.secondMate?.id, - ...ids(branch.workers), - ]); + const order = (busy: string) => ids(rowsFor(fleet(busy)).map((row) => row.thread)); const expected = ["sm-lavish", "sm-t3", "w-old", "w-new"]; - expect(order("none")).toEqual(expected); - expect(order("sm-t3")).toEqual(expected); - expect(order("sm-lavish")).toEqual(expected); - expect(order("w-old")).toEqual(expected); + for (const busy of ["none", "sm-t3", "sm-lavish", "w-old"]) { + expect(order(busy)).toEqual(expected); + } }); it("passes ordinary threads through unchanged and in their order", () => { const plain = [thread("b"), thread("a"), thread("c")]; - const { branches, rest } = buildFleetTree([ + const { rest } = buildFleetTree([ plain[0]!, thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), + thread("w", { fleetRole: "worker", fleetRepo: "t3code", parked: "settled" }), plain[1]!, plain[2]!, ]); expect(rest).toEqual(plain); - expect(branches).toHaveLength(1); - }); - - it("keeps a settled second mate in the tree and sends a settled worker to the shelves", () => { - const secondMate = thread("sm", { - fleetRole: "second-mate", - fleetRepo: "t3code", - settled: true, - }); - const worker = thread("w", { fleetRole: "worker", fleetRepo: "t3code", settled: true }); - const { branches, rest } = buildFleetTree([secondMate, worker], (entry) => entry.settled); - expect(branches.map((branch) => branch.secondMate?.id)).toEqual(["sm"]); - expect(branches[0]!.workers).toEqual([]); - expect(rest).toEqual([worker]); }); +}); - it("lists workers without a second mate after every crewed repository", () => { - const { branches } = buildFleetTree([ - thread("w-orphan", { fleetRole: "worker", fleetRepo: "aaa" }), - thread("sm", { fleetRole: "second-mate", fleetRepo: "zzz" }), +describe("buildFleetRows", () => { + it("keeps settled and snoozed workers under their second mate, marked as parked", () => { + const rows = rowsFor([ + thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), + thread("w-settled", { fleetRole: "worker", fleetRepo: "t3code", parked: "settled" }), + thread("w-snoozed", { + fleetRole: "worker", + fleetRepo: "t3code", + parked: "snoozed", + createdAt: "2026-09-24T11:00:00Z", + }), ]); - expect(branches.map((branch) => [branch.repo, branch.secondMate?.id ?? null])).toEqual([ - ["zzz", "sm"], - ["aaa", null], + expect(rows.map((row) => [row.thread.id, row.depth, row.parked])).toEqual([ + ["sm", 0, null], + ["w-settled", 1, "settled"], + ["w-snoozed", 1, "snoozed"], ]); }); -}); -describe("pinned First Mate", () => { - it("stays at the top, above the fleet tree and every other thread", () => { - const threads = [ - thread("plain"), + it("hides a folded second mate's workers and shows them again when unfolded", () => { + const fleet = [ thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), - thread("fm", { fleetRole: "first-mate", pinnedAt: "2026-09-24T08:00:00Z" }), + thread("w", { fleetRole: "worker", fleetRepo: "t3code" }), ]; - const { slot, rest } = partitionFirstMateThreads(threads); - expect(ids(slot)).toEqual(["fm"]); - expect(ids(rest)).not.toContain("fm"); + const folded = rowsFor(fleet, ["t3code"]); + expect(ids(folded.map((row) => row.thread))).toEqual(["sm"]); + expect(folded[0]!.fold).toEqual({ repo: "t3code", expanded: false }); + expect(ids(rowsFor(fleet).map((row) => row.thread))).toEqual(["sm", "w"]); + }); + + it("keeps the way back for a second mate that was settled, snoozed or pinned before", () => { + const rows = rowsFor([ + thread("sm-settled", { fleetRole: "second-mate", fleetRepo: "a", parked: "settled" }), + thread("sm-snoozed", { fleetRole: "second-mate", fleetRepo: "b", parked: "snoozed" }), + thread("sm-pinned", { + fleetRole: "second-mate", + fleetRepo: "c", + pinnedAt: "2026-09-24T00:00:00Z", + }), + thread("sm-plain", { fleetRole: "second-mate", fleetRepo: "d" }), + ]); + expect(rows.map((row) => [row.thread.id, row.parked, row.pin])).toEqual([ + // Parked rows keep their un-settle or wake control where they stand. + ["sm-settled", "settled", "none"], + ["sm-snoozed", "snoozed", "none"], + // A pinned one keeps the unpin control; an unpinned one has no pin. + ["sm-pinned", null, "unpin-only"], + ["sm-plain", null, "none"], + ]); }); }); @@ -143,18 +206,9 @@ describe("assignSecondMateTones", () => { for (const repo of repos) expect(again.get(repo)).toBe(first.get(repo)); }); - it("gives every second mate its own colour up to the palette size", () => { - const repos = Array.from({ length: SECOND_MATE_TONES.length }, (_, index) => `repo-${index}`); - const tones = assignSecondMateTones(repos); - expect(new Set(tones.values()).size).toBe(repos.length); - }); -}); - -describe("fleetRoleWord", () => { - it("names each fleet role and nothing for an ordinary thread", () => { - expect(fleetRoleWord({ fleetRole: "first-mate" })).toBe("First Mate"); - expect(fleetRoleWord({ fleetRole: "second-mate" })).toBe("Second mate"); - expect(fleetRoleWord({ fleetRole: "worker" })).toBe("Worker"); - expect(fleetRoleWord({ fleetRole: null })).toBeNull(); + it("gives six second mates six different colours", () => { + expect(SECOND_MATE_TONES.length).toBeGreaterThanOrEqual(6); + const repos = ["firstmate", "t3code", "lavish-axi", "agos", "printworks", "site"]; + expect(new Set(assignSecondMateTones(repos).values()).size).toBe(6); }); }); diff --git a/apps/web/src/components/sidebar/fleetSidebar.logic.ts b/apps/web/src/components/sidebar/fleetSidebar.logic.ts index d71deb4b8..40673da1e 100644 --- a/apps/web/src/components/sidebar/fleetSidebar.logic.ts +++ b/apps/web/src/components/sidebar/fleetSidebar.logic.ts @@ -1,3 +1,4 @@ +import type { FleetRole } from "@t3tools/contracts"; import type { FleetThreadFields } from "@t3tools/client-runtime/fleet-threads"; /** @@ -6,43 +7,81 @@ import type { FleetThreadFields } from "@t3tools/client-runtime/fleet-threads"; * repository key for both, and nothing else links them. */ +type FleetThread = FleetThreadFields & { + readonly id: string; + readonly archivedAt: string | null; + readonly createdAt: string; + readonly pinnedAt?: string | null | undefined; +}; + +/** What the top of the sidebar shows: a Start First Mate row, or the First Mate threads. */ +export type FirstMateSlot = + | { readonly kind: "start" } + | { + readonly kind: "first-mates"; + /** The main First Mate: the pinned one, or the most recent live one. */ + readonly main: T; + /** Any other live First Mates, newest first, listed under the main one. */ + readonly others: readonly T[]; + /** First Mate rows carry a pin toggle: the pin marks the main one. */ + readonly pin: "toggle"; + }; + +const newestFirst = (left: { readonly createdAt: string }, right: { readonly createdAt: string }) => + right.createdAt.localeCompare(left.createdAt); + +/** + * The top of the sidebar. First Mate always sits there, pinned or not. When + * several First Mate threads are live, the pin marks the main one; with none + * pinned, the most recent one leads. With none live, the slot offers to start one. + */ +export function planFirstMateSlot(threads: readonly T[]): FirstMateSlot { + const live = threads + .filter((thread) => thread.fleetRole === "first-mate" && thread.archivedAt === null) + .toSorted(newestFirst); + const pinned = live + .filter((thread) => thread.pinnedAt != null) + .toSorted((left, right) => right.pinnedAt!.localeCompare(left.pinnedAt!)); + const main = pinned[0] ?? live[0]; + if (main === undefined) return { kind: "start" }; + return { + kind: "first-mates", + main, + others: live.filter((thread) => thread !== main), + pin: "toggle", + }; +} + /** One second mate and the workers it launched. */ export interface FleetBranch { - readonly repo: string; + /** The shared `fleetRepo`, or null for fleet threads that name none. */ + readonly repo: string | null; /** Null when workers run in a repository that has no live second mate. */ readonly secondMate: T | null; readonly workers: readonly T[]; } -type TreeThread = FleetThreadFields & { - readonly id: string; - readonly archivedAt: string | null; - readonly createdAt: string; -}; - /** * Pull second mates and their workers out of the thread list into a tree. * Branches sort by repository name and workers by launch order, never by - * activity, so no fleet row moves when a thread gets busy. A second mate stays - * in the tree whatever its settled or snoozed state; a `parked` worker (settled - * or snoozed) leaves for the ordinary shelves like any thread. Every other - * thread passes through to `rest` in its original order. + * activity, so no fleet row moves when a thread gets busy. Settled and snoozed + * fleet threads stay in the tree too. Every other thread passes through to + * `rest` in its original order. */ -export function buildFleetTree( +export function buildFleetTree( threads: readonly T[], - parked: (thread: T) => boolean = () => false, ): { readonly branches: FleetBranch[]; readonly rest: T[] } { - const byRepo = new Map(); + const byRepo = new Map(); const rest: T[] = []; for (const thread of threads) { const inTree = thread.archivedAt === null && - (thread.fleetRole === "second-mate" || (thread.fleetRole === "worker" && !parked(thread))); + (thread.fleetRole === "second-mate" || thread.fleetRole === "worker"); if (!inTree) { rest.push(thread); continue; } - const repo = thread.fleetRepo ?? ""; + const repo = thread.fleetRepo ?? null; const branch = byRepo.get(repo) ?? { secondMate: null, workers: [] }; byRepo.set(repo, branch); if (thread.fleetRole === "worker") { @@ -72,25 +111,46 @@ export function buildFleetTree( (left, right) => // Workers with no second mate sort after every crewed repository. Number(left.secondMate === null) - Number(right.secondMate === null) || - left.repo.localeCompare(right.repo), + (left.repo ?? "").localeCompare(right.repo ?? ""), ); return { branches, rest }; } -/** First Mate's text colour. Pink is First Mate's alone. */ -export const FIRST_MATE_TONE = "text-pink-600 dark:text-pink-400"; +/** A text colour a fleet row can wear. */ +export type FleetTone = + | "first-mate" + | "sky" + | "teal" + | "indigo" + | "violet" + | "lime" + | "fuchsia" + | "plain"; /** - * Second mate text colours: a cool set that avoids the status colours (amber - * approval, red failure, green done), four hues far enough apart to tell at a - * glance, in shades picked to read on the sidebar in both light and dark. + * Colour classes per tone. First Mate's pink is its alone. The second mate + * colours keep clear of the status colours (amber approval, red failure, green + * done) and of each other, in shades picked to read in light and dark. */ +export const FLEET_TONE_CLASSES: Record = { + "first-mate": "text-pink-600 dark:text-pink-400", + sky: "text-sky-700 dark:text-sky-300", + teal: "text-teal-700 dark:text-teal-300", + indigo: "text-indigo-600 dark:text-indigo-300", + violet: "text-violet-600 dark:text-violet-300", + lime: "text-lime-700 dark:text-lime-300", + fuchsia: "text-fuchsia-700 dark:text-fuchsia-300", + plain: "text-muted-foreground", +}; + export const SECOND_MATE_TONES = [ - "text-sky-700 dark:text-sky-300", - "text-teal-700 dark:text-teal-300", - "text-indigo-600 dark:text-indigo-300", - "text-violet-600 dark:text-violet-300", -] as const; + "sky", + "teal", + "indigo", + "violet", + "lime", + "fuchsia", +] as const satisfies readonly FleetTone[]; function hashRepo(repo: string): number { // FNV-1a: small, fast, and the same on every client. @@ -103,11 +163,11 @@ function hashRepo(repo: string): number { } /** - * The colour index of each second mate's repository. A repository starts at - * the slot its name hashes to and moves on to the next free slot, visiting - * repositories in name order, so the same fleet always gets the same colours - * and adding a repository rarely moves anyone else's. Up to the palette size, - * every second mate gets its own colour. + * Each second mate's colour index. A repository starts at the slot its name + * hashes to and moves on to the next free one, visiting repositories in name + * order, so the same fleet always gets the same colours and adding a + * repository rarely moves anyone else's. Up to the palette size, every second + * mate gets its own colour. */ export function assignSecondMateTones(repos: readonly string[]): ReadonlyMap { const size = SECOND_MATE_TONES.length; @@ -122,16 +182,75 @@ export function assignSecondMateTones(repos: readonly string[]): ReadonlyMap { + readonly thread: T; + readonly depth: 0 | 1; + readonly tone: FleetTone; + /** A worker: its second mate's colour, faded, at regular weight. */ + readonly quiet: boolean; + readonly parked: FleetParked; + /** + * The pin control. A second mate's place is fixed, so it has none, except + * the unpin a second mate pinned before the tree keeps so it can be undone. + * A worker keeps the ordinary pin. + */ + readonly pin: "none" | "unpin-only" | "default"; + readonly fold?: { readonly repo: string; readonly expanded: boolean } | undefined; +} + +/** + * The fleet tree as rows: each second mate in its colour, then its workers + * indented under it unless folded. Parked threads stay in their place. + */ +export function buildFleetRows( + branches: readonly FleetBranch[], + options: { + readonly isFolded: (repo: string) => boolean; + readonly parkedState: (thread: T) => FleetParked; + }, +): FleetRow[] { + const tones = assignSecondMateTones( + branches.flatMap((branch) => (branch.secondMate && branch.repo !== null ? [branch.repo] : [])), + ); + const rows: FleetRow[] = []; + for (const branch of branches) { + const toneIndex = branch.repo === null ? undefined : tones.get(branch.repo); + const tone: FleetTone = toneIndex === undefined ? "plain" : SECOND_MATE_TONES[toneIndex]!; + const foldKey = branch.repo ?? ""; + const expanded = !options.isFolded(foldKey); + if (branch.secondMate) { + rows.push({ + thread: branch.secondMate, + depth: 0, + tone, + quiet: false, + parked: options.parkedState(branch.secondMate), + pin: branch.secondMate.pinnedAt != null ? "unpin-only" : "none", + fold: branch.workers.length > 0 ? { repo: foldKey, expanded } : undefined, + }); + if (!expanded) continue; + } + for (const worker of branch.workers) { + rows.push({ + thread: worker, + depth: branch.secondMate ? 1 : 0, + tone, + quiet: true, + parked: options.parkedState(worker), + pin: "default", + }); + } } + return rows; } + +/** The role word a fleet row shows before its model. */ +export const FLEET_ROLE_WORDS: Record = { + "first-mate": "First Mate", + "second-mate": "Second mate", + worker: "Worker", +}; diff --git a/apps/web/src/hooks/useStartFirstMateThread.logic.ts b/apps/web/src/hooks/useStartFirstMateThread.logic.ts index 04196c676..c4a750f74 100644 --- a/apps/web/src/hooks/useStartFirstMateThread.logic.ts +++ b/apps/web/src/hooks/useStartFirstMateThread.logic.ts @@ -1,6 +1,6 @@ /** * The folder a new First Mate thread runs in. The setting starts empty, so - * the first "New First Mate" asks for the folder with the app's folder picker + * the first "Start First Mate" asks for the folder with the app's folder picker * and saves the answer; every later start uses the saved folder. Null means * the user cancelled the picker. */ diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index f5fe77c1f..e6da1d161 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -86,25 +86,22 @@ their default order until the server is updated. ## First Mate threads -On web and desktop, the sidebar arranges First Mate's fleet as a small tree above your other -threads. Each fleet row says what it is on its model line, **First Mate**, **Second mate** or -**Worker**, in its own colour, followed by the model and its reasoning level, with a ship, -sailboat or pickaxe beside the project icon. - -- **First Mate** is pink. Pin it to keep it at the top of the sidebar, above a divider. Unpinned, - it lists among your other threads. It never settles automatically, even when a pull request - linked to it merges. -- **Second mates** follow, one per repository, in repository-name order. Each keeps its own colour - and its place, whatever it is doing, and never settles away. -- **Workers** sit indented under the second mate for their repository, in that second mate's - colour. Use the arrow beside a second mate to hide or show its workers. A worker you settle or - snooze moves to the usual shelf. - -When no First Mate thread is live, for example after you archive it, choose **New First Mate** -at the top of the sidebar to start one. It runs in the folder set in **Settings → General → -First Mate working directory**. The first time, the desktop app asks you to choose that folder -and saves it. A new First Mate runs on Claude Opus 5.5 with the 1M context at medium effort. -Only one First Mate thread can be live at a time. +On web and desktop, the sidebar keeps First Mate's fleet together above your other threads, and +each fleet thread is labelled **First Mate**, **Second mate** or **Worker**. + +- **First Mate** always stays at the top. It never settles automatically, even when a pull + request linked to it merges. If you have First Mate threads in more than one environment, pin + the one you want on top. The others list under it. +- **Second mates** follow, one per repository, in repository-name order. Each keeps its place + whatever it is doing. +- **Workers** sit under the second mate for their repository, including workers you settle or + snooze. You can hide a second mate's workers and show them again. + +When no First Mate thread is live, for example after you archive it, choose **Start First Mate** +at the top of the sidebar. It runs in the folder set in **Settings → General → First Mate working +directory**. The first time, the desktop app asks you to choose that folder and saves it. A new +First Mate runs on Claude Opus 5.5 with the 1M context at medium effort. Each environment can have +one live First Mate thread at a time. ## Settle finished work diff --git a/fork-features.json b/fork-features.json index e6e9addd2..290e77037 100644 --- a/fork-features.json +++ b/fork-features.json @@ -162,11 +162,12 @@ }, { "name": "fm-fleet-sidebar-tree", - "description": "The sidebar shows First Mate's fleet as a tree above the other threads. A pinned First Mate sits at the top above a divider; second mates follow in repository-name order and never move or settle away; each worker is indented under the second mate that shares its fleetRepo, and a second mate can fold its workers away. 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 comes from its repository, so it stays the same across reloads.", - "keep": "fleetSidebar.logic.ts, its test and FirstMateIcon.tsx are ours whole. In Sidebar.tsx the fleet tree rows, the FleetRowPlacement prop on SidebarThreadRow, the role line, the fold arrow and the First Mate divider survive; partitionFirstMateThreads and its pinned slot survive 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, settled and snoozed ones included, and a second mate can fold its workers away. A parked fleet row keeps its un-settle or wake control, and a pinned second mate keeps its unpin. 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 comes from its repository, from a palette of six, so it stays the same across reloads.", + "keep": "fleetSidebar.logic.ts, its test, FirstMateIcon.tsx and FleetRoleIcon.tsx are ours whole. In Sidebar.tsx the fleet tree rows, 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/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", From 13c26c6a65e34307b5145e1589e5dee5c95dd8b3 Mon Sep 17 00:00:00 2001 From: autoprintworks Date: Fri, 25 Sep 2026 00:35:47 +0100 Subject: [PATCH 3/4] fix(web): fleet colours come from each project's icon A second mate's role word and role icon, and its workers' quieter ones, now take the colour of the project icon the sidebar shows. A monogram badge or coloured icon gives its own colour. An image logo gives its main colour, read once from the image and cached, with only its lightness set for light and dark. Only an icon with no usable colour, such as an emoji or a grey logo, falls back to a stable colour from the repository name. First Mate keeps its pink. The fixed six-colour palette is gone. A settled or snoozed fleet row now stays a full row, dimmed until hovered, so it keeps its role line and its title is no shorter than any other row's. Its Un-settle or Wake control sits where Settle would. --- apps/web/src/components/FirstMateIcon.tsx | 4 +- apps/web/src/components/Sidebar.tsx | 125 +++++++++++--- apps/web/src/components/sidebar/FleetTone.tsx | 161 ++++++++++++++++++ .../sidebar/fleetSidebar.logic.test.ts | 37 ++-- .../components/sidebar/fleetSidebar.logic.ts | 83 +-------- .../sidebar/fleetTone.logic.test.ts | 59 +++++++ .../src/components/sidebar/fleetTone.logic.ts | 113 ++++++++++++ fork-features.json | 11 +- 8 files changed, 467 insertions(+), 126 deletions(-) create mode 100644 apps/web/src/components/sidebar/FleetTone.tsx create mode 100644 apps/web/src/components/sidebar/fleetTone.logic.test.ts create mode 100644 apps/web/src/components/sidebar/fleetTone.logic.ts diff --git a/apps/web/src/components/FirstMateIcon.tsx b/apps/web/src/components/FirstMateIcon.tsx index a66e39ea5..e3c3816ad 100644 --- a/apps/web/src/components/FirstMateIcon.tsx +++ b/apps/web/src/components/FirstMateIcon.tsx @@ -1,7 +1,7 @@ import { ShipIcon, type LucideProps } from "lucide-react"; import { cn } from "~/lib/utils"; -import { FLEET_TONE_CLASSES } from "./sidebar/fleetSidebar.logic"; +import { FIRST_MATE_TONE_CLASS } from "./sidebar/fleetSidebar.logic"; /** First Mate's mark: a ship in First Mate's colour. */ export function FirstMateIcon({ className, ...props }: LucideProps) { @@ -9,7 +9,7 @@ export function FirstMateIcon({ className, ...props }: LucideProps) { ); } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 491a7a213..9bf34ec35 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -164,11 +164,11 @@ import { FleetRoleIcon } from "./FleetRoleIcon"; import { buildFleetRows, buildFleetTree, + FIRST_MATE_TONE_CLASS, FLEET_ROLE_WORDS, - FLEET_TONE_CLASSES, type FleetParked, - type FleetTone, } from "./sidebar/fleetSidebar.logic"; +import { FleetTone, type FleetTheme } from "./sidebar/FleetTone"; import { resolveReadOnlyThreadModel } from "./chat/readOnlyThreadModel.logic"; import { ProjectEnvironmentBadge } from "./ProjectEnvironmentBadge"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; @@ -992,9 +992,11 @@ const dropVerbBadge: Record = { */ interface FleetRowPlacement { readonly depth: 0 | 1; - readonly tone: FleetTone; + readonly theme: FleetTheme; /** A worker: its second mate's colour, faded, at regular weight. */ readonly quiet: boolean; + /** Settled or snoozed: drawn dimmed in place, with its way back. */ + readonly parked: FleetParked; readonly lifecycle: "none" | "undo-only" | "default"; readonly pin: "none" | "unpin-only" | "default" | "toggle"; readonly fold?: { readonly expanded: boolean; readonly onToggle: () => void } | undefined; @@ -1002,8 +1004,9 @@ interface FleetRowPlacement { const FIRST_MATE_PLACEMENT: FleetRowPlacement = { depth: 0, - tone: "first-mate", + theme: { kind: "first-mate" }, quiet: false, + parked: null, lifecycle: "none", pin: "toggle", }; @@ -1267,10 +1270,21 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { models: providerEntry?.models ?? [], }).thinkingLabel : null; - // A worker wears its second mate's colour, quieter. - const fleetToneClassName = fleet - ? cn(FLEET_TONE_CLASSES[fleet.tone], fleet.quiet && "opacity-70") - : undefined; + // The role mark and word wear the colour of the second mate's project icon; + // a worker wears it quieter. + const fleetRoleIcon = + fleet && fleetRole ? ( + + {(tone) => ( + + )} + + ) : null; const displayTitle = threadDisplayTitle(thread); // The local environment is "this machine" and needs no marker; every other @@ -1478,7 +1492,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // content; surface is reserved for interaction (hover, multi-select, route). const rowSurfaceClassName = cn( "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left outline-none select-none", - variantAction === "unsettle" && "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70", + variantAction === "unsettle" && + fleet === undefined && + "[&:not(:hover):not(:focus-within)_*]:text-secondary-label/70", props.isActive ? "bg-sidebar-row-active text-sidebar-foreground" : isSelected @@ -1740,7 +1756,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {/* A parked fleet row keeps its role mark, so it still reads as part of the tree it sits in. */} - {fleetRole ? : null} + {fleetRoleIcon} {draftIndicator} {title} {roleBadge} @@ -1888,7 +1904,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { /> } > -
    +
    {draftIndicator} {isFirstMate ? ( @@ -1903,9 +1926,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {props.project ? ( ) : null} - {fleetRole ? ( - - ) : null} + {fleetRoleIcon} {/* A second mate's label already names its repository, so it stands in for the project name rather than crowding it. */} {props.projectDisplayName && @@ -2007,6 +2028,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { {props.settlementSupported || showSnoozeButton || + (variantAction === "unsnooze" && props.snoozeSupported) || hasUnsentDraft || showPinButton ? ( Pin as the main First Mate ) : null} - {showSnoozeButton ? ( + {variantAction === "unsnooze" ? ( + // A snoozed fleet row stays a full row in its place. + props.snoozeSupported ? ( + + + } + > + + Wake + + Wake thread now + + ) : null + ) : showSnoozeButton ? ( ) : null} - {props.settlementSupported ? ( + {props.settlementSupported && variantAction === "unsettle" ? ( + // A settled fleet row stays a full row in its place. + + + } + > + + Un-settle + + Un-settle thread + + ) : props.settlementSupported && variantAction === "settle" ? ( - {fleetRole ? ( + {fleet && fleetRole ? ( <> - - {FLEET_ROLE_WORDS[fleetRole]} - + + {(tone) => ( + + {FLEET_ROLE_WORDS[fleetRole]} + + )} + {" \u00b7 "} ) : null} @@ -2884,8 +2954,13 @@ export default function Sidebar() { section: row.parked ?? ("active" as const), placement: { depth: row.depth, - tone: row.tone, + 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 @@ -2893,7 +2968,7 @@ export default function Sidebar() { : undefined, } satisfies FleetRowPlacement, })), - [fleetBranches, fleetParked, foldedFleetRepos, toggleFleetRepo], + [fleetBranches, fleetParked, foldedFleetRepos, projectByKey, toggleFleetRepo], ); const fleetThreads = useMemo(() => fleetRows.map((row) => row.thread), [fleetRows]); @@ -4974,7 +5049,9 @@ export default function Sidebar() { // row: every other thread is a full card. Density comes // from users (or the auto rules) actually parking work, // not from the sidebar second-guessing what still matters. - const isCard = section === "active" || section === "pinned"; + // Fleet rows stay full rows when parked, so the tree keeps one shape. + const isCard = + fleet !== undefined || section === "active" || section === "pinned"; const rowVariant = isCard ? "card" : "slim"; return ( - + {startingFirstMate ? "Starting First Mate…" : "Start First Mate"} diff --git a/apps/web/src/components/sidebar/FleetTone.tsx b/apps/web/src/components/sidebar/FleetTone.tsx new file mode 100644 index 000000000..9bac1c5b6 --- /dev/null +++ b/apps/web/src/components/sidebar/FleetTone.tsx @@ -0,0 +1,161 @@ +import { useAtomValue } from "@effect/atom-react"; +import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; +import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; + +import { deriveProjectIdentity } from "../../projectIdentity"; +import { projectFaviconUrlAtom } from "../../state/assets"; +import type { ProjectFaviconProject } from "../ProjectFavicon"; +import { FIRST_MATE_TONE_CLASS } from "./fleetSidebar.logic"; +import { + chooseFleetTone, + fleetToneStyle, + type FleetIconSource, + type FleetToneChoice, +} from "./fleetTone.logic"; + +/** Whose colour a fleet row wears. */ +export type FleetTheme = + | { readonly kind: "first-mate" } + | { + readonly kind: "project"; + /** The second mate's project, whose icon gives the colour. */ + readonly project: ProjectFaviconProject | null; + /** The repository, for the stable fallback when the icon has no colour. */ + readonly repo: string; + }; + +export interface FleetToneProps { + readonly className: string; + readonly style?: CSSProperties | undefined; + /** How the colour was found; "fallback" means the icon had no usable colour. */ + readonly source: FleetToneChoice["kind"] | "first-mate" | "pending"; +} + +const PENDING: FleetToneProps = { className: "text-muted-foreground", source: "pending" }; + +/** Hands its children the text colour for a fleet row. */ +export function FleetTone(props: { + readonly theme: FleetTheme; + readonly children: (tone: FleetToneProps) => ReactNode; +}) { + if (props.theme.kind === "first-mate") { + return props.children({ className: FIRST_MATE_TONE_CLASS, source: "first-mate" }); + } + if (props.theme.project === null) { + return props.children(toneProps(chooseFleetTone({ kind: "none" }, props.theme.repo))); + } + return ( + + {props.children} + + ); +} + +function toneProps(choice: FleetToneChoice): FleetToneProps { + const { className, style } = fleetToneStyle(choice); + return { className, style: style as CSSProperties | undefined, source: choice.kind }; +} + +function ProjectFleetTone(props: { + readonly project: ProjectFaviconProject; + readonly repo: string; + readonly children: (tone: FleetToneProps) => ReactNode; +}) { + const { project } = props; + // The same URL the project icon shows, so both read the same image. + const src = useAtomValue( + projectFaviconUrlAtom({ + environmentId: project.environmentId, + cwd: project.workspaceRoot, + faviconPath: project.faviconPath, + }), + ); + const icon = project.projectIcon; + // Decide which icon the sidebar shows, in the same order ProjectFavicon does. + const imageSrc = icon == null && src && !isProjectFaviconFallbackUrl(src) ? src : null; + const pixels = useImagePixels(imageSrc); + let source: FleetIconSource; + if (icon?.kind === "monogram" || icon?.kind === "lucide") { + source = { kind: "badge", color: icon.color }; + } else if (icon?.kind === "emoji") { + source = { kind: "none" }; + } else if (imageSrc === null || pixels === "failed") { + // No image, or one that would not load: the sidebar shows the generated badge. + source = { kind: "badge", color: deriveProjectIdentity(project.title).color }; + } else if (pixels === undefined) { + return props.children(PENDING); + } else { + source = { kind: "image", pixels: pixels === "unreadable" ? null : pixels }; + } + return props.children(toneProps(chooseFleetTone(source, props.repo))); +} + +type ImagePixels = Uint8ClampedArray | "unreadable" | "failed"; + +// Each image is read once per page load; every row showing it shares the result. +const SAMPLE_SIZE = 32; +const pendingPixels = new Map>(); +const settledPixels = new Map(); + +async function samplePixels(src: string): Promise { + const image = new Image(); + image.crossOrigin = "anonymous"; + image.src = src; + try { + // decode() holds the image until it settles; a bare load listener does not. + await image.decode(); + } catch { + return "failed"; + } + try { + const canvas = document.createElement("canvas"); + canvas.width = SAMPLE_SIZE; + canvas.height = SAMPLE_SIZE; + const context = canvas.getContext("2d"); + if (!context) return "unreadable"; + context.drawImage(image, 0, 0, SAMPLE_SIZE, SAMPLE_SIZE); + return context.getImageData(0, 0, SAMPLE_SIZE, SAMPLE_SIZE).data; + } catch { + // A cross-origin image without CORS headers cannot be read back. + return "unreadable"; + } +} + +function readImagePixels(src: string): Promise { + const pending = + pendingPixels.get(src) ?? + samplePixels(src).then((pixels) => { + settledPixels.set(src, pixels); + return pixels; + }); + pendingPixels.set(src, pending); + return pending; +} + +/** The image's pixels once read, or undefined while reading. */ +function useImagePixels(src: string | null): ImagePixels | undefined { + // The result lives in state, not only in the shared cache, so the row + // re-renders with it: a read from a module-level map is not a dependency + // React can see. + const [read, setRead] = useState<{ readonly src: string; readonly pixels: ImagePixels } | null>( + () => { + // A row mounting after the image was read starts in its colour. + const pixels = src === null ? undefined : settledPixels.get(src); + return src !== null && pixels !== undefined ? { src, pixels } : null; + }, + ); + useEffect(() => { + if (src === null) return; + let live = true; + void readImagePixels(src).then((pixels) => { + if (!live) return; + setRead((current) => + current?.src === src && current.pixels === pixels ? current : { src, pixels }, + ); + }); + return () => { + live = false; + }; + }, [src]); + return src !== null && read?.src === src ? read.pixels : undefined; +} diff --git a/apps/web/src/components/sidebar/fleetSidebar.logic.test.ts b/apps/web/src/components/sidebar/fleetSidebar.logic.test.ts index 6c4cadbe2..79502d890 100644 --- a/apps/web/src/components/sidebar/fleetSidebar.logic.test.ts +++ b/apps/web/src/components/sidebar/fleetSidebar.logic.test.ts @@ -1,13 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { partitionFirstMateThreads } from "../Sidebar.logic"; -import { - assignSecondMateTones, - buildFleetRows, - buildFleetTree, - planFirstMateSlot, - SECOND_MATE_TONES, -} from "./fleetSidebar.logic"; +import { buildFleetRows, buildFleetTree, planFirstMateSlot } from "./fleetSidebar.logic"; type Role = "first-mate" | "second-mate" | "worker"; @@ -165,6 +159,20 @@ describe("buildFleetRows", () => { ]); }); + it("colours each worker from its second mate's project icon", () => { + const rows = rowsFor([ + thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), + thread("w", { fleetRole: "worker", fleetRepo: "t3code" }), + thread("stray", { fleetRole: "worker", fleetRepo: "elsewhere" }), + ]); + expect(rows.map((row) => [row.thread.id, row.theme.id])).toEqual([ + ["sm", "sm"], + ["w", "sm"], + // A worker with no second mate takes its own project icon. + ["stray", "stray"], + ]); + }); + it("hides a folded second mate's workers and shows them again when unfolded", () => { const fleet = [ thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), @@ -197,18 +205,3 @@ describe("buildFleetRows", () => { ]); }); }); - -describe("assignSecondMateTones", () => { - it("gives the same repository the same colour whatever order the fleet arrives in", () => { - const repos = ["t3code", "firstmate", "lavish-axi"]; - const first = assignSecondMateTones(repos); - const again = assignSecondMateTones(repos.toReversed()); - for (const repo of repos) expect(again.get(repo)).toBe(first.get(repo)); - }); - - it("gives six second mates six different colours", () => { - expect(SECOND_MATE_TONES.length).toBeGreaterThanOrEqual(6); - const repos = ["firstmate", "t3code", "lavish-axi", "agos", "printworks", "site"]; - expect(new Set(assignSecondMateTones(repos).values()).size).toBe(6); - }); -}); diff --git a/apps/web/src/components/sidebar/fleetSidebar.logic.ts b/apps/web/src/components/sidebar/fleetSidebar.logic.ts index 40673da1e..3acb61209 100644 --- a/apps/web/src/components/sidebar/fleetSidebar.logic.ts +++ b/apps/web/src/components/sidebar/fleetSidebar.logic.ts @@ -116,71 +116,8 @@ export function buildFleetTree( return { branches, rest }; } -/** A text colour a fleet row can wear. */ -export type FleetTone = - | "first-mate" - | "sky" - | "teal" - | "indigo" - | "violet" - | "lime" - | "fuchsia" - | "plain"; - -/** - * Colour classes per tone. First Mate's pink is its alone. The second mate - * colours keep clear of the status colours (amber approval, red failure, green - * done) and of each other, in shades picked to read in light and dark. - */ -export const FLEET_TONE_CLASSES: Record = { - "first-mate": "text-pink-600 dark:text-pink-400", - sky: "text-sky-700 dark:text-sky-300", - teal: "text-teal-700 dark:text-teal-300", - indigo: "text-indigo-600 dark:text-indigo-300", - violet: "text-violet-600 dark:text-violet-300", - lime: "text-lime-700 dark:text-lime-300", - fuchsia: "text-fuchsia-700 dark:text-fuchsia-300", - plain: "text-muted-foreground", -}; - -export const SECOND_MATE_TONES = [ - "sky", - "teal", - "indigo", - "violet", - "lime", - "fuchsia", -] as const satisfies readonly FleetTone[]; - -function hashRepo(repo: string): number { - // FNV-1a: small, fast, and the same on every client. - let hash = 0x811c9dc5; - for (let index = 0; index < repo.length; index += 1) { - hash ^= repo.charCodeAt(index); - hash = Math.imul(hash, 0x01000193); - } - return hash >>> 0; -} - -/** - * Each second mate's colour index. A repository starts at the slot its name - * hashes to and moves on to the next free one, visiting repositories in name - * order, so the same fleet always gets the same colours and adding a - * repository rarely moves anyone else's. Up to the palette size, every second - * mate gets its own colour. - */ -export function assignSecondMateTones(repos: readonly string[]): ReadonlyMap { - const size = SECOND_MATE_TONES.length; - const assigned = new Map(); - const used = new Set(); - for (const repo of [...new Set(repos)].toSorted()) { - let slot = hashRepo(repo) % size; - for (let tries = 0; tries < size && used.has(slot); tries += 1) slot = (slot + 1) % size; - used.add(slot); - assigned.set(repo, slot); - } - return assigned; -} +/** First Mate's own colour. Second mates take theirs from their project icon. */ +export const FIRST_MATE_TONE_CLASS = "text-pink-600 dark:text-pink-400"; /** A settled or snoozed fleet thread keeps its place, drawn quietly with its way back. */ export type FleetParked = "settled" | "snoozed" | null; @@ -189,7 +126,8 @@ export type FleetParked = "settled" | "snoozed" | null; export interface FleetRow { readonly thread: T; readonly depth: 0 | 1; - readonly tone: FleetTone; + /** Whose project icon sets the row's colour: the second mate, for its workers too. */ + readonly theme: T; /** A worker: its second mate's colour, faded, at regular weight. */ readonly quiet: boolean; readonly parked: FleetParked; @@ -203,8 +141,8 @@ export interface FleetRow { } /** - * The fleet tree as rows: each second mate in its colour, then its workers - * indented under it unless folded. Parked threads stay in their place. + * The fleet tree as rows: each second mate, then its workers indented under it + * unless folded. Parked threads stay in their place. */ export function buildFleetRows( branches: readonly FleetBranch[], @@ -213,20 +151,15 @@ export function buildFleetRows( readonly parkedState: (thread: T) => FleetParked; }, ): FleetRow[] { - const tones = assignSecondMateTones( - branches.flatMap((branch) => (branch.secondMate && branch.repo !== null ? [branch.repo] : [])), - ); const rows: FleetRow[] = []; for (const branch of branches) { - const toneIndex = branch.repo === null ? undefined : tones.get(branch.repo); - const tone: FleetTone = toneIndex === undefined ? "plain" : SECOND_MATE_TONES[toneIndex]!; const foldKey = branch.repo ?? ""; const expanded = !options.isFolded(foldKey); if (branch.secondMate) { rows.push({ thread: branch.secondMate, depth: 0, - tone, + theme: branch.secondMate, quiet: false, parked: options.parkedState(branch.secondMate), pin: branch.secondMate.pinnedAt != null ? "unpin-only" : "none", @@ -238,7 +171,7 @@ export function buildFleetRows( rows.push({ thread: worker, depth: branch.secondMate ? 1 : 0, - tone, + theme: branch.secondMate ?? worker, quiet: true, parked: options.parkedState(worker), pin: "default", diff --git a/apps/web/src/components/sidebar/fleetTone.logic.test.ts b/apps/web/src/components/sidebar/fleetTone.logic.test.ts new file mode 100644 index 000000000..c31d80d30 --- /dev/null +++ b/apps/web/src/components/sidebar/fleetTone.logic.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { deriveProjectIdentity } from "../../projectIdentity"; +import { chooseFleetTone, fleetToneStyle } from "./fleetTone.logic"; + +/** A square RGBA image: `paint` gives each pixel's colour, or null for transparent. */ +const image = ( + size: number, + paint: (x: number, y: number) => readonly [number, number, number] | null, +) => { + const pixels = new Uint8ClampedArray(size * size * 4); + for (let y = 0; y < size; y += 1) { + for (let x = 0; x < size; x += 1) { + const colour = paint(x, y); + if (colour === null) continue; + pixels.set([...colour, 255], (y * size + x) * 4); + } + } + return pixels; +}; + +describe("chooseFleetTone", () => { + it("takes a monogram badge's own colour", () => { + const tone = chooseFleetTone({ kind: "badge", color: "lime" }, "firstmate"); + expect(tone).toEqual({ kind: "badge", color: "lime" }); + expect(fleetToneStyle(tone).className).toBe("text-lime-600 dark:text-lime-400"); + }); + + it("reads a logo's main colour and keeps its hue in light and dark", () => { + // A blue tile with white lettering and transparent corners, like the T3 logo. + const logo = image(16, (x, y) => { + if ((x < 2 || x > 13) && (y < 2 || y > 13)) return null; + if (y >= 6 && y <= 9 && x >= 4 && x <= 11) return [255, 255, 255]; + return [37, 99, 235]; + }); + const tone = chooseFleetTone({ kind: "image", pixels: logo }, "t3code"); + expect(tone.kind).toBe("image"); + if (tone.kind !== "image") return; + expect(tone.hue).toBeGreaterThan(250); + expect(tone.hue).toBeLessThan(275); + const { style } = fleetToneStyle(tone); + const light = style?.["--fleet-tone-light"]; + const dark = style?.["--fleet-tone-dark"]; + // Only the lightness differs between the two themes. + expect(light?.replace(/^oklch\([\d.]+ /, "")).toBe(dark?.replace(/^oklch\([\d.]+ /, "")); + expect(light).not.toBe(dark); + }); + + it("falls back to the repository's stable colour when the logo has no colour", () => { + const greyLogo = image(8, (x) => (x < 4 ? [20, 20, 20] : [240, 240, 240])); + const expected = { kind: "fallback", color: deriveProjectIdentity("lavish-axi").color }; + expect(chooseFleetTone({ kind: "image", pixels: greyLogo }, "lavish-axi")).toMatchObject( + expected, + ); + // An image the browser would not let us read, and an emoji, fall back the same way. + expect(chooseFleetTone({ kind: "image", pixels: null }, "lavish-axi")).toMatchObject(expected); + expect(chooseFleetTone({ kind: "none" }, "lavish-axi")).toMatchObject(expected); + }); +}); diff --git a/apps/web/src/components/sidebar/fleetTone.logic.ts b/apps/web/src/components/sidebar/fleetTone.logic.ts new file mode 100644 index 000000000..e5b838dcb --- /dev/null +++ b/apps/web/src/components/sidebar/fleetTone.logic.ts @@ -0,0 +1,113 @@ +import type { ProjectIconColor } from "@t3tools/contracts"; + +import { deriveProjectIdentity } from "../../projectIdentity"; +import { projectIconColorClassName } from "../../projectIconColors"; + +/** + * A second mate's colour comes from its project icon as the sidebar shows it, + * so a venture reads in its own colour. Its workers wear the same colour, + * quieter. First Mate keeps its own pink and never comes through here. + */ + +/** What the project icon offers to take a colour from. */ +export type FleetIconSource = + /** A monogram badge or a coloured Lucide icon: its chosen colour. */ + | { readonly kind: "badge"; readonly color: ProjectIconColor } + /** A project image, as RGBA bytes, or null when the browser would not let us read it. */ + | { readonly kind: "image"; readonly pixels: ArrayLike | null } + /** An icon with no colour to take, such as an emoji. */ + | { readonly kind: "none" }; + +export type FleetToneChoice = + | { readonly kind: "badge"; readonly color: ProjectIconColor } + /** An image's main colour, as OKLCH hue in degrees and chroma. */ + | { readonly kind: "image"; readonly hue: number; readonly chroma: number } + /** No usable colour in the icon: a stable colour from the repository name. */ + | { readonly kind: "fallback"; readonly color: ProjectIconColor }; + +export function chooseFleetTone(source: FleetIconSource, repo: string): FleetToneChoice { + if (source.kind === "badge") return { kind: "badge", color: source.color }; + const main = source.kind === "image" && source.pixels ? mainColorOfPixels(source.pixels) : null; + if (main) return { kind: "image", ...main }; + return { kind: "fallback", color: deriveProjectIdentity(repo).color }; +} + +// Text lightness in OKLCH for each theme. Hue and chroma stay the image's own. +const LIGHT_THEME_LIGHTNESS = 0.52; +const DARK_THEME_LIGHTNESS = 0.8; + +/** Classes, and for an image the colour variables they read, for text in this tone. */ +export function fleetToneStyle(tone: FleetToneChoice): { + readonly className: string; + readonly style?: Readonly>; +} { + if (tone.kind !== "image") return { className: projectIconColorClassName(tone.color) }; + const hueAndChroma = `${tone.chroma.toFixed(3)} ${tone.hue.toFixed(1)})`; + return { + className: "text-[color:var(--fleet-tone-light)] dark:text-[color:var(--fleet-tone-dark)]", + style: { + "--fleet-tone-light": `oklch(${LIGHT_THEME_LIGHTNESS} ${hueAndChroma}`, + "--fleet-tone-dark": `oklch(${DARK_THEME_LIGHTNESS} ${hueAndChroma}`, + }, + }; +} + +// Pixels below this chroma read as white, black or grey and carry no colour. +const MIN_CHROMA = 0.05; +// A logo whose coloured pixels are rarer than this is a grey logo with specks. +const MIN_COLOURED_SHARE = 0.1; +const HUE_BINS = 24; + +function toLinear(channel: number) { + const value = channel / 255; + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +} + +/** sRGB bytes to OKLab a and b. */ +function oklabAB(red: number, green: number, blue: number) { + const r = toLinear(red); + const g = toLinear(green); + const b = toLinear(blue); + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return { + a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + }; +} + +/** + * The main colour of an RGBA image: the hue that covers the most coloured + * area, weighted by how strong the colour is. Null when the image has no + * real colour. + */ +export function mainColorOfPixels( + pixels: ArrayLike, +): { readonly hue: number; readonly chroma: number } | null { + const bins = Array.from({ length: HUE_BINS }, () => ({ weight: 0, a: 0, b: 0, count: 0 })); + let opaque = 0; + let coloured = 0; + for (let index = 0; index + 3 < pixels.length; index += 4) { + if (pixels[index + 3]! < 128) continue; + opaque += 1; + const { a, b } = oklabAB(pixels[index]!, pixels[index + 1]!, pixels[index + 2]!); + const chroma = Math.hypot(a, b); + if (chroma < MIN_CHROMA) continue; + coloured += 1; + const hue = (Math.atan2(b, a) * 180) / Math.PI + 360; + const bin = bins[Math.floor(((hue % 360) / 360) * HUE_BINS) % HUE_BINS]!; + bin.weight += chroma; + bin.a += a; + bin.b += b; + bin.count += 1; + } + if (opaque === 0 || coloured / opaque < MIN_COLOURED_SHARE) return null; + const main = bins.reduce((best, bin) => (bin.weight > best.weight ? bin : best)); + const a = main.a / main.count; + const b = main.b / main.count; + return { + hue: ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360, + chroma: Math.hypot(a, b), + }; +} diff --git a/fork-features.json b/fork-features.json index 290e77037..30b11573c 100644 --- a/fork-features.json +++ b/fork-features.json @@ -162,10 +162,12 @@ }, { "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, settled and snoozed ones included, and a second mate can fold its workers away. A parked fleet row keeps its un-settle or wake control, and a pinned second mate keeps its unpin. 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 comes from its repository, from a palette of six, so it stays the same across reloads.", - "keep": "fleetSidebar.logic.ts, its test, FirstMateIcon.tsx and FleetRoleIcon.tsx are ours whole. In Sidebar.tsx the fleet tree rows, 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, settled and snoozed ones included, and a second mate can fold its workers away. A parked fleet row stays a full row, dimmed, with its un-settle or wake control, and a pinned second mate keeps its unpin. 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 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/FirstMateIcon.tsx", "apps/web/src/components/FleetRoleIcon.tsx", "apps/web/src/components/Sidebar.tsx", @@ -173,7 +175,10 @@ "apps/web/src/components/Sidebar.logic.test.ts", "docs/user/thread-sidebar.md" ], - "test": "apps/web/src/components/sidebar/fleetSidebar.logic.test.ts", + "test": [ + "apps/web/src/components/sidebar/fleetSidebar.logic.test.ts", + "apps/web/src/components/sidebar/fleetTone.logic.test.ts" + ], "patchesUpstream": true }, { From 05004f0c45f3df081a083b8031cacd2b785fe4b2 Mon Sep 17 00:00:00 2001 From: autoprintworks Date: Fri, 25 Sep 2026 01:55:02 +0100 Subject: [PATCH 4/4] fix(web): keep the logo colour reader private to its module Knip flagged mainColorOfPixels as an unused export, which failed the Check job. Only fleetTone.logic.ts uses it. --- apps/web/src/components/sidebar/fleetTone.logic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/sidebar/fleetTone.logic.ts b/apps/web/src/components/sidebar/fleetTone.logic.ts index e5b838dcb..8a16f530e 100644 --- a/apps/web/src/components/sidebar/fleetTone.logic.ts +++ b/apps/web/src/components/sidebar/fleetTone.logic.ts @@ -82,7 +82,7 @@ function oklabAB(red: number, green: number, blue: number) { * area, weighted by how strong the colour is. Null when the image has no * real colour. */ -export function mainColorOfPixels( +function mainColorOfPixels( pixels: ArrayLike, ): { readonly hue: number; readonly chroma: number } | null { const bins = Array.from({ length: HUE_BINS }, () => ({ weight: 0, a: 0, b: 0, count: 0 }));