Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions apps/web/src/components/Sidebar.fleetTree.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type {
EnvironmentProject,
EnvironmentThreadShell,
} from "@t3tools/client-runtime/state/models";
import {
EnvironmentId,
OrchestrationProjectShell,
OrchestrationThreadShell,
type ProjectIconColor,
} from "@t3tools/contracts";
import * as Schema from "effect/Schema";
import { act, useEffect } from "react";
import { create, type ReactTestRenderer } from "react-test-renderer";
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";

import { resetUiStateForTests } from "../uiStateStore";
import { useFleetTreeRows } from "./Sidebar";
import { buildFleetTree, type FleetParked } from "./sidebar/fleetSidebar.logic";

const ENVIRONMENT = EnvironmentId.make("environment-1");
const decodeThread = Schema.decodeUnknownSync(OrchestrationThreadShell);
const decodeProject = Schema.decodeUnknownSync(OrchestrationProjectShell);

const thread = (
id: string,
projectId: string,
fleetRole: "second-mate" | "worker",
fleetRepo: string,
createdAt: string,
): EnvironmentThreadShell => ({
...decodeThread({
id,
projectId,
title: id,
modelSelection: { instanceId: "claudeAgent", model: "claude-opus-5-5" },
runtimeMode: "full-access",
branch: null,
worktreePath: null,
latestTurn: null,
createdAt,
updatedAt: createdAt,
fleetRole,
fleetRepo,
session: null,
latestUserMessageAt: null,
hasPendingApprovals: false,
hasPendingUserInput: false,
hasActionableProposedPlan: false,
}),
environmentId: ENVIRONMENT,
});

const project = (id: string, color: ProjectIconColor): EnvironmentProject => ({
...decodeProject({
id,
title: id,
workspaceRoot: `C:\\work\\${id}`,
defaultModelSelection: null,
projectIcon: { kind: "lucide", name: "folder-code", color },
scripts: [],
createdAt: "2026-09-25T10:00:00.000Z",
updatedAt: "2026-09-25T10:00:00.000Z",
}),
environmentId: ENVIRONMENT,
});

// Two second mates, as in the report: firstmate with many workers, sheppi with one.
const THREADS = [
thread("firstmate-mate", "firstmate", "second-mate", "firstmate", "2026-09-25T10:00:00.000Z"),
thread("firstmate-worker-1", "firstmate", "worker", "firstmate", "2026-09-25T10:01:00.000Z"),
thread("firstmate-worker-2", "firstmate", "worker", "firstmate", "2026-09-25T10:02:00.000Z"),
thread("sheppi-mate", "sheppi", "second-mate", "sheppi", "2026-09-25T10:03:00.000Z"),
thread("sheppi-worker", "sheppi", "worker", "sheppi", "2026-09-25T10:04:00.000Z"),
];
const BRANCHES = buildFleetTree(THREADS, { parkedState: () => null }).branches;
const NOT_PARKED: ReadonlyMap<EnvironmentThreadShell, FleetParked> = new Map();

const projectsWithSheppi = (color: ProjectIconColor) =>
new Map(
[project("firstmate", "violet"), project("sheppi", color)].map((entry) => [
`${entry.environmentId}:${entry.id}`,
entry,
]),
);

type FleetTreeRows = ReturnType<typeof useFleetTreeRows>;
let rows: FleetTreeRows = [];
let renderer: ReactTestRenderer | null = null;

function FleetTree(props: { readonly projectByKey: ReadonlyMap<string, EnvironmentProject> }) {
const next = useFleetTreeRows(BRANCHES, NOT_PARKED, props.projectByKey);
useEffect(() => {
rows = next;
}, [next]);
return null;
}

function mountSidebar(projectByKey: ReadonlyMap<string, EnvironmentProject>) {
act(() => {
renderer = create(<FleetTree projectByKey={projectByKey} />);
});
}

function unmountSidebar() {
act(() => renderer?.unmount());
renderer = null;
}

const shownIds = () => rows.map((row) => row.thread.id);
const rowFor = (id: string) => rows.find((row) => row.thread.id === id);
const iconColorOf = (id: string) => {
const theme = rowFor(id)?.placement.theme;
return theme?.kind === "project" ? theme.project?.projectIcon : undefined;
};

beforeEach(resetUiStateForTests);
afterEach(unmountSidebar);

describe("sidebar fleet tree", () => {
it("keeps a folded second mate folded through Settings and a project icon change", () => {
mountSidebar(projectsWithSheppi("green"));
act(() => rowFor("firstmate-mate")?.placement.fold?.onToggle());
expect(shownIds()).toEqual(["firstmate-mate", "sheppi-mate", "sheppi-worker"]);

// Settings unmounts the sidebar. The user changes sheppi's icon colour there, then goes Back.
unmountSidebar();
mountSidebar(projectsWithSheppi("blue"));

expect(shownIds()).toEqual(["firstmate-mate", "sheppi-mate", "sheppi-worker"]);
expect(rowFor("firstmate-mate")?.placement.fold?.expanded).toBe(false);
});

it("gives a second mate and its workers the project's new colour at once", () => {
mountSidebar(projectsWithSheppi("green"));
expect(iconColorOf("sheppi-mate")).toMatchObject({ color: "green" });

// The sidebar stays open while another client changes the icon.
act(() => renderer?.update(<FleetTree projectByKey={projectsWithSheppi("blue")} />));

expect(iconColorOf("sheppi-mate")).toMatchObject({ color: "blue" });
expect(iconColorOf("sheppi-worker")).toMatchObject({ color: "blue" });
});
});
85 changes: 47 additions & 38 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ import {
buildFleetRows,
buildFleetTree,
FIRST_MATE_TONE_CLASS,
type FleetBranch,
FLEET_ROLE_WORDS,
type FleetParked,
resolveParkedState,
Expand Down Expand Up @@ -2421,6 +2422,51 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: {
);
});

/**
* The fleet tree as sidebar rows. Each second mate's fold is a saved sidebar
* setting, like a project's, and each row's colour comes from the project as
* it is now.
*/
export function useFleetTreeRows(
fleetBranches: readonly FleetBranch<EnvironmentThreadShell>[],
fleetParked: ReadonlyMap<EnvironmentThreadShell, FleetParked>,
projectByKey: ReadonlyMap<string, EnvironmentProject>,
) {
const fleetRepoExpandedById = useUiStateStore((store) => store.fleetRepoExpandedById);
const setFleetRepoExpanded = useUiStateStore((store) => store.setFleetRepoExpanded);
return useMemo(
() =>
buildFleetRows(fleetBranches, {
isFolded: (repo) => fleetRepoExpandedById[repo] === false,
parkedState: (thread) => fleetParked.get(thread) ?? null,
}).map((row) => ({
thread: row.thread,
// A parked fleet row draws as today's settled or snoozed row, so it
// keeps its un-settle or wake control where it stands.
section: row.parked ?? ("active" as const),
placement: {
depth: row.depth,
theme: {
kind: "project",
project: projectByKey.get(`${row.theme.environmentId}:${row.theme.projectId}`) ?? null,
repo: row.theme.fleetRepo ?? "",
},
quiet: row.quiet,
parked: row.parked,
lifecycle: row.thread.fleetRole === "second-mate" ? "undo-only" : "default",
pin: row.pin,
fold: row.fold
? {
expanded: row.fold.expanded,
onToggle: () => setFleetRepoExpanded(row.fold!.repo, !row.fold!.expanded),
}
: undefined,
} satisfies FleetRowPlacement,
})),
[fleetBranches, fleetParked, fleetRepoExpandedById, projectByKey, setFleetRepoExpanded],
);
}

export default function Sidebar() {
const projects = useProjects();
const projectOrder = useUiStateStore((store) => store.projectOrder);
Expand Down Expand Up @@ -2946,44 +2992,7 @@ export default function Sidebar() {
};
}, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]);

// Second mates fold their workers away. Folding is a view choice, so it
// lives here rather than on the server.
const [foldedFleetRepos, setFoldedFleetRepos] = useState<ReadonlySet<string>>(() => new Set());
const toggleFleetRepo = useCallback((repo: string) => {
setFoldedFleetRepos((previous) => {
const next = new Set(previous);
if (!next.delete(repo)) next.add(repo);
return next;
});
}, []);
const fleetRows = useMemo(
() =>
buildFleetRows(fleetBranches, {
isFolded: (repo) => foldedFleetRepos.has(repo),
parkedState: (thread) => fleetParked.get(thread) ?? null,
}).map((row) => ({
thread: row.thread,
// A parked fleet row draws as today's settled or snoozed row, so it
// keeps its un-settle or wake control where it stands.
section: row.parked ?? ("active" as const),
placement: {
depth: row.depth,
theme: {
kind: "project",
project: projectByKey.get(`${row.theme.environmentId}:${row.theme.projectId}`) ?? null,
repo: row.theme.fleetRepo ?? "",
},
quiet: row.quiet,
parked: row.parked,
lifecycle: row.thread.fleetRole === "second-mate" ? "undo-only" : "default",
pin: row.pin,
fold: row.fold
? { expanded: row.fold.expanded, onToggle: () => toggleFleetRepo(row.fold!.repo) }
: undefined,
} satisfies FleetRowPlacement,
})),
[fleetBranches, fleetParked, foldedFleetRepos, projectByKey, toggleFleetRepo],
);
const fleetRows = useFleetTreeRows(fleetBranches, fleetParked, projectByKey);
const fleetThreads = useMemo(() => fleetRows.map((row) => row.thread), [fleetRows]);
// Every thread in the fleet tree, folded ones included.
const fleetTreeThreadKeys = useMemo(
Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/uiStateStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
function makeUiState(overrides: Partial<UiState> = {}): UiState {
return {
projectExpandedById: {},
fleetRepoExpandedById: {},
projectOrder: [],
sidebarProjectScopeKey: null,
threadLastVisitedAtById: {},
Expand Down Expand Up @@ -196,6 +197,7 @@ describe("parsePersistedState", () => {
projectExpandedById: {
logical: false,
},
fleetRepoExpandedById: {},
projectOrder: ["physical-b", "physical-a"],
threadLastVisitedAtById: {
"environment:thread-1": "2026-02-25T12:35:00.000Z",
Expand Down Expand Up @@ -318,6 +320,7 @@ describe("uiStateStore persistence", () => {
projectExpandedById: {
logical: false,
},
fleetRepoExpandedById: {},
projectOrder: ["physical-b", "physical-a"],
threadLastVisitedAtById: {
"environment:thread-1": "2026-02-25T12:35:00.000Z",
Expand Down Expand Up @@ -350,6 +353,17 @@ describe("uiStateStore persistence", () => {
);
});

it("restores folded second mates across reloads", () => {
persistState(makeUiState({ fleetRepoExpandedById: { firstmate: false } }));

const persisted = JSON.parse(
localStorageStub.getItem(PERSISTED_STATE_KEY) ?? "{}",
) as PersistedUiState;

expect(parsePersistedState(persisted).fleetRepoExpandedById).toEqual({ firstmate: false });
expect(parsePersistedState({}).fleetRepoExpandedById).toEqual({});
});

it("drops the temporary expanded-only migration fallback when rewriting state", () => {
const migrated = parsePersistedState({
expandedProjectCwds: ["/repo/a"],
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/uiStateStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [

export interface PersistedUiState {
projectExpandedById?: Record<string, boolean>;
fleetRepoExpandedById?: Record<string, boolean>;
projectOrder?: string[];
threadLastVisitedAtById?: Record<string, string>;
collapsedProjectCwds?: string[];
Expand All @@ -35,6 +36,9 @@ export interface PersistedUiState {

export interface UiProjectState {
projectExpandedById: Record<string, boolean>;
// Whether each second mate in the fleet tree shows its workers, by fleet
// repository. A repository with no entry shows them.
fleetRepoExpandedById: Record<string, boolean>;
projectOrder: string[];
// Logical project key the sidebar list is scoped to, or null for "all
// projects". Lives here so routes that unmount the sidebar (Settings)
Expand All @@ -60,6 +64,7 @@ export interface UiState

const initialState: UiState = {
projectExpandedById: {},
fleetRepoExpandedById: {},
projectOrder: [],
sidebarProjectScopeKey: null,
threadLastVisitedAtById: {},
Expand Down Expand Up @@ -147,6 +152,7 @@ export function parsePersistedState(parsed: PersistedUiState): UiState {

return {
projectExpandedById,
fleetRepoExpandedById: sanitizeBooleanRecord(parsed.fleetRepoExpandedById),
projectOrder,
threadLastVisitedAtById: sanitizeTimestampRecord(parsed.threadLastVisitedAtById),
threadChangedFilesExpandedById:
Expand Down Expand Up @@ -225,6 +231,7 @@ export function persistState(state: UiState): void {
PERSISTED_STATE_KEY,
JSON.stringify({
projectExpandedById,
fleetRepoExpandedById: state.fleetRepoExpandedById,
projectOrder: state.projectOrder,
threadLastVisitedAtById: state.threadLastVisitedAtById,
defaultAdvertisedEndpointKey: state.defaultAdvertisedEndpointKey,
Expand Down Expand Up @@ -379,6 +386,16 @@ export function setProjectExpanded(
};
}

function setFleetRepoExpanded(state: UiState, repo: string, expanded: boolean): UiState {
if ((state.fleetRepoExpandedById[repo] ?? true) === expanded) {
return state;
}
return {
...state,
fleetRepoExpandedById: { ...state.fleetRepoExpandedById, [repo]: expanded },
};
}

export function reorderProjects(
state: UiState,
currentProjectOrder: readonly string[],
Expand Down Expand Up @@ -431,6 +448,7 @@ interface UiStateStore extends UiState {
setSidebarProjectScopeKey: (projectKey: string | null) => void;
setPullRequestMergeMethod: (method: PullRequestMergeMethod) => void;
setProjectExpanded: (projectIds: string | readonly string[], expanded: boolean) => void;
setFleetRepoExpanded: (repo: string, expanded: boolean) => void;
reorderProjects: (
currentProjectOrder: readonly string[],
draggedProjectIds: readonly string[],
Expand All @@ -453,6 +471,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
setPullRequestMergeMethod: (method) => set((state) => setPullRequestMergeMethod(state, method)),
setProjectExpanded: (projectIds, expanded) =>
set((state) => setProjectExpanded(state, projectIds, expanded)),
setFleetRepoExpanded: (repo, expanded) =>
set((state) => setFleetRepoExpanded(state, repo, expanded)),
reorderProjects: (currentProjectOrder, draggedProjectIds, targetProjectIds) =>
set((state) =>
reorderProjects(state, currentProjectOrder, draggedProjectIds, targetProjectIds),
Expand All @@ -461,6 +481,10 @@ export const useUiStateStore = create<UiStateStore>((set) => ({

useUiStateStore.subscribe((state) => debouncedPersistState.maybeExecute(state));

export function resetUiStateForTests(): void {
useUiStateStore.setState(initialState);
}

if (typeof window !== "undefined" && typeof window.addEventListener === "function") {
window.addEventListener("beforeunload", () => {
debouncedPersistState.flush();
Expand Down
Loading
Loading