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
142 changes: 94 additions & 48 deletions apps/web/src/j5/fleet/FleetPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { SidebarInset } from "../../components/ui/sidebar";
import { toastManager } from "../../components/ui/toast";
import { isElectron } from "../../env";
import { cn } from "../../lib/utils";
import { useThreadShells } from "../../state/entities";
import { useAllEnvironmentShellsBootstrapped, useThreadShells } from "../../state/entities";
import { buildThreadRouteParams } from "../../threadRoutes";
import { formatElapsedDurationLabel } from "../../timestampFormat";
import { CaptainMark } from "../squadron/CaptainMark";
Expand Down Expand Up @@ -176,7 +176,11 @@ export function FleetPage() {
onOpenThread={openThread}
/>
)}
<RetiredCrews squadron={squadron} onOpenThread={openThread} />
<RetiredCrews
squadron={squadron}
threadsByKey={threadsByKey}
onOpenThread={openThread}
/>
</section>
))}
</main>
Expand Down Expand Up @@ -395,14 +399,15 @@ function FleetNodeRows(
}

/**
* Retired Crews of one Squadron, collapsed: the brief, the approved roster with who approved
* each seat and why, and the Captain's thread, so a successor can be proposed from what was
* decided rather than from memory (Crews AC20). Handoffs live on the Artifacts page.
* Retired Crews of one Squadron as one-line rows that open to the brief and the approved roster
* with each seat's approval version and reason, so a successor can be proposed from what was
* decided rather than from memory (Crews AC20). A retired Crew can never be reactivated, so its
* row offers no action beyond naming its Captain, whose thread holds the ledger; handoffs live on
* the Artifacts page.
*/
function RetiredCrews(props: {
readonly squadron: ScopedFleetSquadron;
readonly onOpenThread: FleetRowsProps["onOpenThread"];
}) {
function RetiredCrews(
props: Omit<FleetRowsProps, "environmentId"> & { readonly squadron: ScopedFleetSquadron },
) {
const crews = retiredCrews(props.squadron);
if (crews.length === 0) return null;
return (
Expand All @@ -414,12 +419,13 @@ function RetiredCrews(props: {
/>
Retired crews ({crews.length})
</summary>
<ul className="mt-2 space-y-2">
<ul className="mt-2 divide-y divide-border/60 overflow-hidden rounded-md border border-border/60">
{crews.map((crew) => (
<RetiredCrewItem
key={crew.crewInstanceId}
crew={crew}
environmentId={props.squadron.environmentId}
threadsByKey={props.threadsByKey}
onOpenThread={props.onOpenThread}
/>
))}
Expand All @@ -428,50 +434,90 @@ function RetiredCrews(props: {
);
}

function RetiredCrewItem(props: {
readonly crew: FleetCrew;
readonly environmentId: EnvironmentId;
readonly onOpenThread: FleetRowsProps["onOpenThread"];
}) {
function RetiredCrewItem(props: FleetRowsProps & { readonly crew: FleetCrew }) {
const { crew } = props;
const retired =
crew.archivedAt === null ? null : formatElapsedDurationLabel(crew.archivedAt) || "just now";
const seatCount = crew.roster.length;
return (
<li className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-sm opacity-80">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="font-medium">Crew · {crew.crewName}</span>
<span className="text-xs text-muted-foreground">
v{crew.version}
{retired === null ? "" : ` · retired ${retired}`}
</span>
{crew.captainThreadId === null ? null : (
<Button
className="ms-auto"
onClick={() => props.onOpenThread(props.environmentId, crew.captainThreadId!)}
size="xs"
variant="ghost"
>
Open Captain
</Button>
)}
</div>
<p className="mt-1 line-clamp-3 whitespace-pre-wrap text-muted-foreground">{crew.brief}</p>
<ul className="mt-2 space-y-0.5 text-xs">
{crew.roster.map((member) => (
<li key={member.seat} className="flex flex-wrap items-baseline gap-x-2">
<span className="uppercase tracking-wide text-muted-foreground">{member.seat}</span>
<span>{member.agentId ?? "Custom seat"}</span>
<span className="text-muted-foreground">
{member.addedVersion > 1 ? `joined at v${member.addedVersion}` : "approved roster"}
{member.reason === null ? "" : ` · ${member.reason}`}
</span>
</li>
))}
</ul>
<li>
<details className="group/retired-crew">
<summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-1.5 text-sm outline-hidden marker:hidden hover:bg-accent/40 focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
<ChevronRightIcon
aria-hidden
className="size-3.5 shrink-0 text-muted-foreground transition-transform duration-150 group-open/retired-crew:rotate-90"
/>
<span className="min-w-0 truncate font-medium text-foreground/80">{crew.crewName}</span>
<span className="ms-auto shrink-0 text-xs text-muted-foreground tabular-nums">
v{crew.version} · {seatCount} {seatCount === 1 ? "seat" : "seats"} · retired{" "}
{crew.archivedAt === null ? (
"?"
) : (
<time dateTime={crew.archivedAt}>
{formatElapsedDurationLabel(crew.archivedAt) || "just now"}
</time>
)}
</span>
</summary>
<div className="border-t border-border/40 px-3 py-2 ps-[2.125rem] text-xs">
<RetiredCrewCaptain {...props} />
<p className="mt-2 whitespace-pre-wrap break-words text-muted-foreground">{crew.brief}</p>
{crew.roster.length === 0 ? (
<p className="mt-2 text-muted-foreground">No seats were approved.</p>
) : (
<ul className="mt-2 space-y-0.5">
{crew.roster.map((member) => (
<li key={member.seat} className="flex flex-wrap items-baseline gap-x-2">
<span className="uppercase tracking-wide text-muted-foreground">
{member.seat}
</span>
<span className="text-foreground/80">{member.agentId ?? "Custom seat"}</span>
<span className="text-muted-foreground">
approved at v{member.addedVersion}
{member.reason === null ? "" : ` · ${member.reason}`}
</span>
</li>
))}
</ul>
)}
</div>
</details>
</li>
);
}

/**
* The Captain's thread keeps the retired Crew's ledger. Archived threads are not in the active
* thread shells (nor are deleted ones), and the thread route redirects home for them, so only a
* live Captain is linked. Until the shells load, a missing thread says nothing about archive.
*/
function RetiredCrewCaptain(props: FleetRowsProps & { readonly crew: FleetCrew }) {
const shellsBootstrapped = useAllEnvironmentShellsBootstrapped();
const { captainThreadId } = props.crew;
if (captainThreadId === null) return null;
const thread = props.threadsByKey.get(
scopedThreadKey(scopeThreadRef(props.environmentId, ThreadId.make(captainThreadId))),
);
return (
<p className="text-muted-foreground">
Captain:{" "}
{thread === undefined ? (
shellsBootstrapped ? (
"no longer active. If it was archived, unarchive it from Settings → Archived to read its ledger."
) : (
"unavailable."
)
) : (
<button
type="button"
className="text-foreground/80 underline underline-offset-2 outline-hidden hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => props.onOpenThread(props.environmentId, captainThreadId)}
>
{thread.title}
</button>
)}
</p>
);
}

function FleetRowItem(
props: FleetRowsProps & {
readonly row: FleetRow;
Expand Down
2 changes: 1 addition & 1 deletion docs/j5/product/features/crews.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ The platform ships the machinery — propose, gate, spawn, render, remind, notif

18. A member whose run ends owing no reply is reported to its Captain; the platform settles neither members nor Captains, and upstream's settle rules apply to both.
19. A Crew archives only as a unit; archiving never deletes a worktree, branch, or checkpoint; archiving with open Exchanges or running turns lists them seat by seat and requires a confirmation token.
20. A successor Crew starts from a new proposal and a fresh brief; nothing from the archived Crew's conversation, obligations, or asks is carried over automatically, and its brief, roster, ledger, and handoffs stay readable: the Fleet page lists a Squadron's retired Crews with the brief, the roster snapshot (seat, agent, who approved it and why, version), and a way to the Captain's thread; handoffs stay on the Artifacts page.
20. A successor Crew starts from a new proposal and a fresh brief; nothing from the archived Crew's conversation, obligations, or asks is carried over automatically, and its brief, roster, ledger, and handoffs stay readable: the Fleet page lists a Squadron's retired Crews with a link to the Captain's thread while it is active (the ledger's home), the brief, the roster snapshot (seat, agent, who approved it and why, version), the version, and when it retired, and offers no action on them because a retired Crew can never be reactivated; handoffs stay on the Artifacts page.
21. A Crew stops as a unit: its Captain over `stop_crew`, or the person from the Crew's Fleet header or the Captain's expander, interrupts every seat with a running turn; idle seats are untouched, nothing settles or is retired, and the result names each seat as interrupted, already idle, or archived.

### State
Expand Down
2 changes: 1 addition & 1 deletion docs/j5/product/features/fleet-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ The Fleet page is **not** the inbox: it never demands a reply. It is **not** a p
### Crews

27. A Crew's header offers Stop crew while a seat is running and Archive crew always; Archive crew shows the seats with their running turns and open asks before retiring the Crew as a unit ([Crews](crews.md) AC17, AC21).
28. A Squadron's retired Crews are listed, collapsed, beneath its agents with the brief, the roster snapshot (seat, agent, who approved it and why, the version it joined at), when it retired, and a way to the Captain's thread; nothing about a retired Crew is inferred ([Crews](crews.md) AC20).
28. A Squadron's retired Crews are listed, collapsed, beneath its agents, one row each showing the Crew's version and when it retired; a row opens to its Captain, the brief, and the roster snapshot (seat, agent, who approved it and why, the version it joined at). The Captain is a link to its thread, which holds the Crew's ledger, while that thread is active; an archived Captain is named as such, with where to unarchive it. A retired Crew offers no other action, since it can never be reactivated, and nothing about it is inferred ([Crews](crews.md) AC20).

## Scenarios

Expand Down
Loading