From 0bf9a76daaf42737ab39130f960a5f2ec9ce1185 Mon Sep 17 00:00:00 2001 From: autoprintworks Date: Fri, 25 Sep 2026 19:11:44 +0100 Subject: [PATCH 1/2] fix(web): settled workers list under Settled, not in the fleet tree A settled worker thread now leaves the fleet tree and lists in the Settled section with the other settled threads, where Un-settle works. Un-settling it puts it back under its second mate. Snoozed workers and settled second mates stay in the tree as before. --- apps/web/src/components/Sidebar.tsx | 31 +++++---- .../sidebar/fleetSidebar.logic.test.ts | 68 ++++++++++++++++--- .../components/sidebar/fleetSidebar.logic.ts | 16 +++-- docs/user/thread-sidebar.md | 5 +- fork-features.json | 4 +- 5 files changed, 92 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 9bf34ec35..c63e0a444 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -2823,23 +2823,30 @@ export default function Sidebar() { (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); - // 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); + // Same order as the sections below: snooze outranks settlement. + const parkedState = (thread: EnvironmentThreadShell): FleetParked => { + const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; + if (capabilities?.threadSnooze === true && effectiveSnoozed(thread, { now: preciseNow })) { + return "snoozed"; + } + if (capabilities?.threadSettlement === true && thread.settledOverride === "settled") { + return "settled"; + } + return null; + }; + // Second mates and their workers leave the sections for the fleet tree. + // A settled worker lists with the other settled threads instead; snoozed + // workers and parked second mates park in place. + const fleetTree = buildFleetTree(inScope, { + isSettled: (thread) => parkedState(thread) === "settled", + }); 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 state = parkedState(thread); + if (state !== null) parked.set(thread, state); } } const visible = fleetTree.rest; diff --git a/apps/web/src/components/sidebar/fleetSidebar.logic.test.ts b/apps/web/src/components/sidebar/fleetSidebar.logic.test.ts index 79502d890..4d8681af2 100644 --- a/apps/web/src/components/sidebar/fleetSidebar.logic.test.ts +++ b/apps/web/src/components/sidebar/fleetSidebar.logic.test.ts @@ -31,8 +31,11 @@ type TestThread = ReturnType; const ids = (threads: ReadonlyArray<{ readonly id: string }>) => threads.map((entry) => entry.id); +const treeFor = (threads: readonly TestThread[]) => + buildFleetTree(threads, { isSettled: (entry) => entry.parked === "settled" }); + const rowsFor = (threads: readonly TestThread[], folded: readonly string[] = []) => - buildFleetRows(buildFleetTree(threads).branches, { + buildFleetRows(treeFor(threads).branches, { isFolded: (repo) => folded.includes(repo), parkedState: (entry) => entry.parked, }); @@ -129,32 +132,75 @@ describe("buildFleetTree", () => { it("passes ordinary threads through unchanged and in their order", () => { const plain = [thread("b"), thread("a"), thread("c")]; - const { rest } = buildFleetTree([ + const { rest } = treeFor([ plain[0]!, thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), - thread("w", { fleetRole: "worker", fleetRepo: "t3code", parked: "settled" }), + thread("w", { fleetRole: "worker", fleetRepo: "t3code", parked: "snoozed" }), plain[1]!, plain[2]!, ]); expect(rest).toEqual(plain); }); -}); -describe("buildFleetRows", () => { - it("keeps settled and snoozed workers under their second mate, marked as parked", () => { - const rows = rowsFor([ + it("lists a settled worker with the other settled threads, not in the tree", () => { + const settled = thread("w-settled", { + fleetRole: "worker", + fleetRepo: "t3code", + parked: "settled", + }); + // A worker whose repository has no second mate leaves the tree the same way. + const stray = thread("stray-settled", { + fleetRole: "worker", + fleetRepo: "elsewhere", + parked: "settled", + }); + const { branches, rest } = treeFor([ + thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), + thread("w", { fleetRole: "worker", fleetRepo: "t3code" }), + settled, + stray, + ]); + expect(branches.map((branch) => [branch.secondMate?.id, ids(branch.workers)])).toEqual([ + ["sm", ["w"]], + ]); + expect(rest).toEqual([settled, stray]); + }); + + it("puts a worker back under its second mate when it is un-settled", () => { + const fleet = (parked: "settled" | null) => [ thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), - thread("w-settled", { fleetRole: "worker", fleetRepo: "t3code", parked: "settled" }), - thread("w-snoozed", { + thread("w", { fleetRole: "worker", fleetRepo: "t3code", parked }), + thread("w-later", { fleetRole: "worker", fleetRepo: "t3code", - parked: "snoozed", createdAt: "2026-09-24T11:00:00Z", }), + ]; + expect(ids(treeFor(fleet("settled")).rest)).toEqual(["w"]); + expect(treeFor(fleet(null)).rest).toEqual([]); + expect(ids(rowsFor(fleet(null)).map((row) => row.thread))).toEqual(["sm", "w", "w-later"]); + }); + + it("keeps a snoozed worker and a settled second mate in the tree", () => { + const { branches, rest } = treeFor([ + thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code", parked: "settled" }), + thread("w-snoozed", { fleetRole: "worker", fleetRepo: "t3code", parked: "snoozed" }), + ]); + expect(branches.map((branch) => [branch.secondMate?.id, ids(branch.workers)])).toEqual([ + ["sm", ["w-snoozed"]], + ]); + expect(rest).toEqual([]); + }); +}); + +describe("buildFleetRows", () => { + it("keeps snoozed workers under their second mate, marked as parked", () => { + const rows = rowsFor([ + thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), + thread("w-snoozed", { fleetRole: "worker", fleetRepo: "t3code", parked: "snoozed" }), ]); expect(rows.map((row) => [row.thread.id, row.depth, row.parked])).toEqual([ ["sm", 0, null], - ["w-settled", 1, "settled"], ["w-snoozed", 1, "snoozed"], ]); }); diff --git a/apps/web/src/components/sidebar/fleetSidebar.logic.ts b/apps/web/src/components/sidebar/fleetSidebar.logic.ts index 3acb61209..d21d0917e 100644 --- a/apps/web/src/components/sidebar/fleetSidebar.logic.ts +++ b/apps/web/src/components/sidebar/fleetSidebar.logic.ts @@ -64,19 +64,22 @@ export interface FleetBranch { /** * 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. Settled and snoozed - * fleet threads stay in the tree too. Every other thread passes through to - * `rest` in its original order. + * activity, so no fleet row moves when a thread gets busy. A settled worker + * leaves the tree for the settled section and comes back when un-settled; + * snoozed workers and parked second mates stay in the tree. Every other thread + * passes through to `rest` in its original order. */ export function buildFleetTree( threads: readonly T[], + options: { readonly isSettled?: (thread: T) => boolean } = {}, ): { 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"); + (thread.fleetRole === "second-mate" || + (thread.fleetRole === "worker" && options.isSettled?.(thread) !== true)); if (!inTree) { rest.push(thread); continue; @@ -119,7 +122,10 @@ export function buildFleetTree( /** 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. */ +/** + * A snoozed fleet thread, or a settled second mate, keeps its place, drawn + * quietly with its way back. + */ export type FleetParked = "settled" | "snoozed" | null; /** One row of the fleet tree, in display order. */ diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index e6da1d161..1232eb596 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -94,8 +94,9 @@ each fleet thread is labelled **First Mate**, **Second mate** or **Worker**. 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. +- **Workers** sit under the second mate for their repository, including workers you snooze. You + can hide a second mate's workers and show them again. A settled worker moves to **Settled** with + your other settled threads. Un-settle it there and it returns under its second mate. 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 diff --git a/fork-features.json b/fork-features.json index d14562fa1..1c57f77a5 100644 --- a/fork-features.json +++ b/fork-features.json @@ -162,8 +162,8 @@ }, { "name": "fm-fleet-sidebar-tree", - "description": "The sidebar shows First Mate's fleet as a tree above the other threads. First Mate always holds the top, above a divider: the pinned First Mate, or else the newest live one, with any others under it; with none live, a Start First Mate row stands there instead. Second mates follow in repository-name order and never move with activity; each worker is indented under the second mate that shares its fleetRepo, 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.", + "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, snoozed ones included, and a second mate can fold its workers away. A settled worker leaves the tree and lists in the Settled section with the other settled threads, and un-settling it puts it back under its second mate. A snoozed fleet row, or a settled second mate, stays a full row in the tree, dimmed, with its wake or un-settle 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 isSettled option passed to buildFleetTree that sends a settled worker to the Settled section, 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", From 8bec146f7d41e1ba3ecdec3fc5e4c806ef034e5e Mon Sep 17 00:00:00 2001 From: autoprintworks Date: Fri, 25 Sep 2026 20:52:19 +0100 Subject: [PATCH 2/2] fix(web): parked fleet threads leave the tree, compact rows keep titles A snoozed worker now lists in Snoozed, and waking it puts it back under its second mate. A settled second mate moves to Settled and takes all its workers out of the tree, each listed by its own state. Un-settling it brings it and its live workers back. A snoozed second mate stays in the tree. resolveParkedState is the one parking rule for the tree and the sections. A drag out of Settled or Snoozed lands a worker in the tree at once instead of passing through Active. Compact rows in Settled and Snoozed keep the whole role word and leave the model and thinking level to the hover card, so the title always shows. --- apps/web/src/components/Sidebar.tsx | 91 +++++---- .../sidebar/fleetSidebar.logic.test.ts | 185 +++++++++++++----- .../components/sidebar/fleetSidebar.logic.ts | 100 +++++++--- docs/user/thread-sidebar.md | 10 +- fork-features.json | 6 +- 5 files changed, 281 insertions(+), 111 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index c63e0a444..03f36d35d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -167,6 +167,8 @@ import { FIRST_MATE_TONE_CLASS, FLEET_ROLE_WORDS, type FleetParked, + resolveParkedState, + sidebarSectionFor, } from "./sidebar/fleetSidebar.logic"; import { FleetTone, type FleetTheme } from "./sidebar/FleetTone"; import { resolveReadOnlyThreadModel } from "./chat/readOnlyThreadModel.logic"; @@ -1591,7 +1593,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const roleBadge = roleLabel ? ( {roleLabel} @@ -1771,7 +1777,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { remain visible AND clickable while the row is hovered. Only the time/jump label yields to the settle affordance. */} {prBadge} - {readOnlyModelBadges} + {/* A compact row leaves the model and thinking level to the hover + card, so the title always has room. */} {sortable?.isDragging ? ( dragDestination ) : ( @@ -2823,29 +2830,29 @@ export default function Sidebar() { (scopedProjectKeys === null || scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); - // Same order as the sections below: snooze outranks settlement. - const parkedState = (thread: EnvironmentThreadShell): FleetParked => { + // One parking rule for the fleet tree and the sections below. A drag the + // server has not confirmed yet counts as landed, so a worker dragged out + // of Settled goes straight back to the tree rather than via Active. + const parkedStateOf = (thread: EnvironmentThreadShell): FleetParked => { const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; - if (capabilities?.threadSnooze === true && effectiveSnoozed(thread, { now: preciseNow })) { - return "snoozed"; - } - if (capabilities?.threadSettlement === true && thread.settledOverride === "settled") { - return "settled"; - } - return null; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return resolveParkedState(thread, { + supportsSnooze: capabilities?.threadSnooze === true, + supportsSettlement: capabilities?.threadSettlement === true, + now: preciseNow, + droppedInto: optimisticDrop?.key === threadKey ? optimisticDrop.section : undefined, + }); }; // Second mates and their workers leave the sections for the fleet tree. - // A settled worker lists with the other settled threads instead; snoozed - // workers and parked second mates park in place. - const fleetTree = buildFleetTree(inScope, { - isSettled: (thread) => parkedState(thread) === "settled", - }); + // A parked worker, or a settled second mate with its workers, lists in the + // sections instead. + const fleetTree = buildFleetTree(inScope, { parkedState: parkedStateOf }); const parked = new Map(); for (const branch of fleetTree.branches) { for (const thread of branch.secondMate ? [branch.secondMate, ...branch.workers] : branch.workers) { - const state = parkedState(thread); + const state = parkedStateOf(thread); if (state !== null) parked.set(thread, state); } } @@ -2858,12 +2865,6 @@ export default function Sidebar() { const activeReorderable = new Set(); for (const thread of visible) { const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; - // Threads on servers without the settlement capability (old server, - // or descriptor not loaded yet) never classify as settled: the user - // could neither un-settle nor pin them, so auto-settling them would - // strand rows in a tail with no working affordances. - const supportsSettlement = capabilities?.threadSettlement === true; - const supportsSnooze = capabilities?.threadSnooze === true; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); if (capabilities?.threadActiveReorder === true) activeReorderable.add(threadKey); // Older servers retain their existing drag actions. Active placement @@ -2888,16 +2889,22 @@ export default function Sidebar() { ? projected : { ...projected, snoozedAt: thread.snoozedAt, snoozedUntil: thread.snoozedUntil }, ); - } else if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { - // Snooze outranks settlement and pinning until the thread wakes. - snoozed.push(thread); - } else if (supportsSettlement && thread.settledOverride === "settled") { - settled.push(thread); - } else if (thread.pinnedAt != null) { - pinned.push(thread); - } else { - active.push(thread); + continue; } + // Threads on servers without the settlement capability (old server, or + // descriptor not loaded yet) never park: the user could neither + // un-settle nor pin them, so parking them would strand rows in a tail + // with no working affordances. Snooze outranks settlement and pinning + // until the thread wakes. + const section = sidebarSectionFor(thread, parkedStateOf(thread)); + (section === "snoozed" + ? snoozed + : section === "settled" + ? settled + : section === "pinned" + ? pinned + : active + ).push(thread); } // One shared rule on every platform (see sortPinnedThreadsByOrderKey): // user-arranged keys first, keyless threads in creation order below. @@ -2978,6 +2985,18 @@ export default function Sidebar() { [fleetBranches, fleetParked, foldedFleetRepos, projectByKey, toggleFleetRepo], ); const fleetThreads = useMemo(() => fleetRows.map((row) => row.thread), [fleetRows]); + // Every thread in the fleet tree, folded ones included. + const fleetTreeThreadKeys = useMemo( + () => + new Set( + fleetBranches.flatMap((branch) => + (branch.secondMate ? [branch.secondMate, ...branch.workers] : branch.workers).map( + (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + ), + ), + [fleetBranches], + ); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -3647,6 +3666,12 @@ export default function Sidebar() { } if (canonicalSection !== optimisticDrop.section) return; if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return; + // A thread that lands in the fleet tree never joins the destination list, + // so the confirmed move above is all there is to wait for. + if (fleetTreeThreadKeys.has(optimisticDrop.key)) { + setOptimisticDrop(null); + return; + } const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys; const canonicalDestination = destinationKeys.flatMap((key) => { const canonical = canonicalByKey.get(key); @@ -3674,7 +3699,7 @@ export default function Sidebar() { if (membershipChanged || foreignKeyLanded || allAssignmentsLanded) { setOptimisticDrop(null); } - }, [activeKeys, optimisticDrop, pinnedKeys, threads]); + }, [activeKeys, fleetTreeThreadKeys, optimisticDrop, pinnedKeys, threads]); const attemptPin = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { diff --git a/apps/web/src/components/sidebar/fleetSidebar.logic.test.ts b/apps/web/src/components/sidebar/fleetSidebar.logic.test.ts index 4d8681af2..aff6e420d 100644 --- a/apps/web/src/components/sidebar/fleetSidebar.logic.test.ts +++ b/apps/web/src/components/sidebar/fleetSidebar.logic.test.ts @@ -1,7 +1,13 @@ import { describe, expect, it } from "vite-plus/test"; import { partitionFirstMateThreads } from "../Sidebar.logic"; -import { buildFleetRows, buildFleetTree, planFirstMateSlot } from "./fleetSidebar.logic"; +import { + buildFleetRows, + buildFleetTree, + planFirstMateSlot, + resolveParkedState, + sidebarSectionFor, +} from "./fleetSidebar.logic"; type Role = "first-mate" | "second-mate" | "worker"; @@ -32,7 +38,14 @@ type TestThread = ReturnType; const ids = (threads: ReadonlyArray<{ readonly id: string }>) => threads.map((entry) => entry.id); const treeFor = (threads: readonly TestThread[]) => - buildFleetTree(threads, { isSettled: (entry) => entry.parked === "settled" }); + buildFleetTree(threads, { parkedState: (entry) => entry.parked }); + +/** The section each thread outside the tree lists in, as the sidebar sorts them. */ +const sectionsOutsideTree = (threads: readonly TestThread[]) => + treeFor(threads).rest.map((entry) => [entry.id, sidebarSectionFor(entry, entry.parked)]); + +const branchesOf = (threads: readonly TestThread[]) => + treeFor(threads).branches.map((branch) => [branch.secondMate?.id, ids(branch.workers)]); const rowsFor = (threads: readonly TestThread[], folded: readonly string[] = []) => buildFleetRows(treeFor(threads).branches, { @@ -87,7 +100,7 @@ describe("First Mate at the top", () => { describe("buildFleetTree", () => { it("puts each worker under the second mate that shares its fleetRepo", () => { - const { branches } = buildFleetTree([ + const { branches } = treeFor([ thread("w-t3", { fleetRole: "worker", fleetRepo: "t3code" }), thread("sm-fm", { fleetRole: "second-mate", fleetRepo: "firstmate" }), thread("w-fm", { fleetRole: "worker", fleetRepo: "firstmate" }), @@ -135,39 +148,39 @@ describe("buildFleetTree", () => { const { rest } = treeFor([ plain[0]!, thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), - thread("w", { fleetRole: "worker", fleetRepo: "t3code", parked: "snoozed" }), + thread("w", { fleetRole: "worker", fleetRepo: "t3code" }), plain[1]!, plain[2]!, ]); expect(rest).toEqual(plain); }); - it("lists a settled worker with the other settled threads, not in the tree", () => { - const settled = thread("w-settled", { - fleetRole: "worker", - fleetRepo: "t3code", - parked: "settled", - }); - // A worker whose repository has no second mate leaves the tree the same way. - const stray = thread("stray-settled", { - fleetRole: "worker", - fleetRepo: "elsewhere", - parked: "settled", - }); - const { branches, rest } = treeFor([ + it("lists a settled worker in Settled, not in the tree", () => { + const fleet = [ thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), thread("w", { fleetRole: "worker", fleetRepo: "t3code" }), - settled, - stray, - ]); - expect(branches.map((branch) => [branch.secondMate?.id, ids(branch.workers)])).toEqual([ - ["sm", ["w"]], + thread("w-settled", { fleetRole: "worker", fleetRepo: "t3code", parked: "settled" }), + // A worker whose repository has no second mate leaves the tree the same way. + thread("stray-settled", { fleetRole: "worker", fleetRepo: "elsewhere", parked: "settled" }), + ]; + expect(branchesOf(fleet)).toEqual([["sm", ["w"]]]); + expect(sectionsOutsideTree(fleet)).toEqual([ + ["w-settled", "settled"], + ["stray-settled", "settled"], ]); - expect(rest).toEqual([settled, stray]); }); - it("puts a worker back under its second mate when it is un-settled", () => { - const fleet = (parked: "settled" | null) => [ + it("lists a snoozed worker in Snoozed, not in the tree", () => { + const fleet = [ + thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), + thread("w-snoozed", { fleetRole: "worker", fleetRepo: "t3code", parked: "snoozed" }), + ]; + expect(branchesOf(fleet)).toEqual([["sm", []]]); + expect(sectionsOutsideTree(fleet)).toEqual([["w-snoozed", "snoozed"]]); + }); + + it("puts a worker back under its second mate when it is un-settled or woken", () => { + const fleet = (parked: "settled" | "snoozed" | null) => [ thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), thread("w", { fleetRole: "worker", fleetRepo: "t3code", parked }), thread("w-later", { @@ -176,35 +189,119 @@ describe("buildFleetTree", () => { createdAt: "2026-09-24T11:00:00Z", }), ]; - expect(ids(treeFor(fleet("settled")).rest)).toEqual(["w"]); + for (const parked of ["settled", "snoozed"] as const) { + expect(ids(treeFor(fleet(parked)).rest)).toEqual(["w"]); + } expect(treeFor(fleet(null)).rest).toEqual([]); expect(ids(rowsFor(fleet(null)).map((row) => row.thread))).toEqual(["sm", "w", "w-later"]); }); - it("keeps a snoozed worker and a settled second mate in the tree", () => { - const { branches, rest } = treeFor([ + it("takes every worker out of the tree with a settled second mate, each to its own section", () => { + const fleet = [ thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code", parked: "settled" }), + thread("w-live", { fleetRole: "worker", fleetRepo: "t3code" }), + thread("w-pinned", { + fleetRole: "worker", + fleetRepo: "t3code", + pinnedAt: "2026-09-24T00:00:00Z", + }), + thread("w-settled", { fleetRole: "worker", fleetRepo: "t3code", parked: "settled" }), thread("w-snoozed", { fleetRole: "worker", fleetRepo: "t3code", parked: "snoozed" }), + thread("other-sm", { fleetRole: "second-mate", fleetRepo: "firstmate" }), + ]; + expect(branchesOf(fleet)).toEqual([["other-sm", []]]); + expect(sectionsOutsideTree(fleet)).toEqual([ + ["sm", "settled"], + ["w-live", "active"], + ["w-pinned", "pinned"], + ["w-settled", "settled"], + ["w-snoozed", "snoozed"], ]); - expect(branches.map((branch) => [branch.secondMate?.id, ids(branch.workers)])).toEqual([ - ["sm", ["w-snoozed"]], + }); + + it("brings a second mate and its live workers back when the second mate is un-settled", () => { + const fleet = (parked: "settled" | null) => [ + thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code", parked }), + thread("w-live", { fleetRole: "worker", fleetRepo: "t3code" }), + thread("w-settled", { fleetRole: "worker", fleetRepo: "t3code", parked: "settled" }), + ]; + expect(branchesOf(fleet("settled"))).toEqual([]); + expect(branchesOf(fleet(null))).toEqual([["sm", ["w-live"]]]); + expect(sectionsOutsideTree(fleet(null))).toEqual([["w-settled", "settled"]]); + }); + + it("keeps a snoozed second mate in the tree with its live workers", () => { + const fleet = [ + thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code", parked: "snoozed" }), + thread("w", { fleetRole: "worker", fleetRepo: "t3code" }), + ]; + expect(branchesOf(fleet)).toEqual([["sm", ["w"]]]); + expect(rowsFor(fleet).map((row) => [row.thread.id, row.parked])).toEqual([ + ["sm", "snoozed"], + ["w", null], ]); - expect(rest).toEqual([]); }); }); -describe("buildFleetRows", () => { - it("keeps snoozed workers under their second mate, marked as parked", () => { - const rows = rowsFor([ - thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), - thread("w-snoozed", { fleetRole: "worker", fleetRepo: "t3code", parked: "snoozed" }), - ]); - expect(rows.map((row) => [row.thread.id, row.depth, row.parked])).toEqual([ - ["sm", 0, null], - ["w-snoozed", 1, "snoozed"], - ]); +describe("resolveParkedState", () => { + const now = "2026-09-24T12:00:00Z"; + const shell = (fields: { + readonly settledOverride?: "settled" | null; + readonly snoozedUntil?: string | null; + }) => ({ + snoozedUntil: null, + snoozedAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + session: null, + latestTurn: null, + settledOverride: null, + ...fields, }); + const both = { supportsSnooze: true, supportsSettlement: true, now }; + it("puts snooze ahead of settlement until the thread wakes", () => { + const thread = shell({ settledOverride: "settled", snoozedUntil: "2026-09-24T13:00:00Z" }); + expect(resolveParkedState(thread, both)).toBe("snoozed"); + expect(resolveParkedState(thread, { ...both, now: "2026-09-24T14:00:00Z" })).toBe("settled"); + }); + + it("never parks a thread on a server without the capability", () => { + const thread = shell({ settledOverride: "settled", snoozedUntil: "2026-09-24T13:00:00Z" }); + expect( + resolveParkedState(thread, { supportsSnooze: false, supportsSettlement: false, now }), + ).toBe(null); + }); + + it("follows a drag before the server confirms it", () => { + const settled = shell({ settledOverride: "settled" }); + expect(resolveParkedState(settled, { ...both, droppedInto: "active" })).toBe(null); + expect(resolveParkedState(settled, { ...both, droppedInto: "pinned" })).toBe(null); + expect(resolveParkedState(shell({}), { ...both, droppedInto: "settled" })).toBe("settled"); + }); + + it("puts a settled worker dragged into Active back in the tree at once", () => { + const fleet = [ + { ...thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), ...shell({}) }, + { + ...thread("w", { fleetRole: "worker", fleetRepo: "t3code" }), + ...shell({ settledOverride: "settled" }), + }, + ]; + const tree = (droppedInto?: "active") => + buildFleetTree(fleet, { + parkedState: (entry) => + resolveParkedState(entry, { + ...both, + droppedInto: entry.id === "w" ? droppedInto : undefined, + }), + }); + expect(ids(tree().rest)).toEqual(["w"]); + expect(tree("active").branches.map((branch) => ids(branch.workers))).toEqual([["w"]]); + }); +}); + +describe("buildFleetRows", () => { it("colours each worker from its second mate's project icon", () => { const rows = rowsFor([ thread("sm", { fleetRole: "second-mate", fleetRepo: "t3code" }), @@ -230,9 +327,8 @@ describe("buildFleetRows", () => { 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", () => { + it("keeps the way back for a second mate that was 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", @@ -242,8 +338,7 @@ describe("buildFleetRows", () => { 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"], + // A snoozed one keeps its wake control where it stands. ["sm-snoozed", "snoozed", "none"], // A pinned one keeps the unpin control; an unpinned one has no pin. ["sm-pinned", null, "unpin-only"], diff --git a/apps/web/src/components/sidebar/fleetSidebar.logic.ts b/apps/web/src/components/sidebar/fleetSidebar.logic.ts index d21d0917e..aeaf288c1 100644 --- a/apps/web/src/components/sidebar/fleetSidebar.logic.ts +++ b/apps/web/src/components/sidebar/fleetSidebar.logic.ts @@ -1,5 +1,9 @@ import type { FleetRole } from "@t3tools/contracts"; import type { FleetThreadFields } from "@t3tools/client-runtime/fleet-threads"; +import { + effectiveSnoozed, + type ThreadSnoozeShell, +} from "@t3tools/client-runtime/state/thread-settled"; /** * How the sidebar arranges and colours First Mate's fleet. A worker belongs to @@ -64,43 +68,55 @@ export interface FleetBranch { /** * 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 settled worker - * leaves the tree for the settled section and comes back when un-settled; - * snoozed workers and parked second mates stay in the tree. Every other thread - * passes through to `rest` in its original order. + * activity, so no fleet row moves when a thread gets busy. + * + * A parked worker leaves the tree for its section, Settled or Snoozed, and + * comes back when un-settled or woken. A settled second mate leaves with all + * of its workers, and each worker lists by its own state: a settled one in + * Settled, a snoozed one in Snoozed, a live one in Active or Pinned. Settled + * rows carry Un-settle, which means nothing to a worker that is not settled. + * A snoozed second mate stays in the tree with its wake control. + * + * Every thread that is not in the tree passes through to `rest` in its + * original order. */ export function buildFleetTree( threads: readonly T[], - options: { readonly isSettled?: (thread: T) => boolean } = {}, + options: { readonly parkedState: (thread: T) => FleetParked }, ): { readonly branches: FleetBranch[]; readonly rest: T[] } { + const inFleet = (thread: T) => + thread.archivedAt === null && + (thread.fleetRole === "second-mate" || thread.fleetRole === "worker"); + // One second mate per repository: the newer one leads, an older one lists + // as an ordinary thread rather than vanishing. + const leads = new Map(); + for (const thread of threads) { + if (!inFleet(thread) || thread.fleetRole !== "second-mate") continue; + const repo = thread.fleetRepo ?? null; + const lead = leads.get(repo); + if (lead === undefined || thread.createdAt > lead.createdAt) leads.set(repo, thread); + } + const settledRepos = new Set( + [...leads].filter(([, lead]) => options.parkedState(lead) === "settled").map(([repo]) => repo), + ); const byRepo = new Map(); const rest: T[] = []; for (const thread of threads) { + const repo = thread.fleetRepo ?? null; const inTree = - thread.archivedAt === null && - (thread.fleetRole === "second-mate" || - (thread.fleetRole === "worker" && options.isSettled?.(thread) !== true)); + inFleet(thread) && + !settledRepos.has(repo) && + (thread.fleetRole === "worker" + ? options.parkedState(thread) === null + : leads.get(repo) === thread); if (!inTree) { rest.push(thread); continue; } - const repo = thread.fleetRepo ?? null; 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; - } + if (thread.fleetRole === "worker") branch.workers.push(thread); + else branch.secondMate = thread; } const byLaunch = (left: T, right: T) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id); @@ -123,11 +139,43 @@ export function buildFleetTree( export const FIRST_MATE_TONE_CLASS = "text-pink-600 dark:text-pink-400"; /** - * A snoozed fleet thread, or a settled second mate, keeps its place, drawn - * quietly with its way back. + * Whether a thread is parked, and how. A parked thread lists in the Settled or + * Snoozed section; the one fleet thread parked in the tree is a snoozed second + * mate, drawn quietly with its wake control. */ export type FleetParked = "settled" | "snoozed" | null; +/** + * The one rule for parking, shared by the fleet tree and the sections so the + * two cannot disagree. Snooze outranks settlement until the thread wakes. A + * server without the capability never parks a thread, because its rows would + * have no working way back. A drag the server has not confirmed yet wins. + */ +export function resolveParkedState( + thread: ThreadSnoozeShell & { readonly settledOverride?: string | null | undefined }, + options: { + readonly supportsSnooze: boolean; + readonly supportsSettlement: boolean; + readonly now: string; + readonly droppedInto?: "pinned" | "active" | "settled" | undefined; + }, +): FleetParked { + if (options.droppedInto !== undefined) { + return options.droppedInto === "settled" ? "settled" : null; + } + if (options.supportsSnooze && effectiveSnoozed(thread, { now: options.now })) return "snoozed"; + if (options.supportsSettlement && thread.settledOverride === "settled") return "settled"; + return null; +} + +/** The section a thread outside the fleet tree lists in. */ +export function sidebarSectionFor( + thread: { readonly pinnedAt?: string | null | undefined }, + parked: FleetParked, +): "snoozed" | "settled" | "pinned" | "active" { + return parked ?? (thread.pinnedAt != null ? "pinned" : "active"); +} + /** One row of the fleet tree, in display order. */ export interface FleetRow { readonly thread: T; @@ -148,7 +196,7 @@ export interface FleetRow { /** * The fleet tree as rows: each second mate, then its workers indented under it - * unless folded. Parked threads stay in their place. + * unless folded. A snoozed second mate is drawn parked in its place. */ export function buildFleetRows( branches: readonly FleetBranch[], diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 1232eb596..a424c3ae6 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -93,10 +93,12 @@ each fleet thread is labelled **First Mate**, **Second mate** or **Worker**. 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 snooze. You - can hide a second mate's workers and show them again. A settled worker moves to **Settled** with - your other settled threads. Un-settle it there and it returns under its second mate. + whatever it is doing. A settled second mate moves to **Settled** and takes its workers out of + the tree with it: each lists by its own state, in **Settled**, **Snoozed**, or your other + threads. Un-settle the second mate and it returns with its workers. +- **Workers** sit under the second mate for their repository. You can hide a second mate's workers + and show them again. A settled or snoozed worker moves to **Settled** or **Snoozed** with your + other threads. Un-settle or wake it there and it returns under its second mate. 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 diff --git a/fork-features.json b/fork-features.json index 1c57f77a5..c09a34266 100644 --- a/fork-features.json +++ b/fork-features.json @@ -133,7 +133,7 @@ }, { "name": "fm-worker-thread-model", - "description": "A read-only worker thread says which model and thinking level is behind it: a strip where the composer would sit, and badges on the sidebar row. ReadOnlyThreadModel.tsx and readOnlyThreadModel.logic.ts are the fork's own modules and upstream has no file at either path, so the test cannot pass on plain upstream. Issue #135.", + "description": "A read-only worker thread says which model and thinking level is behind it: a strip where the composer would sit, and badges on a full sidebar row; a compact row leaves them to the hover card so its title keeps room. ReadOnlyThreadModel.tsx and readOnlyThreadModel.logic.ts are the fork's own modules and upstream has no file at either path, so the test cannot pass on plain upstream. Issue #135.", "keep": "ReadOnlyThreadModel.tsx and readOnlyThreadModel.logic.ts are ours whole, and the model and thinking level strip and sidebar badges they add survive in ChatView.tsx and Sidebar.tsx; everything else in those files takes upstream.", "files": [ "apps/web/src/components/chat/ReadOnlyThreadModel.tsx", @@ -162,8 +162,8 @@ }, { "name": "fm-fleet-sidebar-tree", - "description": "The sidebar shows First Mate's fleet as a tree above the other threads. First Mate always holds the top, above a divider: the pinned First Mate, or else the newest live one, with any others under it; with none live, a Start First Mate row stands there instead. Second mates follow in repository-name order and never move with activity; each worker is indented under the second mate that shares its fleetRepo, snoozed ones included, and a second mate can fold its workers away. A settled worker leaves the tree and lists in the Settled section with the other settled threads, and un-settling it puts it back under its second mate. A snoozed fleet row, or a settled second mate, stays a full row in the tree, dimmed, with its wake or un-settle 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 isSettled option passed to buildFleetTree that sends a settled worker to the Settled section, the FleetRowPlacement prop and onPin on SidebarThreadRow, the role line, the fold arrow, the First Mate slot with its Start First Mate row and divider survive; partitionFirstMateThreads survives in Sidebar.logic.ts and its tests in Sidebar.logic.test.ts; the First Mate threads section of docs/user/thread-sidebar.md is ours. Everything else in those files takes upstream.", + "description": "The sidebar shows First Mate's fleet as a tree above the other threads. First Mate always holds the top, above a divider: the pinned First Mate, or else the newest live one, with any others under it; with none live, a Start First Mate row stands there instead. Second mates follow in repository-name order and never move with activity; each worker is indented under the second mate that shares its fleetRepo, and a second mate can fold its workers away. A settled or snoozed worker leaves the tree and lists in the Settled or Snoozed section with the other threads, and un-settling or waking it puts it back under its second mate. A settled second mate leaves the tree for Settled with all its workers, each listed by its own state, and un-settling it brings it and its live workers back. A snoozed second mate stays a full row in the tree, dimmed, with its wake control, and a pinned second mate keeps its unpin. One rule, resolveParkedState, decides parking for both the tree and the sections, and a drag out of Settled or Snoozed lands a worker straight in the tree. A compact row in Settled or Snoozed always shows its title and the whole role word, and leaves the model and thinking level to the hover card. Fleet rows name their role on the model line in their own colour, then the model and reasoning level, with a ship, sailboat or pickaxe beside the project icon. A second mate's colour, and its workers' quieter one, comes from its project icon as the sidebar shows it: a badge's own colour, or an image logo's main colour read once from the image, with only its lightness set for each theme. Only an icon with no usable colour falls back to a stable colour from the repository name. First Mate keeps its pink.", + "keep": "fleetSidebar.logic.ts, fleetTone.logic.ts, FleetTone.tsx, their tests, FirstMateIcon.tsx and FleetRoleIcon.tsx are ours whole. In Sidebar.tsx the fleet tree rows, the parkedStateOf helper that calls resolveParkedState and feeds both buildFleetTree and the section loop through sidebarSectionFor, the fleetTreeThreadKeys hold on a drag that lands in the tree, the compact row that keeps the whole role word and drops the model badges, the FleetRowPlacement prop and onPin on SidebarThreadRow, the role line, the fold arrow, the First Mate slot with its Start First Mate row and divider survive; partitionFirstMateThreads survives in Sidebar.logic.ts and its tests in Sidebar.logic.test.ts; the First Mate threads section of docs/user/thread-sidebar.md is ours. Everything else in those files takes upstream.", "files": [ "apps/web/src/components/sidebar/fleetSidebar.logic.ts", "apps/web/src/components/sidebar/fleetTone.logic.ts",