diff --git a/packages/cli/src/commands/dashboard-tui/app.tsx b/packages/cli/src/commands/dashboard-tui/app.tsx index 913e77b9a9..a8441ea749 100644 --- a/packages/cli/src/commands/dashboard-tui/app.tsx +++ b/packages/cli/src/commands/dashboard-tui/app.tsx @@ -1613,6 +1613,15 @@ function TaskDetailScreen({ )} + {/* Card-placed custom fields (U13/KTD-14): read-only bracketed labels. */} + {detail.customFields && detail.customFields.length > 0 && ( + + {detail.customFields.map((f) => ( + [{f.label}: {f.value}] + ))} + + )} + {/* Steps section */} diff --git a/packages/cli/src/commands/dashboard-tui/state.ts b/packages/cli/src/commands/dashboard-tui/state.ts index b5bd86f976..8a144a2ac5 100644 --- a/packages/cli/src/commands/dashboard-tui/state.ts +++ b/packages/cli/src/commands/dashboard-tui/state.ts @@ -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 = diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index 8df4be7de2..0618905751 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -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 }).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). diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index 719bfaa214..680c2dfde8 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -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; } +/** 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, + projectId?: string, +): Promise { + return api(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 { diff --git a/packages/dashboard/app/components/Board.tsx b/packages/dashboard/app/components/Board.tsx index 59e68579bd..2e7bb45160 100644 --- a/packages/dashboard/app/components/Board.tsx +++ b/packages/dashboard/app/components/Board.tsx @@ -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(); + if (!boardWorkflows) return map; + const { workflows, taskWorkflowIds, defaultWorkflowId } = boardWorkflows; + const cardDefsByWorkflow = new Map(); + 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} diff --git a/packages/dashboard/app/components/Column.tsx b/packages/dashboard/app/components/Column.tsx index b04835e8e4..309f251064 100644 --- a/packages/dashboard/app/components/Column.tsx +++ b/packages/dashboard/app/components/Column.tsx @@ -140,6 +140,8 @@ interface ColumnProps { lastFetchTimeMs?: number; /** Lookup of workflow step IDs to display names, fetched once at board level. */ workflowStepNameLookup?: ReadonlyMap; + /** Per-task card-placed custom field definitions (U13/KTD-14). */ + taskCardFieldDefs?: ReadonlyMap; /** Precomputed blocker fanout keyed by blocker task ID. */ blockerFanoutMap?: ReadonlyMap; /** 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)} diff --git a/packages/dashboard/app/components/Lane.tsx b/packages/dashboard/app/components/Lane.tsx index da791276a0..c772b95582 100644 --- a/packages/dashboard/app/components/Lane.tsx +++ b/packages/dashboard/app/components/Lane.tsx @@ -68,6 +68,8 @@ export interface LaneProps { onOpenMission?: (missionId: string) => void; lastFetchTimeMs?: number; workflowStepNameLookup?: ReadonlyMap; + /** Per-task card-placed custom field definitions (U13/KTD-14). */ + taskCardFieldDefs?: ReadonlyMap; blockerFanoutMap?: ReadonlyMap; 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} diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index 42edbbfb4d..99da2284b2 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -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; +} diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 896d611b7c..0b32a8fe55 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -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 ( + + {field.name} + + ); + } + if (field.type === "enum") { + if (typeof value !== "string" || value === "") return null; + const color = colorOf(value); + return ( + + {labelOf(value)} + + ); + } + if (field.type === "multi-enum") { + const arr = Array.isArray(value) ? (value as string[]) : []; + if (arr.length === 0) return null; + return ( + + {arr.map((v) => { + const color = colorOf(v); + return ( + + {labelOf(v)} + + ); + })} + + ); + } + // 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 ( + + {display} + + ); +} + 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): 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({
{truncate(task.title, MAX_TITLE_LENGTH) || truncate(task.description, MAX_TITLE_LENGTH) || task.id}
+ {(() => { + // 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 ( +
+ {shown} + {overflow > 0 ? ( + + +{overflow} + + ) : null} +
+ ); + })()} {hasBranchMetadata && (
{branchMetadata.branch && ( diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index c6549955b9..655fbfc77c 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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(null); + const [customFieldValues, setCustomFieldValues] = useState>(task.customFields ?? {}); + const [customFieldError, setCustomFieldError] = useState(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) => { + 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 ? ( + + ) : null} {showNearDuplicateWarning && (
diff --git a/packages/dashboard/app/components/TaskFieldsSection.css b/packages/dashboard/app/components/TaskFieldsSection.css new file mode 100644 index 0000000000..4a4bc08f48 --- /dev/null +++ b/packages/dashboard/app/components/TaskFieldsSection.css @@ -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; +} diff --git a/packages/dashboard/app/components/TaskFieldsSection.tsx b/packages/dashboard/app/components/TaskFieldsSection.tsx new file mode 100644 index 0000000000..4343d3becb --- /dev/null +++ b/packages/dashboard/app/components/TaskFieldsSection.tsx @@ -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 + * - 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; + /** + * 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) => Promise; + /** + * 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["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) => Promise; + 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 ( +
+ {(field.options ?? []).map((opt: WorkflowFieldOption) => ( + + ))} +
+ ); + } + if (widget === "chips") { + return ( +
+ {(field.options ?? []).map((opt) => { + const active = current === opt.value; + return ( + + ); + })} +
+ ); + } + // default: select + return ( + + ); + } + + // multi-enum → chips (multi-select) + if (field.type === "multi-enum") { + const current = Array.isArray(value) ? (value as string[]) : []; + return ( +
+ {(field.options ?? []).map((opt) => { + const active = current.includes(opt.value); + return ( + + ); + })} +
+ ); + } + + // boolean → toggle + if (field.type === "boolean") { + const checked = value === true; + return ( + + ); + } + + // date → date input + if (field.type === "date") { + const current = typeof value === "string" ? value.slice(0, 10) : ""; + return ( + { + 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 ( +