feat(cli): TUI trait-flag bucket mapping + read-only Other (custom) bucket — no card silently dropped (U11)
This commit is contained in:
@@ -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("[C");
|
||||
await waitForFrameUpdateAfterInput();
|
||||
stdin.write("[C");
|
||||
await waitForFrameUpdateAfterInput();
|
||||
stdin.write("[C");
|
||||
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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
84
packages/cli/src/commands/dashboard-tui/bucket-mapping.ts
Normal file
84
packages/cli/src/commands/dashboard-tui/bucket-mapping.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user