diff --git a/apps/web/src/components/FirstMateIcon.tsx b/apps/web/src/components/FirstMateIcon.tsx
index fb761e0b9..e3c3816ad 100644
--- a/apps/web/src/components/FirstMateIcon.tsx
+++ b/apps/web/src/components/FirstMateIcon.tsx
@@ -1,14 +1,15 @@
-import { ShipWheelIcon, type LucideProps } from "lucide-react";
+import { ShipIcon, type LucideProps } from "lucide-react";
import { cn } from "~/lib/utils";
+import { FIRST_MATE_TONE_CLASS } from "./sidebar/fleetSidebar.logic";
-/** First Mate's mark: a pink ship's wheel, shown wherever other rows show a project. */
+/** First Mate's mark: a ship in First Mate's colour. */
export function FirstMateIcon({ className, ...props }: LucideProps) {
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 4c509a263..7fe2a269c 100644
--- a/apps/web/src/components/Sidebar.logic.test.ts
+++ b/apps/web/src/components/Sidebar.logic.test.ts
@@ -2587,7 +2587,7 @@ describe("partitionFirstMateThreads", () => {
readonly fleetRepo?: string | null;
readonly archivedAt?: string | null;
} = {},
- ) => ({ id, archivedAt: null, ...fleet });
+ ) => ({ id, archivedAt: null, createdAt: "2026-09-24T00:00:00Z", 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 });
@@ -2596,7 +2596,7 @@ describe("partitionFirstMateThreads", () => {
const plain = thread("plain");
const { slot, rest } = partitionFirstMateThreads([secondMate, firstMate, worker, plain]);
expect(slot).toEqual([firstMate]);
- // Second mates and workers stay with ordinary threads, under their project.
+ // Second mates and workers pass on for the fleet tree to place.
expect(rest).toEqual([secondMate, worker, plain]);
});
diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts
index 4cd0ac72e..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,22 +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. 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 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 archivedAt: string | null },
+ 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[] } {
- const slot: T[] = [];
- const rest: T[] = [];
- for (const thread of threads) {
- if (!isFirstMateThread(thread)) rest.push(thread);
- else if (thread.archivedAt === null) slot.push(thread);
- }
- return { slot, rest };
+ 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 0b7a1c250..9bf34ec35 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,
@@ -159,6 +160,16 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell";
import { cn } from "~/lib/utils";
import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
import { FirstMateIcon } from "./FirstMateIcon";
+import { FleetRoleIcon } from "./FleetRoleIcon";
+import {
+ buildFleetRows,
+ buildFleetTree,
+ FIRST_MATE_TONE_CLASS,
+ FLEET_ROLE_WORDS,
+ type FleetParked,
+} 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";
import {
@@ -973,9 +984,39 @@ const dropVerbBadge: Record = {
),
};
+/**
+ * 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;
+ 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;
+}
+
+const FIRST_MATE_PLACEMENT: FleetRowPlacement = {
+ depth: 0,
+ theme: { kind: "first-mate" },
+ quiet: false,
+ parked: null,
+ 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.
+ fleet?: FleetRowPlacement | undefined;
// Slim rows are either settled (action: un-settle) or merely quiet
// (seen Ready threads — action: settle).
variantAction: "settle" | "unsettle" | "unsnooze";
@@ -1026,6 +1067,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
@@ -1050,6 +1093,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
onUnsettle,
onUnsnooze,
onUnpin,
+ onPin,
openPullRequestsInRightPanel,
renamingTitle,
thread,
@@ -1214,8 +1258,33 @@ 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;
+ // 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
@@ -1362,6 +1431,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);
@@ -1415,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
@@ -1570,6 +1649,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 ? (
@@ -1599,13 +1680,37 @@ 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 ? (
) : 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. */}
+ {fleetRoleIcon}
{draftIndicator}
{title}
{roleBadge}
@@ -1771,8 +1882,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
// Matches the h-[4.875rem] content box; the py-0.5 padding is added on top.
"list-none py-0.5 [content-visibility:auto] [contain-intrinsic-size:auto_78px]",
sortable?.isDragging && "relative z-20",
+ fleet && "relative",
+ fleet?.depth === 1 && "pl-4",
)}
>
+ {foldButton}
}
>
-
+
{draftIndicator}
{isFirstMate ? (
@@ -1805,6 +1926,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
{props.project ? (
) : 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 &&
@@ -1904,7 +2026,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
threadTimeLabel(thread)
)}
- {props.settlementSupported || showSnoozeButton || hasUnsentDraft ? (
+ {props.settlementSupported ||
+ showSnoozeButton ||
+ (variantAction === "unsnooze" && props.snoozeSupported) ||
+ hasUnsentDraft ||
+ showPinButton ? (
Discard draft
) : null}
- {showSnoozeButton ? (
+ {showPinButton ? (
+
+
+ }
+ >
+
+ Pin
+
+ Pin as the main First Mate
+
+ ) : null}
+ {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" ? (
{/* 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 +2175,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: {
data-testid={`sidebar-model-${thread.id}`}
className="shrink-0 whitespace-nowrap text-muted-foreground/70"
>
+ {fleet && fleetRole ? (
+ <>
+
+ {(tone) => (
+
+ {FLEET_ROLE_WORDS[fleetRole]}
+
+ )}
+
+ {" \u00b7 "}
+ >
+ ) : null}
{modelLabel}
+ {fleetThinkingLabel ? ` \u00b7 ` + fleetThinkingLabel : null}
) : null}
@@ -2596,6 +2798,8 @@ export default function Sidebar() {
} | null>(null);
const {
firstMateThreads,
+ fleetBranches,
+ fleetParked,
pinnedThreads,
draggableThreadKeys,
activeReorderableThreadKeys,
@@ -2610,14 +2814,35 @@ 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.
+ // First Mate takes its own slot above every section and ignores the
+ // project scope.
const { slot: firstMate, rest } = partitionFirstMateThreads(threads);
- const visible = rest.filter(
+ const inScope = rest.filter(
(thread) =>
thread.archivedAt === null &&
(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);
+ 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[] = [];
const snoozed: EnvironmentThreadShell[] = [];
@@ -2676,6 +2901,8 @@ export default function Sidebar() {
const sortedActive = sortThreadsForSidebar(active);
return {
firstMateThreads: firstMate,
+ fleetBranches: fleetTree.branches,
+ fleetParked: parked,
pinnedThreads:
optimisticDrop?.section !== "pinned" || optimisticDrop.order === null
? sortedPinned
@@ -2705,6 +2932,46 @@ export default function Sidebar() {
};
}, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);
+ // Second mates fold their workers away. Folding is a view choice, so it
+ // lives here rather than on the server.
+ const [foldedFleetRepos, setFoldedFleetRepos] = useState>(() => new Set());
+ const toggleFleetRepo = useCallback((repo: string) => {
+ setFoldedFleetRepos((previous) => {
+ const next = new Set(previous);
+ if (!next.delete(repo)) next.add(repo);
+ return next;
+ });
+ }, []);
+ const fleetRows = useMemo(
+ () =>
+ buildFleetRows(fleetBranches, {
+ isFolded: (repo) => foldedFleetRepos.has(repo),
+ parkedState: (thread) => fleetParked.get(thread) ?? null,
+ }).map((row) => ({
+ thread: row.thread,
+ // A parked fleet row draws as today's settled or snoozed row, so it
+ // keeps its un-settle or wake control where it stands.
+ section: row.parked ?? ("active" as const),
+ placement: {
+ depth: row.depth,
+ theme: {
+ kind: "project",
+ project: projectByKey.get(`${row.theme.environmentId}:${row.theme.projectId}`) ?? null,
+ repo: row.theme.fleetRepo ?? "",
+ },
+ quiet: row.quiet,
+ parked: row.parked,
+ lifecycle: row.thread.fleetRole === "second-mate" ? "undo-only" : "default",
+ pin: row.pin,
+ fold: row.fold
+ ? { expanded: row.fold.expanded, onToggle: () => toggleFleetRepo(row.fold!.repo) }
+ : undefined,
+ } satisfies FleetRowPlacement,
+ })),
+ [fleetBranches, fleetParked, foldedFleetRepos, projectByKey, toggleFleetRepo],
+ );
+ const fleetThreads = useMemo(() => fleetRows.map((row) => row.thread), [fleetRows]);
+
const threadSearchInputRef = useRef(null);
const [threadSearchQuery, setThreadSearchQuery] = useState("");
const [activeSearchResultIndex, setActiveSearchResultIndex] = useState(0);
@@ -2712,12 +2979,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 +3124,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(
() =>
@@ -4129,11 +4405,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;
@@ -4756,10 +5036,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,
) => {
+ 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),
);
@@ -4767,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 (
0
? firstMateThreads.map((thread) =>
- renderThreadRowInner(thread, "pinned", undefined, true),
+ renderThreadRowInner(
+ thread,
+ "pinned",
+ undefined,
+ FIRST_MATE_PLACEMENT,
+ ),
)
: [
-
-
,
]),
+ // Sets First Mate apart from every row below.
+ ,
+ ...fleetRows.map((row) =>
+ renderThreadRowInner(row.thread, row.section, undefined, row.placement),
+ ),
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
new file mode 100644
index 000000000..79502d890
--- /dev/null
+++ b/apps/web/src/components/sidebar/fleetSidebar.logic.test.ts
@@ -0,0 +1,207 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { partitionFirstMateThreads } from "../Sidebar.logic";
+import { buildFleetRows, buildFleetTree, planFirstMateSlot } from "./fleetSidebar.logic";
+
+type Role = "first-mate" | "second-mate" | "worker";
+
+const thread = (
+ id: string,
+ fields: {
+ readonly fleetRole?: Role | null;
+ readonly fleetRepo?: string | null;
+ readonly createdAt?: string;
+ readonly updatedAt?: string;
+ readonly archivedAt?: string | null;
+ readonly pinnedAt?: string | null;
+ readonly parked?: "settled" | "snoozed" | null;
+ } = {},
+) => ({
+ id,
+ fleetRole: null,
+ fleetRepo: null,
+ createdAt: "2026-09-24T10:00:00Z",
+ updatedAt: "2026-09-24T10:00:00Z",
+ archivedAt: null,
+ pinnedAt: null,
+ 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([
+ 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) => ids(rowsFor(fleet(busy)).map((row) => row.thread));
+ const expected = ["sm-lavish", "sm-t3", "w-old", "w-new"];
+ 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 { 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);
+ });
+});
+
+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(rows.map((row) => [row.thread.id, row.depth, row.parked])).toEqual([
+ ["sm", 0, null],
+ ["w-settled", 1, "settled"],
+ ["w-snoozed", 1, "snoozed"],
+ ]);
+ });
+
+ 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" }),
+ thread("w", { fleetRole: "worker", fleetRepo: "t3code" }),
+ ];
+ 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"],
+ ]);
+ });
+});
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..3acb61209
--- /dev/null
+++ b/apps/web/src/components/sidebar/fleetSidebar.logic.ts
@@ -0,0 +1,189 @@
+import type { FleetRole } from "@t3tools/contracts";
+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.
+ */
+
+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 {
+ /** 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[];
+}
+
+/**
+ * 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.
+ */
+export function buildFleetTree(
+ threads: readonly T[],
+): { 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");
+ 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;
+ }
+ }
+ 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 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;
+
+/** One row of the fleet tree, in display order. */
+export interface FleetRow {
+ readonly thread: T;
+ readonly depth: 0 | 1;
+ /** 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;
+ /**
+ * 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, 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 rows: FleetRow[] = [];
+ for (const branch of branches) {
+ const foldKey = branch.repo ?? "";
+ const expanded = !options.isFolded(foldKey);
+ if (branch.secondMate) {
+ rows.push({
+ thread: branch.secondMate,
+ depth: 0,
+ theme: branch.secondMate,
+ 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,
+ theme: branch.secondMate ?? worker,
+ 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/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..8a16f530e
--- /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.
+ */
+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/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 b8d3a2fe8..e6da1d161 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -86,16 +86,22 @@ 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.
-
-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 d26ccbae2..30b11573c 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,27 @@
"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. 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",
+ "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",
+ "apps/web/src/components/sidebar/fleetTone.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.",