feat(cli): TUI trait-flag bucket mapping + read-only Other (custom) bucket — no card silently dropped (U11)

This commit is contained in:
gsxdsm
2026-06-04 01:33:04 -07:00
parent 6a15a13917
commit 66cffe904d
6 changed files with 398 additions and 26 deletions

View File

@@ -640,6 +640,89 @@ describe("Board view", () => {
});
});
describe("Board view — U11 custom-column graceful degradation (R18)", () => {
function renderBoardWithTasks(tasks: TaskItem[], cols = 200, rows = 50) {
const controller = newController();
controller.setSystemInfo(makeSystemInfo());
controller.setInteractiveData(makeInteractiveData({
projects: [{ id: "p1", name: "my-project", path: "/tmp/p" }],
tasks,
}));
controller.setMode("interactive");
controller.setInteractiveView("board");
const rendered = render(renderDashboardAppNode(controller));
setTerminalSize(rendered, cols, rows);
rendered.rerender(renderDashboardAppNode(controller));
return rendered;
}
it("renders an 'Other (custom)' bucket between in-review and done", async () => {
const { lastFrame, unmount } = renderBoardWithTasks([
{ id: "t1", title: "Legacy", description: "", column: "todo" },
]);
await waitForFrameContains(lastFrame, "OTHER (CUSTOM)");
const frame = lastFrame() ?? "";
const headerLine = frame.split("\n").find((l) => l.includes("OTHER (CUSTOM)")) ?? "";
// Column headers share a row; verify in-review precedes other precedes done.
expect(headerLine.indexOf("IN REVIEW")).toBeLessThan(headerLine.indexOf("OTHER (CUSTOM)"));
expect(headerLine.indexOf("OTHER (CUSTOM)")).toBeLessThan(headerLine.indexOf("DONE"));
unmount();
});
it("never drops a card: a task in an unknown column with no flags surfaces in 'Other (custom)' with its column name", async () => {
const { lastFrame, unmount } = renderBoardWithTasks([
{ id: "drop1", title: "Should Not Vanish", description: "", column: "staging", columnName: "Staging" },
]);
await waitForFrameContains(lastFrame, "Should Not Vanish");
const frame = lastFrame() ?? "";
// The card is visible AND shows its real column name as a secondary label.
expect(frame).toContain("Should Not Vanish");
expect(frame).toContain("Staging");
// Other bucket header count reflects the card.
expect(frame).toContain("OTHER (CUSTOM) (1)");
unmount();
});
it("maps trait-flagged custom columns into legacy buckets (intake→todo, mergeBlocker→in-review, complete→done, wip→in-progress)", async () => {
const { lastFrame, unmount } = renderBoardWithTasks([
{ id: "i", title: "IntakeCard", description: "", column: "triage", columnName: "Triage", columnFlags: { intake: true } },
{ id: "r", title: "ReviewCard", description: "", column: "gate", columnName: "Gate", columnFlags: { mergeBlocker: true } },
{ id: "d", title: "DoneCard", description: "", column: "shipped", columnName: "Shipped", columnFlags: { complete: true } },
{ id: "w", title: "WipCard", description: "", column: "building", columnName: "Building", columnFlags: { countsTowardWip: true } },
]);
await waitForFrameContains(lastFrame, "IntakeCard");
const frame = lastFrame() ?? "";
// All four mapped to legacy buckets; none in the "other" bucket.
expect(frame).toContain("TODO (1)");
expect(frame).toContain("IN PROGRESS (1)");
expect(frame).toContain("IN REVIEW (1)");
expect(frame).toContain("DONE (1)");
expect(frame).toContain("OTHER (CUSTOM) (0)");
expect(frame).toContain("IntakeCard");
expect(frame).toContain("ReviewCard");
expect(frame).toContain("DoneCard");
expect(frame).toContain("WipCard");
unmount();
});
it("shows a read-only move-disabled hint when the focused column is the 'other' bucket", async () => {
const { lastFrame, stdin, unmount } = renderBoardWithTasks([
{ id: "o1", title: "CustomCard", description: "", column: "staging", columnName: "Staging" },
]);
await waitForFrameContains(lastFrame, "CustomCard");
// Buckets render left→right: todo, in-progress, in-review, other, done.
// Move focus right 3 times to land on the "other" bucket.
stdin.write("");
await waitForFrameUpdateAfterInput();
stdin.write("");
await waitForFrameUpdateAfterInput();
stdin.write("");
await waitForFrameUpdateAfterInput();
await waitForFrameContains(lastFrame, "move disabled here");
unmount();
});
});
describe("LogsPanel indicator", () => {
it("renders the selection arrow on the highlighted log row", async () => {
const controller = newController();

View File

@@ -0,0 +1,109 @@
import { describe, it, expect } from "vitest";
import type { TraitFlags } from "@fusion/core";
import {
bucketForTask,
groupTasksByBucket,
otherBucketSecondaryLabel,
TUI_BUCKETS,
OTHER_BUCKET,
isOtherBucket,
} from "../bucket-mapping.js";
import type { TaskItem } from "../state.js";
function task(partial: Partial<TaskItem> & { id: string; column: string }): TaskItem {
return { description: "", ...partial };
}
describe("bucketForTask (U11 / R18 graceful degradation)", () => {
it("keeps legacy column ids in their own bucket verbatim", () => {
expect(bucketForTask(task({ id: "a", column: "todo" }))).toBe("todo");
expect(bucketForTask(task({ id: "b", column: "in-progress" }))).toBe("in-progress");
expect(bucketForTask(task({ id: "c", column: "in-review" }))).toBe("in-review");
expect(bucketForTask(task({ id: "d", column: "done" }))).toBe("done");
});
it("flag-OFF (no columnFlags) sends a non-legacy column to the read-only 'other' bucket — never dropped", () => {
expect(bucketForTask(task({ id: "x", column: "staging" }))).toBe(OTHER_BUCKET);
expect(bucketForTask(task({ id: "y", column: "qa-review" }))).toBe(OTHER_BUCKET);
});
it("maps each built-in trait flag to the correct legacy bucket", () => {
const cases: Array<[TraitFlags, string]> = [
[{ complete: true }, "done"],
[{ humanReview: true }, "in-review"],
[{ mergeBlocker: true }, "in-review"],
[{ countsTowardWip: true }, "in-progress"],
[{ hold: true }, "todo"],
[{ intake: true }, "todo"],
];
for (const [flags, expected] of cases) {
expect(
bucketForTask(task({ id: "t", column: "custom-col", columnFlags: flags })),
).toBe(expected);
}
});
it("applies flag precedence: complete > review > wip > todo-like", () => {
expect(
bucketForTask(task({ id: "t", column: "c", columnFlags: { complete: true, countsTowardWip: false, humanReview: true } })),
).toBe("done");
expect(
bucketForTask(task({ id: "t", column: "c", columnFlags: { humanReview: true, countsTowardWip: true } })),
).toBe("in-review");
expect(
bucketForTask(task({ id: "t", column: "c", columnFlags: { countsTowardWip: true, hold: true } })),
).toBe("in-progress");
});
it("flagged column with no mapping-relevant flags lands in 'other'", () => {
expect(
bucketForTask(task({ id: "t", column: "weird", columnFlags: { notify: true, timing: true } })),
).toBe(OTHER_BUCKET);
});
});
describe("groupTasksByBucket", () => {
it("never drops a card: every task lands in exactly one of the five buckets", () => {
const tasks: TaskItem[] = [
task({ id: "1", column: "todo" }),
task({ id: "2", column: "in-progress" }),
task({ id: "3", column: "in-review" }),
task({ id: "4", column: "done" }),
task({ id: "5", column: "triage", columnFlags: { intake: true } }),
task({ id: "6", column: "staging" }), // unmapped → other
task({ id: "7", column: "deploying", columnFlags: { notify: true } }), // unmapped → other
task({ id: "8", column: "blocked", columnFlags: { mergeBlocker: true } }),
];
const grouped = groupTasksByBucket(tasks);
const total = TUI_BUCKETS.reduce((n, b) => n + grouped[b].length, 0);
expect(total).toBe(tasks.length);
const allIds = TUI_BUCKETS.flatMap((b) => grouped[b].map((t) => t.id)).sort();
expect(allIds).toEqual(["1", "2", "3", "4", "5", "6", "7", "8"]);
expect(grouped.todo.map((t) => t.id)).toEqual(["1", "5"]); // intake → todo
expect(grouped["in-review"].map((t) => t.id)).toEqual(["3", "8"]); // mergeBlocker → in-review
expect(grouped[OTHER_BUCKET].map((t) => t.id)).toEqual(["6", "7"]);
});
});
describe("bucket ordering + labels", () => {
it("orders 'other' between in-review and done", () => {
expect([...TUI_BUCKETS]).toEqual(["todo", "in-progress", "in-review", OTHER_BUCKET, "done"]);
const reviewIdx = TUI_BUCKETS.indexOf("in-review");
const otherIdx = TUI_BUCKETS.indexOf(OTHER_BUCKET);
const doneIdx = TUI_BUCKETS.indexOf("done");
expect(otherIdx).toBeGreaterThan(reviewIdx);
expect(otherIdx).toBeLessThan(doneIdx);
});
it("isOtherBucket flags only the catch-all", () => {
expect(isOtherBucket(OTHER_BUCKET)).toBe(true);
expect(isOtherBucket("done")).toBe(false);
});
it("secondary label uses the real column name, falling back to the id", () => {
expect(otherBucketSecondaryLabel(task({ id: "a", column: "staging", columnName: "Staging area" }))).toBe("Staging area");
expect(otherBucketSecondaryLabel(task({ id: "b", column: "qa-gate" }))).toBe("qa-gate");
});
});

View File

@@ -70,6 +70,14 @@ import type { LogEntry } from "./log-ring-buffer.js";
import { FUSION_LOGO_LINES, FUSION_LOGO_LARGE_LINES, FUSION_TAGLINE, FUSION_URL, FUSION_VERSION } from "./logo.js";
import { useProjects, useTasks } from "./hooks/use-projects.js";
import { copyToClipboard } from "./utils.js";
import {
TUI_BUCKETS,
OTHER_BUCKET,
isOtherBucket,
groupTasksByBucket,
otherBucketSecondaryLabel,
type TuiBucket,
} from "./bucket-mapping.js";
// ── Format helpers ────────────────────────────────────────────────────────────
@@ -1133,17 +1141,23 @@ function MainHeader({ state }: { state: DashboardState }) {
// ── Kanban board ──────────────────────────────────────────────────────────────
const KANBAN_COLUMNS = ["todo", "in-progress", "in-review", "done"] as const;
type KanbanColumn = typeof KANBAN_COLUMNS[number];
// U11 (R18): the TUI renders five buckets — its four legacy kanban columns plus
// a read-only "Other (custom)" catch-all wedged between in-review and done for
// workflow columns it can't express. `KANBAN_COLUMNS` is the bucket render
// order; see ./bucket-mapping.ts for how tasks land in each.
const KANBAN_COLUMNS = TUI_BUCKETS;
type KanbanColumn = TuiBucket;
const COLUMN_COLORS: Record<string, "yellow" | "cyanBright" | "cyan" | "green"> = {
const COLUMN_COLORS: Record<string, "yellow" | "cyanBright" | "cyan" | "green" | "magenta"> = {
todo: "yellow",
"in-progress": "cyanBright",
"in-review": "cyan",
[OTHER_BUCKET]: "magenta",
done: "green",
};
function columnLabel(col: string): string {
if (col === OTHER_BUCKET) return "Other (custom)";
return col.replace(/-/g, " ").replace(/\b\w/g, (l) => l.toUpperCase());
}
@@ -1151,10 +1165,14 @@ function TaskCard({
task,
selected,
width,
secondaryLabel,
}: {
task: TaskItem;
selected: boolean;
width: number;
// U11: real column name shown under cards in the read-only "other" bucket so
// the user keeps the true position despite the TUI not modeling that column.
secondaryLabel?: string;
}) {
const { t } = useTranslation("cli");
const accent = COLUMN_COLORS[task.column] ?? "white";
@@ -1180,6 +1198,9 @@ function TaskCard({
<Text bold={selected} color={titleColor} wrap="wrap">
{title}
</Text>
{secondaryLabel && (
<Text dimColor wrap="truncate-end">↳ {secondaryLabel}</Text>
)}
</Box>
);
}
@@ -1201,6 +1222,7 @@ function KanbanColumnView({
}) {
const accent = COLUMN_COLORS[column];
const headerColor = isFocused ? "whiteBright" : accent;
const isOther = isOtherBucket(column);
const cardWidth = Math.max(16, width - 2);
const innerHeaderWidth = Math.max(8, width - 2);
const label = `${columnLabel(column).toUpperCase()} (${tasks.length})`;
@@ -1250,6 +1272,7 @@ function KanbanColumnView({
task={task}
selected={isFocused && (windowStart + i) === selectedIndex}
width={cardWidth}
secondaryLabel={isOther ? otherBucketSecondaryLabel(task) : undefined}
/>
))}
{hiddenBelow > 0 && (
@@ -1695,21 +1718,10 @@ function clamp(n: number, min: number, max: number): number {
return Math.max(min, Math.min(max, n));
}
function groupTasksByColumn(tasks: TaskItem[]): Record<KanbanColumn, TaskItem[]> {
const out: Record<KanbanColumn, TaskItem[]> = {
todo: [],
"in-progress": [],
"in-review": [],
done: [],
};
for (const task of tasks) {
const col = (KANBAN_COLUMNS as readonly string[]).includes(task.column)
? (task.column as KanbanColumn)
: "todo";
out[col].push(task);
}
return out;
}
// U11 (R18): bucket tasks into the five TUI buckets via trait-flag mapping so
// cards in workflow columns the TUI can't express land in a legacy bucket or
// the read-only "other" bucket — never dropped. Delegates to the shared helper.
const groupTasksByColumn = groupTasksByBucket;
function BoardView({ state, controller }: { state: DashboardState; controller: DashboardTUI }) {
const { t } = useTranslation("cli");
@@ -1734,6 +1746,7 @@ function BoardView({ state, controller }: { state: DashboardState; controller: D
todo: 0,
"in-progress": 0,
"in-review": 0,
[OTHER_BUCKET]: 0,
done: 0,
});
const [pickerOriginal, setPickerOriginal] = useState(0);
@@ -1859,13 +1872,22 @@ function BoardView({ state, controller }: { state: DashboardState; controller: D
const narrowColumnIndicator = isNarrow
? ` · ${colIndex + 1}/${KANBAN_COLUMNS.length} ${columnLabel(focusedColumn).toUpperCase()} (${focusedTasks.length})`
: "";
// U11 (R18): the "other" bucket is a read-only view of custom workflow
// columns the TUI can't model — moving cards into/out of it from here would
// mean expressing a column the TUI has no name for, so it's disabled with a
// hint. (The TUI has no in-place move action yet; this keeps the affordance
// honest for when one lands.)
const focusedIsReadOnly = isOtherBucket(focusedColumn);
const boardHint = focusedIsReadOnly
? `←→ column · ↑↓ task · Enter open · ${t("tui.boardOtherReadOnlyHint", "custom column — move disabled here")}`
: `←→ column · ↑↓ task · Enter open · n new · p project`;
const hintText = subView === "picker"
? "↑↓ pick · Enter confirm · Esc cancel"
: subView === "detail"
? "Esc back · q quit"
: subView === "create"
? "type a task title · Enter create · Esc cancel"
: `←→ column · ↑↓ task · Enter open · n new · p project${narrowColumnIndicator}`;
: `${boardHint}${narrowColumnIndicator}`;
const submitNewTask = async () => {
const title = newTaskTitle.trim();

View File

@@ -0,0 +1,84 @@
import type { TraitFlags } from "@fusion/core";
import type { TaskItem } from "./state.js";
// ── TUI bucket model (U11, R18) ──────────────────────────────────────────────
//
// The TUI renders a fixed set of buckets. The first four are the legacy kanban
// columns it has always shown. The fifth, "other", is a read-only catch-all for
// cards whose resolved workflow column the TUI cannot express as one of its
// legacy buckets — it sits between "in-review" and "done" so a custom column
// roughly "after review, before done" reads in a sensible place, and each card
// in it keeps its real column name as a secondary label so the user never loses
// the true position. The cardinal rule (R18): a card is NEVER dropped.
export const LEGACY_KANBAN_COLUMNS = ["todo", "in-progress", "in-review", "done"] as const;
export type LegacyKanbanColumn = (typeof LEGACY_KANBAN_COLUMNS)[number];
/** The "other (custom)" catch-all bucket id. Read-only. */
export const OTHER_BUCKET = "other" as const;
/** All TUI buckets in render order: legacy columns with "other" wedged between
* in-review and done (U11 ordering requirement). */
export const TUI_BUCKETS = ["todo", "in-progress", "in-review", OTHER_BUCKET, "done"] as const;
export type TuiBucket = (typeof TUI_BUCKETS)[number];
/** True when the bucket is the read-only custom catch-all. */
export function isOtherBucket(bucket: TuiBucket): bucket is typeof OTHER_BUCKET {
return bucket === OTHER_BUCKET;
}
/**
* Map a task to its TUI bucket (U11, R18).
*
* 1. If the task sits in one of the legacy kanban column ids, keep it there
* verbatim — flag-OFF and all-legacy boards behave exactly as before.
* 2. Otherwise, if the task carries resolved column flags (flag-ON payload),
* map by trait flags into the nearest legacy bucket:
* - complete → done
* - humanReview || mergeBlocker → in-review
* - countsTowardWip → in-progress
* - hold || intake → todo
* 3. Anything still unmapped lands in the read-only "other" bucket. The card is
* never dropped.
*
* Precedence note: `complete` wins over the others (a terminal column is shown
* as done even if it carried other advisory flags); review beats wip; wip beats
* the todo-like flags. This mirrors the lane priority the dashboard board uses.
*/
export function bucketForTask(task: TaskItem): TuiBucket {
if ((LEGACY_KANBAN_COLUMNS as readonly string[]).includes(task.column)) {
return task.column as LegacyKanbanColumn;
}
const flags: TraitFlags | undefined = task.columnFlags;
if (flags) {
if (flags.complete) return "done";
if (flags.humanReview || flags.mergeBlocker) return "in-review";
if (flags.countsTowardWip) return "in-progress";
if (flags.hold || flags.intake) return "todo";
}
return OTHER_BUCKET;
}
/** Group tasks into the five TUI buckets, preserving input order within each.
* Every task lands in exactly one bucket; none are dropped. */
export function groupTasksByBucket(tasks: TaskItem[]): Record<TuiBucket, TaskItem[]> {
const out: Record<TuiBucket, TaskItem[]> = {
todo: [],
"in-progress": [],
"in-review": [],
[OTHER_BUCKET]: [],
done: [],
};
for (const task of tasks) {
out[bucketForTask(task)].push(task);
}
return out;
}
/** Secondary label shown under a card in the "other" bucket: its real column
* name (falling back to the column id) so the user keeps the true position. */
export function otherBucketSecondaryLabel(task: TaskItem): string {
return task.columnName ?? task.column;
}

View File

@@ -1,3 +1,4 @@
import type { TraitFlags } from "@fusion/core";
import type { LogEntry } from "./log-ring-buffer.js";
// ── Public types shared across the whole dashboard-tui module ─────────────────
@@ -131,6 +132,15 @@ export interface TaskItem {
description: string;
column: string;
agentState?: string;
/** Display name of the task's resolved workflow column (U11, flag-ON only).
* Used as the secondary label when a card lands in the "Other (custom)"
* bucket so the user keeps the real position. Absent on flag-OFF / legacy
* boards. */
columnName?: string;
/** Merged trait flags of the task's resolved workflow column (U11, flag-ON
* only). Drives graceful-degradation bucket mapping for non-legacy columns.
* Absent on flag-OFF / legacy boards, where bucketing is by column id. */
columnFlags?: TraitFlags;
}
// Slim agent shape for Agents view list

View File

@@ -16,6 +16,11 @@ import {
GlobalSettingsStore,
resolveGlobalDir,
DEFAULT_AGENT_HEARTBEAT_INTERVAL_MS,
isWorkflowColumnsEnabled,
resolveColumnFlags,
BUILTIN_CODING_WORKFLOW_IR,
type WorkflowIrColumn,
type TraitFlags,
} from "@fusion/core";
import {
createServer,
@@ -936,6 +941,50 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
return projectStore;
}
// ── U11: resolve per-task workflow column flags for the TUI (flag-ON only) ──
//
// The CLI TUI degrades gracefully (R18): cards in workflow columns it can't
// express must map by trait flags into its buckets or a read-only "other"
// bucket, never silently disappear. The TUI is flag-blind, so when
// `workflowColumns` is ON we enrich each slim task with its resolved column's
// display name + merged trait flags. Self-contained: derives everything from
// already-exposed store methods (workflow selection + definition) + the core
// `resolveColumnFlags` export — no dependency on concurrent U9 server work.
// Flag-OFF: returns undefineds and the TUI renders exactly as before.
type ResolvedColumnInfo = { columnName?: string; columnFlags?: TraitFlags };
async function resolveTaskColumnInfo(
projectStore: TaskStore,
flagOn: boolean,
workflowIrCache: Map<string | undefined, WorkflowIrColumn[] | null>,
task: { id: string; column: string },
): Promise<ResolvedColumnInfo> {
if (!flagOn) return {};
try {
const selection = projectStore.getTaskWorkflowSelection(task.id);
const workflowId = selection?.workflowId;
let columns = workflowIrCache.get(workflowId);
if (columns === undefined) {
// Resolve the governing workflow IR. No selection → built-in default
// (KTD-1), matching the store's own resolution order.
const def = workflowId
? await projectStore.getWorkflowDefinition(workflowId)
: undefined;
const ir = def?.ir ?? BUILTIN_CODING_WORKFLOW_IR;
columns = ir.version === "v2" ? ir.columns : [];
workflowIrCache.set(workflowId, columns);
}
if (!columns) return {};
// `task.column` is the IR column id (the store stores the column id).
const column = columns.find((c) => c.id === task.column);
if (!column) return {};
return { columnName: column.name, columnFlags: resolveColumnFlags(column) };
} catch {
// Degrade silently: an unresolvable workflow must never drop a card, just
// fall back to legacy column-id bucketing in the TUI.
return {};
}
}
/**
* Debounced refresh of TUI stats - batches rapid task updates.
* If the BoardView has a scoped project path set on the controller,
@@ -2378,13 +2427,28 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
listTasks: async (projectPath: string) => {
const projectStore = await getProjectStore(projectPath);
const tasks = await projectStore.listTasks({ slim: true, includeArchived: false });
return tasks.map((t) => ({
id: t.id,
title: t.title,
description: t.description ?? "",
column: t.column,
agentState: (t as { agentState?: string }).agentState,
}));
// U11 (R18): when the workflow-columns flag is ON, enrich each task
// with its resolved column display name + trait flags so the
// flag-blind TUI can map non-legacy columns into its buckets (or the
// read-only "other" bucket) instead of silently dropping them. The
// IR cache keeps this O(workflows) rather than O(tasks) DB reads.
const settings = await projectStore.getSettings();
const flagOn = isWorkflowColumnsEnabled(settings);
const workflowIrCache = new Map<string | undefined, WorkflowIrColumn[] | null>();
return Promise.all(
tasks.map(async (t) => {
const info = await resolveTaskColumnInfo(projectStore, flagOn, workflowIrCache, t);
return {
id: t.id,
title: t.title,
description: t.description ?? "",
column: t.column,
agentState: (t as { agentState?: string }).agentState,
...(info.columnName !== undefined ? { columnName: info.columnName } : {}),
...(info.columnFlags !== undefined ? { columnFlags: info.columnFlags } : {}),
};
}),
);
},
createTask: async (projectPath: string, input: { title: string; description?: string }) => {
const projectStore = await getProjectStore(projectPath);