SLOPSHOPPER

workboard

A live pane of the platform Work Board: what needs you, what is in flight, and goal progress

panebandguardcommandtoasttool
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · workboard
│ ┃ Work Board ✕ › fix the failing auth test and add an audit log call │ ┃ not updated yet [ Refresh ] │ ┃ ⏺ Read(src/auth.ts) │ ┃ This session's folder has no Work Board: no ⎿ Read 6 lines │ ┃ .work folder in /work/app or above it. Start ⏺ Update(src/auth.ts) │ ┃ one with: npx @shivaedev/work-board .work ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /workboard │ ⎿ workboard: Work Board pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Work Board
not updated yet [ Refresh ] This session's folder has no Work Board: no .work folder in /work/app or above it. Start one with: npx @shivaedev/work-board .work
README

Work Board plugin

The Work Board inside Claude Code: what needs you, what Fleet has in flight and how your goals are doing, one glance from the prompt.

Why you want this

A Work Board runs beside your sessions, but its browser tab is not where you are looking. With the plugin, each Claude Code session finds its own Board and shows it where you work:

  • a one-line band above the prompt that sums Needs you, In flight and Goals, with a button that opens the pane;
  • a /workboard pane listing each Needs you item as a pressable line, in-flight Fleet work grouped by the goal it implements with its stage, time in stage and PR number, and every goal with a progress bar;
  • a toast when a new decision lands for you;
  • three read-only tools the model can call, named after the Board's own MCP tools: board_overview, needs_you and item.

Using it

Install it from this repository's marketplace. At the prompt of a terminal session, on Claude Code 2.1.299 or later:

/plugin install workboard --marketplace ShivaeDev/platform

Answer y to add the marketplace, then pick a scope. From a shell:

claude plugin install workboard --marketplace ShivaeDev/platform

With the marketplace already added (/plugin marketplace add ShivaeDev/platform), /plugin install workboard@shivaedev installs it too. In the desktop app, where /plugin does not run, click + next to the prompt box, then Plugins, Add marketplace with ShivaeDev/platform, then Add plugin.

The plugin needs a running Work Board. It finds one by walking up from the session's folder to the nearest folder that holds .work, and asks the Board over .work/board.sock. Start the Board in that folder:

npx @shivaedev/work-board .work

Without a .work folder, the pane and every tool say the session's folder has no Work Board and how to start one; with a Board that is not running, they say so and name the start command. The band stays hidden until the Board answers. Pressing a Needs you item opens it with open -g at the URL the Board says it is served on, so opening items works on macOS.

The plugin only reads the Board. Answer decisions in the Board itself or through @shivaedev/work-board.

Developing it

pnpm --filter @shivaedev/work-board-plugin test
pnpm --filter @shivaedev/work-board-plugin typecheck

The pane, the band, the toasts, the hooks and the tool registration are not tested automatically. Check them by hand: start Claude Code with --plugin-dir packages/work-board-plugin, or list the folder in CLAUDE_CODE_PLUGIN_DIRS, in a folder with a running Work Board, then open /workboard.

Source 15 files
src/register.tsx 133 lines
1import type { EngineInterface, HttpResponse, Register, UiOpenResult } from "claude-code";
2import { atom, read, update } from "claude-code";
3import type { WorkboardAsk, WorkboardBoard } from "#types/index.d.ts";
4import { boardInit, readBoard } from "./board.ts";
5import { findBoard } from "./findBoard.ts";
6import { clip, updatedLabel } from "./lines.ts";
7import { paneView } from "./pane.tsx";
8import { registerTools } from "./registerTools.ts";
9import { RPC_URL } from "./rpc.ts";
10import { serveTools } from "./serveTools.ts";
11import { bandLine, needsYou } from "./summary.ts";
12
13const PANE = "workboard";
14const TITLE = "Work Board";
15const REFRESH_MS = 15_000;
16const OPEN_LABEL = "Open board";
17
18const board = atom({ key: "board", plugin: "workboard" } as const, { kind: "loading" });
19const seenAsks = atom({ key: "seenAsks", plugin: "workboard" } as const, null);
20const updatedAt = atom({ key: "updatedAt", plugin: "workboard" } as const, null);
21
22async function refresh($: EngineInterface): Promise<void> {
23	const next = await load($);
24	if (next.kind === "ready") {
25		const now = await $.clock.now();
26		await update($, updatedAt, () => now);
27	}
28	await update($, board, () => next);
29	if (next.kind === "ready" && next.asks.problem === null) {
30		await announce($, next.asks.items);
31	}
32}
33
34async function load($: EngineInterface): Promise<WorkboardBoard> {
35	const cwd = await $.session.cwd();
36	const directory = await findBoard($, cwd);
37	if (directory === null) {
38		return { cwd, kind: "none" };
39	}
40	let response: HttpResponse;
41	try {
42		response = await $.http.fetch(RPC_URL, boardInit(directory));
43	} catch {
44		return { directory, kind: "down" };
45	}
46	return readBoard(response, directory, await $.clock.now());
47}
48
49function openUrl($: EngineInterface, url: string | null): void {
50	if (url === null) {
51		$.ui.toast("The Work Board did not say where it is served.");
52		return;
53	}
54	void $.process.run(["open", "-g", url]).catch(() => $.ui.toast(`Could not open ${url}`));
55}
56
57async function announce($: EngineInterface, asks: readonly WorkboardAsk[]): Promise<void> {
58	let fresh: WorkboardAsk[] = [];
59	await update($, seenAsks, (seen) => {
60		fresh = seen === null ? [] : asks.filter((ask) => !seen.includes(ask.key));
61		return asks.map((ask) => ask.key);
62	});
63	const [first] = fresh;
64	if (first === undefined) {
65		return;
66	}
67	$.ui.toast(fresh.length === 1 ? `New decision: ${first.title}` : `${fresh.length} new decisions`);
68}
69
70async function openPane($: EngineInterface): Promise<UiOpenResult> {
71	const opened = await $.ui.open({ focus: true, id: PANE, title: TITLE });
72	await refresh($);
73	return opened;
74}
75
76export const register: Register = (on) => {
77	serveTools(on);
78
79	on("session.start", async ($, e, next) => {
80		await $.command.register({
81			description: "Open the Work Board pane: what needs you, what is in flight, goal progress",
82			name: "workboard",
83		});
84		await registerTools($);
85		$.clock.every(REFRESH_MS, () => void refresh($));
86		void refresh($);
87
88		return next(e);
89	});
90
91	on("command.run", { command: "workboard" }, async ($) => {
92		const opened = await openPane($);
93
94		return { text: opened.isPlaced ? "Work Board pane opened." : `Work Board pane is waiting: ${opened.reason}` };
95	});
96
97	on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
98		const state = await read($, board);
99		const stamp = await read($, updatedAt);
100
101		return paneView($.ui.resolve(e), {
102			board: state,
103			columns: Math.max(16, e.props.bodyColumns),
104			onOpen: (ask) => openUrl($, ask.url),
105			onRefresh: () => void refresh($),
106			surface: e.surface,
107			updated: updatedLabel(stamp, await $.clock.now()),
108		});
109	});
110
111	on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
112		if (e.props.hasSurvey) {
113			return next(e);
114		}
115		const state = await read($, board);
116		if (state.kind !== "ready") {
117			return next(e);
118		}
119
120		const { Box, Button, Text } = $.ui.resolve(e);
121		const room = Math.max(1, e.props.bodyColumns - OPEN_LABEL.length - 5);
122
123		return (
124			<Box flexDirection="row" gap={1}>
125				<Text dimColor={needsYou(state) === 0} key="band">
126					{clip(bandLine(state), room)}
127				</Text>
128				<Button key="open-board" label={OPEN_LABEL} onPress={() => void openPane($)} />
129			</Box>
130		);
131	});
132};
133
src/board.ts 89 lines
1import type { WorkboardAsk, WorkboardBlock, WorkboardBoard } from "#types/index.d.ts";
2import { flightsOf, goalsOf, goalTitlesOf } from "./answers.ts";
3import { refusedLine, silentLine } from "./lines.ts";
4import { isRecord, type RpcExit, type RpcInit, type RpcReply, readExits, records, rpcInit, text } from "./rpc.ts";
5
6const QUERIES = ["inbox", "fleetItems", "goals", "server"] as const;
7type Query = (typeof QUERIES)[number];
8type Exits = ReadonlyMap<Query, RpcExit>;
9
10export function boardInit(directory: string): RpcInit {
11	return rpcInit(
12		directory,
13		QUERIES.map((query) => ({ payload: null, tag: `work-board.${query}` })),
14	);
15}
16
17export function readBoard(response: RpcReply, directory: string, now: number): WorkboardBoard {
18	if (!response.ok) {
19		return { kind: "error", message: refusedLine(response, directory) };
20	}
21	const exits = exitsByQuery(response.text);
22	if (exits.size === 0) {
23		return { kind: "error", message: silentLine(directory) };
24	}
25	const server = answerTo(exits, "server");
26	const url = isRecord(server) && text(server.directory) !== undefined ? (text(server.url) ?? null) : null;
27	const titles = goalTitlesOf(answerTo(exits, "goals"));
28	return {
29		asks: block(exits, "inbox", (value) => asksOf(value, url)),
30		flights: block(exits, "fleetItems", (value) => flightsOf(value, now, titles)),
31		goals: block(exits, "goals", goalsOf),
32		kind: "ready",
33	};
34}
35
36function exitsByQuery(stream: string): Exits {
37	const exits = new Map<Query, RpcExit>();
38	for (const [index, exit] of readExits(stream)) {
39		const query = QUERIES[index];
40		if (query !== undefined) {
41			exits.set(query, exit);
42		}
43	}
44	return exits;
45}
46
47function answerTo(exits: Exits, query: Query): unknown {
48	const exit = exits.get(query);
49	return exit !== undefined && "value" in exit ? exit.value : undefined;
50}
51
52function block<T>(exits: Exits, query: Query, read: (value: unknown) => WorkboardBlock<T>): WorkboardBlock<T> {
53	const exit = exits.get(query);
54	if (exit === undefined) {
55		return { items: [], problem: "The Board sent no answer for this query." };
56	}
57	return "failure" in exit ? { items: [], problem: "The Board could not answer this query." } : read(exit.value);
58}
59
60function asksOf(value: unknown, boardUrl: string | null): WorkboardBlock<WorkboardAsk> {
61	const rows = isRecord(value) ? records(value.you) : [];
62	const seen = new Map<string, number>();
63	const items = rows.map((row) => {
64		const id = text(row.id) ?? text(row.file) ?? "ask";
65		const count = seen.get(id) ?? 0;
66		seen.set(id, count + 1);
67		return {
68			key: count === 0 ? `open:${id}` : `open:${id}:${count}`,
69			title: text(row.title) ?? id,
70			url: resolveUrl(text(row.href) ?? "/", boardUrl),
71		};
72	});
73	return { items, problem: null };
74}
75
76function resolveUrl(href: string, base: string | null): string | null {
77	if (/^https?:/u.test(href)) {
78		return href;
79	}
80	if (base === null) {
81		return null;
82	}
83	try {
84		return new URL(href, base).href;
85	} catch {
86		return null;
87	}
88}
89
src/findBoard.ts 22 lines
1import type { EngineInterface } from "claude-code";
2import { workFolder } from "./rpc.ts";
3
4function ancestors(cwd: string): string[] {
5	const parts = cwd.split("/").filter((part) => part !== "");
6	const directories: string[] = [];
7	for (let depth = parts.length; depth >= 0; depth -= 1) {
8		directories.push(`/${parts.slice(0, depth).join("/")}`);
9	}
10	return directories;
11}
12
13export async function findBoard($: EngineInterface, cwd: string): Promise<string | null> {
14	for (const directory of ancestors(cwd)) {
15		const stat = await $.fs.stat(workFolder(directory)).catch(() => undefined);
16		if (stat?.kind === "dir") {
17			return directory;
18		}
19	}
20	return null;
21}
22
src/lines.ts 62 lines
1import type { WorkboardBoard } from "#types/index.d.ts";
2import type { RpcReply } from "./rpc.ts";
3
4const START_COMMAND = "npx @shivaedev/work-board .work";
5
6export function waitingLine(state: Exclude<WorkboardBoard, { kind: "ready" }>): string {
7	if (state.kind === "none") {
8		return `This session's folder has no Work Board: no .work folder in ${state.cwd} or above it. Start one with: ${START_COMMAND}`;
9	}
10	if (state.kind === "down") {
11		return `The Work Board for ${state.directory} isn't running. Start it there: ${START_COMMAND}`;
12	}
13	if (state.kind === "error") {
14		return state.message;
15	}
16	return "Loading the Work Board…";
17}
18
19export function refusedLine(response: RpcReply, directory: string): string {
20	return `The Work Board for ${directory} refused the request (${response.status}): ${response.text.trim()}`;
21}
22
23export function silentLine(directory: string): string {
24	return `The Work Board for ${directory} did not answer with its RPC stream.`;
25}
26
27export function formatAge(ms: number | null): string {
28	if (ms === null) {
29		return "?";
30	}
31	const minutes = Math.floor(ms / 60_000);
32	if (minutes < 1) {
33		return "<1m";
34	}
35	if (minutes < 60) {
36		return `${minutes}m`;
37	}
38	const hours = Math.floor(minutes / 60);
39	if (hours < 48) {
40		return `${hours}h`;
41	}
42	return `${Math.floor(hours / 24)}d`;
43}
44
45export function clip(line: string, columns: number): string {
46	const flat = line.replace(/\s+/gu, " ").trim();
47	return flat.length <= columns ? flat : `${flat.slice(0, Math.max(0, columns - 1))}…`;
48}
49
50export function updatedLabel(updatedAt: number | null, now: number): string | null {
51	if (updatedAt === null) {
52		return null;
53	}
54	const ms = Math.max(0, now - updatedAt);
55	return ms < 60_000 ? `updated ${Math.floor(ms / 1000)}s ago` : `updated ${formatAge(ms)} ago`;
56}
57
58export function prLabel(url: string): string {
59	const number = /\/pull\/(?<number>\d+)/u.exec(url)?.groups?.number;
60	return number === undefined ? "PR" : `#${number}`;
61}
62
src/pane.tsx 91 lines
1import type { Elements, RenderElement, RenderSurface } from "claude-code";
2import type { WorkboardAsk, WorkboardBlock, WorkboardBoard } from "#types/index.d.ts";
3import { flightHeading, flightRows } from "./flights.tsx";
4import { goalRows } from "./goalRows.tsx";
5import { clip, waitingLine } from "./lines.ts";
6import { countOf } from "./summary.ts";
7
8export type Table = Elements[RenderSurface];
9
10export interface PaneView {
11	board: WorkboardBoard;
12	columns: number;
13	onOpen: (ask: WorkboardAsk) => void;
14	onRefresh: () => void;
15	surface: RenderSurface;
16	updated: string | null;
17}
18
19export function paneView(t: Table, view: PaneView): RenderElement {
20	const { Box, Text, Button } = t;
21	const header = (
22		<Box alignItems="center" flexDirection="row" gap={1} justifyContent="space-between">
23			<Text dimColor size="footnote">
24				{view.updated ?? "not updated yet"}
25			</Text>
26			<Button key="refresh" label="Refresh" onPress={view.onRefresh} />
27		</Box>
28	);
29
30	if (view.board.kind !== "ready") {
31		return (
32			<Box flexDirection="column" gap={1}>
33				{header}
34				<Text dimColor={view.board.kind === "loading"}>{waitingLine(view.board)}</Text>
35			</Box>
36		);
37	}
38
39	const { asks, flights, goals } = view.board;
40
41	return (
42		<Box flexDirection="column" gap={1}>
43			{header}
44			{section(t, titled(t, "Needs you", asks), asks, "Nothing waits on you.", (items) => askRows(t, items, view))}
45			{section(t, flightHeading(t, flights) ?? titled(t, "In flight", flights), flights, "Nothing in flight.", (items) =>
46				flightRows(t, items, view.columns),
47			)}
48			{section(t, titled(t, "Goals", goals), goals, "No open goals.", (items) => goalRows(t, items, view))}
49		</Box>
50	);
51}
52
53function titled<T>(t: Table, title: string, block: WorkboardBlock<T>): RenderElement {
54	const { Box, Text } = t;
55	return (
56		<Box flexDirection="row" gap={1}>
57			<Text weight="semibold">{title}</Text>
58			<Text dimColor tabular>{`(${countOf(block)})`}</Text>
59		</Box>
60	);
61}
62
63function section<T>(t: Table, heading: RenderElement, block: WorkboardBlock<T>, empty: string, rows: (items: T[]) => RenderElement): RenderElement {
64	const { Box } = t;
65	return (
66		<Box flexDirection="column">
67			{heading}
68			{sectionBody(t, block, empty, rows)}
69		</Box>
70	);
71}
72
73function sectionBody<T>(t: Table, block: WorkboardBlock<T>, empty: string, rows: (items: T[]) => RenderElement): RenderElement {
74	const { Text } = t;
75	if (block.problem !== null) {
76		return <Text dimColor>{block.problem}</Text>;
77	}
78	return block.items.length === 0 ? <Text dimColor>{empty}</Text> : rows(block.items);
79}
80
81function askRows(t: Table, asks: WorkboardAsk[], view: PaneView): RenderElement {
82	const { Box, Button } = t;
83	return (
84		<Box flexDirection="column">
85			{asks.map((ask) => (
86				<Button key={ask.key} label={clip(ask.title, view.columns)} onPress={() => view.onOpen(ask)} plain />
87			))}
88		</Box>
89	);
90}
91
src/registerTools.ts 37 lines
1import type { EngineInterface, ToolSpec } from "claude-code";
2
3const NO_INPUT = { additionalProperties: false, properties: {}, type: "object" };
4
5const SPECS: readonly ToolSpec[] = [
6	{
7		description:
8			"Read-only: what needs you on the Work Board (count, titles and reasons), in-flight Fleet items with stage, time in stage, PR URL and the goal each implements, and goal progress done/total.",
9		inputSchema: NO_INPUT,
10		isDeferred: false,
11		name: "board_overview",
12	},
13	{
14		description: "Read-only: the Work Board's needs-you items, each with its reason, options, prompts, recommendation and age in milliseconds.",
15		inputSchema: NO_INPUT,
16		isDeferred: false,
17		name: "needs_you",
18	},
19	{
20		description: "Read-only: one Work Board item's computed status, owner, criteria, relationships, Fleet state and open decisions, by its id.",
21		inputSchema: {
22			additionalProperties: false,
23			properties: { id: { description: "The item id, such as task.first", type: "string" } },
24			required: ["id"],
25			type: "object",
26		},
27		isDeferred: false,
28		name: "item",
29	},
30];
31
32export async function registerTools($: EngineInterface): Promise<void> {
33	for (const spec of SPECS) {
34		await $.tool.register(spec);
35	}
36}
37
src/rpc.ts 96 lines
1export const RPC_URL = "http://localhost/_board/rpc";
2
3export interface RpcRequest {
4	payload: unknown;
5	tag: string;
6}
7export type RpcExit = { value: unknown } | { failure: string };
8
9export interface RpcInit {
10	body: string;
11	headers: Record<string, string>;
12	method: "POST";
13	socketPath: string;
14}
15
16export interface RpcReply {
17	ok: boolean;
18	status: number;
19	text: string;
20}
21
22export function workFolder(directory: string): string {
23	return `${directory === "/" ? "" : directory}/.work`;
24}
25
26export function rpcInit(directory: string, requests: readonly RpcRequest[]): RpcInit {
27	const body = requests.map((request, index) =>
28		JSON.stringify({ _tag: "Request", headers: [], id: String(index + 1), payload: request.payload, tag: request.tag }),
29	);
30	return {
31		body: `${body.join("\n")}\n`,
32		headers: { "content-type": "application/ndjson" },
33		method: "POST",
34		socketPath: `${workFolder(directory)}/board.sock`,
35	};
36}
37
38export function readExits(stream: string): Map<number, RpcExit> {
39	const exits = new Map<number, RpcExit>();
40	for (const line of stream.split("\n")) {
41		const message = parseLine(line);
42		if (!isRecord(message) || message._tag !== "Exit" || typeof message.requestId !== "string") {
43			continue;
44		}
45		const index = Number(message.requestId) - 1;
46		if (!Number.isInteger(index) || index < 0 || !isRecord(message.exit)) {
47			continue;
48		}
49		exits.set(index, message.exit._tag === "Success" ? { value: message.exit.value } : { failure: failureOf(message.exit.cause) });
50	}
51	return exits;
52}
53
54function failureOf(cause: unknown): string {
55	const reasons = (Array.isArray(cause) ? cause : []).filter(isRecord).map((entry) => {
56		const error = isRecord(entry.error) ? entry.error : {};
57		return text(error.message) ?? text(error._tag) ?? text(entry._tag) ?? "unknown failure";
58	});
59	return reasons.length === 0 ? "unknown failure" : reasons.join("; ");
60}
61
62function parseLine(line: string): unknown {
63	if (line.trim() === "") {
64		return undefined;
65	}
66	try {
67		return JSON.parse(line);
68	} catch {
69		return undefined;
70	}
71}
72
73export function fileUrl(file: string): string {
74	return `/${file.split("/").map(encodeURIComponent).join("/")}`;
75}
76
77export function identityUrl(id: string): string {
78	return `/_board/item/${encodeURIComponent(id)}/`;
79}
80
81export function responseUrl(item: string, request: string): string {
82	return `${identityUrl(item)}#${encodeURIComponent(`answer-card-${request}`)}`;
83}
84
85export function isRecord(value: unknown): value is Record<string, unknown> {
86	return typeof value === "object" && value !== null && !Array.isArray(value);
87}
88
89export function text(value: unknown): string | undefined {
90	return typeof value === "string" && value !== "" ? value : undefined;
91}
92
93export function records(value: unknown): Record<string, unknown>[] {
94	return Array.isArray(value) ? value.filter(isRecord) : [];
95}
96
src/serveTools.ts 25 lines
1import type { EngineInterface, On } from "claude-code";
2import { type Board, item, needsYou, overview } from "./boardTools.ts";
3import { connect } from "./connect.ts";
4import { text } from "./rpc.ts";
5
6async function answer($: EngineInterface, read: (board: Board) => Promise<unknown>) {
7	try {
8		return { result: JSON.stringify(await read(await connect($))) };
9	} catch (error) {
10		return { deny: error instanceof Error ? error.message : String(error) };
11	}
12}
13
14export function serveTools(on: On): void {
15	on("tool.call", { tool: "mcp__workboard__board_overview" }, ($) => answer($, overview));
16	on("tool.call", { tool: "mcp__workboard__needs_you" }, ($) => answer($, needsYou));
17	on("tool.call", { tool: "mcp__workboard__item" }, ($, e) => {
18		const id = text(e.id);
19		if (id === undefined) {
20			return { deny: "item needs the id of a Work Board item, such as task.first." };
21		}
22		return answer($, (board) => item(board, id));
23	});
24}
25
src/summary.ts 72 lines
1import type { WorkboardBlock, WorkboardBoard, WorkboardFlight, WorkboardGoal, WorkboardGoalRef } from "#types/index.d.ts";
2import { STAGES } from "./answers.ts";
3
4type ReadyBoard = Extract<WorkboardBoard, { kind: "ready" }>;
5
6export interface StageCount {
7	count: number;
8	stage: string;
9}
10
11export interface FlightGroup {
12	goal: WorkboardGoalRef | null;
13	items: WorkboardFlight[];
14}
15
16export function countOf<T>(block: WorkboardBlock<T>): string {
17	return block.problem === null ? String(block.items.length) : "?";
18}
19
20export function needsYou(board: ReadyBoard): number {
21	return board.asks.problem === null ? board.asks.items.length : 0;
22}
23
24export function bandLine(board: ReadyBoard): string {
25	const { asks, flights, goals } = board;
26	let done = 0;
27	let total = 0;
28	for (const goal of goals.items) {
29		done += goal.done;
30		total += goal.total;
31	}
32	const progress = goals.problem === null ? `${done}/${total}` : "?";
33	return `Needs you ${countOf(asks)} · In flight ${countOf(flights)} · Goals ${progress}`;
34}
35
36export function goalShare(goal: WorkboardGoal): number {
37	return goal.total === 0 ? 0 : Math.min(1, goal.done / goal.total);
38}
39
40export function startedShare(goal: WorkboardGoal): number {
41	return goal.total === 0 ? 0 : Math.min(1, (goal.done + goal.ready) / goal.total);
42}
43
44function goalRank(goal: WorkboardGoal): number {
45	if (goal.total === 0) {
46		return 1;
47	}
48	return goal.done >= goal.total ? 2 : 0;
49}
50
51export function goalOrder(goals: readonly WorkboardGoal[]): WorkboardGoal[] {
52	return [...goals].sort((a, b) => goalRank(a) - goalRank(b) || goalShare(a) - goalShare(b));
53}
54
55export function stageCounts(flights: readonly WorkboardFlight[]): StageCount[] {
56	return STAGES.map((stage) => ({ count: flights.filter((flight) => flight.stage === stage).length, stage })).filter((entry) => entry.count > 0);
57}
58
59export function flightGroups(flights: readonly WorkboardFlight[]): FlightGroup[] {
60	const groups = new Map<string | null, FlightGroup>();
61	for (const flight of flights) {
62		const id = flight.goal?.id ?? null;
63		const group = groups.get(id) ?? { goal: flight.goal, items: [] };
64		group.items.push(flight);
65		groups.set(id, group);
66	}
67	const goals = [...groups.values()].filter((group) => group.goal !== null);
68	goals.sort((a, b) => b.items.length - a.items.length || (a.goal?.title ?? "").localeCompare(b.goal?.title ?? ""));
69	const other = groups.get(null);
70	return other === undefined ? goals : [...goals, other];
71}
72
src/answers.ts 82 lines
1import type { WorkboardBlock, WorkboardFlight, WorkboardGoal } from "#types/index.d.ts";
2import { isRecord, records, text } from "./rpc.ts";
3
4export const STAGES = ["working", "reviewing", "waiting"] as const;
5const STAGE_NAMES: ReadonlySet<unknown> = new Set(STAGES);
6const DONE = ["done"];
7
8export function flightsOf(value: unknown, now: number, titles: ReadonlyMap<string, string> = new Map()): WorkboardBlock<WorkboardFlight> {
9	if (!isRecord(value)) {
10		return { items: [], problem: "Fleet sent no items." };
11	}
12	if (value.state !== "ready") {
13		return { items: [], problem: `Fleet is unavailable: ${text(value.reason) ?? "no reason given"}` };
14	}
15	const items = records(value.items)
16		.filter((row) => STAGE_NAMES.has(row.stage))
17		.map((row) => {
18			const workId = text(row.workId) ?? "unknown";
19			const entered = typeof row.stageEnteredAt === "number" ? row.stageEnteredAt : null;
20			const document = isRecord(row.document) ? row.document : {};
21			const goalId = goalIdOf(document.metadata, titles);
22			return {
23				goal: goalId === null ? null : { id: goalId, title: titles.get(goalId) ?? goalId },
24				inStageMs: entered === null ? null : Math.max(0, now - entered),
25				prUrl: text(row.prUrl) ?? null,
26				stage: text(row.stage) ?? "unknown",
27				title: text(document.title) ?? workId,
28				workId,
29			};
30		});
31	return { items, problem: null };
32}
33
34export function goalsOf(value: unknown): WorkboardBlock<WorkboardGoal> {
35	const rows = isRecord(value) ? records(value.goals) : [];
36	const items = rows.map((row) => {
37		const counts = isRecord(row.counts) ? Object.entries(row.counts) : [];
38		let done = 0;
39		let ready = 0;
40		let total = 0;
41		for (const [status, count] of counts) {
42			if (typeof count !== "number") {
43				continue;
44			}
45			total += count;
46			if (DONE.includes(status)) {
47				done += count;
48			} else if (status === "ready") {
49				ready += count;
50			}
51		}
52		const id = text(row.id) ?? "goal";
53		return { done, id, ready, title: text(row.title) ?? id, total };
54	});
55	return { items, problem: null };
56}
57
58function goalIdOf(metadata: unknown, titles: ReadonlyMap<string, string>): string | null {
59	const links = isRecord(metadata) ? records(metadata.relationships) : [];
60	for (const link of links) {
61		const id = link.kind === "implements" ? text(link.target)?.split("#")[0] : undefined;
62		if (id !== undefined && (titles.has(id) || id.startsWith("goal."))) {
63			return id;
64		}
65	}
66	return null;
67}
68
69export function goalTitlesOf(value: unknown): Map<string, string> {
70	const titles = new Map<string, string>();
71	if (!isRecord(value)) {
72		return titles;
73	}
74	for (const row of [...records(value.goals), ...records(value.deferredGoals), ...records(value.closedGoals)]) {
75		const id = text(row.id);
76		if (id !== undefined) {
77			titles.set(id, text(row.title) ?? id);
78		}
79	}
80	return titles;
81}
82
src/flights.tsx 73 lines
1import type { Color, RenderElement } from "claude-code";
2import type { WorkboardBlock, WorkboardFlight } from "#types/index.d.ts";
3import { clip, formatAge, prLabel } from "./lines.ts";
4import type { Table } from "./pane.tsx";
5import { type FlightGroup, flightGroups, stageCounts } from "./summary.ts";
6
7const STAGE_COLORS: Readonly<Record<string, Color>> = {
8	adopting: "#39c5cf",
9	executing: "#3fb950",
10	repairing: "#e3913b",
11	reviewing: "#a371f7",
12};
13const DOT = "●";
14const OTHER = "Other";
15
16function stageColor(stage: string): Color {
17	return STAGE_COLORS[stage] ?? "subtle";
18}
19
20export function flightHeading(t: Table, flights: WorkboardBlock<WorkboardFlight>): RenderElement | null {
21	if (flights.problem !== null || flights.items.length === 0) {
22		return null;
23	}
24	const { Box, Text } = t;
25	return (
26		<Box columnGap={2} flexDirection="row" flexWrap="wrap">
27			<Text weight="semibold">In flight</Text>
28			{stageCounts(flights.items).map(({ stage, count }) => (
29				<Text key={`stage:${stage}`}>
30					<Text color={stageColor(stage)}>{DOT}</Text>
31					{` ${count} ${stage}`}
32				</Text>
33			))}
34		</Box>
35	);
36}
37
38export function flightRows(t: Table, flights: WorkboardFlight[], columns: number): RenderElement {
39	const { Box } = t;
40	return <Box flexDirection="column">{flightGroups(flights).map((group) => flightGroup(t, group, columns))}</Box>;
41}
42
43function flightGroup(t: Table, group: FlightGroup, columns: number): RenderElement {
44	const { Box, Text } = t;
45	return (
46		<Box flexDirection="column" key={`group:${group.goal?.id ?? OTHER}`}>
47			<Text dimColor size="footnote" wrap="truncate-end">
48				{clip(group.goal?.title ?? OTHER, columns)}
49			</Text>
50			{group.items.map((flight) => flightRow(t, flight, columns))}
51		</Box>
52	);
53}
54
55function flightRow(t: Table, flight: WorkboardFlight, columns: number): RenderElement {
56	const { Box, Text, Link } = t;
57	const age = formatAge(flight.inStageMs);
58	const pr = flight.prUrl === null ? null : { href: flight.prUrl, label: prLabel(flight.prUrl) };
59	const room = columns - DOT.length - age.length - 2 - (pr === null ? 0 : pr.label.length + 1);
60	return (
61		<Box flexDirection="row" gap={1} key={`flight:${flight.workId}`}>
62			<Text color={stageColor(flight.stage)}>{DOT}</Text>
63			<Box flexGrow={1} flexShrink={1}>
64				<Text wrap="truncate-end">{clip(flight.title, room)}</Text>
65			</Box>
66			<Text dimColor tabular>
67				{age}
68			</Text>
69			{pr !== null && <Link href={pr.href} label={pr.label} />}
70		</Box>
71	);
72}
73
src/goalRows.tsx 111 lines
1import type { RenderElement } from "claude-code";
2import type { WorkboardGoal } from "#types/index.d.ts";
3import { clip } from "./lines.ts";
4import type { PaneView, Table } from "./pane.tsx";
5import { goalOrder, goalShare, startedShare } from "./summary.ts";
6
7type Drawn = Extract<Table, { Svg: unknown }>;
8
9const BAR_FILL = "#3fa36b";
10const BAR_ACTIVE = "#5b9cf5";
11const BAR_TRACK = "#8a8f98";
12const BAR_HEIGHT = 6;
13const BAR_CELLS = 8;
14
15export function goalRows(t: Table, goals: WorkboardGoal[], view: PaneView): RenderElement {
16	const { Box } = t;
17	const countWidth = Math.max(1, ...goals.map((goal) => countLabel(goal).length));
18	const titleRoom = view.columns - BAR_CELLS - countWidth - 2;
19	const drawn = view.surface !== "terminal" && "Svg" in t ? t : null;
20	return (
21		<Box flexDirection="column">
22			{goalOrder(goals).map((goal) => {
23				const open = goal.total > 0 && goal.done < goal.total;
24				return goalRow(t, goal, { countWidth, drawn, open, titleRoom });
25			})}
26		</Box>
27	);
28}
29
30interface GoalRowLayout {
31	countWidth: number;
32	drawn: Drawn | null;
33	open: boolean;
34	titleRoom: number;
35}
36
37function goalRow(t: Table, goal: WorkboardGoal, layout: GoalRowLayout): RenderElement {
38	const { Box, Text } = t;
39	return (
40		<Box flexDirection="row" gap={1} key={`goal:${goal.id}`}>
41			<Box flexGrow={1} flexShrink={1}>
42				<Text dimColor={!layout.open} wrap="truncate-end">
43					{clip(goal.title, layout.titleRoom)}
44				</Text>
45			</Box>
46			<Box flexShrink={0} width={BAR_CELLS}>
47				{goalBar(t, goal, layout)}
48			</Box>
49			<Box flexShrink={0} justifyContent="flex-end" width={layout.countWidth}>
50				<Text dimColor tabular>
51					{countLabel(goal)}
52				</Text>
53			</Box>
54		</Box>
55	);
56}
57
58function goalBar(t: Table, goal: WorkboardGoal, layout: GoalRowLayout): RenderElement {
59	const { Text } = t;
60	if (goal.total === 0) {
61		return <Text dimColor>—</Text>;
62	}
63	if (!layout.open) {
64		return <Text dimColor>✓</Text>;
65	}
66	return layout.drawn === null ? textBar(t, goal) : svgBar(layout.drawn, goal);
67}
68
69function countLabel(goal: WorkboardGoal): string {
70	return goal.total === 0 ? "" : `${goal.done}/${goal.total}`;
71}
72
73function barSource(goal: WorkboardGoal): string {
74	const filled = percent(goalShare(goal));
75	const started = percent(startedShare(goal));
76	const active =
77		started > filled ? `<rect x="${filled}" width="${percent((started - filled) / 100)}" height="${BAR_HEIGHT}" fill="${BAR_ACTIVE}"/>` : "";
78	return (
79		`<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="${BAR_HEIGHT}" viewBox="0 0 100 ${BAR_HEIGHT}" preserveAspectRatio="none">`
80		+ `<rect width="100" height="${BAR_HEIGHT}" fill="${BAR_TRACK}" fill-opacity="0.28"/>`
81		+ `<rect width="${filled}" height="${BAR_HEIGHT}" fill="${BAR_FILL}"/>${active}</svg>`
82	);
83}
84
85function percent(share: number): number {
86	return Math.round(share * 1000) / 10;
87}
88
89function barLabel(goal: WorkboardGoal): string {
90	const done = `${goal.done} of ${goal.total} done`;
91	return goal.ready === 0 ? done : `${done}, ${goal.ready} ready`;
92}
93
94function svgBar(t: Drawn, goal: WorkboardGoal): RenderElement {
95	return <t.Svg alt={barLabel(goal)} height={BAR_HEIGHT} source={barSource(goal)} />;
96}
97
98function textBar(t: Table, goal: WorkboardGoal): RenderElement {
99	const { Text } = t;
100	const filled = Math.floor(BAR_CELLS * goalShare(goal));
101	const reached = Math.floor(BAR_CELLS * startedShare(goal));
102	const active = goal.ready > 0 && reached === filled && filled < BAR_CELLS ? 1 : reached - filled;
103	return (
104		<Text>
105			<Text color={BAR_FILL}>{"▰".repeat(filled)}</Text>
106			{active > 0 && <Text color={BAR_ACTIVE}>{"▰".repeat(active)}</Text>}
107			<Text dimColor>{"▱".repeat(BAR_CELLS - filled - active)}</Text>
108		</Text>
109	);
110}
111