feat(dashboard,cli): U13 — schema-driven task fields UI (TaskFieldsSection, card badges, PATCH route, board-workflows fields payload, TUI chips)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-04 12:26:05 -07:00
parent 2cfa8a3282
commit 5fa5740afc
26 changed files with 1613 additions and 8 deletions

View File

@@ -1613,6 +1613,15 @@ function TaskDetailScreen({
</Box>
)}
{/* Card-placed custom fields (U13/KTD-14): read-only bracketed labels. */}
{detail.customFields && detail.customFields.length > 0 && (
<Box flexDirection="row" gap={1} flexWrap="wrap" flexShrink={0}>
{detail.customFields.map((f) => (
<Text key={f.label} color="magenta">[{f.label}: {f.value}]</Text>
))}
</Box>
)}
<Box height={1} flexShrink={0} />
{/* Steps section */}

View File

@@ -230,6 +230,10 @@ export interface TaskDetailData {
currentStepIndex?: number;
steps: TaskStep[];
recentLogs: TaskLogEntry[]; // last ~200 entries on initial load
/** Card-placed custom field values, pre-rendered as read-only bracketed
* labels for the task detail view (U13/KTD-14). Absent/empty when the
* workflow declares no card fields or none have values. */
customFields?: Array<{ label: string; value: string }>;
}
export type TaskEvent =

View File

@@ -19,6 +19,7 @@ import {
isWorkflowColumnsEnabled,
resolveColumnFlags,
BUILTIN_CODING_WORKFLOW_IR,
parseWorkflowIr,
type WorkflowIrColumn,
type TraitFlags,
} from "@fusion/core";
@@ -2742,6 +2743,48 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
text: entry.outcome ? `${entry.action} → ${entry.outcome}` : entry.action,
source: entry.runContext?.agentId ? "agent" : "executor",
}));
// Card-placed custom fields → read-only bracketed labels
// (U13/KTD-14). Resolve the task's workflow IR, filter
// card-placed field defs, and render any present values.
// Best-effort: any resolution failure simply omits the chips.
let customFields: Array<{ label: string; value: string }> | undefined;
try {
const values = (t as { customFields?: Record<string, unknown> }).customFields;
if (values && Object.keys(values).length > 0) {
const selection = projectStore.getTaskWorkflowSelection(t.id);
const def = selection?.workflowId
? await projectStore.getWorkflowDefinition(selection.workflowId)
: undefined;
const ir = def
? (typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir)
: BUILTIN_CODING_WORKFLOW_IR;
const fields = ir.version === "v2" ? (ir.fields ?? []) : [];
const chips: Array<{ label: string; value: string }> = [];
for (const field of fields) {
if (field.render?.placement !== "card") continue;
const raw = values[field.id];
if (raw === undefined || raw === null || raw === "") continue;
const optLabel = (v: string): string =>
field.options?.find((o) => o.value === v)?.label ?? v;
let display: string;
if (field.type === "boolean") {
if (raw !== true) continue;
display = field.name;
} else if (field.type === "multi-enum" && Array.isArray(raw)) {
if (raw.length === 0) continue;
display = raw.map((v) => optLabel(String(v))).join(", ");
} else if (field.type === "enum") {
display = optLabel(String(raw));
} else {
display = String(raw);
}
chips.push({ label: field.name, value: display });
}
if (chips.length > 0) customFields = chips;
}
} catch {
customFields = undefined;
}
return {
id: t.id,
title: t.title,
@@ -2753,6 +2796,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
currentStepIndex: t.currentStep,
steps,
recentLogs,
...(customFields ? { customFields } : {}),
};
} catch {
// Task not found (deleted/archived between selection and fetch).

View File

@@ -552,10 +552,52 @@ export interface BoardWorkflowColumn {
flags: BoardWorkflowColumnFlags;
}
/** Supported custom-field value types (mirrors core `WorkflowFieldType`, KTD-13).
* Duplicated client-side (same posture as the BoardWorkflow* types above) since
* the core field-schema types are not exported through the `@fusion/core`
* barrel. */
export type WorkflowFieldType =
| "string"
| "text"
| "number"
| "boolean"
| "enum"
| "multi-enum"
| "date"
| "url";
/** A single enum/multi-enum option (KTD-13). */
export interface WorkflowFieldOption {
value: string;
label: string;
color?: string;
}
/** Rendering instructions for a custom field (KTD-14). */
export interface WorkflowFieldRender {
placement?: "card" | "detail" | "detail-section";
widget?: "select" | "radio" | "chips" | "input" | "textarea" | "toggle";
badge?: boolean;
}
/** A workflow-defined custom task field (KTD-13). */
export interface WorkflowFieldDefinition {
id: string;
name: string;
type: WorkflowFieldType;
required?: boolean;
default?: unknown;
options?: WorkflowFieldOption[];
render?: WorkflowFieldRender;
}
export interface BoardWorkflowDefinition {
id: string;
name: string;
columns: BoardWorkflowColumn[];
/** Custom field definitions declared by this workflow (U13/KTD-14). Absent on
* workflows with no fields, or from older servers. */
fields?: WorkflowFieldDefinition[];
}
export interface BoardWorkflowsPayload {
@@ -565,6 +607,31 @@ export interface BoardWorkflowsPayload {
taskWorkflowIds: Record<string, string>;
}
/** A typed custom-field rejection surfaced by the PATCH endpoint (KTD-13). */
export interface CustomFieldRejection {
code: "no-fields-defined" | "unknown-field" | "type-mismatch" | "enum-violation";
fieldId: string;
detail: string;
}
/**
* Patch a task's custom field values (U13/KTD-14). The server validates the
* patch against the task's workflow field schema and returns the updated task;
* a validation failure surfaces as a 400 carrying `{ fieldId, code, detail }`.
* A `null` value for a field deletes it.
*/
export function updateTaskCustomFields(
id: string,
customFields: Record<string, unknown>,
projectId?: string,
): Promise<Task> {
return api<Task>(withProjectId(`/tasks/${id}/custom-fields`, projectId), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ customFields }),
});
}
/** Fetch the multi-lane board metadata (U9). When the flag is OFF the server
* returns `{ flagEnabled: false }` and the board renders its legacy form. */
export function fetchBoardWorkflows(projectId?: string): Promise<BoardWorkflowsPayload> {

View File

@@ -379,6 +379,28 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
return result;
}, [boardWorkflows, flagOn, tasks]);
// Card-placed custom field definitions per task (U13/KTD-14). Resolves each
// task's workflow from the board-workflows payload and exposes that workflow's
// card-placed field defs so TaskCard can render value badges. Empty map when
// no workflow declares card fields — cards stay byte-identical.
const taskCardFieldDefs = useMemo(() => {
const map = new Map<string, import("../api").WorkflowFieldDefinition[]>();
if (!boardWorkflows) return map;
const { workflows, taskWorkflowIds, defaultWorkflowId } = boardWorkflows;
const cardDefsByWorkflow = new Map<string, import("../api").WorkflowFieldDefinition[]>();
for (const wf of workflows) {
const cardDefs = (wf.fields ?? []).filter((f) => f.render?.placement === "card");
if (cardDefs.length > 0) cardDefsByWorkflow.set(wf.id, cardDefs);
}
if (cardDefsByWorkflow.size === 0) return map;
for (const task of tasks) {
const workflowId = taskWorkflowIds[task.id] ?? defaultWorkflowId;
const defs = cardDefsByWorkflow.get(workflowId);
if (defs) map.set(task.id, defs);
}
return map;
}, [boardWorkflows, tasks]);
// Drag pre-check (R17): adjacency + capacity from the lane's column metadata.
// Cross-lane drag → workflow-mismatch. Deterministic rejections return a
// messageKey (no-move); null = allowed.
@@ -467,6 +489,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
/>
@@ -508,6 +531,7 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMerge={autoMerge}

View File

@@ -140,6 +140,8 @@ interface ColumnProps {
lastFetchTimeMs?: number;
/** Lookup of workflow step IDs to display names, fetched once at board level. */
workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Per-task card-placed custom field definitions (U13/KTD-14). */
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
/** Precomputed blocker fanout keyed by blocker task ID. */
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
@@ -168,7 +170,7 @@ interface ColumnProps {
getDraggingTaskId?: () => string | null;
}
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, blockerFanoutMap, prAuthAvailable, workflowMode, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask, onPauseTask, onOpenDetail, onOpenGroupModal, addToast, onQuickCreate, onNewTask, autoMerge, onToggleAutoMerge, globalPaused, onUpdateTask, onRetryTask, onArchiveTask, onUnarchiveTask, onDeleteTask, onArchiveAllDone, collapsed, onToggleCollapse, allTasks, availableModels, onPlanningMode, onSubtaskBreakdown, onOpenDetailWithTab, favoriteProviders, favoriteModels, onToggleFavorite, onToggleModelFavorite, isSearchActive, taskStuckTimeoutMs, onOpenMission, lastFetchTimeMs, workflowStepNameLookup, taskCardFieldDefs, blockerFanoutMap, prAuthAvailable, workflowMode, columnDisplayName, columnFlags, onPromote, canDropTask, getDraggingTaskId }: ColumnProps) {
const { t } = useTranslation("app");
// Anchor the board.rejection.* catalog keys for the i18next extractor (it
// scopes `t` to the useTranslation binding, so the shared translateRejection
@@ -695,6 +697,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
taskCardFieldDefs={taskCardFieldDefs}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
@@ -725,6 +728,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onMoveTask={onMoveTask}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}

View File

@@ -68,6 +68,8 @@ export interface LaneProps {
onOpenMission?: (missionId: string) => void;
lastFetchTimeMs?: number;
workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Per-task card-placed custom field definitions (U13/KTD-14). */
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
prAuthAvailable?: boolean;
}
@@ -191,6 +193,7 @@ function LaneComponent(props: LaneProps) {
onOpenMission={props.onOpenMission}
lastFetchTimeMs={props.lastFetchTimeMs}
workflowStepNameLookup={props.workflowStepNameLookup}
taskCardFieldDefs={props.taskCardFieldDefs}
blockerFanoutMap={props.blockerFanoutMap}
prAuthAvailable={props.prAuthAvailable}
autoMerge={props.autoMerge}

View File

@@ -1447,3 +1447,53 @@
flex-wrap: wrap;
}
}
/* Card-placed custom field badges (U13 / KTD-14). */
.card-field-badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
margin: 4px 0 2px;
}
.card-field-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 7px;
border: 1px solid var(--border-color, #2a2d34);
border-radius: 999px;
background: var(--chip-bg, #1c1f26);
color: var(--text-secondary, #b4b8c0);
font-size: 11px;
line-height: 1.5;
max-width: 16ch;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-field-badge--boolean {
background: var(--accent, #4f7cff);
border-color: var(--accent, #4f7cff);
color: #fff;
}
.card-field-badge--multi {
gap: 3px;
max-width: none;
}
.card-field-badge-token {
display: inline-flex;
align-items: center;
padding: 0 5px;
border-radius: 999px;
border: 1px solid var(--border-color, #2a2d34);
background: var(--chip-bg, #1c1f26);
}
.card-field-badge--overflow {
font-weight: 600;
}

View File

@@ -1,7 +1,7 @@
import "./TaskCard.css";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { memo, useCallback, useState, useRef, useEffect, useMemo } from "react";
import { memo, useCallback, useState, useRef, useEffect, useMemo, type ReactElement } from "react";
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest } from "lucide-react";
import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction } from "@fusion/core";
import {
@@ -11,7 +11,7 @@ import {
VALID_TRANSITIONS,
getErrorMessage,
} from "@fusion/core";
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent } from "../api";
import { fetchTaskDetail, uploadAttachment, fetchMission, fetchAgent, type WorkflowFieldDefinition } from "../api";
import { GitHubBadge } from "./GitHubBadge";
import { PrCreateModal } from "./PrCreateModal";
import { ProviderIcon } from "./ProviderIcon";
@@ -299,6 +299,72 @@ export function formatElapsedDurationDone(elapsedMs: number): string {
}
/** Max number of card-placed custom fields rendered before an overflow chip
* (KTD-14: "max 3 card fields rendered with a +N overflow indicator"). */
const MAX_CARD_FIELDS = 3;
/** Render a single card-placed custom field value as a badge/chip (U13/KTD-14).
* Returns null for empty/unset values so absent fields take no card space. */
function renderCardFieldBadge(
field: WorkflowFieldDefinition,
value: unknown,
): ReactElement | null {
const colorOf = (v: string): string | undefined => field.options?.find((o) => o.value === v)?.color;
const labelOf = (v: string): string => field.options?.find((o) => o.value === v)?.label ?? v;
if (field.type === "boolean") {
// Boolean true → labeled chip; false/unset → nothing.
if (value !== true) return null;
return (
<span key={field.id} className="card-field-badge card-field-badge--boolean" title={field.name}>
{field.name}
</span>
);
}
if (field.type === "enum") {
if (typeof value !== "string" || value === "") return null;
const color = colorOf(value);
return (
<span
key={field.id}
className="card-field-badge card-field-badge--enum"
title={`${field.name}: ${labelOf(value)}`}
style={color ? { backgroundColor: color, borderColor: color, color: "#fff" } : undefined}
>
{labelOf(value)}
</span>
);
}
if (field.type === "multi-enum") {
const arr = Array.isArray(value) ? (value as string[]) : [];
if (arr.length === 0) return null;
return (
<span key={field.id} className="card-field-badge card-field-badge--multi" title={field.name}>
{arr.map((v) => {
const color = colorOf(v);
return (
<span
key={v}
className="card-field-badge-token"
style={color ? { backgroundColor: color, borderColor: color, color: "#fff" } : undefined}
>
{labelOf(v)}
</span>
);
})}
</span>
);
}
// string / text / number / date / url → simple labeled chip.
if (value === undefined || value === null || value === "") return null;
const display = field.type === "date" && typeof value === "string" ? value.slice(0, 10) : String(value);
return (
<span key={field.id} className="card-field-badge" title={`${field.name}: ${display}`}>
{display}
</span>
);
}
interface TaskCardProps {
task: Task;
projectId?: string;
@@ -338,6 +404,9 @@ interface TaskCardProps {
prAuthAvailable?: boolean;
/** Whether project-level auto-merge is enabled (hides manual Create PR quick action when true). */
autoMergeEnabled?: boolean;
/** Card-placed custom field definitions for this task's workflow (U13/KTD-14).
* Empty/undefined → no field badges render (card byte-identical to today). */
cardFieldDefs?: WorkflowFieldDefinition[];
}
function getTaskPrimaryPrInfo(task: Pick<Task, "prInfo" | "prInfos">): PrInfo | undefined {
@@ -471,6 +540,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previous.taskStuckTimeoutMs === next.taskStuckTimeoutMs &&
previous.prAuthAvailable === next.prAuthAvailable &&
previous.autoMergeEnabled === next.autoMergeEnabled &&
previous.cardFieldDefs === next.cardFieldDefs &&
JSON.stringify(previousTask.customFields ?? null) === JSON.stringify(nextTask.customFields ?? null) &&
previous.onOpenDetail === next.onOpenDetail &&
previous.onOpenGroupModal === next.onOpenGroupModal &&
previous.addToast === next.addToast &&
@@ -584,6 +655,7 @@ function TaskCardComponent({
fanout,
prAuthAvailable,
autoMergeEnabled = false,
cardFieldDefs,
}: TaskCardProps) {
const { t } = useTranslation("app");
const columnLabel = useColumnLabel();
@@ -1947,6 +2019,30 @@ function TaskCardComponent({
<div className="card-title" title={task.title || task.description || undefined}>
{truncate(task.title, MAX_TITLE_LENGTH) || truncate(task.description, MAX_TITLE_LENGTH) || task.id}
</div>
{(() => {
// Card-placed custom field badges (U13/KTD-14). Bounded to MAX_CARD_FIELDS
// with a "+N" overflow chip. Nothing renders when no card fields are
// defined or all values are empty — card stays byte-identical to today.
const cardDefs = (cardFieldDefs ?? []).filter((f) => f.render?.placement === "card");
if (cardDefs.length === 0) return null;
const values = task.customFields ?? {};
const badges = cardDefs
.map((f) => renderCardFieldBadge(f, values[f.id]))
.filter((b): b is ReactElement => b !== null);
if (badges.length === 0) return null;
const shown = badges.slice(0, MAX_CARD_FIELDS);
const overflow = badges.length - shown.length;
return (
<div className="card-field-badges" data-testid="card-field-badges">
{shown}
{overflow > 0 ? (
<span className="card-field-badge card-field-badge--overflow" data-testid="card-field-overflow">
+{overflow}
</span>
) : null}
</div>
);
})()}
{hasBranchMetadata && (
<div className="card-branch-row" aria-label={t("tasks.branchMetadata", "Branch metadata")}>
{branchMetadata.branch && (

View File

@@ -21,8 +21,10 @@ import {
resolveTaskPlanningModel,
resolveTaskValidatorModel,
} from "@fusion/core";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus } from "../api";
import type { RecoverBranchBindingOutcome } from "../api";
import { uploadAttachment, deleteAttachment, updateTask, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, recoverBranchBinding, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields } from "../api";
import type { RecoverBranchBindingOutcome, WorkflowFieldDefinition, CustomFieldRejection } from "../api";
import { ApiRequestError } from "../api";
import { TaskFieldsSection } from "./TaskFieldsSection";
import type { ToastType } from "../hooks/useToast";
import { useAgentLogs } from "../hooks/useAgentLogs";
import { useConfirm } from "../hooks/useConfirm";
@@ -605,6 +607,59 @@ export function TaskDetailContent({
const [showRefineModal, setShowRefineModal] = useState(false);
const [prCreateOpen, setPrCreateOpen] = useState(false);
// Custom field definitions (U13/KTD-14). Resolved for this task's workflow
// from the board-workflows payload; absent when the workflow declares none,
// in which case the fields section renders nothing (today's UI byte-identical).
const [customFieldDefs, setCustomFieldDefs] = useState<WorkflowFieldDefinition[] | null>(null);
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>(task.customFields ?? {});
const [customFieldError, setCustomFieldError] = useState<CustomFieldRejection | null>(null);
// Keep local field values in sync when the task prop changes (SSE refresh).
useEffect(() => {
setCustomFieldValues(task.customFields ?? {});
}, [task.id, task.customFields]);
// Resolve this task's workflow field definitions once per task. Best-effort:
// a failed fetch (or flag-OFF empty payload) leaves defs null → no section.
useEffect(() => {
let cancelled = false;
void fetchBoardWorkflows(projectId)
.then((payload) => {
if (cancelled) return;
const workflowId = payload.taskWorkflowIds[task.id] ?? payload.defaultWorkflowId;
const workflow = payload.workflows.find((w) => w.id === workflowId);
setCustomFieldDefs(workflow?.fields ?? null);
})
.catch(() => {
if (!cancelled) setCustomFieldDefs(null);
});
return () => {
cancelled = true;
};
}, [task.id, projectId]);
const handleSaveCustomFields = useCallback(
async (patch: Record<string, unknown>) => {
setCustomFieldError(null);
try {
const updated = await updateTaskCustomFields(task.id, patch, projectId);
setCustomFieldValues(updated.customFields ?? {});
onTaskUpdated?.(updated);
} catch (err) {
if (err instanceof ApiRequestError && err.details && typeof err.details.fieldId === "string") {
setCustomFieldError({
code: (err.details.code as CustomFieldRejection["code"]) ?? "type-mismatch",
fieldId: err.details.fieldId,
detail: typeof err.details.detail === "string" ? err.details.detail : err.message,
});
return;
}
addToast(getErrorMessage(err) || t("taskFields.saveFailed", "Failed to save field"), "error");
}
},
[task.id, projectId, onTaskUpdated, addToast, t],
);
useEffect(() => {
if (activeTab !== "logs" || logSubview !== "activity") {
setHighlightStallCode(null);
@@ -2485,6 +2540,15 @@ export function TaskDetailContent({
</>
);
})()}
{customFieldDefs && customFieldDefs.length > 0 ? (
<TaskFieldsSection
fieldDefs={customFieldDefs}
customFields={customFieldValues}
onSave={handleSaveCustomFields}
error={customFieldError}
readOnly={Boolean(task.column === "archived")}
/>
) : null}
{showNearDuplicateWarning && (
<div className="detail-near-duplicate-banner" role="status" aria-live="polite">
<div className="detail-near-duplicate-banner__header">

View File

@@ -0,0 +1,214 @@
/* Schema-driven custom-field form section (U13 / KTD-14). */
.task-fields-section {
display: flex;
flex-direction: column;
gap: 12px;
margin: 12px 0;
}
.task-field-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-field-label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary, #8a8f98);
text-transform: uppercase;
letter-spacing: 0.02em;
}
.task-field-required {
color: var(--accent-danger, #e5484d);
}
.task-field-control {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.task-field-input,
.task-field-textarea,
.task-field-select {
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
border: 1px solid var(--border-color, #2a2d34);
border-radius: 6px;
background: var(--input-bg, #16181d);
color: var(--text-primary, #e6e6e6);
font-size: 13px;
font-family: inherit;
}
.task-field-textarea {
resize: vertical;
min-height: 56px;
}
.task-field-input:disabled,
.task-field-textarea:disabled,
.task-field-select:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Chips (enum single + multi-enum) */
.task-field-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.task-field-chip {
padding: 3px 10px;
border: 1px solid var(--border-color, #2a2d34);
border-radius: 999px;
background: var(--chip-bg, #1c1f26);
color: var(--text-secondary, #b4b8c0);
font-size: 12px;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.task-field-chip:hover:not(:disabled) {
border-color: var(--accent, #4f7cff);
}
.task-field-chip.is-active {
background: var(--accent, #4f7cff);
border-color: var(--accent, #4f7cff);
color: #fff;
}
.task-field-chip:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Radio group */
.task-field-radio-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-field-radio {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-primary, #e6e6e6);
cursor: pointer;
}
/* Boolean toggle */
.task-field-toggle {
display: inline-flex;
align-items: center;
cursor: pointer;
}
.task-field-toggle input {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.task-field-toggle-track {
display: inline-block;
width: 34px;
height: 18px;
border-radius: 999px;
background: var(--border-color, #2a2d34);
position: relative;
transition: background 0.15s ease;
}
.task-field-toggle-track::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
transition: transform 0.15s ease;
}
.task-field-toggle input:checked + .task-field-toggle-track {
background: var(--accent, #4f7cff);
}
.task-field-toggle input:checked + .task-field-toggle-track::after {
transform: translateX(16px);
}
.task-field-toggle input:disabled + .task-field-toggle-track {
opacity: 0.6;
}
/* Inline validation error */
.task-field-error {
font-size: 12px;
color: var(--accent-danger, #e5484d);
}
.task-field-row.has-error .task-field-input,
.task-field-row.has-error .task-field-textarea,
.task-field-row.has-error .task-field-select {
border-color: var(--accent-danger, #e5484d);
}
/* Collapsible detail-section group */
.task-fields-group,
.task-fields-orphaned {
border-top: 1px solid var(--border-color, #2a2d34);
padding-top: 8px;
}
.task-fields-group-header,
.task-fields-orphaned-header {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 4px 0;
background: none;
border: none;
color: var(--text-secondary, #8a8f98);
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
cursor: pointer;
}
.task-fields-group-body,
.task-fields-orphaned-body {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.task-fields-orphaned-count {
margin-left: auto;
background: var(--chip-bg, #1c1f26);
border-radius: 999px;
padding: 0 8px;
font-size: 11px;
}
.task-field-orphaned-value {
font-size: 13px;
color: var(--text-secondary, #b4b8c0);
word-break: break-word;
}

View File

@@ -0,0 +1,412 @@
/**
* Schema-driven custom-field form section (U13 / KTD-14).
*
* Renders a task's workflow-defined custom fields ({@link WorkflowFieldDefinition})
* as editable widgets, grouped by `render.placement`:
* - `detail` (and the default when unset) → inline, near the description.
* - `detail-section` → inside a collapsible group.
* Card-placed fields (`placement: "card"`) are intentionally NOT rendered here —
* those surface as badges on {@link TaskCard}.
*
* Widget selection (per `type` + optional `render.widget`):
* - enum → select (default) | radio | chips (single-select)
* - multi-enum → chips (multi-select)
* - boolean → toggle
* - date → date input
* - url/number → validated <input>
* - string → text input
* - text → textarea
*
* Editing is per-field, save-on-commit (blur for inputs, change for
* toggles/selects/chips/radio). Each save calls `onSave({ [fieldId]: value })`;
* on a 400 the caller surfaces the typed rejection through `error`, which this
* component renders inline beneath the offending field.
*
* Orphaned values — keys in `customFields` with no matching definition — render
* read-only under a collapsed "Orphaned fields" disclosure (never destroyed,
* KTD-13).
*
* Zero field definitions AND zero orphaned values → the component renders
* nothing (null), so a task on a field-less workflow is byte-identical to
* today's UI (snapshot-guarded by the test suite).
*/
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, ChevronDown } from "lucide-react";
import type {
WorkflowFieldDefinition,
WorkflowFieldOption,
CustomFieldRejection,
} from "../api";
import "./TaskFieldsSection.css";
export interface TaskFieldsSectionProps {
/** The task's workflow field definitions (from board-workflows payload). */
fieldDefs: WorkflowFieldDefinition[];
/** Current custom field values, keyed by field id. */
customFields: Record<string, unknown>;
/**
* Persist a single-field patch. Resolves on success; the caller is expected
* to throw / reject with the server's typed rejection so it can flow into
* `error`. May be omitted to render read-only (e.g. archived tasks).
*/
onSave?: (patch: Record<string, unknown>) => Promise<void>;
/**
* The most recent typed rejection from a failed save (400), surfaced inline
* beneath the matching field. Cleared by the caller on a successful save.
*/
error?: CustomFieldRejection | null;
/** When true, fields render read-only (no edit affordances). */
readOnly?: boolean;
}
function optionLabel(field: WorkflowFieldDefinition, value: string): string {
return field.options?.find((o) => o.value === value)?.label ?? value;
}
function optionColor(field: WorkflowFieldDefinition, value: string): string | undefined {
return field.options?.find((o) => o.value === value)?.color;
}
/** Resolve the effective widget for a field, applying the per-type default. */
function resolveWidget(field: WorkflowFieldDefinition): NonNullable<WorkflowFieldDefinition["render"]>["widget"] {
const explicit = field.render?.widget;
if (explicit) return explicit;
switch (field.type) {
case "enum":
return "select";
case "multi-enum":
return "chips";
case "boolean":
return "toggle";
case "text":
return "textarea";
default:
return "input";
}
}
interface FieldRowProps {
field: WorkflowFieldDefinition;
value: unknown;
onSave?: (patch: Record<string, unknown>) => Promise<void>;
error?: CustomFieldRejection | null;
readOnly: boolean;
}
function FieldRow({ field, value, onSave, error, readOnly }: FieldRowProps) {
const { t } = useTranslation("app");
const widget = resolveWidget(field);
const fieldError = error && error.fieldId === field.id ? error : null;
const disabled = readOnly || !onSave;
const commit = useCallback(
(next: unknown) => {
if (!onSave) return;
void onSave({ [field.id]: next });
},
[onSave, field.id],
);
const labelId = `task-field-label-${field.id}`;
const controlId = `task-field-${field.id}`;
const renderControl = () => {
// enum → select / radio / chips (single)
if (field.type === "enum") {
const current = typeof value === "string" ? value : "";
if (widget === "radio") {
return (
<div className="task-field-radio-group" role="radiogroup" aria-labelledby={labelId}>
{(field.options ?? []).map((opt: WorkflowFieldOption) => (
<label key={opt.value} className="task-field-radio">
<input
type="radio"
name={controlId}
value={opt.value}
checked={current === opt.value}
disabled={disabled}
onChange={() => commit(opt.value)}
/>
<span>{opt.label}</span>
</label>
))}
</div>
);
}
if (widget === "chips") {
return (
<div className="task-field-chips" role="group" aria-labelledby={labelId}>
{(field.options ?? []).map((opt) => {
const active = current === opt.value;
return (
<button
key={opt.value}
type="button"
className={`task-field-chip${active ? " is-active" : ""}`}
disabled={disabled}
aria-pressed={active}
style={active && opt.color ? { backgroundColor: opt.color, borderColor: opt.color } : undefined}
onClick={() => commit(active ? null : opt.value)}
>
{opt.label}
</button>
);
})}
</div>
);
}
// default: select
return (
<select
id={controlId}
className="task-field-select"
value={current}
disabled={disabled}
aria-labelledby={labelId}
onChange={(e) => commit(e.target.value === "" ? null : e.target.value)}
>
<option value="">{t("taskFields.unset", "—")}</option>
{(field.options ?? []).map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
// multi-enum → chips (multi-select)
if (field.type === "multi-enum") {
const current = Array.isArray(value) ? (value as string[]) : [];
return (
<div className="task-field-chips" role="group" aria-labelledby={labelId}>
{(field.options ?? []).map((opt) => {
const active = current.includes(opt.value);
return (
<button
key={opt.value}
type="button"
className={`task-field-chip${active ? " is-active" : ""}`}
disabled={disabled}
aria-pressed={active}
style={active && opt.color ? { backgroundColor: opt.color, borderColor: opt.color } : undefined}
onClick={() => {
const next = active
? current.filter((v) => v !== opt.value)
: [...current, opt.value];
commit(next);
}}
>
{opt.label}
</button>
);
})}
</div>
);
}
// boolean → toggle
if (field.type === "boolean") {
const checked = value === true;
return (
<label className="task-field-toggle">
<input
type="checkbox"
id={controlId}
checked={checked}
disabled={disabled}
aria-labelledby={labelId}
onChange={(e) => commit(e.target.checked)}
/>
<span className="task-field-toggle-track" aria-hidden="true" />
</label>
);
}
// date → date input
if (field.type === "date") {
const current = typeof value === "string" ? value.slice(0, 10) : "";
return (
<input
id={controlId}
type="date"
className="task-field-input"
defaultValue={current}
disabled={disabled}
aria-labelledby={labelId}
onBlur={(e) => {
const next = e.target.value;
if (next === current) return;
commit(next === "" ? null : next);
}}
/>
);
}
// text → textarea
if (field.type === "text") {
const current = typeof value === "string" ? value : "";
return (
<textarea
id={controlId}
className="task-field-textarea"
defaultValue={current}
disabled={disabled}
aria-labelledby={labelId}
rows={3}
onBlur={(e) => {
if (e.target.value === current) return;
commit(e.target.value === "" ? null : e.target.value);
}}
/>
);
}
// number / url / string → validated input
const current =
field.type === "number"
? (typeof value === "number" ? String(value) : "")
: (typeof value === "string" ? value : "");
return (
<input
id={controlId}
type={field.type === "number" ? "number" : field.type === "url" ? "url" : "text"}
className="task-field-input"
defaultValue={current}
disabled={disabled}
aria-labelledby={labelId}
onBlur={(e) => {
const raw = e.target.value;
if (raw === current) return;
if (raw === "") {
commit(null);
return;
}
if (field.type === "number") {
const num = Number(raw);
commit(Number.isFinite(num) ? num : raw);
return;
}
commit(raw);
}}
/>
);
};
return (
<div
className={`task-field-row${fieldError ? " has-error" : ""}`}
data-testid={`task-field-row-${field.id}`}
data-field-type={field.type}
>
<div className="task-field-label" id={labelId}>
{field.name}
{field.required ? <span className="task-field-required" aria-hidden="true"> *</span> : null}
</div>
<div className="task-field-control">{renderControl()}</div>
{fieldError ? (
<div className="task-field-error" role="alert" data-testid={`task-field-error-${field.id}`}>
{fieldError.detail}
</div>
) : null}
</div>
);
}
export function TaskFieldsSection({
fieldDefs,
customFields,
onSave,
error,
readOnly = false,
}: TaskFieldsSectionProps) {
const { t } = useTranslation("app");
const [sectionOpen, setSectionOpen] = useState(true);
const [orphanedOpen, setOrphanedOpen] = useState(false);
const inlineFields = useMemo(
() => fieldDefs.filter((f) => (f.render?.placement ?? "detail") === "detail"),
[fieldDefs],
);
const sectionFields = useMemo(
() => fieldDefs.filter((f) => f.render?.placement === "detail-section"),
[fieldDefs],
);
// Orphaned: stored keys with no matching definition (KTD-13). Card-placed
// defs are excluded from the detail form, but their VALUES are not orphaned —
// only keys with no def at all qualify.
const orphaned = useMemo(() => {
const defIds = new Set(fieldDefs.map((f) => f.id));
return Object.entries(customFields ?? {}).filter(([id]) => !defIds.has(id));
}, [fieldDefs, customFields]);
// Byte-identical-to-today guard: nothing to render at all.
if (inlineFields.length === 0 && sectionFields.length === 0 && orphaned.length === 0) {
return null;
}
const renderRow = (field: WorkflowFieldDefinition) => (
<FieldRow
key={field.id}
field={field}
value={(customFields ?? {})[field.id]}
onSave={onSave}
error={error}
readOnly={readOnly}
/>
);
return (
<section className="task-fields-section" data-testid="task-fields-section">
{inlineFields.map(renderRow)}
{sectionFields.length > 0 ? (
<div className="task-fields-group">
<button
type="button"
className="task-fields-group-header"
aria-expanded={sectionOpen}
data-testid="task-fields-group-toggle"
onClick={() => setSectionOpen((o) => !o)}
>
{sectionOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>{t("taskFields.moreFields", "Additional fields")}</span>
</button>
{sectionOpen ? <div className="task-fields-group-body">{sectionFields.map(renderRow)}</div> : null}
</div>
) : null}
{orphaned.length > 0 ? (
<div className="task-fields-orphaned">
<button
type="button"
className="task-fields-orphaned-header"
aria-expanded={orphanedOpen}
data-testid="task-fields-orphaned-toggle"
onClick={() => setOrphanedOpen((o) => !o)}
>
{orphanedOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
<span>{t("taskFields.orphaned", "Orphaned fields")}</span>
<span className="task-fields-orphaned-count">{orphaned.length}</span>
</button>
{orphanedOpen ? (
<div className="task-fields-orphaned-body" data-testid="task-fields-orphaned-body">
{orphaned.map(([id, value]) => (
<div key={id} className="task-field-row task-field-orphaned-row" data-testid={`task-field-orphaned-${id}`}>
<div className="task-field-label">{id}</div>
<div className="task-field-control task-field-orphaned-value">
{Array.isArray(value) ? value.join(", ") : String(value)}
</div>
</div>
))}
</div>
) : null}
</div>
) : null}
</section>
);
}
export default TaskFieldsSection;

View File

@@ -28,6 +28,8 @@ interface WorktreeGroupProps {
lastFetchTimeMs?: number;
/** Lookup of workflow step IDs to display names, fetched once at board level. */
workflowStepNameLookup?: ReadonlyMap<string, string>;
/** Per-task card-placed custom field definitions (U13/KTD-14). */
taskCardFieldDefs?: ReadonlyMap<string, import("../api").WorkflowFieldDefinition[]>;
/** Precomputed blocker fanout keyed by blocker task ID. */
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
@@ -51,6 +53,7 @@ function WorktreeGroupComponent({
onOpenMission,
lastFetchTimeMs,
workflowStepNameLookup,
taskCardFieldDefs,
blockerFanoutMap,
prAuthAvailable,
autoMergeEnabled,
@@ -68,7 +71,7 @@ function WorktreeGroupComponent({
<span className="worktree-label">{label}</span>
</div>
{activeTasks.map((task) => (
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} fanout={blockerFanoutMap?.get(task.id)} prAuthAvailable={prAuthAvailable} autoMergeEnabled={autoMergeEnabled} />
<TaskCard key={task.id} task={task} projectId={projectId} onOpenDetail={onOpenDetail} addToast={addToast} globalPaused={globalPaused} onUpdateTask={onUpdateTask} onRetryTask={onRetryTask} onOpenDetailWithTab={onOpenDetailWithTab} taskStuckTimeoutMs={taskStuckTimeoutMs} onOpenMission={onOpenMission} lastFetchTimeMs={lastFetchTimeMs} workflowStepNameLookup={workflowStepNameLookup} cardFieldDefs={taskCardFieldDefs?.get(task.id)} fanout={blockerFanoutMap?.get(task.id)} prAuthAvailable={prAuthAvailable} autoMergeEnabled={autoMergeEnabled} />
))}
{queuedTasks.map((task) => (
<TaskCard
@@ -86,6 +89,7 @@ function WorktreeGroupComponent({
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
cardFieldDefs={taskCardFieldDefs?.get(task.id)}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={autoMergeEnabled}

View File

@@ -4532,3 +4532,84 @@ describe("TaskCard agent badge", () => {
expect(screen.queryByTitle(/Assigned to/)).toBeNull();
});
});
describe("TaskCard custom field badges (U13/KTD-14)", () => {
type FieldDef = import("../../api").WorkflowFieldDefinition;
const cardDef = (over: Partial<FieldDef> & Pick<FieldDef, "id" | "name" | "type">): FieldDef => ({
render: { placement: "card" },
...over,
});
it("renders no badges and stays byte-identical when no field defs are passed", () => {
const { container: withTask } = render(
<TaskCard task={makeTask({ customFields: { x: "y" } })} onOpenDetail={noop} addToast={noop} />,
);
expect(withTask.querySelector('[data-testid="card-field-badges"]')).toBeNull();
});
it("renders an enum badge with the option color and label", () => {
const defs: FieldDef[] = [
cardDef({ id: "sev", name: "Severity", type: "enum", options: [{ value: "high", label: "High", color: "#ef4444" }] }),
];
render(
<TaskCard
task={makeTask({ customFields: { sev: "high" } })}
onOpenDetail={noop}
addToast={noop}
cardFieldDefs={defs}
/>,
);
const badge = screen.getByText("High");
expect(badge.getAttribute("style")).toContain("rgb(239, 68, 68)");
});
it("renders a labeled chip for boolean true and nothing for false", () => {
const defs: FieldDef[] = [cardDef({ id: "blk", name: "Blocked", type: "boolean" })];
const { rerender } = render(
<TaskCard task={makeTask({ customFields: { blk: true } })} onOpenDetail={noop} addToast={noop} cardFieldDefs={defs} />,
);
expect(screen.getByText("Blocked")).toBeTruthy();
rerender(
<TaskCard task={makeTask({ customFields: { blk: false } })} onOpenDetail={noop} addToast={noop} cardFieldDefs={defs} />,
);
expect(screen.queryByTestId("card-field-badges")).toBeNull();
});
it("caps at 3 badges and shows a +N overflow indicator", () => {
const defs: FieldDef[] = [
cardDef({ id: "a", name: "A", type: "string" }),
cardDef({ id: "b", name: "B", type: "string" }),
cardDef({ id: "c", name: "C", type: "string" }),
cardDef({ id: "d", name: "D", type: "string" }),
cardDef({ id: "e", name: "E", type: "string" }),
];
render(
<TaskCard
task={makeTask({ customFields: { a: "1", b: "2", c: "3", d: "4", e: "5" } })}
onOpenDetail={noop}
addToast={noop}
cardFieldDefs={defs}
/>,
);
const overflow = screen.getByTestId("card-field-overflow");
expect(overflow.textContent).toBe("+2");
// Exactly 3 value badges + 1 overflow chip.
const container = screen.getByTestId("card-field-badges");
expect(container.querySelectorAll(".card-field-badge").length).toBe(4);
});
it("ignores non-card-placed defs", () => {
const defs: FieldDef[] = [
{ id: "detailOnly", name: "Detail", type: "string", render: { placement: "detail" } },
];
render(
<TaskCard
task={makeTask({ customFields: { detailOnly: "x" } })}
onOpenDetail={noop}
addToast={noop}
cardFieldDefs={defs}
/>,
);
expect(screen.queryByTestId("card-field-badges")).toBeNull();
});
});

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import {
makeTask,
noop,
noopDelete,
noopMerge,
noopMove,
noopOpenDetail,
setupTaskDetailModalHooks,
} from "./TaskDetailModal.test-helpers";
import { TaskDetailModal } from "../TaskDetailModal";
import * as dashboardApi from "../../api";
import { FileBrowserProvider } from "../../context/FileBrowserContext";
setupTaskDetailModalHooks();
function renderModal(task = makeTask({ column: "done" })) {
return render(
<FileBrowserProvider openFile={vi.fn()}>
<TaskDetailModal
task={task}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>
</FileBrowserProvider>,
);
}
describe("TaskDetailModal custom fields (U13/KTD-14)", () => {
beforeEach(() => vi.clearAllMocks());
it("renders no fields section when the workflow declares no fields (today's UI)", async () => {
vi.spyOn(dashboardApi, "fetchBoardWorkflows").mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [{ id: "builtin:coding", name: "Coding", columns: [] }],
taskWorkflowIds: {},
});
renderModal();
// Allow the field-defs fetch to settle.
await waitFor(() => expect(dashboardApi.fetchBoardWorkflows).toHaveBeenCalled());
expect(screen.queryByTestId("task-fields-section")).toBeNull();
});
it("renders the schema-driven fields section when the workflow declares fields", async () => {
vi.spyOn(dashboardApi, "fetchBoardWorkflows").mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows: [
{
id: "builtin:coding",
name: "Coding",
columns: [],
fields: [
{ id: "owner", name: "Owner", type: "string", render: { placement: "detail" } },
],
},
],
taskWorkflowIds: { "FN-001": "builtin:coding" },
});
renderModal(makeTask({ id: "FN-001", column: "done", customFields: { owner: "alice" } }));
await waitFor(() => expect(screen.getByTestId("task-fields-section")).toBeTruthy());
expect((screen.getByLabelText("Owner") as HTMLInputElement).value).toBe("alice");
});
});

View File

@@ -0,0 +1,180 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { TaskFieldsSection } from "../TaskFieldsSection";
import type { WorkflowFieldDefinition, CustomFieldRejection } from "../../api";
const enumField: WorkflowFieldDefinition = {
id: "severity",
name: "Severity",
type: "enum",
options: [
{ value: "low", label: "Low", color: "#22c55e" },
{ value: "high", label: "High", color: "#ef4444" },
],
render: { placement: "detail", widget: "select" },
};
describe("TaskFieldsSection", () => {
beforeEach(() => vi.clearAllMocks());
it("renders nothing when there are no fields and no orphaned values (today's UI)", () => {
const { container } = render(
<TaskFieldsSection fieldDefs={[]} customFields={{}} onSave={vi.fn()} />,
);
expect(container.firstChild).toBeNull();
});
it("renders nothing when only card-placed fields exist (those go on the card)", () => {
const { container } = render(
<TaskFieldsSection
fieldDefs={[{ id: "x", name: "X", type: "string", render: { placement: "card" } }]}
customFields={{}}
onSave={vi.fn()}
/>,
);
expect(container.firstChild).toBeNull();
});
it("enum select renders options and edits via onSave", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TaskFieldsSection fieldDefs={[enumField]} customFields={{ severity: "low" }} onSave={onSave} />);
const select = screen.getByLabelText("Severity") as HTMLSelectElement;
expect(select.value).toBe("low");
fireEvent.change(select, { target: { value: "high" } });
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ severity: "high" }));
});
it("enum radio widget commits the chosen option", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const field: WorkflowFieldDefinition = { ...enumField, render: { placement: "detail", widget: "radio" } };
render(<TaskFieldsSection fieldDefs={[field]} customFields={{}} onSave={onSave} />);
fireEvent.click(screen.getByLabelText("High"));
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ severity: "high" }));
});
it("enum chips widget toggles selection and applies option color", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const field: WorkflowFieldDefinition = { ...enumField, render: { placement: "detail", widget: "chips" } };
render(<TaskFieldsSection fieldDefs={[field]} customFields={{ severity: "high" }} onSave={onSave} />);
const highChip = screen.getByRole("button", { name: "High" });
// Enum color applied to the active chip.
expect(highChip.getAttribute("style")).toContain("rgb(239, 68, 68)");
// Clicking the active chip clears it (commits null).
fireEvent.click(highChip);
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ severity: null }));
});
it("multi-enum chips add/remove members", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const field: WorkflowFieldDefinition = {
id: "tags",
name: "Tags",
type: "multi-enum",
options: [
{ value: "a", label: "Alpha" },
{ value: "b", label: "Beta" },
],
render: { placement: "detail" },
};
render(<TaskFieldsSection fieldDefs={[field]} customFields={{ tags: ["a"] }} onSave={onSave} />);
fireEvent.click(screen.getByRole("button", { name: "Beta" }));
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ tags: ["a", "b"] }));
});
it("boolean toggle commits true/false", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const field: WorkflowFieldDefinition = { id: "done", name: "Done", type: "boolean", render: { placement: "detail" } };
render(<TaskFieldsSection fieldDefs={[field]} customFields={{ done: false }} onSave={onSave} />);
fireEvent.click(screen.getByLabelText("Done"));
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ done: true }));
});
it("string input commits on blur", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const field: WorkflowFieldDefinition = { id: "owner", name: "Owner", type: "string", render: { placement: "detail" } };
render(<TaskFieldsSection fieldDefs={[field]} customFields={{}} onSave={onSave} />);
const input = screen.getByLabelText("Owner") as HTMLInputElement;
fireEvent.change(input, { target: { value: "alice" } });
fireEvent.blur(input);
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ owner: "alice" }));
});
it("text widget renders a textarea and commits on blur", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const field: WorkflowFieldDefinition = { id: "notes", name: "Notes", type: "text", render: { placement: "detail" } };
render(<TaskFieldsSection fieldDefs={[field]} customFields={{}} onSave={onSave} />);
const ta = screen.getByLabelText("Notes") as HTMLTextAreaElement;
expect(ta.tagName).toBe("TEXTAREA");
fireEvent.change(ta, { target: { value: "hi" } });
fireEvent.blur(ta);
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ notes: "hi" }));
});
it("number input commits a numeric value", async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const field: WorkflowFieldDefinition = { id: "count", name: "Count", type: "number", render: { placement: "detail" } };
render(<TaskFieldsSection fieldDefs={[field]} customFields={{}} onSave={onSave} />);
const input = screen.getByLabelText("Count") as HTMLInputElement;
fireEvent.change(input, { target: { value: "42" } });
fireEvent.blur(input);
await waitFor(() => expect(onSave).toHaveBeenCalledWith({ count: 42 }));
});
it("url and date inputs render with the correct input type", () => {
const fields: WorkflowFieldDefinition[] = [
{ id: "link", name: "Link", type: "url", render: { placement: "detail" } },
{ id: "due", name: "Due", type: "date", render: { placement: "detail" } },
];
render(<TaskFieldsSection fieldDefs={fields} customFields={{ due: "2026-06-04T00:00:00.000Z" }} onSave={vi.fn()} />);
expect((screen.getByLabelText("Link") as HTMLInputElement).type).toBe("url");
const due = screen.getByLabelText("Due") as HTMLInputElement;
expect(due.type).toBe("date");
expect(due.value).toBe("2026-06-04");
});
it("surfaces the typed rejection inline beneath the offending field", () => {
const error: CustomFieldRejection = { code: "enum-violation", fieldId: "severity", detail: "value not allowed" };
render(<TaskFieldsSection fieldDefs={[enumField]} customFields={{}} onSave={vi.fn()} error={error} />);
expect(screen.getByTestId("task-field-error-severity").textContent).toBe("value not allowed");
expect(screen.getByTestId("task-field-row-severity").className).toContain("has-error");
});
it("groups detail-section fields under a collapsible disclosure", () => {
const fields: WorkflowFieldDefinition[] = [
{ id: "a", name: "Inline", type: "string", render: { placement: "detail" } },
{ id: "b", name: "Sectioned", type: "string", render: { placement: "detail-section" } },
];
render(<TaskFieldsSection fieldDefs={fields} customFields={{}} onSave={vi.fn()} />);
// Both visible while the section is open by default.
expect(screen.getByLabelText("Inline")).toBeTruthy();
expect(screen.getByLabelText("Sectioned")).toBeTruthy();
// Collapsing hides the sectioned field but keeps the inline one.
fireEvent.click(screen.getByTestId("task-fields-group-toggle"));
expect(screen.queryByLabelText("Sectioned")).toBeNull();
expect(screen.getByLabelText("Inline")).toBeTruthy();
});
it("renders orphaned values read-only under a collapsed disclosure", () => {
render(
<TaskFieldsSection
fieldDefs={[enumField]}
customFields={{ severity: "low", legacyField: "stale" }}
onSave={vi.fn()}
/>,
);
// Disclosure present but collapsed by default → body hidden.
expect(screen.getByTestId("task-fields-orphaned-toggle")).toBeTruthy();
expect(screen.queryByTestId("task-fields-orphaned-body")).toBeNull();
fireEvent.click(screen.getByTestId("task-fields-orphaned-toggle"));
const body = screen.getByTestId("task-fields-orphaned-body");
expect(body.textContent).toContain("legacyField");
expect(body.textContent).toContain("stale");
});
it("does not call onSave when readOnly", () => {
const onSave = vi.fn();
render(<TaskFieldsSection fieldDefs={[enumField]} customFields={{ severity: "low" }} onSave={onSave} readOnly />);
const select = screen.getByLabelText("Severity") as HTMLSelectElement;
expect(select.disabled).toBe(true);
});
});

View File

@@ -0,0 +1,160 @@
// @vitest-environment node
//
// U13 / KTD-14: HTTP coverage for custom task fields.
// - PATCH /tasks/:id/custom-fields validates a value patch through the store
// write authority (updateTaskCustomFields): a valid patch returns 200 with
// the updated task; an enum violation returns 400 with { fieldId, code,
// detail }; an unknown field returns 400; a malformed body returns 400.
// - GET /tasks/board-workflows carries the workflow's `fields` declaration in
// each described workflow definition (flag ON).
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { TaskStore } from "@fusion/core";
import type { WorkflowIr } from "@fusion/core";
import { createApiRoutes } from "../../routes.js";
import { buildBoardWorkflowsPayload } from "../board-workflows.js";
import { request as REQUEST } from "../../test-request.js";
/** A linear v2 workflow declaring two custom fields (KTD-13). */
function fieldedWorkflow(name: string): WorkflowIr {
return {
version: "v2",
name,
columns: [
{ id: "c-intake", name: "Intake", traits: [{ trait: "intake" }] },
{ id: "c-run", name: "Run", traits: [{ trait: "wip", config: { limit: 5 } }] },
{ id: "c-done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "c-intake" },
{ id: "end", kind: "end", column: "c-done" },
],
edges: [{ from: "start", to: "end" }],
fields: [
{
id: "severity",
name: "Severity",
type: "enum",
options: [
{ value: "low", label: "Low", color: "#22c55e" },
{ value: "high", label: "High", color: "#ef4444" },
],
render: { placement: "card" },
},
{ id: "owner", name: "Owner", type: "string", render: { placement: "detail" } },
],
} as WorkflowIr;
}
describe("custom task fields routes (U13/KTD-14)", () => {
let store: TaskStore;
let rootDir: string;
let globalDir: string;
let app: express.Express;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "cf-route-root-"));
globalDir = mkdtempSync(join(tmpdir(), "cf-route-global-"));
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
});
afterEach(() => {
store.close();
rmSync(rootDir, { recursive: true, force: true });
rmSync(globalDir, { recursive: true, force: true });
});
const patch = (path: string, body: unknown) =>
REQUEST(app, "PATCH", path, JSON.stringify(body), { "content-type": "application/json" });
const get = (path: string) => REQUEST(app, "GET", path);
async function taskWithFields() {
const wf = await store.createWorkflowDefinition({ name: "Fielded", ir: fieldedWorkflow("fielded") });
const task = await store.createTask({ description: "card" });
await store.selectTaskWorkflowAndReconcile(task.id, wf.id);
return { wf, task };
}
it("PATCH custom-fields accepts a valid patch and returns the updated task", async () => {
const { task } = await taskWithFields();
const res = await patch(`/api/tasks/${task.id}/custom-fields`, {
customFields: { severity: "high", owner: "alice" },
});
expect(res.status).toBe(200);
const body = res.body as { id: string; customFields: Record<string, unknown> };
expect(body.id).toBe(task.id);
expect(body.customFields.severity).toBe("high");
expect(body.customFields.owner).toBe("alice");
});
it("PATCH custom-fields rejects an enum violation with 400 { fieldId, code, detail }", async () => {
const { task } = await taskWithFields();
const res = await patch(`/api/tasks/${task.id}/custom-fields`, {
customFields: { severity: "nope" },
});
expect(res.status).toBe(400);
const details = (res.body as { details?: { fieldId?: string; code?: string; detail?: string } }).details;
expect(details?.fieldId).toBe("severity");
expect(details?.code).toBe("enum-violation");
expect(typeof details?.detail).toBe("string");
});
it("PATCH custom-fields rejects an unknown field with 400 unknown-field", async () => {
const { task } = await taskWithFields();
const res = await patch(`/api/tasks/${task.id}/custom-fields`, {
customFields: { nonexistent: "x" },
});
expect(res.status).toBe(400);
const details = (res.body as { details?: { fieldId?: string; code?: string } }).details;
expect(details?.fieldId).toBe("nonexistent");
expect(details?.code).toBe("unknown-field");
});
it("PATCH custom-fields rejects a malformed body with 400", async () => {
const { task } = await taskWithFields();
const res = await patch(`/api/tasks/${task.id}/custom-fields`, { customFields: "not-an-object" });
expect(res.status).toBe(400);
});
it("PATCH custom-fields deletes a value via null", async () => {
const { task } = await taskWithFields();
await patch(`/api/tasks/${task.id}/custom-fields`, { customFields: { owner: "alice" } });
const res = await patch(`/api/tasks/${task.id}/custom-fields`, { customFields: { owner: null } });
expect(res.status).toBe(200);
const body = res.body as { customFields: Record<string, unknown> };
expect(body.customFields.owner).toBeUndefined();
});
it("board-workflows payload (flag ON) carries the workflow's fields declaration", async () => {
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
const { wf, task } = await taskWithFields();
// Drive the payload builder with the explicit task-id set the route would
// pass — isolates the fields pass-through from the route's slim-list read
// (subject to the known startup-slim-memo staleness, see board-workflows-route.test).
const payload = await buildBoardWorkflowsPayload(store, [task.id]);
expect(payload.flagEnabled).toBe(true);
const fielded = payload.workflows.find((w) => w.id === wf.id) as
| { id: string; fields?: Array<{ id: string; type: string; render?: { placement?: string } }> }
| undefined;
expect(fielded?.fields).toBeDefined();
expect(fielded?.fields?.map((f) => f.id).sort()).toEqual(["owner", "severity"]);
const severity = fielded?.fields?.find((f) => f.id === "severity");
expect(severity?.render?.placement).toBe("card");
});
it("GET /tasks/board-workflows route returns 200 with flagEnabled true", async () => {
await store.updateGlobalSettings({ experimentalFeatures: { workflowColumns: true } });
await taskWithFields();
const res = await get("/api/tasks/board-workflows");
expect(res.status).toBe(200);
expect((res.body as { flagEnabled: boolean }).flagEnabled).toBe(true);
});
});

View File

@@ -30,6 +30,25 @@ import {
type WorkflowIrV2,
} from "@fusion/core";
/** A workflow-defined custom task field as the board client needs it (U13/
* KTD-14). Structurally mirrors core's `WorkflowFieldDefinition`; declared
* locally because the core field-schema types are not exported through the
* `@fusion/core` barrel. The payload is a verbatim pass-through of the IR's
* `fields` array. */
export interface BoardWorkflowField {
id: string;
name: string;
type: "string" | "text" | "number" | "boolean" | "enum" | "multi-enum" | "date" | "url";
required?: boolean;
default?: unknown;
options?: Array<{ value: string; label: string; color?: string }>;
render?: {
placement?: "card" | "detail" | "detail-section";
widget?: "select" | "radio" | "chips" | "input" | "textarea" | "toggle";
badge?: boolean;
};
}
/** Stable id the client uses for the implicit default lane (null selection). */
export const DEFAULT_WORKFLOW_LANE_ID = "builtin:coding";
@@ -45,6 +64,9 @@ export interface BoardWorkflowDefinition {
id: string;
name: string;
columns: BoardWorkflowColumn[];
/** Custom field definitions declared by the workflow (U13/KTD-14). Absent
* when the workflow declares no fields. */
fields?: BoardWorkflowField[];
}
/** The full board-workflows payload. `flagEnabled: false` short-circuits the
@@ -73,6 +95,16 @@ function describeColumns(ir: WorkflowIr): BoardWorkflowColumn[] {
}));
}
/** Pass through the workflow's declared custom fields (U13/KTD-14). Returns
* `undefined` when the workflow declares none, so the payload stays compact and
* byte-identical for field-less workflows. */
function describeFields(ir: WorkflowIr): BoardWorkflowField[] | undefined {
const v2 = toV2(ir);
const fields = v2?.fields;
if (!fields || fields.length === 0) return undefined;
return fields as BoardWorkflowField[];
}
async function describeWorkflow(
store: Pick<TaskStore, "getWorkflowDefinition">,
workflowId: string,
@@ -82,7 +114,8 @@ async function describeWorkflow(
if (isBuiltinWorkflowId(workflowId)) {
const ir = await resolveWorkflowIrById(store, workflowId);
const name = getBuiltinWorkflow(workflowId)?.name ?? ir.name;
return { id: workflowId, name, columns: describeColumns(ir) };
const fields = describeFields(ir);
return { id: workflowId, name, columns: describeColumns(ir), ...(fields ? { fields } : {}) };
}
// Custom workflow: fetch the definition once and derive both IR and name from
// it (previously getWorkflowDefinition was called twice per workflow).
@@ -97,7 +130,8 @@ async function describeWorkflow(
} catch {
// fall through to the default IR/name
}
return { id: workflowId, name, columns: describeColumns(ir) };
const fields = describeFields(ir);
return { id: workflowId, name, columns: describeColumns(ir), ...(fields ? { fields } : {}) };
}
/**

View File

@@ -3217,6 +3217,53 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
// Patch a task's custom field values (U13/KTD-14). Delegates to the single
// store write authority (`updateTaskCustomFields`), which validates the patch
// against the task's workflow field schema. A typed rejection surfaces as a
// 400 carrying `{ fieldId, code, detail }` so the dashboard can render an
// inline per-field error. `null`/`undefined` values delete the field.
router.patch("/tasks/:id/custom-fields", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const body = req.body as { customFields?: unknown };
const patch = body?.customFields;
if (patch === undefined || patch === null || typeof patch !== "object" || Array.isArray(patch)) {
throw badRequest("customFields must be an object");
}
const storeWithFields = scopedStore as TaskStore & {
updateTaskCustomFields?: (
taskId: string,
patch: Record<string, unknown>,
) => Promise<{ ok: true; task: Task } | { ok: false; rejection: { code: string; fieldId: string; detail: string } }>;
};
if (typeof storeWithFields.updateTaskCustomFields !== "function") {
throw notFound("custom fields unavailable");
}
const result = await storeWithFields.updateTaskCustomFields(
req.params.id,
patch as Record<string, unknown>,
);
if (!result.ok) {
throw new ApiError(400, result.rejection.detail, {
fieldId: result.rejection.fieldId,
code: result.rejection.code,
detail: result.rejection.detail,
});
}
res.json(result.task);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err as NodeJS.ErrnoException).code === "ENOENT" || (err instanceof Error ? err.message : String(err)).includes("not found")) {
throw notFound(err instanceof Error ? err.message : String(err));
}
rethrowAsApiError(err);
}
});
// Accept review - clear assignee and awaiting-user-review status, keep in in-review
router.post("/tasks/:id/accept-review", async (req, res) => {
try {

View File

@@ -165,12 +165,14 @@ const qualityAppComponentTests = [
"TaskDetailModal",
"TaskDetailModal.allow-resurrection",
"TaskDetailModal.create-pr-e2e",
"TaskDetailModal.custom-fields",
"TestModeBanner",
"TaskDetailModal.create-pr-integration",
"TaskDetailModal.github-tracking-header",
"TaskDetailModal.github-tracking-stale",
"TaskDetailModal.rebind-banner",
"TaskDocumentsTab",
"TaskFieldsSection",
"TaskForm",
"TaskIdIntegrityBanner",
"TrackingRepoSelect",

View File

@@ -6792,5 +6792,11 @@
"installRequestTitle": "Worktrunk install request",
"sha256": "SHA-256",
"version": "Version"
},
"taskFields": {
"unset": "—",
"moreFields": "Additional fields",
"orphaned": "Orphaned fields",
"saveFailed": "Failed to save field"
}
}

View File

@@ -6792,5 +6792,11 @@
"installRequestTitle": "Solicitud de instalación de Worktrunk",
"sha256": "SHA-256",
"version": "Versión"
},
"taskFields": {
"unset": "—",
"moreFields": "Campos adicionales",
"orphaned": "Campos huérfanos",
"saveFailed": "No se pudo guardar el campo"
}
}

View File

@@ -6792,5 +6792,11 @@
"installRequestTitle": "Demande d'installation de Worktrunk",
"sha256": "SHA-256",
"version": "Version"
},
"taskFields": {
"unset": "—",
"moreFields": "Champs supplémentaires",
"orphaned": "Champs orphelins",
"saveFailed": "Échec de l'enregistrement du champ"
}
}

View File

@@ -6792,5 +6792,11 @@
"installRequestTitle": "Worktrunk 설치 요청",
"sha256": "SHA-256",
"version": "버전"
},
"taskFields": {
"unset": "—",
"moreFields": "추가 필드",
"orphaned": "고아 필드",
"saveFailed": "필드 저장 실패"
}
}

View File

@@ -6792,5 +6792,11 @@
"installRequestTitle": "Worktrunk 安装请求",
"sha256": "SHA-256",
"version": "版本"
},
"taskFields": {
"unset": "—",
"moreFields": "其他字段",
"orphaned": "孤立字段",
"saveFailed": "保存字段失败"
}
}

View File

@@ -6792,5 +6792,11 @@
"installRequestTitle": "Worktrunk 安裝請求",
"sha256": "SHA-256",
"version": "版本"
},
"taskFields": {
"unset": "—",
"moreFields": "其他欄位",
"orphaned": "孤立欄位",
"saveFailed": "儲存欄位失敗"
}
}