Merge remote branch updates (workflow-defined custom columns stream + main) into plugin install fixes
This commit is contained in:
@@ -17,6 +17,11 @@ These tools are **not** part of the user-invokable extension surface. They are i
|
||||
| `fn_task_document_read` | triage, executor, heartbeat | Read one task document or list all | `key?` (string) |
|
||||
| `fn_workflow_list` | executor | List the project's custom workflows (read-only built-ins plus user definitions) | none |
|
||||
| `fn_workflow_select` | executor | Assign a custom workflow to a task (defaults to the current task) | `workflow_id` (string), `task_id?` (string) |
|
||||
| `fn_workflow_create` | executor | Create a custom workflow definition from a graph IR (validated server-side) | `name` (string), `description?` (string), `ir` (object), `layout?` (object) |
|
||||
| `fn_workflow_update` | executor | Update a custom workflow definition's name/description/ir/layout (built-ins cannot be edited) | `workflow_id` (string), `name?` (string), `description?` (string), `ir?` (object), `layout?` (object), `rehome_to?` (string) |
|
||||
| `fn_workflow_delete` | executor | Delete a custom workflow definition (built-ins cannot be deleted); selecting tasks are re-homed to the default workflow's entry column | `workflow_id` (string) |
|
||||
| `fn_task_promote` | executor | Promote a held task out of a manual-release hold column (defaults to the current task) | `task_id?` (string) |
|
||||
| `fn_trait_list` | executor | List the registered column trait catalog (built-in and plugin traits) | none |
|
||||
| `fn_memory_search` | triage, executor, heartbeat | Search project memory plus per-agent layered memory snippets | `query` (string), `limit?` (number) |
|
||||
| `fn_memory_get` | triage, executor, heartbeat | Read a bounded memory file window (including bounded per-agent layered paths) | `path` (string), `startLine?` (number), `lineCount?` (number) |
|
||||
| `fn_memory_append` | executor, heartbeat (when writable backend enabled) | Append memory notes with explicit scope: `scope="agent"` for private operating context, `scope="project"` for workspace-wide durable knowledge | `scope?` (`project` \| `agent`), `layer` (`long-term` \| `daily`), `content` (string) |
|
||||
|
||||
@@ -6,7 +6,8 @@ vi.mock("node:readline/promises", () => ({
|
||||
}));
|
||||
|
||||
// Mock @fusion/core before importing
|
||||
vi.mock("@fusion/core", () => ({
|
||||
vi.mock("@fusion/core", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@fusion/core")>()),
|
||||
TaskStore: vi.fn(),
|
||||
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
|
||||
COLUMN_LABELS: {
|
||||
|
||||
@@ -6,7 +6,8 @@ vi.mock("node:readline/promises", () => ({
|
||||
}));
|
||||
|
||||
// Mock @fusion/core before importing
|
||||
vi.mock("@fusion/core", () => ({
|
||||
vi.mock("@fusion/core", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@fusion/core")>()),
|
||||
TaskStore: vi.fn(),
|
||||
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
|
||||
COLUMN_LABELS: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
|
||||
import { TaskStore, COLUMNS, COLUMN_LABELS, CentralCore, buildAutoPauseClearPatch, buildManualRetryResetPatch, extractIntentSignature, findNearDuplicates, getTaskDuplicateLineage, reconcileDeterministicDuplicate, runDeterministicDuplicateGuard, type Settings, type Column, type ColumnId, type StepStatus, type AgentLogType, type AgentLogEntry, type IntentSignature, type NearDuplicateCandidate, type NearDuplicateMatch, type TaskDependencyMutation } from "@fusion/core";
|
||||
import { aiMergeTask } from "@fusion/engine";
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||
@@ -19,6 +19,12 @@ import { findNodeByNameOrId } from "./node.js";
|
||||
|
||||
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
||||
|
||||
/** #1403: display a column's label, falling back to the raw id for
|
||||
* workflow-defined custom columns that have no legacy label. */
|
||||
function columnLabel(column: ColumnId): string {
|
||||
return (COLUMN_LABELS as Record<string, string>)[column] ?? column;
|
||||
}
|
||||
|
||||
// Register GitHub tracking hook so CLI task creation paths (add, duplicate,
|
||||
// refine, import, delegate) trigger tracking issue creation.
|
||||
try {
|
||||
@@ -806,7 +812,7 @@ export async function runTaskShow(id: string, projectName?: string) {
|
||||
|
||||
console.log();
|
||||
console.log(` ${task.id}: ${task.title || task.description}`);
|
||||
console.log(` Column: ${COLUMN_LABELS[task.column]}${task.size ? ` · Size: ${task.size}` : ""}${task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""}`);
|
||||
console.log(` Column: ${columnLabel(task.column)}${task.size ? ` · Size: ${task.size}` : ""}${task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""}`);
|
||||
if (task.dependencies.length) {
|
||||
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
|
||||
}
|
||||
@@ -959,7 +965,7 @@ export async function runTaskMove(id: string, column: string, projectName?: stri
|
||||
const task = await store.moveTask(id, column as Column);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Moved ${task.id} → ${COLUMN_LABELS[task.column as Column]}`);
|
||||
console.log(` ✓ Moved ${task.id} → ${columnLabel(task.column)}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -1010,7 +1016,7 @@ export async function runTaskArchive(id: string, projectName?: string) {
|
||||
const task = await store.archiveTask(id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Archived ${task.id} → ${COLUMN_LABELS[task.column]}`);
|
||||
console.log(` ✓ Archived ${task.id} → ${columnLabel(task.column)}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
@@ -1019,7 +1025,7 @@ export async function runTaskUnarchive(id: string, projectName?: string) {
|
||||
const task = await store.unarchiveTask(id);
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}`);
|
||||
console.log(` ✓ Unarchived ${task.id} → ${columnLabel(task.column)}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
buildManualRetryResetPatch,
|
||||
validateNodeOverrideChange,
|
||||
type Task,
|
||||
type ColumnId,
|
||||
type InsightCategory,
|
||||
type TaskPriority,
|
||||
type InsightStatus,
|
||||
@@ -57,6 +58,12 @@ import { spawn, type ChildProcess } from "node:child_process";
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────
|
||||
|
||||
/** #1403: display a column's label, falling back to the raw id for
|
||||
* workflow-defined custom columns that have no legacy label. */
|
||||
function columnLabel(column: ColumnId): string {
|
||||
return (COLUMN_LABELS as Record<string, string>)[column] ?? column;
|
||||
}
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
@@ -782,7 +789,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
const lines: string[] = [];
|
||||
lines.push(`${task.id}: ${task.title || task.description}`);
|
||||
lines.push(
|
||||
`Column: ${COLUMN_LABELS[task.column]}` +
|
||||
`Column: ${columnLabel(task.column)}` +
|
||||
(task.size ? ` · Size: ${task.size}` : "") +
|
||||
(task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""),
|
||||
);
|
||||
@@ -1145,7 +1152,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
const task = await store.archiveTask(params.id);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Archived ${task.id} → ${COLUMN_LABELS[task.column]}` }],
|
||||
content: [{ type: "text", text: `Archived ${task.id} → ${columnLabel(task.column)}` }],
|
||||
details: { taskId: task.id, column: task.column },
|
||||
};
|
||||
},
|
||||
@@ -1173,7 +1180,7 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
const task = await store.unarchiveTask(params.id);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Unarchived ${task.id} → ${COLUMN_LABELS[task.column]}` }],
|
||||
content: [{ type: "text", text: `Unarchived ${task.id} → ${columnLabel(task.column)}` }],
|
||||
details: { taskId: task.id, column: task.column },
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user