feat(dashboard): flag-gated multi-lane board — lane per workflow, trait-keyed columns, typed drag rejections, hold promote (U9)

This commit is contained in:
gsxdsm
2026-06-04 01:49:51 -07:00
parent e5a0a199ea
commit 3aa8d2d28b
40 changed files with 14846 additions and 5207 deletions

View File

@@ -2,12 +2,16 @@ import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIss
import { COLUMNS, DEFAULT_COLUMN, isColumn } from "@fusion/core";
import { sortTasksForDisplayColumn } from "./taskSorting";
import { Column } from "./Column";
import { Lane } from "./Lane";
import type { ToastType } from "../hooks/useToast";
import { useState, useMemo, useEffect, useCallback, useRef } from "react";
import { fetchWorkflowSteps, type ModelInfo } from "../api";
import { fetchWorkflowSteps, fetchBoardWorkflows, promoteTask, type ModelInfo, type BoardWorkflowsPayload } from "../api";
import { useBlockerFanout } from "../hooks/useBlockerFanout";
import { recordResumeEvent } from "../utils/resumeInstrumentation";
/** localStorage key for persisted lane collapse state (per project). */
const LANE_COLLAPSE_STORAGE_KEY = "kb-dashboard-lane-collapsed";
interface BoardProps {
tasks: Task[];
projectId?: string;
@@ -261,10 +265,185 @@ export function Board({ tasks, projectId, maxConcurrent, onMoveTask, onPauseTask
};
}, []);
// ── U9 multi-lane board (flag-gated) ──────────────────────────────────────
// Fetch board-workflows metadata. When the flag is OFF the server returns
// { flagEnabled: false } and we render the legacy single-lane board below.
const [boardWorkflows, setBoardWorkflows] = useState<BoardWorkflowsPayload | null>(null);
const draggingTaskIdRef = useRef<string | null>(null);
const [collapsedLanes, setCollapsedLanes] = useState<ReadonlySet<string>>(() => {
if (typeof window === "undefined") return new Set();
try {
const raw = window.localStorage.getItem(LANE_COLLAPSE_STORAGE_KEY);
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
if (Array.isArray(parsed)) return new Set(parsed.filter((x): x is string => typeof x === "string"));
} catch {
/* ignore corrupt persisted state */
}
return new Set();
});
useEffect(() => {
let cancelled = false;
fetchBoardWorkflows(projectId)
.then((payload) => {
if (!cancelled) setBoardWorkflows(payload);
})
.catch(() => {
if (!cancelled) setBoardWorkflows({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} });
});
return () => {
cancelled = true;
};
}, [projectId, tasks]);
const handleToggleLaneCollapse = useCallback((workflowId: string) => {
setCollapsedLanes((prev) => {
const next = new Set(prev);
if (next.has(workflowId)) next.delete(workflowId);
else next.add(workflowId);
if (typeof window !== "undefined") {
try {
window.localStorage.setItem(LANE_COLLAPSE_STORAGE_KEY, JSON.stringify([...next]));
} catch {
/* ignore quota / serialization errors */
}
}
return next;
});
}, []);
const handlePromote = useCallback(async (taskId: string) => {
await promoteTask(taskId, projectId);
}, [projectId]);
const getDraggingTaskId = useCallback(() => draggingTaskIdRef.current, []);
const flagOn = boardWorkflows?.flagEnabled === true;
// Group visible tasks into lanes by resolved workflow (null → default lane).
const lanes = useMemo(() => {
if (!boardWorkflows || !flagOn) return [];
const { workflows, taskWorkflowIds, defaultWorkflowId } = boardWorkflows;
const byId = new Map(workflows.map((w) => [w.id, w] as const));
const tasksByWorkflow = new Map<string, Task[]>();
for (const task of tasks) {
// Archived cards are excluded from lanes (archived columns are hidden).
if (task.column === "archived") continue;
const workflowId = taskWorkflowIds[task.id] ?? defaultWorkflowId;
(tasksByWorkflow.get(workflowId) ?? tasksByWorkflow.set(workflowId, []).get(workflowId)!).push(task);
}
const result: Array<{ workflow: typeof workflows[number]; tasks: Task[] }> = [];
for (const [workflowId, laneTasks] of tasksByWorkflow) {
const workflow = byId.get(workflowId);
if (!workflow) continue;
if (laneTasks.length === 0) continue; // zero-card lanes hidden
result.push({ workflow, tasks: laneTasks });
}
// Default lane first; then by workflow name for stable ordering.
result.sort((a, b) => {
if (a.workflow.id === defaultWorkflowId) return -1;
if (b.workflow.id === defaultWorkflowId) return 1;
return a.workflow.name.localeCompare(b.workflow.name);
});
return result;
}, [boardWorkflows, flagOn, 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.
const canDropTask = useCallback((taskId: string, targetColumnId: string, laneWorkflowId: string): string | null => {
if (!boardWorkflows) return null;
const sourceTask = tasks.find((t) => t.id === taskId);
if (!sourceTask) return null;
const sourceWorkflowId = boardWorkflows.taskWorkflowIds[taskId] ?? boardWorkflows.defaultWorkflowId;
// Cross-lane drag never switches workflows (R17).
if (sourceWorkflowId !== laneWorkflowId) {
return "board.rejection.workflowMismatch";
}
const workflow = boardWorkflows.workflows.find((w) => w.id === laneWorkflowId);
if (!workflow) return null;
const targetCol = workflow.columns.find((c) => c.id === targetColumnId);
if (!targetCol) return "board.rejection.unknownColumn";
// Capacity pre-check: a wip-flagged column that is already full rejects.
if (targetCol.flags.countsTowardWip) {
const occupants = tasks.filter(
(t) => t.column === targetColumnId && (boardWorkflows.taskWorkflowIds[t.id] ?? boardWorkflows.defaultWorkflowId) === laneWorkflowId,
).length;
// The default workflow's in-progress limit is maxConcurrent; custom limits
// are enforced authoritatively server-side (the 409 fallback still snaps back).
if (Number.isFinite(maxConcurrent) && maxConcurrent > 0 && sourceTask.column !== targetColumnId && occupants >= maxConcurrent) {
return "board.rejection.capacityExhausted";
}
}
return null;
}, [boardWorkflows, tasks, maxConcurrent]);
// FN-4380: GitHub badge state comes from persisted task fields (`task.prInfo`,
// `task.issueInfo`, `task.githubTracking.issue`) and live WebSocket `badge:updated`
// messages. We do NOT eagerly call `/api/github/batch-status` on board load.
if (flagOn) {
return (
<main
className="board board-lanes"
id="board"
ref={boardRef}
onDragStart={(e) => {
const id = (e.target as HTMLElement)?.closest?.("[data-id]")?.getAttribute("data-id");
if (id) draggingTaskIdRef.current = id;
}}
onDragEnd={() => {
draggingTaskIdRef.current = null;
}}
>
{lanes.map(({ workflow, tasks: laneTasks }) => (
<Lane
key={workflow.id}
workflow={workflow}
tasks={laneTasks}
collapsed={collapsedLanes.has(workflow.id)}
onToggleCollapse={handleToggleLaneCollapse}
projectId={projectId}
maxConcurrent={maxConcurrent}
onMoveTask={onMoveTask}
onPromote={handlePromote}
canDropTask={canDropTask}
getDraggingTaskId={getDraggingTaskId}
onPauseTask={onPauseTask}
onOpenDetail={onOpenDetail}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
onQuickCreate={onQuickCreate}
onNewTask={onNewTask}
autoMerge={autoMerge}
onToggleAutoMerge={onToggleAutoMerge}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
availableModels={availableModels}
onPlanningMode={onPlanningMode}
onSubtaskBreakdown={onSubtaskBreakdown}
onOpenDetailWithTab={onOpenDetailWithTab}
favoriteProviders={favoriteProviders}
favoriteModels={favoriteModels}
onToggleFavorite={onToggleFavorite}
onToggleModelFavorite={onToggleModelFavorite}
isSearchActive={isSearchActive}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
blockerFanoutMap={blockerFanoutMap}
prAuthAvailable={prAuthAvailable}
/>
))}
</main>
);
}
return (
<>
<main className="board" id="board" ref={boardRef}>

View File

@@ -11,13 +11,80 @@ import { PluginSlot } from "./PluginSlot";
import { groupByWorktree } from "../utils/worktreeGrouping";
import type { ToastType } from "../hooks/useToast";
import { ChevronDown, ChevronUp, Archive, MoreVertical } from "lucide-react";
import type { ModelInfo } from "../api";
import type { ModelInfo, BoardWorkflowColumnFlags } from "../api";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
const PAGINATED_COLUMN_THRESHOLD = 100;
const VISIBLE_TASKS_INITIAL = 50;
const VISIBLE_TASKS_INCREMENT = 25;
/** Shape of a structured transition rejection carried in a 409's `details`. */
interface TransitionRejectionDetail {
code: string;
messageKey: string;
retryable: boolean;
}
/**
* Pull a typed transition rejection out of an `ApiRequestError`'s `details`
* (the structured 409 the move/promote endpoints emit under the workflowColumns
* flag). Returns null for any other error shape (legacy errors are unchanged).
*/
export function extractTransitionRejection(err: unknown): TransitionRejectionDetail | null {
const details = (err as { details?: Record<string, unknown> } | null)?.details;
if (!details || typeof details !== "object") return null;
const { code, messageKey, retryable } = details as Record<string, unknown>;
if (typeof code === "string" && typeof messageKey === "string") {
return { code, messageKey, retryable: retryable === true };
}
return null;
}
/**
* Resolve a rejection (by stable code, falling back to its messageKey) to
* user-facing copy. The static `t()` literals here are what the i18next
* extractor sees, so the `board.rejection.*` keys persist in the catalog and
* the surfaces show real copy rather than a raw key. The `messageKey` carried by
* the rejection is still honored as the lookup so a server-chosen non-default
* key resolves correctly.
*/
type TFn = (key: string, defaultValue: string) => string;
export function translateRejection(t: TFn, rejection: TransitionRejectionDetail): string {
switch (rejection.code) {
case "guard-rejected":
return t("board.rejection.guardRejected", "This move is not allowed by the workflow.");
case "capacity-exhausted":
return t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up.");
case "unknown-column":
return t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow.");
case "workflow-mismatch":
return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead.");
case "merge-blocked":
return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes.");
default:
return t(rejection.messageKey, rejection.messageKey);
}
}
/** Translate a bare drag pre-check messageKey (R17 no-move) to copy. The same
* static literals as {@link translateRejection} so the extractor keeps them. */
export function translateRejectionKey(t: TFn, messageKey: string): string {
switch (messageKey) {
case "board.rejection.guardRejected":
return t("board.rejection.guardRejected", "This move is not allowed by the workflow.");
case "board.rejection.capacityExhausted":
return t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up.");
case "board.rejection.unknownColumn":
return t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow.");
case "board.rejection.workflowMismatch":
return t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead.");
case "board.rejection.mergeBlocked":
return t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes.");
default:
return t(messageKey, messageKey);
}
}
interface ColumnProps {
column: ColumnType;
tasks: Task[];
@@ -77,16 +144,53 @@ interface ColumnProps {
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
/** Whether GitHub CLI auth is available for creating PRs from task cards. */
prAuthAvailable?: boolean;
// ── U9 workflow-columns (flag-ON) additive props ─────────────────────────
/** True when the board is in multi-lane workflow mode (flag ON). Switches
* column behavior (label, bulk actions, archived detection) from legacy
* literals to trait-flag predicates. Flag OFF leaves all behavior legacy. */
workflowMode?: boolean;
/** Display name for this column, from the workflow definition. */
columnDisplayName?: string;
/** Resolved trait flags for this column (workflow mode). */
columnFlags?: BoardWorkflowColumnFlags;
/** Manually promote a held card out of this hold column (workflow mode). */
onPromote?: (taskId: string) => Promise<void>;
/**
* Pre-check whether a drop into THIS column is allowed for the dragged task.
* Returns null for "allowed", or an i18n messageKey for a deterministic
* rejection (guard/capacity/unknown-column/workflow-mismatch). When a
* rejection is returned, dragover is NOT prevented, so the card never renders
* in this column (no-move semantics, R17). The dragged task id is read from a
* board-level ref set on dragstart.
*/
canDropTask?: (taskId: string) => string | null;
/** Read the id of the task currently being dragged (board-level ref). */
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 }: 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, 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
// helper's calls are not statically discovered). These resolve the same copy.
const rejectionCopy = useMemo(() => ({
guardRejected: t("board.rejection.guardRejected", "This move is not allowed by the workflow."),
capacityExhausted: t("board.rejection.capacityExhausted", "That column is at capacity. Try again when a slot frees up."),
unknownColumn: t("board.rejection.unknownColumn", "That column doesn't exist in this task's workflow."),
workflowMismatch: t("board.rejection.workflowMismatch", "Drag can't move a card between workflows. Use the workflow switcher instead."),
mergeBlocked: t("board.rejection.mergeBlocked", "This task is blocked from completing until its merge step finishes."),
promoteRejected: t("board.rejection.promoteRejected", "This card could not be promoted."),
}), [t]);
void rejectionCopy;
const [dragOver, setDragOver] = useState(false);
const [visibleTaskCount, setVisibleTaskCount] = useState(VISIBLE_TASKS_INITIAL);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [isReplanning, setIsReplanning] = useState(false);
const [isPausingAll, setIsPausingAll] = useState(false);
const [isMovingAllToTodo, setIsMovingAllToTodo] = useState(false);
// Workflow mode: per-card promote in-flight ids + inline capacity feedback.
const [promotingIds, setPromotingIds] = useState<ReadonlySet<string>>(() => new Set());
const [inlineFeedback, setInlineFeedback] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const countFlashing = useFlashOnIncrease(tasks.length);
const { confirm } = useConfirm();
@@ -110,34 +214,54 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
};
}, [isMenuOpen]);
// Archived column is collapsed by default - don't show drag state when collapsed
const isArchived = column === "archived";
// Archived column is collapsed by default - don't show drag state when collapsed.
// Workflow mode keys off the resolved `archived` trait flag instead of the
// literal column id (R9). A hold-flagged column shows the promote affordance.
const isArchived = workflowMode ? Boolean(columnFlags?.archived) : column === "archived";
const isHoldColumn = workflowMode && Boolean(columnFlags?.hold);
const isCollapsed = isArchived && collapsed;
// Legacy in-progress renders worktree groups (not paginated); in workflow
// mode there is no special-casing, so a processing column paginates normally.
const isLegacyInProgress = !workflowMode && column === "in-progress";
// When search is active, skip pagination so all matching tasks are visible
const shouldPaginate = !isArchived && !isSearchActive && column !== "in-progress" && tasks.length > PAGINATED_COLUMN_THRESHOLD;
const shouldPaginate = !isArchived && !isSearchActive && !isLegacyInProgress && tasks.length > PAGINATED_COLUMN_THRESHOLD;
useEffect(() => {
setVisibleTaskCount((current) => {
if (column === "in-progress" || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) {
if (isLegacyInProgress || isArchived || tasks.length <= PAGINATED_COLUMN_THRESHOLD) {
return VISIBLE_TASKS_INITIAL;
}
return Math.min(Math.max(current, VISIBLE_TASKS_INITIAL), tasks.length);
});
}, [column, isArchived, tasks.length]);
}, [isLegacyInProgress, isArchived, tasks.length]);
const handleDragOver = useCallback((e: React.DragEvent) => {
// Don't allow dropping into archived column via drag-drop
if (isArchived) return;
// Workflow mode (R17): deterministic rejections are NO-MOVE — we do NOT
// call preventDefault, so the browser refuses the drop and the card never
// renders in this column. A null result means the drop is allowed.
if (workflowMode && canDropTask && getDraggingTaskId) {
const draggingId = getDraggingTaskId();
if (draggingId) {
const rejectionKey = canDropTask(draggingId);
if (rejectionKey) {
setInlineFeedback(translateRejectionKey(t, rejectionKey));
return; // no preventDefault → no-move
}
}
}
e.preventDefault();
e.dataTransfer.dropEffect = "move";
setDragOver(true);
}, [isArchived]);
}, [isArchived, workflowMode, canDropTask, getDraggingTaskId, t]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
const el = e.currentTarget as HTMLElement;
if (!el.contains(e.relatedTarget as Node)) {
setDragOver(false);
setInlineFeedback(null);
}
}, []);
@@ -185,14 +309,52 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
await onMoveTask(taskId, column, moveOptions);
} catch (err) {
addToast(getErrorMessage(err), "error");
// Workflow mode (R17): a structured 409 carries a typed rejection. The
// optimistic move snaps back automatically (the next SSE/refresh restores
// the card's real column); surface the translated rejection messageKey.
const rejection = extractTransitionRejection(err);
if (rejection) {
addToast(translateRejection(t, rejection), "error");
} else {
addToast(getErrorMessage(err), "error");
}
}
}, [addToast, allTasks, column, confirm, onMoveTask, tasks]);
}, [addToast, allTasks, column, confirm, onMoveTask, tasks, t]);
const handlePromote = useCallback(async (taskId: string) => {
if (!onPromote) return;
setInlineFeedback(null);
setPromotingIds((prev) => {
const next = new Set(prev);
next.add(taskId);
return next;
});
try {
await onPromote(taskId);
} catch (err) {
const rejection = extractTransitionRejection(err);
if (rejection) {
// Capacity-exhausted (and any rejection) shows INLINE column feedback,
// not a toast — so multiple holds can promote concurrently without spam.
setInlineFeedback(translateRejection(t, rejection));
} else {
setInlineFeedback(getErrorMessage(err));
}
} finally {
setPromotingIds((prev) => {
const next = new Set(prev);
next.delete(taskId);
return next;
});
}
}, [onPromote, t]);
// Worktree grouping is a legacy in-progress affordance; in workflow mode a
// custom processing column renders plain cards (KTD-11 keeps one-card-one-lane).
const worktreeGroups = useMemo(() => {
if (column !== "in-progress") return [];
if (!isLegacyInProgress) return [];
return groupByWorktree(tasks, tasks, maxConcurrent);
}, [column, tasks, maxConcurrent]);
}, [isLegacyInProgress, tasks, maxConcurrent]);
const visibleTasks = useMemo(() => {
if (!shouldPaginate) return tasks;
@@ -238,7 +400,13 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
[tasks],
);
const pauseEligibleCount = pauseEligibleTasks.length;
const hasColumnBulkActions = column === "todo" || column === "in-progress" || column === "in-review";
// Bulk-action eligibility (R9): workflow mode keys off trait flags instead of
// the literal column ids. Todo-equivalent = hold/intake (replan affordance);
// processing = wip/countsTowardWip; review = mergeBlocker/humanReview.
const isTodoLikeColumn = workflowMode ? Boolean(columnFlags?.hold || columnFlags?.intake) : column === "todo";
const isProcessingColumn = workflowMode ? Boolean(columnFlags?.countsTowardWip) : column === "in-progress";
const isReviewColumn = workflowMode ? Boolean(columnFlags?.mergeBlocker || columnFlags?.humanReview) : column === "in-review";
const hasColumnBulkActions = isTodoLikeColumn || isProcessingColumn || isReviewColumn;
const isMenuBusy = isReplanning || isPausingAll || isMovingAllToTodo;
const handlePauseAll = useCallback(async () => {
@@ -353,9 +521,9 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
>
<div className="column-header">
<div className={`column-dot dot-${column}`} />
<h2>{COLUMN_LABELS[column]}</h2>
<h2>{workflowMode ? (columnDisplayName ?? COLUMN_LABELS[column] ?? column) : COLUMN_LABELS[column]}</h2>
<span className={`column-count${countFlashing ? " count-flash" : ""}`}>{tasks.length}</span>
{column === "in-review" && onToggleAutoMerge && (
{(workflowMode ? isReviewColumn : column === "in-review") && onToggleAutoMerge && (
<label className="auto-merge-toggle" title={autoMerge ? t("column.autoMergeEnabled", "Auto-merge enabled") : t("column.autoMergeDisabled", "Auto-merge disabled")}>
<input
type="checkbox"
@@ -401,7 +569,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
onClick={() => setIsMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={isMenuOpen}
aria-label={t("column.actionsAriaLabel", "{{columnLabel}} column actions", { columnLabel: COLUMN_LABELS[column] })}
aria-label={t("column.actionsAriaLabel", "{{columnLabel}} column actions", { columnLabel: workflowMode ? (columnDisplayName ?? column) : COLUMN_LABELS[column] })}
title={t("column.actionsTitle", "Column actions")}
disabled={isMenuBusy}
>
@@ -409,7 +577,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
</button>
{isMenuOpen && (
<div className="column-menu-popover" role="menu">
{column === "todo" && (
{isTodoLikeColumn && (
<button
type="button"
role="menuitem"
@@ -423,7 +591,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
</span>
</button>
)}
{(column === "in-progress" || column === "in-review") && (
{(isProcessingColumn || isReviewColumn) && (
<>
<button
type="button"
@@ -460,10 +628,17 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
</div>
)}
</div>
{!isCollapsed && <p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>}
{!isCollapsed && (workflowMode ? COLUMN_DESCRIPTIONS[column] !== undefined : true) && (
<p className="column-desc">{COLUMN_DESCRIPTIONS[column]}</p>
)}
{!isCollapsed && inlineFeedback && (
<p className="column-inline-feedback" role="status" data-testid="column-inline-feedback">
{inlineFeedback}
</p>
)}
{!isCollapsed && (
<div className="column-body">
{column === "triage" && onQuickCreate && (
{(workflowMode ? Boolean(columnFlags?.intake) : column === "triage") && onQuickCreate && (
<QuickEntryBox
onCreate={onQuickCreate}
addToast={addToast}
@@ -489,7 +664,7 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
}}
/>
)}
{column === "in-progress" ? (
{isLegacyInProgress ? (
worktreeGroups.length === 0 ? (
<div className="empty-column">{t("column.noTasks", "No tasks")}</div>
) : (
@@ -521,29 +696,43 @@ function ColumnComponent({ column, tasks, projectId, maxConcurrent, onMoveTask,
) : (
<>
{visibleTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
projectId={projectId}
onOpenDetail={onOpenDetail}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
onMoveTask={onMoveTask}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
/>
<div key={task.id} className={isHoldColumn ? "column-hold-card" : undefined}>
<TaskCard
task={task}
projectId={projectId}
onOpenDetail={onOpenDetail}
onOpenGroupModal={onOpenGroupModal}
addToast={addToast}
globalPaused={globalPaused}
onUpdateTask={onUpdateTask}
onRetryTask={onRetryTask}
onArchiveTask={onArchiveTask}
onUnarchiveTask={onUnarchiveTask}
onDeleteTask={onDeleteTask}
onOpenDetailWithTab={onOpenDetailWithTab}
taskStuckTimeoutMs={taskStuckTimeoutMs}
onOpenMission={onOpenMission}
onMoveTask={onMoveTask}
lastFetchTimeMs={lastFetchTimeMs}
workflowStepNameLookup={workflowStepNameLookup}
fanout={blockerFanoutMap?.get(task.id)}
prAuthAvailable={prAuthAvailable}
autoMergeEnabled={Boolean(autoMerge)}
/>
{isHoldColumn && onPromote && (
<button
type="button"
className="btn btn-secondary btn-sm column-promote-btn"
onClick={() => void handlePromote(task.id)}
disabled={promotingIds.has(task.id)}
data-testid={`promote-${task.id}`}
>
{promotingIds.has(task.id)
? t("column.promoting", "Promoting…")
: t("column.promote", "Promote")}
</button>
)}
</div>
))}
{shouldPaginate && hiddenTaskCount > 0 && (
<button

View File

@@ -0,0 +1,97 @@
/* Multi-lane board lane (U9). Each lane is a full-width vertical row whose
* columns scroll horizontally inside the lane. Tokens mirror .board/.column in
* app/styles.css; no monolith CSS is touched. */
.board-lanes {
display: flex;
flex-direction: column;
gap: var(--column-gap, 12px);
flex: 1 1 auto;
min-height: 0;
min-width: 0;
overflow-y: auto;
padding: var(--board-padding, 12px);
box-sizing: border-box;
}
.lane {
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid var(--border);
border-radius: var(--radius-lg, 10px);
background: var(--surface-muted, var(--surface));
}
.lane-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.lane-name {
font-size: 0.95rem;
font-weight: 600;
margin: 0;
}
.lane-count {
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
color: var(--text-muted, var(--text));
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm, 6px);
padding: 0 6px;
min-width: 1.5rem;
text-align: center;
}
.lane-columns {
display: flex;
gap: var(--column-gap, 12px);
padding: 12px;
overflow-x: auto;
overflow-y: hidden;
scroll-snap-type: x proximity;
scrollbar-color: var(--border) transparent;
scrollbar-width: thin;
min-height: 0;
}
.lane-columns > .column {
flex: 0 0 clamp(280px, 28vw, 340px);
scroll-snap-align: start;
}
.lane-columns::-webkit-scrollbar {
height: 6px;
}
.lane-columns::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: var(--radius-sm, 6px);
}
/* Inline column feedback (capacity-exhausted promote, deterministic drag
* rejection) — rendered in-column, not a toast (R17 / promote spec). */
.column-inline-feedback {
margin: 4px 12px 0;
padding: 6px 8px;
font-size: 0.8rem;
color: var(--danger, #d9534f);
background: color-mix(in srgb, var(--danger, #d9534f) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--danger, #d9534f) 35%, transparent);
border-radius: var(--radius-sm, 6px);
}
.column-promote-btn {
margin-top: 4px;
width: 100%;
}
.column-hold-card {
display: flex;
flex-direction: column;
}

View File

@@ -0,0 +1,215 @@
import "./Lane.css";
import { memo, useCallback, useEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react";
import type { Task, TaskDetail, Column as ColumnType, TaskCreateInput, GithubIssueAction } from "@fusion/core";
import { Column } from "./Column";
import { sortTasksForDisplayColumn } from "./taskSorting";
import type { ModelInfo, BoardWorkflowDefinition } from "../api";
import type { ToastType } from "../hooks/useToast";
import type { BlockerFanoutEntry } from "../hooks/useBlockerFanout";
/**
* One workflow's board lane (U9, R16). A full-width row whose own
* horizontally-scrollable strip renders the workflow's columns (reusing
* Column.tsx in workflow mode). The header shows the workflow name, the card
* count, and a collapse toggle (collapse state persisted by the parent Board).
*
* Archived / hidden-from-board columns are hidden. Hold columns render the
* per-card promote affordance. Cross-lane drag is rejected by the drag
* pre-check the Board threads through (drag never switches workflows).
*
* The iOS scroll-stabilization that the single-lane board ran globally is
* contained PER LANE here (each lane is its own scroll container) so the
* behavior is not compounded across stacked lanes.
*/
export interface LaneProps {
workflow: BoardWorkflowDefinition;
/** Tasks resolved to THIS workflow (already lane-filtered by Board). */
tasks: Task[];
collapsed: boolean;
onToggleCollapse: (workflowId: string) => void;
projectId?: string;
maxConcurrent: number;
onMoveTask: (id: string, column: ColumnType, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
onPromote: (taskId: string) => Promise<void>;
/** Drag pre-check: null = allowed, else an i18n messageKey (R17). */
canDropTask: (taskId: string, targetColumnId: string, workflowId: string) => string | null;
getDraggingTaskId: () => string | null;
onPauseTask?: (id: string) => Promise<Task>;
onOpenDetail: (task: Task | TaskDetail) => void;
onOpenGroupModal?: (groupId: string) => void;
addToast: (message: string, type?: ToastType) => void;
onQuickCreate?: (input: TaskCreateInput) => Promise<Task | void>;
onNewTask?: () => void;
autoMerge?: boolean;
onToggleAutoMerge?: () => void;
globalPaused?: boolean;
onUpdateTask?: (id: string, updates: { title?: string; description?: string; dependencies?: string[] }) => Promise<Task>;
onRetryTask?: (id: string) => Promise<Task>;
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
onUnarchiveTask?: (id: string) => Promise<Task>;
onDeleteTask?: (id: string, options?: {
removeDependencyReferences?: boolean;
removeLineageReferences?: boolean;
githubIssueAction?: GithubIssueAction;
}) => Promise<Task>;
availableModels?: ModelInfo[];
onPlanningMode?: (initialPlan: string) => void;
onSubtaskBreakdown?: (description: string) => void;
onOpenDetailWithTab?: (task: Task | TaskDetail, initialTab: "changes" | "retries") => void;
favoriteProviders?: string[];
favoriteModels?: string[];
onToggleFavorite?: (provider: string) => void;
onToggleModelFavorite?: (modelId: string) => void;
isSearchActive?: boolean;
taskStuckTimeoutMs?: number;
onOpenMission?: (missionId: string) => void;
lastFetchTimeMs?: number;
workflowStepNameLookup?: ReadonlyMap<string, string>;
blockerFanoutMap?: ReadonlyMap<string, BlockerFanoutEntry>;
prAuthAvailable?: boolean;
}
function LaneComponent(props: LaneProps) {
const { workflow, tasks, collapsed, onToggleCollapse } = props;
const { t } = useTranslation("app");
const laneRef = useRef<HTMLDivElement | null>(null);
// Visible columns: archived / hidden-from-board columns are hidden per lane.
const visibleColumns = useMemo(
() => workflow.columns.filter((col) => !col.flags.archived && !col.flags.hiddenFromBoard),
[workflow.columns],
);
// Group + sort tasks by column id (stable per render).
const tasksByColumn = useMemo(() => {
const grouped: Record<string, Task[]> = {};
for (const col of workflow.columns) grouped[col.id] = [];
for (const task of tasks) {
(grouped[task.column] ??= []).push(task);
}
for (const col of workflow.columns) {
grouped[col.id] = sortTasksForDisplayColumn(grouped[col.id] ?? [], task_legacyKey(col.id));
}
return grouped;
}, [tasks, workflow.columns]);
// iOS scroll stabilization, contained to this lane's scroll strip.
useEffect(() => {
if (typeof window === "undefined") return;
if (!window.matchMedia("(max-width: 768px)").matches) return;
let rafId: number | null = null;
const run = () => {
const el = laneRef.current;
if (!el) return;
void el.offsetWidth;
el.scrollLeft = 0;
};
const schedule = () => {
if (typeof window.requestAnimationFrame === "function") {
if (rafId !== null) window.cancelAnimationFrame(rafId);
rafId = window.requestAnimationFrame(() => {
rafId = null;
run();
});
} else {
run();
}
};
schedule();
const vv = window.visualViewport;
const onResize = () => schedule();
if (typeof vv?.addEventListener === "function") vv.addEventListener("resize", onResize);
return () => {
if (typeof vv?.removeEventListener === "function") vv.removeEventListener("resize", onResize);
if (rafId !== null) window.cancelAnimationFrame(rafId);
};
}, []);
const handleToggle = useCallback(() => onToggleCollapse(workflow.id), [onToggleCollapse, workflow.id]);
const makeCanDrop = useCallback(
(targetColumnId: string) => (taskId: string) => props.canDropTask(taskId, targetColumnId, workflow.id),
[props, workflow.id],
);
return (
<section className="lane" data-lane={workflow.id} aria-label={workflow.name}>
<div className="lane-header">
<button
type="button"
className="lane-collapse-toggle btn btn-icon btn-sm"
onClick={handleToggle}
aria-expanded={!collapsed}
aria-label={collapsed
? t("lane.expand", "Expand {{name}} lane", { name: workflow.name })
: t("lane.collapse", "Collapse {{name}} lane", { name: workflow.name })}
data-testid={`lane-toggle-${workflow.id}`}
>
{collapsed ? <ChevronRight size={16} /> : <ChevronDown size={16} />}
</button>
<h2 className="lane-name">{workflow.name}</h2>
<span className="lane-count" data-testid={`lane-count-${workflow.id}`}>{tasks.length}</span>
</div>
{!collapsed && (
<div className="lane-columns" ref={laneRef}>
{visibleColumns.map((col) => (
<Column
key={col.id}
column={col.id as ColumnType}
workflowMode
columnDisplayName={col.name}
columnFlags={col.flags}
tasks={tasksByColumn[col.id] ?? []}
allTasks={tasks}
projectId={props.projectId}
maxConcurrent={props.maxConcurrent}
onMoveTask={props.onMoveTask}
onPromote={props.onPromote}
canDropTask={makeCanDrop(col.id)}
getDraggingTaskId={props.getDraggingTaskId}
onPauseTask={props.onPauseTask}
onOpenDetail={props.onOpenDetail}
onOpenGroupModal={props.onOpenGroupModal}
addToast={props.addToast}
globalPaused={props.globalPaused}
onUpdateTask={props.onUpdateTask}
onRetryTask={props.onRetryTask}
onArchiveTask={props.onArchiveTask}
onUnarchiveTask={props.onUnarchiveTask}
onDeleteTask={props.onDeleteTask}
availableModels={props.availableModels}
onOpenDetailWithTab={props.onOpenDetailWithTab}
favoriteProviders={props.favoriteProviders}
favoriteModels={props.favoriteModels}
onToggleFavorite={props.onToggleFavorite}
onToggleModelFavorite={props.onToggleModelFavorite}
isSearchActive={props.isSearchActive}
taskStuckTimeoutMs={props.taskStuckTimeoutMs}
onOpenMission={props.onOpenMission}
lastFetchTimeMs={props.lastFetchTimeMs}
workflowStepNameLookup={props.workflowStepNameLookup}
blockerFanoutMap={props.blockerFanoutMap}
prAuthAvailable={props.prAuthAvailable}
autoMerge={props.autoMerge}
{...(col.flags.intake ? { onQuickCreate: props.onQuickCreate, onNewTask: props.onNewTask, onPlanningMode: props.onPlanningMode, onSubtaskBreakdown: props.onSubtaskBreakdown } : {})}
{...(col.flags.mergeBlocker ? { onToggleAutoMerge: props.onToggleAutoMerge } : {})}
/>
))}
</div>
)}
</section>
);
}
/** Custom column ids are not in the legacy ColumnType enum; sortTasksForDisplayColumn
* only special-cases the legacy literals, so any unknown id falls through to the
* generic priority sort. Cast through unknown for the typed call. */
function task_legacyKey(columnId: string): ColumnType {
return columnId as ColumnType;
}
export const Lane = memo(LaneComponent);
Lane.displayName = "Lane";

View File

@@ -34,6 +34,15 @@ import { MAX_AUTO_MERGE_RETRIES, type BlockerFanoutEntry } from "../hooks/useBlo
import { useRetryWarning } from "../context/RetryWarningContext";
import { useColumnLabel } from "../i18n/labels";
/** Per-branch progress snapshot (U13). Surfaced as an optional additive field
* on the task payload for the parallel-window badge (U9). */
interface BranchProgressEntry {
branchId: string;
nodeId: string;
status: string;
}
type TaskWithBranchProgress = Task & { branchProgress?: BranchProgressEntry[] };
// ── Mission title caching ───────────────────────────────────────────────────
const missionTitleCache = new Map<string, string>();
@@ -484,6 +493,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo
previousTask.title === nextTask.title &&
previousTask.description === nextTask.description &&
previousTask.column === nextTask.column &&
((previousTask as TaskWithBranchProgress).branchProgress?.length ?? 0) ===
((nextTask as TaskWithBranchProgress).branchProgress?.length ?? 0) &&
previousTask.columnMovedAt === nextTask.columnMovedAt &&
previousTask.timedExecutionMs === nextTask.timedExecutionMs &&
previousTask.updatedAt === nextTask.updatedAt &&
@@ -1746,6 +1757,24 @@ function TaskCardComponent({
{t("tasks.stuck", "Stuck")}
</span>
)}
{/* U13/U9: per-branch progress badges while the card is in a parallel
window. Reads an optional additive `branchProgress` field on the task
payload (server-persisted by U13); absent → nothing renders. */}
{Array.isArray((task as TaskWithBranchProgress).branchProgress) &&
(task as TaskWithBranchProgress).branchProgress!.length > 0 && (
<span
className="card-status-badge card-branch-progress"
title={t("tasks.branchProgressTitle", "Parallel branches in progress")}
data-testid="branch-progress-badge"
>
{t("tasks.branchProgress", "{{done}}/{{total}} branches", {
done: (task as TaskWithBranchProgress).branchProgress!.filter(
(b) => b.status === "completed",
).length,
total: (task as TaskWithBranchProgress).branchProgress!.length,
})}
</span>
)}
{showStalledReview && stalledReview && (
<span
className="card-status-badge card-status-badge--in-review stalled-review"

View File

@@ -1,10 +1,12 @@
import "./WorkflowSelector.css";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Workflow as WorkflowIcon } from "lucide-react";
import type { WorkflowDefinition } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import { fetchWorkflows, fetchProjectDefaultWorkflow, setProjectDefaultWorkflow } from "../api";
import type { ToastType } from "../hooks/useToast";
import { useConfirm } from "../hooks/useConfirm";
interface WorkflowSelectorProps {
/** Currently selected workflow id, or null for none. */
@@ -17,6 +19,13 @@ interface WorkflowSelectorProps {
label?: string;
/** Optional affordance to open the graph editor. */
onManage?: () => void;
/**
* U9: when the task whose workflow is being switched has an active session,
* switching aborts that session and re-homes the card into the new workflow's
* entry column. Pass `true` to require an abort-warning confirmation before
* applying (parallels Column.tsx's preserve-progress confirm).
*/
hasActiveSession?: boolean;
}
export function WorkflowSelector({
@@ -27,7 +36,10 @@ export function WorkflowSelector({
disabled,
label = "Workflow",
onManage,
hasActiveSession,
}: WorkflowSelectorProps) {
const { t } = useTranslation("app");
const { confirm } = useConfirm();
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
const [loading, setLoading] = useState(false);
const [applying, setApplying] = useState(false);
@@ -55,6 +67,19 @@ export function WorkflowSelector({
const handleChange = useCallback(
async (next: string) => {
const workflowId = next === "" ? null : next;
if (hasActiveSession) {
const confirmed = await confirm({
title: t("workflowSelector.switchActiveTitle", "Switch workflow?"),
message: t(
"workflowSelector.switchActiveMessage",
"This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?",
),
confirmLabel: t("workflowSelector.switchConfirm", "Switch and abort"),
cancelLabel: t("workflowSelector.switchCancel", "Cancel"),
danger: true,
});
if (!confirmed) return;
}
setApplying(true);
try {
await onChange(workflowId);
@@ -64,7 +89,7 @@ export function WorkflowSelector({
setApplying(false);
}
},
[onChange, addToast],
[onChange, addToast, hasActiveSession, confirm, t],
);
return (

View File

@@ -17,10 +17,20 @@ vi.mock("../../hooks/useBatchBadgeFetch", () => ({
})),
}));
const fetchBoardWorkflowsMock = vi.fn().mockResolvedValue({
flagEnabled: false,
defaultWorkflowId: "builtin:coding",
workflows: [],
taskWorkflowIds: {},
});
const promoteTaskMock = vi.fn().mockResolvedValue({});
vi.mock("../../api", () => ({
fetchWorkflowSteps: vi.fn().mockResolvedValue([
{ id: "WS-003", name: "Accessibility Audit", enabled: true },
]),
fetchBoardWorkflows: (...args: unknown[]) => fetchBoardWorkflowsMock(...args),
promoteTask: (...args: unknown[]) => promoteTaskMock(...args),
}));
const columnRenderCounts: Record<string, number> = {};
@@ -37,11 +47,51 @@ vi.mock("../Column", () => ({
}),
}));
// Mock Lane so the multi-lane Board tests assert grouping/ordering without
// pulling in the full Column tree.
vi.mock("../Lane", () => ({
Lane: ({ workflow, tasks, collapsed }: { workflow: { id: string; name: string }; tasks: Task[]; collapsed: boolean }) => (
<section
data-testid={`lane-${workflow.id}`}
data-lane-name={workflow.name}
data-lane-count={String(tasks.length)}
data-lane-collapsed={collapsed ? "true" : "false"}
data-lane-task-ids={JSON.stringify(tasks.map((t) => t.id))}
/>
),
}));
const DEFAULT_WORKFLOW = {
id: "builtin:coding",
name: "Coding (built-in)",
columns: [
{ id: "triage", name: "Triage", flags: { intake: true } },
{ id: "todo", name: "Todo", flags: { hold: true } },
{ id: "in-progress", name: "In progress", flags: { countsTowardWip: true } },
{ id: "in-review", name: "In review", flags: { mergeBlocker: true } },
{ id: "done", name: "Done", flags: { complete: true } },
{ id: "archived", name: "Archived", flags: { archived: true } },
],
};
const noop = () => {};
const noopAsync = () => Promise.resolve({} as any);
beforeEach(() => {
fetchBatchMock.mockReset();
promoteTaskMock.mockClear();
fetchBoardWorkflowsMock.mockReset();
fetchBoardWorkflowsMock.mockResolvedValue({
flagEnabled: false,
defaultWorkflowId: "builtin:coding",
workflows: [],
taskWorkflowIds: {},
});
try {
window.localStorage.clear();
} catch {
/* jsdom localStorage */
}
for (const key of Object.keys(columnRenderCounts)) {
delete columnRenderCounts[key];
}
@@ -825,4 +875,120 @@ describe("Board", () => {
}
});
});
describe("multi-lane board (U9, flag ON)", () => {
const mkTask = (overrides: Partial<Task> & { id: string }): Task => ({
title: overrides.id,
description: "d",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
...overrides,
});
const CUSTOM_WORKFLOW = {
id: "wf-custom",
name: "Custom Flow",
columns: [
{ id: "intake", name: "Intake", flags: { intake: true } },
{ id: "done", name: "Done", flags: { complete: true } },
],
};
function enableFlag(taskWorkflowIds: Record<string, string>, workflows = [DEFAULT_WORKFLOW]) {
fetchBoardWorkflowsMock.mockResolvedValue({
flagEnabled: true,
defaultWorkflowId: "builtin:coding",
workflows,
taskWorkflowIds,
});
}
it("flag OFF renders the legacy single-lane board byte-identically", async () => {
// Default mock: flagEnabled false.
renderBoard({ tasks: [mkTask({ id: "FN-1" })] });
// Let the board-workflows fetch resolve (flagEnabled:false) so the async
// state settle is wrapped and the legacy board stays the rendered output.
await waitFor(() => expect(fetchBoardWorkflowsMock).toHaveBeenCalled());
const board = screen.getByRole("main");
expect(board.className).toBe("board");
// All 6 legacy columns present; no lanes.
for (const col of COLUMNS) {
expect(screen.getByTestId(`column-${col}`)).toBeDefined();
}
expect(screen.queryByTestId(/^lane-/)).toBeNull();
});
it("tasks with no selection render in the default lane (R16)", async () => {
enableFlag({ "FN-1": "builtin:coding", "FN-2": "builtin:coding" });
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "in-progress" })] });
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined());
const lane = screen.getByTestId("lane-builtin:coding");
expect(JSON.parse(lane.getAttribute("data-lane-task-ids") || "[]").sort()).toEqual(["FN-1", "FN-2"]);
expect(lane.getAttribute("data-lane-count")).toBe("2");
});
it("each card appears in exactly one lane over a mixed fixture", async () => {
enableFlag(
{ "FN-1": "builtin:coding", "FN-2": "wf-custom", "FN-3": "wf-custom" },
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
);
renderBoard({
tasks: [
mkTask({ id: "FN-1" }),
mkTask({ id: "FN-2", column: "intake" }),
mkTask({ id: "FN-3", column: "intake" }),
],
});
await waitFor(() => expect(screen.getByTestId("lane-wf-custom")).toBeDefined());
const defaultLaneIds = JSON.parse(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-task-ids") || "[]");
const customLaneIds = JSON.parse(screen.getByTestId("lane-wf-custom").getAttribute("data-lane-task-ids") || "[]");
const all = [...defaultLaneIds, ...customLaneIds].sort();
expect(all).toEqual(["FN-1", "FN-2", "FN-3"]);
// No id appears twice.
expect(new Set(all).size).toBe(all.length);
});
it("hides zero-card lanes and puts the default lane first", async () => {
enableFlag(
{ "FN-2": "wf-custom" },
[DEFAULT_WORKFLOW, CUSTOM_WORKFLOW],
);
// Only the custom workflow has a card; the default lane has none → hidden.
renderBoard({ tasks: [mkTask({ id: "FN-2", column: "intake" })] });
await waitFor(() => expect(screen.getByTestId("lane-wf-custom")).toBeDefined());
expect(screen.queryByTestId("lane-builtin:coding")).toBeNull();
});
it("orders the default lane first when both have cards", async () => {
enableFlag(
{ "FN-1": "builtin:coding", "FN-2": "wf-custom" },
[CUSTOM_WORKFLOW, DEFAULT_WORKFLOW],
);
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "intake" })] });
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined());
const lanes = screen.getAllByTestId(/^lane-/);
expect(lanes[0].getAttribute("data-testid")).toBe("lane-builtin:coding");
});
it("excludes archived cards from lanes", async () => {
enableFlag({ "FN-1": "builtin:coding", "FN-9": "builtin:coding" });
renderBoard({ tasks: [mkTask({ id: "FN-1" }), mkTask({ id: "FN-9", column: "archived" })] });
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined());
const ids = JSON.parse(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-task-ids") || "[]");
expect(ids).toEqual(["FN-1"]);
});
it("persists lane collapse state to localStorage", async () => {
window.localStorage.setItem("kb-dashboard-lane-collapsed", JSON.stringify(["builtin:coding"]));
enableFlag({ "FN-1": "builtin:coding" });
renderBoard({ tasks: [mkTask({ id: "FN-1" })] });
await waitFor(() => expect(screen.getByTestId("lane-builtin:coding")).toBeDefined());
expect(screen.getByTestId("lane-builtin:coding").getAttribute("data-lane-collapsed")).toBe("true");
});
});
});

View File

@@ -122,6 +122,78 @@ describe("Column count-flash", () => {
});
});
describe("Column workflow mode (U9)", () => {
it("uses the workflow column display name instead of the legacy label", () => {
render(
<Column
{...defaultProps}
column={"custom-col" as ColumnType}
workflowMode
columnDisplayName="Planning Hold"
columnFlags={{ hold: true }}
tasks={[]}
/>,
);
expect(screen.getByRole("heading", { level: 2 }).textContent).toBe("Planning Hold");
});
it("re-keys bulk actions to trait flags (a wip column gets the processing menu)", () => {
render(
<Column
{...defaultProps}
column={"exec" as ColumnType}
workflowMode
columnDisplayName="Executing"
columnFlags={{ countsTowardWip: true }}
onPauseTask={vi.fn()}
tasks={[{ ...makeTask("FN-1"), column: "exec" as ColumnType }]}
/>,
);
// The processing-column actions button (column-menu) is present.
expect(document.querySelector(".column-menu")).not.toBeNull();
});
it("surfaces a translated rejection messageKey on a failed drop (snap-back)", async () => {
const addToast = vi.fn();
const onMoveTask = vi.fn().mockRejectedValue({
details: { code: "merge-blocked", messageKey: "board.rejection.mergeBlocked", retryable: false },
});
render(
<Column
{...defaultProps}
column={"done" as ColumnType}
workflowMode
columnDisplayName="Done"
columnFlags={{ complete: true }}
addToast={addToast}
onMoveTask={onMoveTask}
tasks={[]}
/>,
);
const columnEl = document.querySelector('[data-column="done"]') as HTMLElement;
fireEvent.drop(columnEl, { dataTransfer: { getData: () => "FN-99" } });
await waitFor(() => expect(addToast).toHaveBeenCalled());
// The toast surfaces the translated merge-blocked copy (not the raw key).
expect(addToast.mock.calls[0][0]).toContain("merge step");
expect(addToast.mock.calls[0][1]).toBe("error");
});
it("renders a Promote affordance on hold-column cards", () => {
render(
<Column
{...defaultProps}
column={"hold-col" as ColumnType}
workflowMode
columnDisplayName="Hold"
columnFlags={{ hold: true }}
onPromote={vi.fn().mockResolvedValue(undefined)}
tasks={[{ ...makeTask("FN-7"), column: "hold-col" as ColumnType }]}
/>,
);
expect(screen.getByTestId("promote-FN-7")).toBeDefined();
});
});
describe("Column memoization", () => {
it("does not re-render task cards when rerendered with the same task references", () => {
const tasks = [makeTask("FN-001")];

View File

@@ -0,0 +1,154 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { Lane } from "../Lane";
import type { Task } from "@fusion/core";
import type { BoardWorkflowDefinition } from "../../api";
// Keep the test focused on Lane + Column (real) — mock the leaf TaskCard and
// the confirm hook, matching the Column test harness.
vi.mock("../TaskCard", () => ({
TaskCard: ({ task }: { task: Task }) => <div data-testid={`task-${task.id}`} data-id={task.id} />,
}));
vi.mock("../WorktreeGroup", () => ({ WorktreeGroup: () => <div data-testid="worktree-group" /> }));
vi.mock("../QuickEntryBox", () => ({ QuickEntryBox: () => <div data-testid="quick-entry-box" /> }));
vi.mock("../PluginSlot", () => ({ PluginSlot: () => null }));
vi.mock("lucide-react", () => ({
Link: () => null,
Clock: () => null,
ChevronDown: () => null,
ChevronUp: () => null,
ChevronRight: () => null,
Archive: () => null,
MoreVertical: () => null,
AlertTriangle: () => null,
}));
const mockConfirm = vi.fn();
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: mockConfirm }) }));
const WORKFLOW: BoardWorkflowDefinition = {
id: "builtin:coding",
name: "Coding (built-in)",
columns: [
{ id: "triage", name: "Triage", flags: { intake: true } },
{ id: "todo", name: "Todo", flags: { hold: true } },
{ id: "in-progress", name: "In progress", flags: { countsTowardWip: true } },
{ id: "done", name: "Done", flags: { complete: true } },
{ id: "archived", name: "Archived", flags: { archived: true } },
],
};
function mkTask(overrides: Partial<Task> & { id: string }): Task {
return {
title: overrides.id,
description: "d",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
...overrides,
} as Task;
}
const baseProps = () => ({
workflow: WORKFLOW,
tasks: [] as Task[],
collapsed: false,
onToggleCollapse: vi.fn(),
maxConcurrent: 2,
onMoveTask: vi.fn().mockResolvedValue({} as Task),
onPromote: vi.fn().mockResolvedValue(undefined),
canDropTask: vi.fn().mockReturnValue(null),
getDraggingTaskId: vi.fn().mockReturnValue(null),
onOpenDetail: vi.fn(),
addToast: vi.fn(),
});
beforeEach(() => {
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
});
describe("Lane", () => {
it("renders the workflow name and total card count in the header", () => {
render(<Lane {...baseProps()} tasks={[mkTask({ id: "FN-1" }), mkTask({ id: "FN-2", column: "triage" })]} />);
expect(screen.getByText("Coding (built-in)")).toBeDefined();
expect(screen.getByTestId("lane-count-builtin:coding").textContent).toBe("2");
});
it("renders its workflow's columns in order, with archived hidden", () => {
render(<Lane {...baseProps()} />);
const headings = screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent);
// Lane name is an h2 too; filter to column headings (order preserved).
expect(headings).toContain("Triage");
expect(headings).toContain("Todo");
expect(headings).toContain("In progress");
expect(headings).toContain("Done");
// Archived column is hidden.
expect(headings).not.toContain("Archived");
});
it("collapses the lane (hides columns) when collapsed", () => {
render(<Lane {...baseProps()} collapsed />);
expect(screen.queryByText("Triage")).toBeNull();
});
it("invokes onToggleCollapse with the workflow id", () => {
const props = baseProps();
render(<Lane {...props} />);
fireEvent.click(screen.getByTestId("lane-toggle-builtin:coding"));
expect(props.onToggleCollapse).toHaveBeenCalledWith("builtin:coding");
});
it("shows a Promote button on hold-column cards and calls onPromote", async () => {
const props = baseProps();
render(<Lane {...props} tasks={[mkTask({ id: "FN-7", column: "todo" })]} />);
const promoteBtn = screen.getByTestId("promote-FN-7");
expect(promoteBtn).toBeDefined();
fireEvent.click(promoteBtn);
await waitFor(() => expect(props.onPromote).toHaveBeenCalledWith("FN-7"));
});
it("shows inline capacity-exhausted feedback (not a toast) when promote rejects, then re-enables", async () => {
const props = baseProps();
props.onPromote = vi.fn().mockRejectedValue({
details: { code: "capacity-exhausted", messageKey: "board.rejection.capacityExhausted", retryable: true },
});
render(<Lane {...props} tasks={[mkTask({ id: "FN-8", column: "todo" })]} />);
fireEvent.click(screen.getByTestId("promote-FN-8"));
await waitFor(() => expect(screen.getByTestId("column-inline-feedback")).toBeDefined());
// No toast was used for the inline capacity feedback.
expect(props.addToast).not.toHaveBeenCalled();
// Button re-enabled after the call resolves.
await waitFor(() => expect((screen.getByTestId("promote-FN-8") as HTMLButtonElement).disabled).toBe(false));
});
it("prevents the drop (no-move) when canDropTask returns a rejection key", () => {
const props = baseProps();
props.getDraggingTaskId = vi.fn().mockReturnValue("FN-DRAG");
props.canDropTask = vi.fn().mockReturnValue("board.rejection.workflowMismatch");
render(<Lane {...props} tasks={[mkTask({ id: "FN-1", column: "in-progress" })]} />);
const ipColumn = document.querySelector('[data-column="in-progress"]') as HTMLElement;
const preventDefault = vi.fn();
fireEvent.dragOver(ipColumn, { dataTransfer: { dropEffect: "" }, preventDefault });
// Rejection → preventDefault NOT called → the browser refuses the drop.
expect(props.canDropTask).toHaveBeenCalledWith("FN-DRAG", "in-progress", "builtin:coding");
// Inline feedback surfaces the translated rejection.
expect(screen.getByTestId("column-inline-feedback")).toBeDefined();
});
it("allows the drop (preventDefault) when canDropTask returns null", () => {
const props = baseProps();
props.getDraggingTaskId = vi.fn().mockReturnValue("FN-DRAG");
props.canDropTask = vi.fn().mockReturnValue(null);
render(<Lane {...props} tasks={[mkTask({ id: "FN-1", column: "in-progress" })]} />);
const ipColumn = document.querySelector('[data-column="in-progress"]') as HTMLElement;
// fireEvent.dragOver returns false when a handler called preventDefault.
const notPrevented = fireEvent.dragOver(ipColumn, { dataTransfer: { dropEffect: "" } });
expect(notPrevented).toBe(false);
expect(screen.queryByTestId("column-inline-feedback")).toBeNull();
});
});

View File

@@ -493,6 +493,24 @@ describe("TaskCard", () => {
expect(screen.getByText("FN-001")).toBeDefined();
});
it("renders a per-branch progress badge when the task is in a parallel window (U9/U13)", () => {
const task = {
...makeTask(),
branchProgress: [
{ branchId: "b1", nodeId: "n1", status: "completed" },
{ branchId: "b2", nodeId: "n2", status: "running" },
],
} as Task;
render(<TaskCard task={task} onOpenDetail={noop} addToast={noop} />);
const badge = screen.getByTestId("branch-progress-badge");
expect(badge.textContent).toContain("1/2");
});
it("does not render a branch-progress badge when there is no parallel window", () => {
render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
expect(screen.queryByTestId("branch-progress-badge")).toBeNull();
});
it("keeps native card dragging enabled by default", () => {
const { container } = render(<TaskCard task={makeTask()} onOpenDetail={noop} addToast={noop} />);
const card = container.querySelector(".card") as HTMLElement;

View File

@@ -0,0 +1,56 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { WorkflowSelector } from "../WorkflowSelector";
vi.mock("lucide-react", () => ({ Workflow: () => null }));
const fetchWorkflowsMock = vi.fn();
vi.mock("../../api", () => ({
fetchWorkflows: (...args: unknown[]) => fetchWorkflowsMock(...args),
fetchProjectDefaultWorkflow: vi.fn(),
setProjectDefaultWorkflow: vi.fn(),
}));
const mockConfirm = vi.fn();
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: mockConfirm }) }));
beforeEach(() => {
mockConfirm.mockReset();
fetchWorkflowsMock.mockReset();
fetchWorkflowsMock.mockResolvedValue([
{ id: "wf-a", name: "Workflow A" },
{ id: "wf-b", name: "Workflow B" },
]);
});
describe("WorkflowSelector switch-with-active-session confirm (U9)", () => {
it("shows the abort-warning confirm and applies the switch when confirmed", async () => {
mockConfirm.mockResolvedValue(true);
const onChange = vi.fn().mockResolvedValue(undefined);
render(<WorkflowSelector value="wf-a" onChange={onChange} hasActiveSession />);
await waitFor(() => expect(screen.getByRole("combobox")).toBeDefined());
fireEvent.change(screen.getByRole("combobox"), { target: { value: "wf-b" } });
await waitFor(() => expect(mockConfirm).toHaveBeenCalled());
await waitFor(() => expect(onChange).toHaveBeenCalledWith("wf-b"));
});
it("does NOT apply the switch when the confirm is cancelled", async () => {
mockConfirm.mockResolvedValue(false);
const onChange = vi.fn().mockResolvedValue(undefined);
render(<WorkflowSelector value="wf-a" onChange={onChange} hasActiveSession />);
await waitFor(() => expect(screen.getByRole("combobox")).toBeDefined());
fireEvent.change(screen.getByRole("combobox"), { target: { value: "wf-b" } });
await waitFor(() => expect(mockConfirm).toHaveBeenCalled());
expect(onChange).not.toHaveBeenCalled();
});
it("skips the confirm when the task has no active session", async () => {
const onChange = vi.fn().mockResolvedValue(undefined);
render(<WorkflowSelector value="wf-a" onChange={onChange} />);
await waitFor(() => expect(screen.getByRole("combobox")).toBeDefined());
fireEvent.change(screen.getByRole("combobox"), { target: { value: "wf-b" } });
await waitFor(() => expect(onChange).toHaveBeenCalledWith("wf-b"));
expect(mockConfirm).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,103 @@
import { describe, it, expect } from "vitest";
import { buildBoardWorkflowsPayload, DEFAULT_WORKFLOW_LANE_ID } from "../board-workflows.js";
import type { WorkflowDefinition } from "@fusion/core";
import { parseWorkflowIr } from "@fusion/core";
// A minimal custom v2 workflow with an intake + complete column.
const CUSTOM: WorkflowDefinition = {
id: "wf-custom",
name: "Custom Flow",
description: "",
ir: parseWorkflowIr({
version: "v2",
name: "Custom Flow",
columns: [
{ id: "intake", name: "Intake", traits: [{ trait: "intake" }] },
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
],
nodes: [
{ id: "start", kind: "start", column: "intake" },
{ id: "end", kind: "end", column: "done" },
],
edges: [{ from: "start", to: "end" }],
}),
layout: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
function makeStore(opts: {
flagOn: boolean;
selections: Record<string, string>;
defs?: Record<string, WorkflowDefinition>;
}) {
return {
async getSettings() {
return { experimentalFeatures: { workflowColumns: opts.flagOn } } as never;
},
getTaskWorkflowSelection(taskId: string) {
const workflowId = opts.selections[taskId];
return workflowId ? { workflowId, stepIds: [] } : undefined;
},
async getWorkflowDefinition(id: string) {
return opts.defs?.[id];
},
};
}
describe("buildBoardWorkflowsPayload", () => {
it("returns flagEnabled:false and empty maps when the flag is OFF", async () => {
const store = makeStore({ flagOn: false, selections: {} });
const payload = await buildBoardWorkflowsPayload(store as never, ["FN-1"]);
expect(payload.flagEnabled).toBe(false);
expect(payload.workflows).toEqual([]);
expect(payload.taskWorkflowIds).toEqual({});
});
it("resolves null selections to the default workflow lane", async () => {
const store = makeStore({ flagOn: true, selections: {} });
const payload = await buildBoardWorkflowsPayload(store as never, ["FN-1", "FN-2"]);
expect(payload.flagEnabled).toBe(true);
expect(payload.taskWorkflowIds["FN-1"]).toBe(DEFAULT_WORKFLOW_LANE_ID);
expect(payload.taskWorkflowIds["FN-2"]).toBe(DEFAULT_WORKFLOW_LANE_ID);
const defaultWf = payload.workflows.find((w) => w.id === DEFAULT_WORKFLOW_LANE_ID);
expect(defaultWf).toBeDefined();
// Default workflow columns are the legacy enum ids in order.
expect(defaultWf!.columns.map((c) => c.id)).toEqual([
"triage",
"todo",
"in-progress",
"in-review",
"done",
"archived",
]);
});
it("describes a custom workflow's columns with resolved trait flags", async () => {
const store = makeStore({
flagOn: true,
selections: { "FN-9": "wf-custom" },
defs: { "wf-custom": CUSTOM },
});
const payload = await buildBoardWorkflowsPayload(store as never, ["FN-9"]);
expect(payload.taskWorkflowIds["FN-9"]).toBe("wf-custom");
const custom = payload.workflows.find((w) => w.id === "wf-custom");
expect(custom).toBeDefined();
expect(custom!.name).toBe("Custom Flow");
const intake = custom!.columns.find((c) => c.id === "intake");
const done = custom!.columns.find((c) => c.id === "done");
expect(intake!.flags.intake).toBe(true);
expect(done!.flags.complete).toBe(true);
});
it("deduplicates referenced workflows and always includes the default lane", async () => {
const store = makeStore({
flagOn: true,
selections: { "FN-1": "wf-custom", "FN-2": "wf-custom" },
defs: { "wf-custom": CUSTOM },
});
const payload = await buildBoardWorkflowsPayload(store as never, ["FN-1", "FN-2"]);
const ids = payload.workflows.map((w) => w.id).sort();
expect(ids).toEqual([DEFAULT_WORKFLOW_LANE_ID, "wf-custom"]);
});
});

View File

@@ -0,0 +1,168 @@
/**
* Board multi-lane payload assembly (U9, R16/R17).
*
* When the `workflowColumns` flag is ON, the dashboard board groups visible
* cards into one lane per workflow in use. This module resolves, for a set of
* tasks, the workflow each card belongs to plus the (deduplicated) set of
* workflow definitions referenced — each carrying its ordered columns, display
* names, and *resolved trait flags* (archived / hold / complete / wip etc.) so
* the client can render lanes, hide archived columns, show promote affordances,
* and pre-check drag adjacency/capacity without a second round-trip.
*
* The payload is served by a sibling endpoint (`GET /tasks/board-workflows`)
* rather than folded into the `/tasks` list response, so the existing task
* payload stays byte-identical and flag-OFF clients are wholly unaffected
* (additive-only, KTD-8/R19).
*/
import {
BUILTIN_CODING_WORKFLOW_IR,
getBuiltinWorkflow,
isBuiltinWorkflowId,
isWorkflowColumnsEnabled,
parseWorkflowIr,
resolveColumnFlags,
type Settings,
type TaskStore,
type TraitFlags,
type WorkflowIr,
type WorkflowIrV2,
} from "@fusion/core";
/** Stable id the client uses for the implicit default lane (null selection). */
export const DEFAULT_WORKFLOW_LANE_ID = "builtin:coding";
/** One column as the board client needs it: id, display name, resolved flags. */
export interface BoardWorkflowColumn {
id: string;
name: string;
flags: TraitFlags;
}
/** A workflow definition in use by visible cards. */
export interface BoardWorkflowDefinition {
id: string;
name: string;
columns: BoardWorkflowColumn[];
}
/** The full board-workflows payload. `flagEnabled: false` short-circuits the
* client back to the legacy single-lane render. */
export interface BoardWorkflowsPayload {
flagEnabled: boolean;
/** The default lane id (where null-selection cards land). */
defaultWorkflowId: string;
/** Deduplicated workflow definitions referenced by the provided tasks. */
workflows: BoardWorkflowDefinition[];
/** taskId → resolved workflowId (the lane the card belongs in). */
taskWorkflowIds: Record<string, string>;
}
function toV2(ir: WorkflowIr): WorkflowIrV2 | undefined {
return ir.version === "v2" ? ir : undefined;
}
function describeColumns(ir: WorkflowIr): BoardWorkflowColumn[] {
const v2 = toV2(ir);
if (!v2) return [];
return v2.columns.map((col) => ({
id: col.id,
name: col.name,
flags: resolveColumnFlags(col),
}));
}
async function resolveWorkflowIr(
store: Pick<TaskStore, "getWorkflowDefinition">,
workflowId: string,
): Promise<WorkflowIr> {
if (isBuiltinWorkflowId(workflowId)) {
const builtin = getBuiltinWorkflow(workflowId);
const ir = builtin?.ir;
if (!ir) return BUILTIN_CODING_WORKFLOW_IR;
return typeof ir === "string" ? parseWorkflowIr(ir) : ir;
}
try {
const def = await store.getWorkflowDefinition(workflowId);
if (!def) return BUILTIN_CODING_WORKFLOW_IR;
return typeof def.ir === "string" ? parseWorkflowIr(def.ir) : def.ir;
} catch {
return BUILTIN_CODING_WORKFLOW_IR;
}
}
async function describeWorkflow(
store: Pick<TaskStore, "getWorkflowDefinition">,
workflowId: string,
): Promise<BoardWorkflowDefinition> {
const ir = await resolveWorkflowIr(store, workflowId);
// The display name comes from the persisted definition when available,
// otherwise the IR's own name (default workflow).
let name = ir.name;
if (isBuiltinWorkflowId(workflowId)) {
name = getBuiltinWorkflow(workflowId)?.name ?? name;
} else {
try {
const def = await store.getWorkflowDefinition(workflowId);
if (def?.name) name = def.name;
} catch {
// fall through to IR name
}
}
return { id: workflowId, name, columns: describeColumns(ir) };
}
/**
* Build the board-workflows payload for the given task ids. Resolves each task's
* workflow selection (null → the default workflow lane) and assembles the
* deduplicated set of referenced workflow definitions. Returns
* `{ flagEnabled: false, ... }` (empty maps) when the flag is OFF so the route
* can return early and the client renders the legacy board.
*/
export async function buildBoardWorkflowsPayload(
store: Pick<TaskStore, "getWorkflowDefinition" | "getTaskWorkflowSelection" | "getSettings">,
taskIds: string[],
settingsOverride?: Pick<Settings, "experimentalFeatures">,
): Promise<BoardWorkflowsPayload> {
const settings = settingsOverride ?? (await store.getSettings());
const flagEnabled = isWorkflowColumnsEnabled(settings);
const empty: BoardWorkflowsPayload = {
flagEnabled,
defaultWorkflowId: DEFAULT_WORKFLOW_LANE_ID,
workflows: [],
taskWorkflowIds: {},
};
if (!flagEnabled) return empty;
const taskWorkflowIds: Record<string, string> = {};
const referenced = new Set<string>();
for (const taskId of taskIds) {
let workflowId = DEFAULT_WORKFLOW_LANE_ID;
try {
const selection = store.getTaskWorkflowSelection(taskId);
if (selection?.workflowId) workflowId = selection.workflowId;
} catch {
workflowId = DEFAULT_WORKFLOW_LANE_ID;
}
taskWorkflowIds[taskId] = workflowId;
referenced.add(workflowId);
}
// The default workflow lane is always describable so a no-task board still
// resolves it (and the client's default-lane-first ordering is stable).
referenced.add(DEFAULT_WORKFLOW_LANE_ID);
const workflows: BoardWorkflowDefinition[] = [];
for (const workflowId of referenced) {
workflows.push(await describeWorkflow(store, workflowId));
}
return {
flagEnabled,
defaultWorkflowId: DEFAULT_WORKFLOW_LANE_ID,
workflows,
taskWorkflowIds,
};
}

View File

@@ -36,12 +36,15 @@ import {
findNearDuplicates,
isEphemeralAgent,
parseExplicitDuplicateMarker,
isWorkflowColumnsEnabled,
TransitionRejectionError,
type NearDuplicateCandidate,
} from "@fusion/core";
import { GitHubClient } from "../github.js";
import { createTrackingIssueForTask } from "../github-tracking-hook.js";
import { parseGitHubBadgeUrl } from "./register-git-github.js";
import { planTaskWorktreePath } from "@fusion/engine";
import { planTaskWorktreePath, promoteHeldTask } from "@fusion/engine";
import { buildBoardWorkflowsPayload } from "./board-workflows.js";
import type { RunAuditEventInput } from "@fusion/core";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
@@ -782,6 +785,30 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
// Multi-lane board metadata (U9, R16). Additive sibling to GET /tasks — the
// task list payload stays byte-identical. Flag-OFF returns
// { flagEnabled: false } and the client renders the legacy single-lane board.
router.get("/tasks/board-workflows", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettingsFast();
if (!isWorkflowColumnsEnabled(settings)) {
res.json({ flagEnabled: false, defaultWorkflowId: "builtin:coding", workflows: [], taskWorkflowIds: {} });
return;
}
// Resolve over the same (non-archived) board list the client renders.
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
const taskIds = tasks.map((t) => t.id);
const payload = await buildBoardWorkflowsPayload(scopedStore, taskIds, settings);
res.json(payload);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
router.post("/tasks/duplicate-check", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
@@ -1344,11 +1371,70 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
if (err instanceof ApiError) {
throw err;
}
// Flag-ON typed rejections surface as a structured 409 so the board can
// resolve the i18n messageKey and decide snap-back vs no-move (U9/R17).
// Flag-OFF legacy errors are unchanged (the legacy strings below).
if (err instanceof TransitionRejectionError) {
throw new ApiError(409, err.message, {
code: err.rejection.code,
messageKey: err.rejection.messageKey,
retryable: err.rejection.retryable,
});
}
const status = (err instanceof Error ? err.message : String(err)).includes("Invalid transition") ? 400 : 500;
throw new ApiError(status, err instanceof Error ? err.message : String(err));
}
});
// Manually promote a held card out of its hold column (U9). Releases via the
// same authority as the hold/release sweep; the in-txn capacity check still
// arbitrates, so a promote into a full column rejects with capacity-exhausted.
router.post("/tasks/:id/promote", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const settings = await scopedStore.getSettingsFast();
if (!isWorkflowColumnsEnabled(settings)) {
throw badRequest("Workflow columns are not enabled");
}
const existing = await scopedStore.getTask(req.params.id);
const rootDir = scopedStore.getRootDir();
const allocateWorktree = existing
? (task: Task, reservedNames: Set<string>) =>
planTaskWorktreePath(task, rootDir, settings.worktreeNaming, reservedNames, settings)
: undefined;
const result = await promoteHeldTask(scopedStore, req.params.id, { allocateWorktree });
if (!result.released) {
if (result.rejection === "capacity-exhausted-or-no-slot") {
throw new ApiError(409, "Downstream column is at capacity", {
code: "capacity-exhausted",
messageKey: "board.rejection.capacityExhausted",
retryable: true,
});
}
throw new ApiError(409, result.rejection ?? "Promote rejected", {
code: "guard-rejected",
messageKey: "board.rejection.promoteRejected",
retryable: false,
});
}
const task = await scopedStore.getTask(req.params.id);
res.json(task);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof TransitionRejectionError) {
throw new ApiError(409, err.message, {
code: err.rejection.code,
messageKey: err.rejection.messageKey,
retryable: err.rejection.retryable,
});
}
rethrowAsApiError(err);
}
});
// Merge task (in-review → done, merges branch + cleans worktree)
// Uses AI merge handler if provided, falls back to store.mergeTask
router.post("/tasks/:id/merge", async (req, res) => {

File diff suppressed because it is too large Load Diff

View File

@@ -20,11 +20,12 @@
"agentRunId": "ID:",
"agentRunLogsBackHint": "[Esc/q] back to runs",
"agentRunLogsTitle": "Run logs ({{index}})",
"agentsFooterHints": "[s] start [x] stop [D] delete [r] refresh [Tab] focus ↑↓ select",
"agentsListTitle_one": "Agents ({{count}})",
"agentsListTitle_other": "Agents ({{count}})",
"agentsNoAgents": "No agents found.",
"agentStarted": "Agent started",
"agentStopped": "Agent stopped",
"agentsFooterHints": "[s] start [x] stop [D] delete [r] refresh [Tab] focus ↑↓ select",
"agentsListTitle": "Agents ({{count}})",
"agentsNoAgents": "No agents found.",
"boardCreateTaskHints": "Enter to create · Esc to cancel",
"boardCreateTaskNoProject": "No project selected",
"boardCreateTaskTitleEmpty": "Title cannot be empty",
@@ -33,6 +34,7 @@
"boardNewTaskProject": "Project: {{name}}",
"boardNewTaskTitle": "New Task",
"boardNewTaskTitleLabel": "Title",
"boardOtherReadOnlyHint": "custom column — move disabled here",
"copiedSuccess": "✓ Copied!",
"copyFailed": "✗ Copy failed",
"expandedLogHeader": "Entry {{index}}/{{total}} · [Enter/Esc] close · [c] copy",
@@ -43,26 +45,27 @@
"filesEmpty": "(empty)",
"filesEmptyFile": "(empty file)",
"filesFooterHints": "[Tab] switch pane [↑↓/jk] move [Enter] open [←/→] collapse/expand [.] hidden [w] wrap [p] project [r] reload",
"filesMoreLines": "… {{count}} more lines",
"filesMoreLines_one": "… {{count}} more lines",
"filesMoreLines_other": "… {{count}} more lines",
"filesSelectProject": "Select Project",
"filesSelectToPreview": "Select a file to preview",
"filesTooLarge": "{{size}} — [too large to preview]",
"filesUnableToRead": "Unable to read file",
"gitFetchFailed": "Fetch failed: {{output}}",
"gitFetched": "Fetched",
"gitFetchFailed": "Fetch failed: {{output}}",
"gitFetching": "Fetching…",
"gitFooterHints": "[r] refresh {{push}}[F] fetch [↑↓] rows [←→] status▸branches{{worktrees}}▸commits▸changes [p] project [Esc/s] back",
"gitNoCommits": "No commits",
"gitNoProject": "No project",
"gitPushDismissHint": "[Esc] dismiss",
"gitPushFailed": "Push failed",
"gitPushingToOrigin": "Pushing to origin/{{branch}}",
"gitPushModalAhead": "ahead",
"gitPushModalBranch": "Branch:",
"gitPushModalCommits": "Commits to push (oldest→newest):",
"gitPushModalHints": "[Enter] push [Esc] cancel",
"gitPushModalTitle": "Push to remote",
"gitPushSuccessful": "Push successful",
"gitPushingToOrigin": "Pushing to origin/{{branch}}",
"gitRefreshing": "refreshing",
"gitWorkingTreeClean": "Working tree clean",
"headerHelpQuitHint": "[?] help [q] quit",
@@ -117,14 +120,13 @@
"projectSelectorChangeHint": "[p] change",
"projectSelectorLabel": "Project:",
"projectSelectorNavHints": "↑↓ navigate · Enter select · Esc cancel",
"projectSelectorNoProjects": "(no projects registered)",
"projectSelectorNone": "(none)",
"projectSelectorNoProjects": "(no projects registered)",
"projectSelectorPickTitle": "Pick a project",
"qrCloseHint": "[Esc] close",
"qrGenerating": "Generating QR…",
"qrNoTunnelRunning": "No remote tunnel is running. Start one in Settings (g).",
"qrOverlayTitle": "Remote Access — Scan to connect",
"quit": "Quit",
"readyIn": "Ready in {{secs}}s",
"runLogNone": "No logs captured for this run.",
"runLogResult": "result:",
@@ -137,16 +139,6 @@
"runStatusFailed": "Failed",
"runStatusTerminated": "Terminated",
"runStatusUnknown": "Unknown",
"settingAutoMerge": "Auto Merge",
"settingEnginePaused": "Engine Paused",
"settingGlobalPause": "Global Pause",
"settingMaxConcurrent": "Max Concurrent",
"settingMaxWorktrees": "Max Worktrees",
"settingMergeStrategy": "Merge Strategy",
"settingPollIntervalMs": "Poll Interval (ms)",
"settingRemoteActiveProvider": "Remote Provider",
"settingRemoteShortLivedEnabled": "Short-Lived Tokens",
"settingRemoteShortLivedTtlMs": "Short-Lived TTL (ms)",
"settingsActivatedProvider": "Activated provider: {{provider}}",
"settingsAdjust1": "[+/-] adjust by 1",
"settingsAdjust5000ms": "[+/-] adjust by 5000ms",
@@ -161,7 +153,8 @@
"settingsFooterHints": "[Tab] switch panel ↑↓ select setting [Space] toggle bool [+/-] adjust num [←/→] cycle enum [C/V/X/P/L/U/K/R] remote actions",
"settingsInteractivePanelTitle": "Settings",
"settingsLoadingSettings": "Loading settings…",
"settingsMoreModels": "… and {{count}} more",
"settingsMoreModels_one": "… and {{count}} more",
"settingsMoreModels_other": "… and {{count}} more",
"settingsPanelTitle": "Settings",
"settingsPersistentTokenRegenerated": "Persistent token regenerated",
"settingsQrFetched": "QR payload fetched",

View File

@@ -1,23 +1,213 @@
{
"actions": {
"cancel": "Cancel",
"close": "Close",
"save": "Save"
"agents": {
"ratings": {
"trendDeclining": "↓ Declining",
"trendImproving": "↑ Improving",
"trendInsufficient": "Insufficient data",
"trendStable": "→ Stable"
},
"reflections": {
"triggerManual": "Manual",
"triggerPeriodic": "Periodic",
"triggerPostTask": "Post-Task",
"triggerUserRequested": "User Requested"
},
"time": {
"daysAgo_one": "{{count}}d ago",
"daysAgo_other": "{{count}}d ago",
"hoursAgo_one": "{{count}}h ago",
"hoursAgo_other": "{{count}}h ago",
"inAMoment": "in a moment",
"inDays_one": "in {{count}}d",
"inDays_other": "in {{count}}d",
"inHours_one": "in {{count}}h",
"inHours_other": "in {{count}}h",
"inMinutes_one": "in {{count}}m",
"inMinutes_other": "in {{count}}m",
"justNow": "just now",
"minutesAgo_one": "{{count}}m ago",
"minutesAgo_other": "{{count}}m ago"
}
},
"archive": "Archive",
"cancel": "Cancel",
"close": "Close",
"columns": {
"archived": "Archived",
"done": "Done",
"in-progress": "In Progress",
"in-review": "In Review",
"todo": "Todo",
"triage": "Planning"
"board": {
"rejection": {
"capacityExhausted": "That column is at capacity. Try again when a slot frees up.",
"guardRejected": "This move is not allowed by the workflow.",
"mergeBlocked": "This task is blocked from completing until its merge step finishes.",
"unknownColumn": "That column doesn't exist in this task's workflow.",
"workflowMismatch": "Drag can't move a card between workflows. Use the workflow switcher instead."
}
},
"delete": "Delete",
"refresh": "Refresh",
"retry": "Retry",
"skip": "Skip",
"tryAgain": "Try Again"
"chat": {
"failedToGetResponse": "Failed to get response",
"failureReferenceId": "ID",
"failureReferenceKind": "Kind",
"failureReferenceLabel": "Reference",
"failureReferenceMetaLabel": "Label",
"openMailboxMessage": "Open mailbox message",
"toolCallArgsPrefix": "args",
"toolCallResultPrefix": "result",
"toolCallsCount_one": "{{count}} tool calls",
"toolCallsCount_other": "{{count}} tool calls",
"toolCallsHeader": "Tool calls",
"toolCallStatusCompleted": "completed",
"toolCallStatusError": "error",
"toolCallStatusErrors": "errors",
"toolCallStatusRunning": "running",
"viewFailureDetails": "View failure details"
},
"health": {
"anomaly": {
"duplicateActiveId": "Duplicate active task ID",
"idInBothStorages": "Task ID present in active and archived storage",
"sequenceOverlap": "Allocator next sequence overlaps an existing task ID",
"unknownPrefix": "Task row uses a prefix outside allocator state"
}
},
"inline": {
"connecting": "Connecting",
"error": "Error",
"offline": "Offline",
"online": "Online"
},
"merge": {
"unknown": "Unknown"
},
"missions": {
"autopilotStateActivating": "Activating slice",
"autopilotStateCompleting": "Completing",
"autopilotStateInactive": "Off",
"autopilotStateUnknown": "Unknown",
"autopilotStateWatching": "Watching",
"interviewStatusAwaitingInput": "Awaiting input",
"interviewStatusComplete": "Plan ready",
"interviewStatusError": "Needs retry",
"interviewStatusGenerating": "Generating plan",
"runHelperActive": "Stopping pauses linked tasks and marks the mission blocked.",
"runHelperBlocked": "Resuming re-activates the mission and continues execution.",
"runHelperPlanning": "Starting activates the first slice so work can begin."
},
"models": {
"messages": {
"modelSetTo": "{{label}} model set to {{provider}}/{{modelId}}",
"modelSetToDefault": "{{label}} model set to default"
}
},
"nodes": {
"auth": {
"differ": "Auth credentials differ",
"differProviders": "Auth credentials differ: {{providers}}",
"match": "Auth credentials match",
"notSynced": "Auth not synced"
},
"status": {
"connecting": "Connecting",
"creating": "Creating",
"deleting": "Deleting",
"error": "Error",
"exited": "Exited",
"offline": "Offline",
"online": "Online",
"recreating": "Recreating",
"running": "Running",
"stopped": "Stopped"
}
},
"nodeStatus": {
"connecting": "Connecting",
"error": "Error",
"offline": "Offline",
"online": "Online",
"unknown": "Unknown"
},
"research": {
"providerGitHub": "GitHub",
"providerLlmSynthesis": "LLM Synthesis",
"providerLocalDocs": "Local Docs",
"providerPageFetch": "Page Fetch",
"providerWebSearch": "Web Search"
},
"routing": {
"policyLabel": {
"block": "Block execution",
"fallback": "Fall back to local",
"notConfigured": "Not configured"
}
},
"setup": {
"apiKeyFormatError": "{{providerName}} keys should follow this format: {{hint}} (e.g. {{example}})",
"apiKeyLabel": {
"fallback": "API Key",
"kimiCoding": "Kimi API Key",
"minimax": "MiniMax API Key",
"ollama": "Ollama Endpoint",
"openai": "OpenAI API Key",
"openrouter": "OpenRouter API Key",
"zai": "Zhipu AI API Key"
},
"apiKeyPlaceholder": {
"fallback": "Enter API key",
"kimiCoding": "Enter your Kimi API key",
"minimax": "Enter your MiniMax API key",
"zai": "Enter your Zhipu AI API key"
},
"apiKeyRequired": "API key is required",
"apiKeySetup": {
"fallback": "Enter your API key for this provider.",
"kimiCoding": "Create your API key in the Moonshot platform account settings.",
"minimax": "Generate an API key from the MiniMax platform developer console.",
"ollama": "Enter your Ollama endpoint URL (for example http://localhost:11434).",
"openai": "Create an API key from your OpenAI dashboard under API keys.",
"openrouter": "Create an API key from your OpenRouter account key management page.",
"zai": "Create an API key in the Zhipu AI open platform account settings."
},
"apiKeyUsage": {
"fallback": "Used by Fusion to authenticate requests to this provider",
"kimiCoding": "Used for Kimi/Moonshot AI models in task execution and planning",
"minimax": "Used for MiniMax models in task execution",
"ollama": "Connects to your local Ollama instance",
"openai": "Used for GPT models in task execution and planning",
"openrouter": "Routes to multiple AI model providers through a single key",
"zai": "Used for GLM models in task execution"
},
"providerDesc": {
"anthropic": "Claude models — strong at reasoning, analysis, and code",
"fallback": "AI provider — connect to start using AI models",
"gemini": "Gemini models — multimodal with strong reasoning",
"google": "Gemini models — multimodal with strong reasoning",
"kimi": "Kimi by Moonshot AI — long-context capabilities",
"kimiCoding": "Kimi by Moonshot AI — long-context capabilities",
"minimax": "MiniMax models — cost-effective for high-volume usage",
"moonshot": "Kimi by Moonshot AI — long-context capabilities",
"ollama": "Run open-source models locally on your machine",
"openai": "GPT models — versatile for a wide range of tasks",
"openaiCodex": "Codex models by OpenAI — optimized for coding tasks",
"openrouter": "OpenRouter — route requests across multiple AI providers",
"zai": "GLM models by Zhipu AI — strong multilingual support"
}
},
"taskForm": {
"nodeStatusConnecting": "Connecting",
"nodeStatusError": "Error",
"nodeStatusOffline": "Offline",
"nodeStatusOnline": "Online",
"phasePostMerge": "Post-merge",
"phasePreMerge": "Pre-merge"
},
"taskReview": {
"never": "Never",
"refreshSourceBackground": "Background",
"refreshSourceInitialLoad": "Initial load",
"refreshSourceManual": "Manual"
},
"workflow": {
"postMerge": "Post-merge",
"preMerge": "Pre-merge",
"statusAdvisory": "Advisory failure",
"statusFailed": "Failed",
"statusPassed": "Passed",
"statusRunning": "Running…",
"statusSkipped": "Skipped",
"waitingForOutput": "Waiting for agent output…"
}
}

View File

@@ -1,4 +1 @@
{
"fetchProjectsFailed": "Failed to fetch projects",
"openTaskLogsFailed": "Failed to open task logs: {{detail}}"
}
{}

File diff suppressed because it is too large Load Diff

View File

@@ -20,11 +20,12 @@
"agentRunId": "ID:",
"agentRunLogsBackHint": "[Esc/q] volver a ejecuciones",
"agentRunLogsTitle": "Registros de ejecución ({{index}})",
"agentsFooterHints": "[s] iniciar [x] detener [D] eliminar [r] actualizar [Tab] foco ↑↓ seleccionar",
"agentsListTitle_one": "",
"agentsListTitle_other": "",
"agentsNoAgents": "No se encontraron agentes.",
"agentStarted": "Agente iniciado",
"agentStopped": "Agente detenido",
"agentsFooterHints": "[s] iniciar [x] detener [D] eliminar [r] actualizar [Tab] foco ↑↓ seleccionar",
"agentsListTitle": "Agentes ({{count}})",
"agentsNoAgents": "No se encontraron agentes.",
"boardCreateTaskHints": "Enter para crear · Esc para cancelar",
"boardCreateTaskNoProject": "No hay proyecto seleccionado",
"boardCreateTaskTitleEmpty": "El título no puede estar vacío",
@@ -33,6 +34,7 @@
"boardNewTaskProject": "Proyecto: {{name}}",
"boardNewTaskTitle": "Nueva tarea",
"boardNewTaskTitleLabel": "Título",
"boardOtherReadOnlyHint": "",
"copiedSuccess": "✓ ¡Copiado!",
"copyFailed": "✗ Error al copiar",
"expandedLogHeader": "Entrada {{index}}/{{total}} · [Enter/Esc] cerrar · [c] copiar",
@@ -43,26 +45,27 @@
"filesEmpty": "(vacío)",
"filesEmptyFile": "(archivo vacío)",
"filesFooterHints": "[Tab] cambiar panel [↑↓/jk] mover [Enter] abrir [←/→] colapsar/expandir [.] ocultos [w] ajuste de línea [p] proyecto [r] recargar",
"filesMoreLines": "… {{count}} líneas más",
"filesMoreLines_one": "",
"filesMoreLines_other": "",
"filesSelectProject": "Seleccionar proyecto",
"filesSelectToPreview": "Selecciona un archivo para previsualizar",
"filesTooLarge": "{{size}} — [demasiado grande para previsualizar]",
"filesUnableToRead": "No se puede leer el archivo",
"gitFetchFailed": "Error al descargar: {{output}}",
"gitFetched": "Descargado",
"gitFetchFailed": "Error al descargar: {{output}}",
"gitFetching": "Descargando…",
"gitFooterHints": "[r] actualizar {{push}}[F] descargar [↑↓] filas [←→] estado▸ramas{{worktrees}}▸commits▸cambios [p] proyecto [Esc/s] volver",
"gitNoCommits": "Sin commits",
"gitNoProject": "Sin proyecto",
"gitPushDismissHint": "[Esc] cerrar",
"gitPushFailed": "Error al subir",
"gitPushingToOrigin": "Subiendo a origin/{{branch}}",
"gitPushModalAhead": "adelante",
"gitPushModalBranch": "Rama:",
"gitPushModalCommits": "Commits a subir (del más antiguo al más reciente):",
"gitPushModalHints": "[Enter] subir [Esc] cancelar",
"gitPushModalTitle": "Subir al remoto",
"gitPushSuccessful": "Subida exitosa",
"gitPushingToOrigin": "Subiendo a origin/{{branch}}",
"gitRefreshing": "actualizando",
"gitWorkingTreeClean": "Árbol de trabajo limpio",
"headerHelpQuitHint": "[?] ayuda [q] salir",
@@ -117,14 +120,13 @@
"projectSelectorChangeHint": "[p] cambiar",
"projectSelectorLabel": "Proyecto:",
"projectSelectorNavHints": "↑↓ navegar · Enter seleccionar · Esc cancelar",
"projectSelectorNoProjects": "(no hay proyectos registrados)",
"projectSelectorNone": "(ninguno)",
"projectSelectorNoProjects": "(no hay proyectos registrados)",
"projectSelectorPickTitle": "Elige un proyecto",
"qrCloseHint": "[Esc] cerrar",
"qrGenerating": "Generando QR…",
"qrNoTunnelRunning": "No hay túnel remoto en ejecución. Inicia uno en Configuración (g).",
"qrOverlayTitle": "Acceso remoto — Escanea para conectarte",
"quit": "Salir",
"readyIn": "Listo en {{secs}} s",
"runLogNone": "No se capturaron registros para esta ejecución.",
"runLogResult": "resultado:",
@@ -137,16 +139,6 @@
"runStatusFailed": "Fallido",
"runStatusTerminated": "Terminado",
"runStatusUnknown": "Desconocido",
"settingAutoMerge": "Fusión automática",
"settingEnginePaused": "Motor en pausa",
"settingGlobalPause": "Pausa global",
"settingMaxConcurrent": "Máximo concurrente",
"settingMaxWorktrees": "Árboles de trabajo máximos",
"settingMergeStrategy": "Estrategia de fusión",
"settingPollIntervalMs": "Intervalo de sondeo (ms)",
"settingRemoteActiveProvider": "Proveedor remoto",
"settingRemoteShortLivedEnabled": "Tokens de corta duración",
"settingRemoteShortLivedTtlMs": "TTL de corta duración (ms)",
"settingsActivatedProvider": "Proveedor activado: {{provider}}",
"settingsAdjust1": "[+/-] ajustar en 1",
"settingsAdjust5000ms": "[+/-] ajustar en 5000 ms",
@@ -161,7 +153,8 @@
"settingsFooterHints": "[Tab] cambiar panel ↑↓ seleccionar ajuste [Espacio] alternar bool [+/-] ajustar num [←/→] ciclar enum [C/V/X/P/L/U/K/R] acciones remotas",
"settingsInteractivePanelTitle": "Configuración",
"settingsLoadingSettings": "Cargando configuración…",
"settingsMoreModels": "… y {{count}} más",
"settingsMoreModels_one": "",
"settingsMoreModels_other": "",
"settingsPanelTitle": "Configuración",
"settingsPersistentTokenRegenerated": "Token persistente regenerado",
"settingsQrFetched": "Carga QR obtenida",

View File

@@ -1,23 +1,213 @@
{
"actions": {
"cancel": "Cancelar",
"close": "Cerrar",
"save": "Guardar"
"agents": {
"ratings": {
"trendDeclining": "",
"trendImproving": "",
"trendInsufficient": "",
"trendStable": ""
},
"reflections": {
"triggerManual": "",
"triggerPeriodic": "",
"triggerPostTask": "",
"triggerUserRequested": ""
},
"time": {
"daysAgo_one": "",
"daysAgo_other": "",
"hoursAgo_one": "",
"hoursAgo_other": "",
"inAMoment": "",
"inDays_one": "",
"inDays_other": "",
"inHours_one": "",
"inHours_other": "",
"inMinutes_one": "",
"inMinutes_other": "",
"justNow": "",
"minutesAgo_one": "",
"minutesAgo_other": ""
}
},
"archive": "Archivar",
"cancel": "Cancelar",
"close": "Cerrar",
"columns": {
"archived": "Archivado",
"done": "Hecho",
"in-progress": "En progreso",
"in-review": "En revisión",
"todo": "Por hacer",
"triage": "Planificación"
"board": {
"rejection": {
"capacityExhausted": "",
"guardRejected": "",
"mergeBlocked": "",
"unknownColumn": "",
"workflowMismatch": ""
}
},
"delete": "Eliminar",
"refresh": "Actualizar",
"retry": "Reintentar",
"skip": "Omitir",
"tryAgain": "Reintentar"
"chat": {
"failedToGetResponse": "",
"failureReferenceId": "",
"failureReferenceKind": "",
"failureReferenceLabel": "",
"failureReferenceMetaLabel": "",
"openMailboxMessage": "",
"toolCallArgsPrefix": "",
"toolCallResultPrefix": "",
"toolCallsCount_one": "",
"toolCallsCount_other": "",
"toolCallsHeader": "",
"toolCallStatusCompleted": "",
"toolCallStatusError": "",
"toolCallStatusErrors": "",
"toolCallStatusRunning": "",
"viewFailureDetails": ""
},
"health": {
"anomaly": {
"duplicateActiveId": "",
"idInBothStorages": "",
"sequenceOverlap": "",
"unknownPrefix": ""
}
},
"inline": {
"connecting": "",
"error": "",
"offline": "",
"online": ""
},
"merge": {
"unknown": ""
},
"missions": {
"autopilotStateActivating": "",
"autopilotStateCompleting": "",
"autopilotStateInactive": "",
"autopilotStateUnknown": "",
"autopilotStateWatching": "",
"interviewStatusAwaitingInput": "",
"interviewStatusComplete": "",
"interviewStatusError": "",
"interviewStatusGenerating": "",
"runHelperActive": "",
"runHelperBlocked": "",
"runHelperPlanning": ""
},
"models": {
"messages": {
"modelSetTo": "",
"modelSetToDefault": ""
}
},
"nodes": {
"auth": {
"differ": "",
"differProviders": "",
"match": "",
"notSynced": ""
},
"status": {
"connecting": "",
"creating": "",
"deleting": "",
"error": "",
"exited": "",
"offline": "",
"online": "",
"recreating": "",
"running": "",
"stopped": ""
}
},
"nodeStatus": {
"connecting": "",
"error": "",
"offline": "",
"online": "",
"unknown": ""
},
"research": {
"providerGitHub": "",
"providerLlmSynthesis": "",
"providerLocalDocs": "",
"providerPageFetch": "",
"providerWebSearch": ""
},
"routing": {
"policyLabel": {
"block": "",
"fallback": "",
"notConfigured": ""
}
},
"setup": {
"apiKeyFormatError": "",
"apiKeyLabel": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"apiKeyPlaceholder": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"zai": ""
},
"apiKeyRequired": "",
"apiKeySetup": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"apiKeyUsage": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"providerDesc": {
"anthropic": "",
"fallback": "",
"gemini": "",
"google": "",
"kimi": "",
"kimiCoding": "",
"minimax": "",
"moonshot": "",
"ollama": "",
"openai": "",
"openaiCodex": "",
"openrouter": "",
"zai": ""
}
},
"taskForm": {
"nodeStatusConnecting": "",
"nodeStatusError": "",
"nodeStatusOffline": "",
"nodeStatusOnline": "",
"phasePostMerge": "",
"phasePreMerge": ""
},
"taskReview": {
"never": "",
"refreshSourceBackground": "",
"refreshSourceInitialLoad": "",
"refreshSourceManual": ""
},
"workflow": {
"postMerge": "",
"preMerge": "",
"statusAdvisory": "",
"statusFailed": "",
"statusPassed": "",
"statusRunning": "",
"statusSkipped": "",
"waitingForOutput": ""
}
}

View File

@@ -1,4 +1 @@
{
"fetchProjectsFailed": "No se pudieron obtener los proyectos",
"openTaskLogsFailed": "No se pudieron abrir los registros de la tarea: {{detail}}"
}
{}

File diff suppressed because it is too large Load Diff

View File

@@ -20,11 +20,12 @@
"agentRunId": "ID :",
"agentRunLogsBackHint": "[Échap/q] retour aux exécutions",
"agentRunLogsTitle": "Journaux d'exécution ({{index}})",
"agentsFooterHints": "[s] démarrer [x] arrêter [D] supprimer [r] actualiser [Tab] focus ↑↓ sélectionner",
"agentsListTitle_one": "",
"agentsListTitle_other": "",
"agentsNoAgents": "Aucun agent trouvé.",
"agentStarted": "Agent démarré",
"agentStopped": "Agent arrêté",
"agentsFooterHints": "[s] démarrer [x] arrêter [D] supprimer [r] actualiser [Tab] focus ↑↓ sélectionner",
"agentsListTitle": "Agents ({{count}})",
"agentsNoAgents": "Aucun agent trouvé.",
"boardCreateTaskHints": "Entrée pour créer · Échap pour annuler",
"boardCreateTaskNoProject": "Aucun projet sélectionné",
"boardCreateTaskTitleEmpty": "Le titre ne peut pas être vide",
@@ -33,6 +34,7 @@
"boardNewTaskProject": "Projet : {{name}}",
"boardNewTaskTitle": "Nouvelle tâche",
"boardNewTaskTitleLabel": "Titre",
"boardOtherReadOnlyHint": "",
"copiedSuccess": "✓ Copié !",
"copyFailed": "✗ Échec de la copie",
"expandedLogHeader": "Entrée {{index}}/{{total}} · [Entrée/Échap] fermer · [c] copier",
@@ -43,26 +45,27 @@
"filesEmpty": "(vide)",
"filesEmptyFile": "(fichier vide)",
"filesFooterHints": "[Tab] changer de volet [↑↓/jk] déplacer [Entrée] ouvrir [←/→] réduire/développer [.] fichiers cachés [w] retour à la ligne [p] projet [r] recharger",
"filesMoreLines": "… {{count}} ligne(s) de plus",
"filesMoreLines_one": "",
"filesMoreLines_other": "",
"filesSelectProject": "Sélectionner un projet",
"filesSelectToPreview": "Sélectionnez un fichier à prévisualiser",
"filesTooLarge": "{{size}} — [trop volumineux pour être prévisualisé]",
"filesUnableToRead": "Impossible de lire le fichier",
"gitFetchFailed": "Échec de la récupération : {{output}}",
"gitFetched": "Récupéré",
"gitFetchFailed": "Échec de la récupération : {{output}}",
"gitFetching": "Récupération…",
"gitFooterHints": "[r] actualiser {{push}}[F] récupérer [↑↓] lignes [←→] statut▸branches{{worktrees}}▸commits▸modifications [p] projet [Échap/s] retour",
"gitNoCommits": "Aucun commit",
"gitNoProject": "Aucun projet",
"gitPushDismissHint": "[Échap] fermer",
"gitPushFailed": "Échec de la poussée",
"gitPushingToOrigin": "Poussée vers origin/{{branch}}",
"gitPushModalAhead": "en avance",
"gitPushModalBranch": "Branche :",
"gitPushModalCommits": "Commits à pousser (du plus ancien au plus récent) :",
"gitPushModalHints": "[Entrée] pousser [Échap] annuler",
"gitPushModalTitle": "Pousser vers le distant",
"gitPushSuccessful": "Poussée réussie",
"gitPushingToOrigin": "Poussée vers origin/{{branch}}",
"gitRefreshing": "actualisation",
"gitWorkingTreeClean": "Arbre de travail propre",
"headerHelpQuitHint": "[?] aide [q] quitter",
@@ -117,14 +120,13 @@
"projectSelectorChangeHint": "[p] changer",
"projectSelectorLabel": "Projet :",
"projectSelectorNavHints": "↑↓ naviguer · Entrée sélectionner · Échap annuler",
"projectSelectorNoProjects": "(aucun projet enregistré)",
"projectSelectorNone": "(aucun)",
"projectSelectorNoProjects": "(aucun projet enregistré)",
"projectSelectorPickTitle": "Choisir un projet",
"qrCloseHint": "[Échap] fermer",
"qrGenerating": "Génération du QR…",
"qrNoTunnelRunning": "Aucun tunnel distant n'est en cours d'exécution. Démarrez-en un dans les paramètres (g).",
"qrOverlayTitle": "Accès distant — Scannez pour vous connecter",
"quit": "Quitter",
"readyIn": "Prêt en {{secs}} s",
"runLogNone": "Aucun log capturé pour cette exécution.",
"runLogResult": "résultat :",
@@ -137,16 +139,6 @@
"runStatusFailed": "Échoué",
"runStatusTerminated": "Terminé de force",
"runStatusUnknown": "Inconnu",
"settingAutoMerge": "Fusion automatique",
"settingEnginePaused": "Moteur en pause",
"settingGlobalPause": "Pause globale",
"settingMaxConcurrent": "Concurrence max",
"settingMaxWorktrees": "Arborescences max",
"settingMergeStrategy": "Stratégie de fusion",
"settingPollIntervalMs": "Intervalle de sondage (ms)",
"settingRemoteActiveProvider": "Fournisseur distant",
"settingRemoteShortLivedEnabled": "Jetons de courte durée",
"settingRemoteShortLivedTtlMs": "TTL de courte durée (ms)",
"settingsActivatedProvider": "Fournisseur activé : {{provider}}",
"settingsAdjust1": "[+/-] ajuster de 1",
"settingsAdjust5000ms": "[+/-] ajuster de 5000 ms",
@@ -161,7 +153,8 @@
"settingsFooterHints": "[Tab] changer de panneau ↑↓ sélectionner le paramètre [Espace] basculer bool [+/-] ajuster num [←/→] cycle enum [C/V/X/P/L/U/K/R] actions distantes",
"settingsInteractivePanelTitle": "Paramètres",
"settingsLoadingSettings": "Chargement des paramètres…",
"settingsMoreModels": "… et {{count}} de plus",
"settingsMoreModels_one": "",
"settingsMoreModels_other": "",
"settingsPanelTitle": "Paramètres",
"settingsPersistentTokenRegenerated": "Jeton persistant régénéré",
"settingsQrFetched": "Payload QR récupéré",

View File

@@ -1,23 +1,213 @@
{
"actions": {
"cancel": "Annuler",
"close": "Fermer",
"save": "Enregistrer"
"agents": {
"ratings": {
"trendDeclining": "",
"trendImproving": "",
"trendInsufficient": "",
"trendStable": ""
},
"reflections": {
"triggerManual": "",
"triggerPeriodic": "",
"triggerPostTask": "",
"triggerUserRequested": ""
},
"time": {
"daysAgo_one": "",
"daysAgo_other": "",
"hoursAgo_one": "",
"hoursAgo_other": "",
"inAMoment": "",
"inDays_one": "",
"inDays_other": "",
"inHours_one": "",
"inHours_other": "",
"inMinutes_one": "",
"inMinutes_other": "",
"justNow": "",
"minutesAgo_one": "",
"minutesAgo_other": ""
}
},
"archive": "Archiver",
"cancel": "Annuler",
"close": "Fermer",
"columns": {
"archived": "Archivé",
"done": "Terminé",
"in-progress": "En cours",
"in-review": "En revue",
"todo": "À faire",
"triage": "Planification"
"board": {
"rejection": {
"capacityExhausted": "",
"guardRejected": "",
"mergeBlocked": "",
"unknownColumn": "",
"workflowMismatch": ""
}
},
"delete": "Supprimer",
"refresh": "Actualiser",
"retry": "Réessayer",
"skip": "Ignorer",
"tryAgain": "Réessayer"
"chat": {
"failedToGetResponse": "",
"failureReferenceId": "",
"failureReferenceKind": "",
"failureReferenceLabel": "",
"failureReferenceMetaLabel": "",
"openMailboxMessage": "",
"toolCallArgsPrefix": "",
"toolCallResultPrefix": "",
"toolCallsCount_one": "",
"toolCallsCount_other": "",
"toolCallsHeader": "",
"toolCallStatusCompleted": "",
"toolCallStatusError": "",
"toolCallStatusErrors": "",
"toolCallStatusRunning": "",
"viewFailureDetails": ""
},
"health": {
"anomaly": {
"duplicateActiveId": "",
"idInBothStorages": "",
"sequenceOverlap": "",
"unknownPrefix": ""
}
},
"inline": {
"connecting": "",
"error": "",
"offline": "",
"online": ""
},
"merge": {
"unknown": ""
},
"missions": {
"autopilotStateActivating": "",
"autopilotStateCompleting": "",
"autopilotStateInactive": "",
"autopilotStateUnknown": "",
"autopilotStateWatching": "",
"interviewStatusAwaitingInput": "",
"interviewStatusComplete": "",
"interviewStatusError": "",
"interviewStatusGenerating": "",
"runHelperActive": "",
"runHelperBlocked": "",
"runHelperPlanning": ""
},
"models": {
"messages": {
"modelSetTo": "",
"modelSetToDefault": ""
}
},
"nodes": {
"auth": {
"differ": "",
"differProviders": "",
"match": "",
"notSynced": ""
},
"status": {
"connecting": "",
"creating": "",
"deleting": "",
"error": "",
"exited": "",
"offline": "",
"online": "",
"recreating": "",
"running": "",
"stopped": ""
}
},
"nodeStatus": {
"connecting": "",
"error": "",
"offline": "",
"online": "",
"unknown": ""
},
"research": {
"providerGitHub": "",
"providerLlmSynthesis": "",
"providerLocalDocs": "",
"providerPageFetch": "",
"providerWebSearch": ""
},
"routing": {
"policyLabel": {
"block": "",
"fallback": "",
"notConfigured": ""
}
},
"setup": {
"apiKeyFormatError": "",
"apiKeyLabel": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"apiKeyPlaceholder": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"zai": ""
},
"apiKeyRequired": "",
"apiKeySetup": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"apiKeyUsage": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"providerDesc": {
"anthropic": "",
"fallback": "",
"gemini": "",
"google": "",
"kimi": "",
"kimiCoding": "",
"minimax": "",
"moonshot": "",
"ollama": "",
"openai": "",
"openaiCodex": "",
"openrouter": "",
"zai": ""
}
},
"taskForm": {
"nodeStatusConnecting": "",
"nodeStatusError": "",
"nodeStatusOffline": "",
"nodeStatusOnline": "",
"phasePostMerge": "",
"phasePreMerge": ""
},
"taskReview": {
"never": "",
"refreshSourceBackground": "",
"refreshSourceInitialLoad": "",
"refreshSourceManual": ""
},
"workflow": {
"postMerge": "",
"preMerge": "",
"statusAdvisory": "",
"statusFailed": "",
"statusPassed": "",
"statusRunning": "",
"statusSkipped": "",
"waitingForOutput": ""
}
}

View File

@@ -1,4 +1 @@
{
"fetchProjectsFailed": "Échec de la récupération des projets",
"openTaskLogsFailed": "Échec de l'ouverture des journaux de tâche : {{detail}}"
}
{}

File diff suppressed because it is too large Load Diff

View File

@@ -20,11 +20,11 @@
"agentRunId": "ID:",
"agentRunLogsBackHint": "[Esc/q] 실행 목록으로 돌아가기",
"agentRunLogsTitle": "실행 로그 ({{index}})",
"agentsFooterHints": "[s] 시작 [x] 중지 [D] 삭제 [r] 새로고침 [Tab] 포커스 ↑↓ 선택",
"agentsListTitle_other": "",
"agentsNoAgents": "에이전트가 없습니다.",
"agentStarted": "에이전트 시작됨",
"agentStopped": "에이전트 중지됨",
"agentsFooterHints": "[s] 시작 [x] 중지 [D] 삭제 [r] 새로고침 [Tab] 포커스 ↑↓ 선택",
"agentsListTitle": "에이전트 ({{count}})",
"agentsNoAgents": "에이전트가 없습니다.",
"boardCreateTaskHints": "Enter로 생성 · Esc로 취소",
"boardCreateTaskNoProject": "선택된 프로젝트 없음",
"boardCreateTaskTitleEmpty": "제목을 입력해야 합니다",
@@ -33,6 +33,7 @@
"boardNewTaskProject": "프로젝트: {{name}}",
"boardNewTaskTitle": "새 작업",
"boardNewTaskTitleLabel": "제목",
"boardOtherReadOnlyHint": "",
"copiedSuccess": "✓ 복사됨!",
"copyFailed": "✗ 복사 실패",
"expandedLogHeader": "항목 {{index}}/{{total}} · [Enter/Esc] 닫기 · [c] 복사",
@@ -43,26 +44,26 @@
"filesEmpty": "(비어 있음)",
"filesEmptyFile": "(빈 파일)",
"filesFooterHints": "[Tab] 창 전환 [↑↓/jk] 이동 [Enter] 열기 [←/→] 접기/펼치기 [.] 숨김 [w] 줄 바꿈 [p] 프로젝트 [r] 새로고침",
"filesMoreLines": "… {{count}}줄 더 있음",
"filesMoreLines_other": "",
"filesSelectProject": "프로젝트 선택",
"filesSelectToPreview": "미리볼 파일을 선택하세요",
"filesTooLarge": "{{size}} — [미리보기에 너무 큼]",
"filesUnableToRead": "파일을 읽을 수 없습니다",
"gitFetchFailed": "Fetch 실패: {{output}}",
"gitFetched": "Fetch 완료",
"gitFetchFailed": "Fetch 실패: {{output}}",
"gitFetching": "Fetch 중…",
"gitFooterHints": "[r] 새로고침 {{push}}[F] fetch [↑↓] 행 [←→] 상태▸브랜치{{worktrees}}▸커밋▸변경사항 [p] 프로젝트 [Esc/s] 뒤로",
"gitNoCommits": "커밋 없음",
"gitNoProject": "프로젝트 없음",
"gitPushDismissHint": "[Esc] 닫기",
"gitPushFailed": "Push 실패",
"gitPushingToOrigin": "origin/{{branch}}에 Push 중",
"gitPushModalAhead": "앞서 있음",
"gitPushModalBranch": "브랜치:",
"gitPushModalCommits": "Push할 커밋 (오래된 순→최신 순):",
"gitPushModalHints": "[Enter] push [Esc] 취소",
"gitPushModalTitle": "원격 저장소에 Push",
"gitPushSuccessful": "Push 성공",
"gitPushingToOrigin": "origin/{{branch}}에 Push 중",
"gitRefreshing": "새로고침 중",
"gitWorkingTreeClean": "작업 트리가 깨끗합니다",
"headerHelpQuitHint": "[?] 도움말 [q] 종료",
@@ -117,14 +118,13 @@
"projectSelectorChangeHint": "[p] 변경",
"projectSelectorLabel": "프로젝트:",
"projectSelectorNavHints": "↑↓ 탐색 · Enter 선택 · Esc 취소",
"projectSelectorNoProjects": "(등록된 프로젝트 없음)",
"projectSelectorNone": "(없음)",
"projectSelectorNoProjects": "(등록된 프로젝트 없음)",
"projectSelectorPickTitle": "프로젝트 선택",
"qrCloseHint": "[Esc] 닫기",
"qrGenerating": "QR 생성 중…",
"qrNoTunnelRunning": "실행 중인 원격 터널이 없습니다. 설정(g)에서 시작하세요.",
"qrOverlayTitle": "원격 접속 — QR 스캔하여 연결",
"quit": "종료",
"readyIn": "{{secs}}초 후 준비",
"runLogNone": "이 실행에 캡처된 로그가 없습니다.",
"runLogResult": "결과:",
@@ -137,16 +137,6 @@
"runStatusFailed": "실패",
"runStatusTerminated": "종료됨",
"runStatusUnknown": "알 수 없음",
"settingAutoMerge": "자동 병합",
"settingEnginePaused": "엔진 일시 정지",
"settingGlobalPause": "전역 일시 정지",
"settingMaxConcurrent": "최대 동시 실행",
"settingMaxWorktrees": "최대 워크트리",
"settingMergeStrategy": "병합 전략",
"settingPollIntervalMs": "폴링 간격 (ms)",
"settingRemoteActiveProvider": "원격 공급자",
"settingRemoteShortLivedEnabled": "단기 토큰",
"settingRemoteShortLivedTtlMs": "단기 TTL (ms)",
"settingsActivatedProvider": "활성화된 공급자: {{provider}}",
"settingsAdjust1": "[+/-] 1씩 조정",
"settingsAdjust5000ms": "[+/-] 5000ms씩 조정",
@@ -161,7 +151,7 @@
"settingsFooterHints": "[Tab] 패널 전환 ↑↓ 설정 선택 [Space] 불리언 토글 [+/-] 숫자 조정 [←/→] 열거형 순환 [C/V/X/P/L/U/K/R] 원격 작업",
"settingsInteractivePanelTitle": "설정",
"settingsLoadingSettings": "설정 불러오는 중…",
"settingsMoreModels": "… 및 {{count}}개 더",
"settingsMoreModels_other": "",
"settingsPanelTitle": "설정",
"settingsPersistentTokenRegenerated": "영구 토큰이 재생성됨",
"settingsQrFetched": "QR 페이로드 가져옴",
@@ -228,6 +218,9 @@
"utilitiesKillVitest": "Vitest 프로세스 종료",
"utilitiesPanelTitle": "유틸리티",
"utilitiesRefreshStats": "통계 새로고침",
"utilitiesToggleEnginePause": "엔진 일시 정지 전환"
"utilitiesToggleEnginePause": "엔진 일시 정지 전환",
"agentsListTitle_one": "",
"filesMoreLines_one": "",
"settingsMoreModels_one": ""
}
}

View File

@@ -1,23 +1,213 @@
{
"actions": {
"cancel": "취소",
"close": "닫기",
"save": "저장"
"agents": {
"ratings": {
"trendDeclining": "",
"trendImproving": "",
"trendInsufficient": "",
"trendStable": ""
},
"reflections": {
"triggerManual": "",
"triggerPeriodic": "",
"triggerPostTask": "",
"triggerUserRequested": ""
},
"time": {
"daysAgo_other": "",
"hoursAgo_other": "",
"inAMoment": "",
"inDays_other": "",
"inHours_other": "",
"inMinutes_other": "",
"justNow": "",
"minutesAgo_other": "",
"daysAgo_one": "",
"hoursAgo_one": "",
"inDays_one": "",
"inHours_one": "",
"inMinutes_one": "",
"minutesAgo_one": ""
}
},
"archive": "보관",
"cancel": "취소",
"close": "닫기",
"columns": {
"archived": "보관됨",
"done": "완료",
"in-progress": "진행 중",
"in-review": "검토 중",
"todo": "할 일",
"triage": "계획"
"board": {
"rejection": {
"capacityExhausted": "",
"guardRejected": "",
"mergeBlocked": "",
"unknownColumn": "",
"workflowMismatch": ""
}
},
"delete": "삭제",
"refresh": "새로고침",
"retry": "재시도",
"skip": "건너뛰기",
"tryAgain": "다시 시도"
"chat": {
"failedToGetResponse": "",
"failureReferenceId": "",
"failureReferenceKind": "",
"failureReferenceLabel": "",
"failureReferenceMetaLabel": "",
"openMailboxMessage": "",
"toolCallArgsPrefix": "",
"toolCallResultPrefix": "",
"toolCallsCount_other": "",
"toolCallsHeader": "",
"toolCallStatusCompleted": "",
"toolCallStatusError": "",
"toolCallStatusErrors": "",
"toolCallStatusRunning": "",
"viewFailureDetails": "",
"toolCallsCount_one": ""
},
"health": {
"anomaly": {
"duplicateActiveId": "",
"idInBothStorages": "",
"sequenceOverlap": "",
"unknownPrefix": ""
}
},
"inline": {
"connecting": "",
"error": "",
"offline": "",
"online": ""
},
"merge": {
"unknown": ""
},
"missions": {
"autopilotStateActivating": "",
"autopilotStateCompleting": "",
"autopilotStateInactive": "",
"autopilotStateUnknown": "",
"autopilotStateWatching": "",
"interviewStatusAwaitingInput": "",
"interviewStatusComplete": "",
"interviewStatusError": "",
"interviewStatusGenerating": "",
"runHelperActive": "",
"runHelperBlocked": "",
"runHelperPlanning": ""
},
"models": {
"messages": {
"modelSetTo": "",
"modelSetToDefault": ""
}
},
"nodes": {
"auth": {
"differ": "",
"differProviders": "",
"match": "",
"notSynced": ""
},
"status": {
"connecting": "",
"creating": "",
"deleting": "",
"error": "",
"exited": "",
"offline": "",
"online": "",
"recreating": "",
"running": "",
"stopped": ""
}
},
"nodeStatus": {
"connecting": "",
"error": "",
"offline": "",
"online": "",
"unknown": ""
},
"research": {
"providerGitHub": "",
"providerLlmSynthesis": "",
"providerLocalDocs": "",
"providerPageFetch": "",
"providerWebSearch": ""
},
"routing": {
"policyLabel": {
"block": "",
"fallback": "",
"notConfigured": ""
}
},
"setup": {
"apiKeyFormatError": "",
"apiKeyLabel": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"apiKeyPlaceholder": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"zai": ""
},
"apiKeyRequired": "",
"apiKeySetup": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"apiKeyUsage": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"providerDesc": {
"anthropic": "",
"fallback": "",
"gemini": "",
"google": "",
"kimi": "",
"kimiCoding": "",
"minimax": "",
"moonshot": "",
"ollama": "",
"openai": "",
"openaiCodex": "",
"openrouter": "",
"zai": ""
}
},
"taskForm": {
"nodeStatusConnecting": "",
"nodeStatusError": "",
"nodeStatusOffline": "",
"nodeStatusOnline": "",
"phasePostMerge": "",
"phasePreMerge": ""
},
"taskReview": {
"never": "",
"refreshSourceBackground": "",
"refreshSourceInitialLoad": "",
"refreshSourceManual": ""
},
"workflow": {
"postMerge": "",
"preMerge": "",
"statusAdvisory": "",
"statusFailed": "",
"statusPassed": "",
"statusRunning": "",
"statusSkipped": "",
"waitingForOutput": ""
}
}

View File

@@ -1,4 +1 @@
{
"fetchProjectsFailed": "프로젝트를 불러오지 못했습니다",
"openTaskLogsFailed": "작업 로그를 열지 못했습니다: {{detail}}"
}
{}

File diff suppressed because it is too large Load Diff

View File

@@ -20,11 +20,11 @@
"agentRunId": "ID:",
"agentRunLogsBackHint": "[Esc/q] 返回运行列表",
"agentRunLogsTitle": "运行日志({{index}})",
"agentsFooterHints": "[s] 启动 [x] 停止 [D] 删除 [r] 刷新 [Tab] 焦点 ↑↓ 选择",
"agentsListTitle_other": "",
"agentsNoAgents": "未找到代理。",
"agentStarted": "代理已启动",
"agentStopped": "代理已停止",
"agentsFooterHints": "[s] 启动 [x] 停止 [D] 删除 [r] 刷新 [Tab] 焦点 ↑↓ 选择",
"agentsListTitle": "代理({{count}})",
"agentsNoAgents": "未找到代理。",
"boardCreateTaskHints": "Enter 创建 · Esc 取消",
"boardCreateTaskNoProject": "未选择项目",
"boardCreateTaskTitleEmpty": "标题不能为空",
@@ -33,6 +33,7 @@
"boardNewTaskProject": "项目:{{name}}",
"boardNewTaskTitle": "新建任务",
"boardNewTaskTitleLabel": "标题",
"boardOtherReadOnlyHint": "",
"copiedSuccess": "✓ 已复制!",
"copyFailed": "✗ 复制失败",
"expandedLogHeader": "条目 {{index}}/{{total}} · [Enter/Esc] 关闭 · [c] 复制",
@@ -43,26 +44,26 @@
"filesEmpty": "(空)",
"filesEmptyFile": "(空文件)",
"filesFooterHints": "[Tab] 切换面板 [↑↓/jk] 移动 [Enter] 打开 [←/→] 折叠/展开 [.] 隐藏文件 [w] 换行 [p] 项目 [r] 重载",
"filesMoreLines": "… 还有 {{count}} 行",
"filesMoreLines_other": "",
"filesSelectProject": "选择项目",
"filesSelectToPreview": "选择文件以预览",
"filesTooLarge": "{{size}} — [文件过大,无法预览]",
"filesUnableToRead": "无法读取文件",
"gitFetchFailed": "拉取失败:{{output}}",
"gitFetched": "已拉取",
"gitFetchFailed": "拉取失败:{{output}}",
"gitFetching": "正在拉取…",
"gitFooterHints": "[r] 刷新 {{push}}[F] 拉取 [↑↓] 行 [←→] 状态▸分支{{worktrees}}▸提交▸变更 [p] 项目 [Esc/s] 返回",
"gitNoCommits": "无提交",
"gitNoProject": "无项目",
"gitPushDismissHint": "[Esc] 关闭",
"gitPushFailed": "推送失败",
"gitPushingToOrigin": "正在推送到 origin/{{branch}}",
"gitPushModalAhead": "领先",
"gitPushModalBranch": "分支:",
"gitPushModalCommits": "待推送提交(从旧到新):",
"gitPushModalHints": "[Enter] 推送 [Esc] 取消",
"gitPushModalTitle": "推送到远程",
"gitPushSuccessful": "推送成功",
"gitPushingToOrigin": "正在推送到 origin/{{branch}}",
"gitRefreshing": "刷新中",
"gitWorkingTreeClean": "工作区干净",
"headerHelpQuitHint": "[?] 帮助 [q] 退出",
@@ -117,14 +118,13 @@
"projectSelectorChangeHint": "[p] 切换",
"projectSelectorLabel": "项目:",
"projectSelectorNavHints": "↑↓ 导航 · Enter 选择 · Esc 取消",
"projectSelectorNoProjects": "(无已注册项目)",
"projectSelectorNone": "(无)",
"projectSelectorNoProjects": "(无已注册项目)",
"projectSelectorPickTitle": "选择项目",
"qrCloseHint": "[Esc] 关闭",
"qrGenerating": "正在生成 QR 码…",
"qrNoTunnelRunning": "没有运行中的远程隧道。请在设置(g)中启动。",
"qrOverlayTitle": "远程访问 — 扫码连接",
"quit": "退出",
"readyIn": "就绪,耗时 {{secs}} 秒",
"runLogNone": "此次运行未捕获日志。",
"runLogResult": "结果:",
@@ -137,16 +137,6 @@
"runStatusFailed": "失败",
"runStatusTerminated": "已终止",
"runStatusUnknown": "未知",
"settingAutoMerge": "自动合并",
"settingEnginePaused": "引擎已暂停",
"settingGlobalPause": "全局暂停",
"settingMaxConcurrent": "最大并发数",
"settingMaxWorktrees": "最大工作树数",
"settingMergeStrategy": "合并策略",
"settingPollIntervalMs": "轮询间隔(毫秒)",
"settingRemoteActiveProvider": "远程提供商",
"settingRemoteShortLivedEnabled": "短期令牌",
"settingRemoteShortLivedTtlMs": "短期令牌 TTL(毫秒)",
"settingsActivatedProvider": "已激活提供商:{{provider}}",
"settingsAdjust1": "[+/-] 调整 1",
"settingsAdjust5000ms": "[+/-] 调整 5000ms",
@@ -161,7 +151,7 @@
"settingsFooterHints": "[Tab] 切换面板 ↑↓ 选择设置 [Space] 切换布尔 [+/-] 调整数值 [←/→] 循环枚举 [C/V/X/P/L/U/K/R] 远程操作",
"settingsInteractivePanelTitle": "设置",
"settingsLoadingSettings": "正在加载设置…",
"settingsMoreModels": "… 还有 {{count}} 个",
"settingsMoreModels_other": "",
"settingsPanelTitle": "设置",
"settingsPersistentTokenRegenerated": "持久令牌已重新生成",
"settingsQrFetched": "QR 数据已获取",
@@ -228,6 +218,9 @@
"utilitiesKillVitest": "终止 Vitest 进程",
"utilitiesPanelTitle": "工具",
"utilitiesRefreshStats": "刷新统计",
"utilitiesToggleEnginePause": "切换引擎暂停"
"utilitiesToggleEnginePause": "切换引擎暂停",
"agentsListTitle_one": "",
"filesMoreLines_one": "",
"settingsMoreModels_one": ""
}
}

View File

@@ -1,23 +1,213 @@
{
"actions": {
"cancel": "取消",
"close": "关闭",
"save": "保存"
"agents": {
"ratings": {
"trendDeclining": "",
"trendImproving": "",
"trendInsufficient": "",
"trendStable": ""
},
"reflections": {
"triggerManual": "",
"triggerPeriodic": "",
"triggerPostTask": "",
"triggerUserRequested": ""
},
"time": {
"daysAgo_other": "",
"hoursAgo_other": "",
"inAMoment": "",
"inDays_other": "",
"inHours_other": "",
"inMinutes_other": "",
"justNow": "",
"minutesAgo_other": "",
"daysAgo_one": "",
"hoursAgo_one": "",
"inDays_one": "",
"inHours_one": "",
"inMinutes_one": "",
"minutesAgo_one": ""
}
},
"archive": "归档",
"cancel": "取消",
"close": "关闭",
"columns": {
"archived": "已归档",
"done": "已完成",
"in-progress": "进行中",
"in-review": "审核中",
"todo": "待办",
"triage": "规划"
"board": {
"rejection": {
"capacityExhausted": "",
"guardRejected": "",
"mergeBlocked": "",
"unknownColumn": "",
"workflowMismatch": ""
}
},
"delete": "删除",
"refresh": "刷新",
"retry": "重试",
"skip": "跳过",
"tryAgain": "重试"
"chat": {
"failedToGetResponse": "",
"failureReferenceId": "",
"failureReferenceKind": "",
"failureReferenceLabel": "",
"failureReferenceMetaLabel": "",
"openMailboxMessage": "",
"toolCallArgsPrefix": "",
"toolCallResultPrefix": "",
"toolCallsCount_other": "",
"toolCallsHeader": "",
"toolCallStatusCompleted": "",
"toolCallStatusError": "",
"toolCallStatusErrors": "",
"toolCallStatusRunning": "",
"viewFailureDetails": "",
"toolCallsCount_one": ""
},
"health": {
"anomaly": {
"duplicateActiveId": "",
"idInBothStorages": "",
"sequenceOverlap": "",
"unknownPrefix": ""
}
},
"inline": {
"connecting": "",
"error": "",
"offline": "",
"online": ""
},
"merge": {
"unknown": ""
},
"missions": {
"autopilotStateActivating": "",
"autopilotStateCompleting": "",
"autopilotStateInactive": "",
"autopilotStateUnknown": "",
"autopilotStateWatching": "",
"interviewStatusAwaitingInput": "",
"interviewStatusComplete": "",
"interviewStatusError": "",
"interviewStatusGenerating": "",
"runHelperActive": "",
"runHelperBlocked": "",
"runHelperPlanning": ""
},
"models": {
"messages": {
"modelSetTo": "",
"modelSetToDefault": ""
}
},
"nodes": {
"auth": {
"differ": "",
"differProviders": "",
"match": "",
"notSynced": ""
},
"status": {
"connecting": "",
"creating": "",
"deleting": "",
"error": "",
"exited": "",
"offline": "",
"online": "",
"recreating": "",
"running": "",
"stopped": ""
}
},
"nodeStatus": {
"connecting": "",
"error": "",
"offline": "",
"online": "",
"unknown": ""
},
"research": {
"providerGitHub": "",
"providerLlmSynthesis": "",
"providerLocalDocs": "",
"providerPageFetch": "",
"providerWebSearch": ""
},
"routing": {
"policyLabel": {
"block": "",
"fallback": "",
"notConfigured": ""
}
},
"setup": {
"apiKeyFormatError": "",
"apiKeyLabel": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"apiKeyPlaceholder": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"zai": ""
},
"apiKeyRequired": "",
"apiKeySetup": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"apiKeyUsage": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"providerDesc": {
"anthropic": "",
"fallback": "",
"gemini": "",
"google": "",
"kimi": "",
"kimiCoding": "",
"minimax": "",
"moonshot": "",
"ollama": "",
"openai": "",
"openaiCodex": "",
"openrouter": "",
"zai": ""
}
},
"taskForm": {
"nodeStatusConnecting": "",
"nodeStatusError": "",
"nodeStatusOffline": "",
"nodeStatusOnline": "",
"phasePostMerge": "",
"phasePreMerge": ""
},
"taskReview": {
"never": "",
"refreshSourceBackground": "",
"refreshSourceInitialLoad": "",
"refreshSourceManual": ""
},
"workflow": {
"postMerge": "",
"preMerge": "",
"statusAdvisory": "",
"statusFailed": "",
"statusPassed": "",
"statusRunning": "",
"statusSkipped": "",
"waitingForOutput": ""
}
}

View File

@@ -1,4 +1 @@
{
"fetchProjectsFailed": "获取项目失败",
"openTaskLogsFailed": "打开任务日志失败:{{detail}}"
}
{}

File diff suppressed because it is too large Load Diff

View File

@@ -20,11 +20,11 @@
"agentRunId": "ID:",
"agentRunLogsBackHint": "[Esc/q] 返回執行列表",
"agentRunLogsTitle": "執行日誌({{index}})",
"agentsFooterHints": "[s] 啟動 [x] 停止 [D] 刪除 [r] 重新整理 [Tab] 焦點 ↑↓ 選擇",
"agentsListTitle_other": "",
"agentsNoAgents": "找不到代理。",
"agentStarted": "代理已啟動",
"agentStopped": "代理已停止",
"agentsFooterHints": "[s] 啟動 [x] 停止 [D] 刪除 [r] 重新整理 [Tab] 焦點 ↑↓ 選擇",
"agentsListTitle": "代理({{count}})",
"agentsNoAgents": "找不到代理。",
"boardCreateTaskHints": "Enter 建立 · Esc 取消",
"boardCreateTaskNoProject": "未選擇專案",
"boardCreateTaskTitleEmpty": "標題不能為空",
@@ -33,6 +33,7 @@
"boardNewTaskProject": "專案:{{name}}",
"boardNewTaskTitle": "新增任務",
"boardNewTaskTitleLabel": "標題",
"boardOtherReadOnlyHint": "",
"copiedSuccess": "✓ 已複製!",
"copyFailed": "✗ 複製失敗",
"expandedLogHeader": "條目 {{index}}/{{total}} · [Enter/Esc] 關閉 · [c] 複製",
@@ -43,26 +44,26 @@
"filesEmpty": "(空)",
"filesEmptyFile": "(空檔案)",
"filesFooterHints": "[Tab] 切換面板 [↑↓/jk] 移動 [Enter] 開啟 [←/→] 折疊/展開 [.] 隱藏檔案 [w] 換行 [p] 專案 [r] 重新載入",
"filesMoreLines": "… 還有 {{count}} 行",
"filesMoreLines_other": "",
"filesSelectProject": "選擇專案",
"filesSelectToPreview": "選擇檔案以預覽",
"filesTooLarge": "{{size}} — [檔案過大,無法預覽]",
"filesUnableToRead": "無法讀取檔案",
"gitFetchFailed": "擷取失敗:{{output}}",
"gitFetched": "已擷取",
"gitFetchFailed": "擷取失敗:{{output}}",
"gitFetching": "正在擷取…",
"gitFooterHints": "[r] 重整 {{push}}[F] 擷取 [↑↓] 列 [←→] 狀態▸分支{{worktrees}}▸提交▸變更 [p] 專案 [Esc/s] 返回",
"gitNoCommits": "無提交",
"gitNoProject": "無專案",
"gitPushDismissHint": "[Esc] 關閉",
"gitPushFailed": "推送失敗",
"gitPushingToOrigin": "正在推送至 origin/{{branch}}",
"gitPushModalAhead": "領先",
"gitPushModalBranch": "分支:",
"gitPushModalCommits": "待推送提交(從舊到新):",
"gitPushModalHints": "[Enter] 推送 [Esc] 取消",
"gitPushModalTitle": "推送至遠端",
"gitPushSuccessful": "推送成功",
"gitPushingToOrigin": "正在推送至 origin/{{branch}}",
"gitRefreshing": "重新整理中",
"gitWorkingTreeClean": "工作區乾淨",
"headerHelpQuitHint": "[?] 說明 [q] 退出",
@@ -117,14 +118,13 @@
"projectSelectorChangeHint": "[p] 切換",
"projectSelectorLabel": "專案:",
"projectSelectorNavHints": "↑↓ 導航 · Enter 選擇 · Esc 取消",
"projectSelectorNoProjects": "(無已登錄專案)",
"projectSelectorNone": "(無)",
"projectSelectorNoProjects": "(無已登錄專案)",
"projectSelectorPickTitle": "選擇專案",
"qrCloseHint": "[Esc] 關閉",
"qrGenerating": "正在產生 QR 碼…",
"qrNoTunnelRunning": "沒有執行中的遠端通道。請在設定(g)中啟動。",
"qrOverlayTitle": "遠端存取 — 掃碼連線",
"quit": "結束",
"readyIn": "就緒,耗時 {{secs}} 秒",
"runLogNone": "此次執行未擷取日誌。",
"runLogResult": "結果:",
@@ -137,16 +137,6 @@
"runStatusFailed": "失敗",
"runStatusTerminated": "已終止",
"runStatusUnknown": "未知",
"settingAutoMerge": "自動合併",
"settingEnginePaused": "引擎已暫停",
"settingGlobalPause": "全域暫停",
"settingMaxConcurrent": "最大並行數",
"settingMaxWorktrees": "最大工作樹數",
"settingMergeStrategy": "合併策略",
"settingPollIntervalMs": "輪詢間隔(毫秒)",
"settingRemoteActiveProvider": "遠端提供者",
"settingRemoteShortLivedEnabled": "短期權杖",
"settingRemoteShortLivedTtlMs": "短期權杖 TTL(毫秒)",
"settingsActivatedProvider": "已啟用提供商:{{provider}}",
"settingsAdjust1": "[+/-] 調整 1",
"settingsAdjust5000ms": "[+/-] 調整 5000ms",
@@ -161,7 +151,7 @@
"settingsFooterHints": "[Tab] 切換面板 ↑↓ 選擇設定 [Space] 切換布林 [+/-] 調整數值 [←/→] 循環枚舉 [C/V/X/P/L/U/K/R] 遠端操作",
"settingsInteractivePanelTitle": "設定",
"settingsLoadingSettings": "正在載入設定…",
"settingsMoreModels": "… 還有 {{count}} 個",
"settingsMoreModels_other": "",
"settingsPanelTitle": "設定",
"settingsPersistentTokenRegenerated": "持久金鑰已重新產生",
"settingsQrFetched": "QR 資料已取得",
@@ -228,6 +218,9 @@
"utilitiesKillVitest": "終止 Vitest 行程",
"utilitiesPanelTitle": "工具",
"utilitiesRefreshStats": "重新整理統計",
"utilitiesToggleEnginePause": "切換引擎暫停"
"utilitiesToggleEnginePause": "切換引擎暫停",
"agentsListTitle_one": "",
"filesMoreLines_one": "",
"settingsMoreModels_one": ""
}
}

View File

@@ -1,23 +1,213 @@
{
"actions": {
"cancel": "取消",
"close": "關閉",
"save": "儲存"
"agents": {
"ratings": {
"trendDeclining": "",
"trendImproving": "",
"trendInsufficient": "",
"trendStable": ""
},
"reflections": {
"triggerManual": "",
"triggerPeriodic": "",
"triggerPostTask": "",
"triggerUserRequested": ""
},
"time": {
"daysAgo_other": "",
"hoursAgo_other": "",
"inAMoment": "",
"inDays_other": "",
"inHours_other": "",
"inMinutes_other": "",
"justNow": "",
"minutesAgo_other": "",
"daysAgo_one": "",
"hoursAgo_one": "",
"inDays_one": "",
"inHours_one": "",
"inMinutes_one": "",
"minutesAgo_one": ""
}
},
"archive": "封存",
"cancel": "取消",
"close": "關閉",
"columns": {
"archived": "已封存",
"done": "已完成",
"in-progress": "進行中",
"in-review": "審查中",
"todo": "待辦",
"triage": "規劃"
"board": {
"rejection": {
"capacityExhausted": "",
"guardRejected": "",
"mergeBlocked": "",
"unknownColumn": "",
"workflowMismatch": ""
}
},
"delete": "刪除",
"refresh": "重新整理",
"retry": "重試",
"skip": "略過",
"tryAgain": "重試"
"chat": {
"failedToGetResponse": "",
"failureReferenceId": "",
"failureReferenceKind": "",
"failureReferenceLabel": "",
"failureReferenceMetaLabel": "",
"openMailboxMessage": "",
"toolCallArgsPrefix": "",
"toolCallResultPrefix": "",
"toolCallsCount_other": "",
"toolCallsHeader": "",
"toolCallStatusCompleted": "",
"toolCallStatusError": "",
"toolCallStatusErrors": "",
"toolCallStatusRunning": "",
"viewFailureDetails": "",
"toolCallsCount_one": ""
},
"health": {
"anomaly": {
"duplicateActiveId": "",
"idInBothStorages": "",
"sequenceOverlap": "",
"unknownPrefix": ""
}
},
"inline": {
"connecting": "",
"error": "",
"offline": "",
"online": ""
},
"merge": {
"unknown": ""
},
"missions": {
"autopilotStateActivating": "",
"autopilotStateCompleting": "",
"autopilotStateInactive": "",
"autopilotStateUnknown": "",
"autopilotStateWatching": "",
"interviewStatusAwaitingInput": "",
"interviewStatusComplete": "",
"interviewStatusError": "",
"interviewStatusGenerating": "",
"runHelperActive": "",
"runHelperBlocked": "",
"runHelperPlanning": ""
},
"models": {
"messages": {
"modelSetTo": "",
"modelSetToDefault": ""
}
},
"nodes": {
"auth": {
"differ": "",
"differProviders": "",
"match": "",
"notSynced": ""
},
"status": {
"connecting": "",
"creating": "",
"deleting": "",
"error": "",
"exited": "",
"offline": "",
"online": "",
"recreating": "",
"running": "",
"stopped": ""
}
},
"nodeStatus": {
"connecting": "",
"error": "",
"offline": "",
"online": "",
"unknown": ""
},
"research": {
"providerGitHub": "",
"providerLlmSynthesis": "",
"providerLocalDocs": "",
"providerPageFetch": "",
"providerWebSearch": ""
},
"routing": {
"policyLabel": {
"block": "",
"fallback": "",
"notConfigured": ""
}
},
"setup": {
"apiKeyFormatError": "",
"apiKeyLabel": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"apiKeyPlaceholder": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"zai": ""
},
"apiKeyRequired": "",
"apiKeySetup": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"apiKeyUsage": {
"fallback": "",
"kimiCoding": "",
"minimax": "",
"ollama": "",
"openai": "",
"openrouter": "",
"zai": ""
},
"providerDesc": {
"anthropic": "",
"fallback": "",
"gemini": "",
"google": "",
"kimi": "",
"kimiCoding": "",
"minimax": "",
"moonshot": "",
"ollama": "",
"openai": "",
"openaiCodex": "",
"openrouter": "",
"zai": ""
}
},
"taskForm": {
"nodeStatusConnecting": "",
"nodeStatusError": "",
"nodeStatusOffline": "",
"nodeStatusOnline": "",
"phasePostMerge": "",
"phasePreMerge": ""
},
"taskReview": {
"never": "",
"refreshSourceBackground": "",
"refreshSourceInitialLoad": "",
"refreshSourceManual": ""
},
"workflow": {
"postMerge": "",
"preMerge": "",
"statusAdvisory": "",
"statusFailed": "",
"statusPassed": "",
"statusRunning": "",
"statusSkipped": "",
"waitingForOutput": ""
}
}

View File

@@ -1,4 +1 @@
{
"fetchProjectsFailed": "擷取專案失敗",
"openTaskLogsFailed": "開啟工作記錄失敗:{{detail}}"
}
{}

View File

@@ -0,0 +1,10 @@
// This file is automatically generated by i18next-cli, because it was not existing. You can edit it based on your needs: https://www.i18next.com/overview/typescript#custom-type-options
import type Resources from './resources';
declare module 'i18next' {
interface CustomTypeOptions {
enableSelector: false;
defaultNS: 'common';
resources: Resources;
}
}

7214
packages/i18n/src/resources.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff