FN-173: replace duplicate Keep action with dismissible tag
Replace the duplicate-task Keep button with a dismissible duplicate tag and update the related operator guidance. - Add dismissible duplicate tags across task cards, detail views, docks, and overflow surfaces. - Update localized copy, documentation, notifications, styling, and regression coverage. - Record the published CLI/dashboard change in a changeset. Files changed: .changeset/fn-173-removal.md | 7 + docs/settings-reference.md | 4 +- docs/task-management.md | 8 +- packages/dashboard/app/App.tsx | 2 +- packages/dashboard/app/components/DockTaskList.tsx | 9 +- packages/dashboard/app/components/TaskCard.css | 47 ++++--- packages/dashboard/app/components/TaskCard.tsx | 31 +++-- .../dashboard/app/components/TaskDetailModal.css | 23 ++++ .../dashboard/app/components/TaskDetailModal.tsx | 51 ++++--- .../__tests__/TaskCard.duplicate-tag.test.tsx | 150 +++++++++++++++++++++ .../app/components/__tests__/TaskCard.test.tsx | 10 +- .../__tests__/TaskDetailModal.rendering.test.tsx | 30 ++++- .../app/components/dashboard/MainContent.tsx | 2 + .../app/components/overflowViewRegistry.tsx | 2 + .../app/components/useRightDockController.tsx | 3 + .../__tests__/notification-service.test.ts | 31 +++++ .../src/notification/notification-service.ts | 3 +- packages/i18n/locales/en/app.json | 20 +-- packages/i18n/locales/es/app.json | 20 +-- packages/i18n/locales/fr/app.json | 20 +-- packages/i18n/locales/ko/app.json | 20 +-- packages/i18n/locales/pt-BR/app.json | 20 +-- packages/i18n/locales/zh-CN/app.json | 20 +-- packages/i18n/locales/zh-TW/app.json | 20 +-- packages/i18n/src/resources.d.ts | 18 +-- 25 files changed, 431 insertions(+), 140 deletions(-) Fusion-Task-Id: FN-173 Fusion-Task-Lineage: efc810f4-d2b9-4ee9-874e-56ea2c00fa32 Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-173-removal.md
Normal file
7
.changeset/fn-173-removal.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Replace duplicate Keep buttons with dismissible duplicate tags.
|
||||
category: feature
|
||||
dev: Removes five Keep i18n keys while retaining the dismissNearDuplicate seam.
|
||||
@@ -722,7 +722,7 @@ Default notes:
|
||||
| `autoArchiveDoneTasksEnabled` | `boolean` | `true` | Enable periodic auto-archiving of done tasks; tasks with live lineage children are retained for operator action. |
|
||||
| `autoArchiveDoneAfterMs` | `number` | `172800000` | Age in ms after entering done before auto-archive (48h). |
|
||||
| `doneAutoArchiveDays` | `number` | `0` | Integer day-based done-task retention. `0` disables day override; values `> 0` take precedence over `autoArchiveDoneAfterMs`. |
|
||||
| `autoArchiveDuplicateTasksEnabled` | `boolean` | `false` | FN-7658/FN-8401: gates whether same-agent duplicate intake on every create backend auto-archives the later/new task. Default `false` — the duplicate is flagged in place (`nearDuplicateOf`/`nearDuplicateScore` marker, yellow "Duplicate" chip with Keep/Archive actions), and no live sibling is deleted or archived automatically. Set `true` to restore opt-in archival of the new task only. Does not affect ghost-bug preflight or tombstone-resurrection blocking. |
|
||||
| `autoArchiveDuplicateTasksEnabled` | `boolean` | `false` | FN-7658/FN-8401: gates whether same-agent duplicate intake on every create backend auto-archives the later/new task. Default `false` — the duplicate is flagged in place (`nearDuplicateOf`/`nearDuplicateScore` marker, yellow "Duplicate" chip with a clear-the-flag control and Archive action), and no live sibling is deleted or archived automatically. Set `true` to restore opt-in archival of the new task only. Does not affect ghost-bug preflight or tombstone-resurrection blocking. |
|
||||
| `archiveAgentLogMode` | `"none" \| "compact" \| "full"` | `"compact"` | Agent log retention strategy for cold archive snapshots. |
|
||||
| `autoUpdatePrStatus` | `boolean` | `false` | Auto-refresh PR status badges. |
|
||||
| `githubCommentOnDone` | `boolean` | `false` | When enabled, tasks imported from GitHub issues post a completion comment to the source issue when the task moves to `done`. Suppressed when the source issue is also the task's *tracked* issue (`githubTracking.enabled` with the same `owner/repo#number`): the GitHub tracking comment already reports completion there, with commit/branch/PR/files details, so the issue would otherwise receive two comments. In that case `githubCommentTemplate` is not used and the task log records `Skipped GitHub issue completion comment`. When tracking points at a *different* issue, both issues are commented as before. |
|
||||
@@ -1921,7 +1921,7 @@ One terminal `tool_error` after the current execution-run cursor therefore quali
|
||||
|
||||
Choose the alternate model with the standard provider-aware selector in **Settings → Models · Project**; clearing it removes both persisted pair keys, and incomplete legacy pairs display as unset. **Settings → Scheduling** retains the enable toggle, optional node target, and retry policy. Escalation is enabled only when the toggle is true and either a complete provider/model pair or a node ID is configured. It is single-shot: after FN-7996 exhausts same-model retries, Fusion persists the override and tries once before the existing terminal park. The alternate model enters the [model-selection hierarchy](#model-selection-hierarchy) as a task-level override; a node target enters `resolveEffectiveNode` as a task-level routing override and is requeued so scheduler routing is recalculated. This remains opt-in by default to avoid unexpected model cost or execution behavior. Column-agent overrides still govern their sessions and can supersede a task-level model target.
|
||||
|
||||
| `triageDuplicateResolution` | `"prompt" \| "keep" \| "delete"` | `"prompt"` | Controls `DUPLICATE: FN-NNNN` markers emitted during triage. **prompt** flags and system-pauses the task for an operator Keep/Delete decision; the existing decision banner links to the canonical task. **keep** dismisses the marker and replans a real task. **delete** restores legacy auto-delete behavior. |
|
||||
| `triageDuplicateResolution` | `"prompt" \| "keep" \| "delete"` | `"prompt"` | Controls `DUPLICATE: FN-NNNN` markers emitted during triage. **prompt** flags and system-pauses the task until an operator clears the flag or deletes the task; the existing decision banner links to the canonical task. **keep** dismisses the marker and replans a real task. **delete** restores legacy auto-delete behavior. |
|
||||
|
||||
### `mobileNavPrimaryItems`
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ Near-duplicate flagging now keeps the task in its normal flow column (`todo` / a
|
||||
- `source.sourceMetadata.nearDuplicateOf = <canonicalTaskId>`
|
||||
- `source.sourceMetadata.nearDuplicateScore = <number>`
|
||||
- `source.sourceMetadata.nearDuplicateSharedTokens = <string[]>`
|
||||
- optional `source.sourceMetadata.nearDuplicateDismissed = true` after user chooses Keep
|
||||
- optional `source.sourceMetadata.nearDuplicateDismissed = true` after the operator clears the duplicate flag
|
||||
- activity event `task:near-duplicate-flagged`
|
||||
|
||||
A near-duplicate flag is only actionable while the canonical task is active. The triage backstop does not persist `nearDuplicateOf` for archived, soft-deleted, done, or missing canonicals; when a canonical later becomes inactive through archive, soft-delete, or move-to-done, the store clears `nearDuplicateOf`, `nearDuplicateScore`, `nearDuplicateSharedTokens`, and `nearDuplicateDismissed` from active referrers and records an informational log entry without pausing or failing those tasks.
|
||||
@@ -102,7 +102,7 @@ A near-duplicate flag is only actionable while the canonical task is active. The
|
||||
Dashboard surfaces this as a yellow Duplicate chip plus modal actions only while the canonical exists and is active:
|
||||
|
||||
- **Archive** (user-initiated archive path)
|
||||
- **Keep** (dismisses the warning by setting `nearDuplicateDismissed: true`)
|
||||
- **Clear the duplicate flag** (dismisses the warning by setting `nearDuplicateDismissed: true`)
|
||||
|
||||
This layer complements, rather than replaces, FN-4829 similarity detection, FN-4918 deterministic deduplication, and FN-4892 same-agent intake heuristics.
|
||||
|
||||
@@ -144,7 +144,7 @@ Layer behavior:
|
||||
- **Triage planning loop** — after triage reads the generated `PROMPT.md`, an exact redirect marker short-circuits directly into `finalizeApprovedTask()`. Normal plans run deterministic spec hygiene checks in triage, then the selected workflow's optional Plan Review gate owns AI plan review before execution.
|
||||
- **Self-healing sweep** — maintenance Batch 2 runs `resolveExplicitDuplicateMarkerTasks()` across `triage`/`todo` tasks to clean up older stuck marker tasks. The sweep is best-effort, capped at 50 marker tasks per cycle, and can be disabled with the internal setting `resolveExplicitDuplicateMarkerEnabled: false` (default `true`).
|
||||
|
||||
An operator's decision is durable for a task and its active canonical pair. **Keep** records the acknowledgement, clears the marker-only prompt and triage decision hold, and lets planning continue; triage and self-healing will not ask again if that same marker is reprocessed. A marker for a different active canonical remains a new decision. **Delete** for an explicit-marker decision soft-deletes the duplicate, while **Archive** for an ordinary near-duplicate leaves it terminal in Archived; neither outcome is reopened as a duplicate decision.
|
||||
An operator's decision is durable for a task and its active canonical pair. **Clearing the duplicate flag** records the acknowledgement, retires the marker source, clears the triage decision hold, and lets planning continue; triage and self-healing will not ask again if that same marker is reprocessed. A marker for a different active canonical remains a new decision. **Delete** for an explicit-marker decision soft-deletes the duplicate, while **Archive** for an ordinary near-duplicate leaves it terminal in Archived; neither outcome is reopened as a duplicate decision.
|
||||
|
||||
All three layers fail open: parse errors, task lookup failures, file-read failures, activity-recording errors, or other unexpected exceptions log a warning and continue normal intake/triage/self-healing flow instead of blocking task creation or recovery.
|
||||
|
||||
@@ -162,7 +162,7 @@ Fusion applies two conservative intake heuristics that may auto-archive newly fi
|
||||
|
||||
- **Ghost-bug preflight** (triage finalize path): for bug-fix-shaped specs that cite concrete constructs/commands, Fusion probes current `main`. If all definitive probes show the cited bug does not reproduce, the task is archived as `auto-resolved-ghost-bug`.
|
||||
- **Same-agent duplicate intake** (all task-create backends): if the same `source.sourceAgentId` (or `source.sourceParentTaskId`) filed a highly similar task within 24h (threshold `0.75`), Fusion still detects the near-duplicate — but what happens next depends on the `autoArchiveDuplicateTasksEnabled` project/global setting (default **`false`**, FN-7658/FN-8401):
|
||||
- **Default (`false`)**: the later task is left in place and flagged via the same near-duplicate marker used elsewhere (`sourceMetadata.nearDuplicateOf` / `nearDuplicateScore`), so the dashboard's yellow "Duplicate" chip with Keep/Archive actions surfaces it for a human decision. Neither the new task nor its live siblings are moved to `archived` or deleted automatically.
|
||||
- **Default (`false`)**: the later task is left in place and flagged via the same near-duplicate marker used elsewhere (`sourceMetadata.nearDuplicateOf` / `nearDuplicateScore`), so the dashboard's yellow "Duplicate" chip with a clear-the-flag control and Archive action surfaces it for a human decision. Neither the new task nor its live siblings are moved to `archived` or deleted automatically.
|
||||
- **`true`** (legacy behavior, opt-in): only the later/new task is archived as `auto-resolved-duplicate`; its live siblings remain intact.
|
||||
|
||||
Ghost-bug preflight is unaffected by `autoArchiveDuplicateTasksEnabled` — it is a distinct heuristic and always auto-archives on a definitive non-repro.
|
||||
|
||||
@@ -1592,7 +1592,7 @@ function AppInner() {
|
||||
|
||||
// Props for the extracted <MainContent> switch (see components/dashboard/MainContent.tsx).
|
||||
// Every value is passed by its App name; the switch renders the same subtrees as before.
|
||||
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, columnFlagsByTaskId: footerColumnFlagsByTaskId, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openTaskPopup: popOutTaskDetailForCurrentView, onOpenSessionInNewWindow: openSessionInNewWindow, openMobileTasksInPopup, openFileInBrowser, onMoveTask: moveTask, onDeleteTask: deleteTask, onArchiveTask: archiveTask, onRevertTask: revertTask, onMergeTask: mergeTask, onRetryTask: retryTask, onPauseTask: pauseTask, onUnpauseTask: unpauseTask, onBypassReview: bypassReview, onResetTask: resetTask, onDuplicateTask: duplicateTask, onTaskUpdated: (task: Task) => ingestCreatedTasks([task]), openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, taskDetailChatFirst, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
|
||||
const rightDock = useRightDockController({ active: rightDockActive, projectId: currentProject?.id, addToast, columnFlagsByTaskId: footerColumnFlagsByTaskId, settingsLoaded, researchReadinessVersion, goalAnchorId, tasks: isRemote && remoteData.tasks.length > 0 ? remoteData.tasks : tasks, workflowSteps, subscribePluginEvents, openDetailTask, openTaskPopup: popOutTaskDetailForCurrentView, onOpenSessionInNewWindow: openSessionInNewWindow, openMobileTasksInPopup, openFileInBrowser, onMoveTask: moveTask, onUpdateTask: updateTask, onDeleteTask: deleteTask, onArchiveTask: archiveTask, onRevertTask: revertTask, onMergeTask: mergeTask, onRetryTask: retryTask, onPauseTask: pauseTask, onUnpauseTask: unpauseTask, onBypassReview: bypassReview, onResetTask: resetTask, onDuplicateTask: duplicateTask, onTaskUpdated: (task: Task) => ingestCreatedTasks([task]), openSettings: (section?: string) => openSettingsWithNav(section as SectionId), onOpenUsage: openUsageWithNav, onOpenActivityLog: openActivityLogWithNav, onOpenGitHubImport: openGitHubImportWithNav, onOpenGitManager: openGitManagerWithNav, onOpenSchedules: openSchedulesWithNav, onSendSelectionToTask: modalManager.openNewTaskWithDescription, onCreateTaskFromInsight: handleInsightTaskCreate, onNavigateToMission: handleOpenMission, onTaskCreated: (task: Task) => ingestCreatedTasks([task]), prAuthAvailable, autoMerge, taskDetailChatFirst, visibilityOptions: { experimentalFeatures: { insights: insightsEnabled, memoryView: memoryEnabled, devServerView: devServerEnabled, researchView: researchEnabled, evalsView: evalsEnabled, goalsView: goalsEnabled }, showSkillsTab: skillsEnabled, pluginDashboardViews }, footerVisible: executorFooterVisible });
|
||||
|
||||
/*
|
||||
FNXC:OpenTasksInRightSidebar 2026-06-28-00:00:
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface DockTaskListProps {
|
||||
projectId?: string;
|
||||
onOpenTask?: (task: Task | TaskDetail) => void;
|
||||
onReviseTask?: (task: Task) => void;
|
||||
onUpdateTask?: (id: string, updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean; githubTracking?: { enabled?: boolean } }) => Promise<Task>;
|
||||
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; allowResurrection?: boolean }) => Promise<Task>;
|
||||
addToast?: (message: string, type?: ToastType) => void;
|
||||
prAuthAvailable?: boolean;
|
||||
@@ -45,10 +46,15 @@ export function DockTaskList({ columnFlagsByTaskId,
|
||||
onOpenTask,
|
||||
onDeleteTask,
|
||||
onReviseTask,
|
||||
onUpdateTask,
|
||||
addToast = () => {},
|
||||
prAuthAvailable = false,
|
||||
autoMergeEnabled = false,
|
||||
}: DockTaskListProps) {
|
||||
/*
|
||||
FNXC:NearDuplicateDetection 2026-08-23-04:10:
|
||||
A host that renders a duplicate tag must also pass its clear action, otherwise a triage hold has no UI release.
|
||||
*/
|
||||
const { t } = useTranslation("app");
|
||||
const [showDone, setShowDone] = useState(false);
|
||||
|
||||
@@ -113,7 +119,7 @@ export function DockTaskList({ columnFlagsByTaskId,
|
||||
{revertedTasks.length > 0 && (
|
||||
<section className="dock-task-list__reverted" aria-label={t("tasks.revertedTasks", "Reverted Tasks")} data-testid="dock-reverted-tasks">
|
||||
<h3>{t("tasks.revertedTasks", "Reverted Tasks")}</h3>
|
||||
{revertedTasks.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={columnFlagsByTaskId?.get(task.id)} projectId={projectId} onOpenDetail={handleOpenTask} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} addToast={addToast} />)}
|
||||
{revertedTasks.map((task) => <TaskCard key={`reverted-${task.id}`} task={task} taskColumnFlags={columnFlagsByTaskId?.get(task.id)} projectId={projectId} onOpenDetail={handleOpenTask} onDeleteTask={onDeleteTask} onReviseTask={onReviseTask} onUpdateTask={onUpdateTask} addToast={addToast} />)}
|
||||
</section>
|
||||
)}
|
||||
{isEmpty ? (
|
||||
@@ -138,6 +144,7 @@ export function DockTaskList({ columnFlagsByTaskId,
|
||||
Every task Delete affordance must reach the shared confirm→delete flow. The right-dock Tasks list is a TaskCard host, so it must pass onDeleteTask instead of rendering cards that silently lack/delete-disable the destructive path.
|
||||
*/
|
||||
onDeleteTask={onDeleteTask}
|
||||
onUpdateTask={onUpdateTask}
|
||||
addToast={addToast}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMergeEnabled={autoMergeEnabled}
|
||||
|
||||
@@ -1178,8 +1178,14 @@ The execution-time badge is part of the footer's bottom-right chip cluster, so i
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.card-duplicate-chip,
|
||||
.card-duplicate-keep {
|
||||
.card-duplicate-chip-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-duplicate-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
@@ -1216,30 +1222,41 @@ The execution-time badge is part of the footer's bottom-right chip cluster, so i
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.card-duplicate-keep {
|
||||
border-color: color-mix(in srgb, var(--color-warning) 45%, transparent);
|
||||
background: color-mix(in srgb, var(--surface-2) 90%, transparent);
|
||||
color: var(--text);
|
||||
.card-duplicate-dismiss {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: var(--card-chip-height);
|
||||
min-height: var(--card-chip-height);
|
||||
padding: var(--space-xs);
|
||||
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-warning) 45%, transparent);
|
||||
border-radius: var(--radius-pill);
|
||||
background: color-mix(in srgb, var(--color-warning) 16%, transparent);
|
||||
color: var(--color-warning);
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: none;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast),
|
||||
box-shadow var(--transition-fast);
|
||||
transition: background-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-duplicate-keep:hover {
|
||||
.card-duplicate-dismiss::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: calc(var(--space-sm) * -1);
|
||||
}
|
||||
|
||||
.card-duplicate-dismiss:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.card-duplicate-keep:focus-visible {
|
||||
.card-duplicate-dismiss:focus-visible {
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-duplicate-chip {
|
||||
.card-duplicate-chip,
|
||||
.card-duplicate-chip-group,
|
||||
.card-duplicate-dismiss {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { memo, useCallback, useState, useRef, useEffect, useLayoutEffect, useMemo, type CSSProperties, type ReactElement } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight, Eye, MoreHorizontal, Sparkles } from "lucide-react";
|
||||
import { Link, Clock, Layers, Pencil, ChevronDown, Folder, Target, Bot, Trash2, RotateCw, Zap, GitBranch, GitPullRequest, AlertTriangle, ArrowUpRight, Eye, MoreHorizontal, Sparkles, X } from "lucide-react";
|
||||
import type { Task, TaskDetail, Column, ColumnId, PrInfo, IssueInfo, TaskPriority, GithubIssueAction, MergeResult, PlannerOversightLevel } from "@fusion/core";
|
||||
import {
|
||||
DEFAULT_PLANNER_OVERSIGHT_LEVEL,
|
||||
@@ -2264,9 +2264,9 @@ function TaskCardComponent({
|
||||
|
||||
try {
|
||||
await onUpdateTask(task.id, { dismissNearDuplicate: true });
|
||||
addToast(t("tasks.duplicateDismissed", "Kept {{taskId}}; duplicate warning dismissed", { taskId: task.id }), "success");
|
||||
addToast(t("tasks.duplicateDismissed", "Duplicate flag cleared for {{taskId}}", { taskId: task.id }), "success");
|
||||
} catch (err) {
|
||||
addToast(t("tasks.keepFailed", "Failed to keep {{taskId}}: {{error}}", { taskId: task.id, error: getErrorMessage(err) }), "error");
|
||||
addToast(t("tasks.duplicateDismissFailed", "Failed to clear the duplicate flag for {{taskId}}: {{error}}", { taskId: task.id, error: getErrorMessage(err) }), "error");
|
||||
}
|
||||
}, [addToast, onUpdateTask, task.id]);
|
||||
|
||||
@@ -3272,26 +3272,35 @@ function TaskCardComponent({
|
||||
</span>
|
||||
)}
|
||||
{showNearDuplicateChip && (
|
||||
<>
|
||||
/*
|
||||
FNXC:NearDuplicateDetection 2026-08-23-04:10:
|
||||
FN-173 removed the undiscoverable Keep decision button. The tag is an acknowledgeable notification;
|
||||
clearing it deliberately uses dismissNearDuplicate so triage-marker holds release instead of stranding paused cards.
|
||||
*/
|
||||
<span className="card-duplicate-chip-group">
|
||||
<span
|
||||
className="card-duplicate-chip"
|
||||
title={t("tasks.nearDuplicateTitle", "Potential near-duplicate of {{id}}", { id: String(task.sourceMetadata?.nearDuplicateOf) })}
|
||||
aria-label={t("tasks.nearDuplicateTitle", "Potential near-duplicate of {{id}}", { id: String(task.sourceMetadata?.nearDuplicateOf) })}
|
||||
title={isTriageDuplicateDecision
|
||||
? t("tasks.nearDuplicateHeldTitle", "Flagged as a duplicate of {{id}}. This task stays paused until you clear this flag or delete it.", { id: String(task.sourceMetadata?.nearDuplicateOf) })
|
||||
: t("tasks.nearDuplicateTitle", "Flagged as a possible duplicate of {{id}}. This task continues normally; clear the flag once you have read it.", { id: String(task.sourceMetadata?.nearDuplicateOf) })}
|
||||
aria-label={isTriageDuplicateDecision
|
||||
? t("tasks.nearDuplicateHeldTitle", "Flagged as a duplicate of {{id}}. This task stays paused until you clear this flag or delete it.", { id: String(task.sourceMetadata?.nearDuplicateOf) })
|
||||
: t("tasks.nearDuplicateTitle", "Flagged as a possible duplicate of {{id}}. This task continues normally; clear the flag once you have read it.", { id: String(task.sourceMetadata?.nearDuplicateOf) })}
|
||||
>
|
||||
<span>{t("tasks.duplicateOf", "Duplicate of {{id}}", { id: String(task.sourceMetadata?.nearDuplicateOf) })}</span>
|
||||
</span>
|
||||
{onUpdateTask && (
|
||||
<button
|
||||
type="button"
|
||||
className="card-duplicate-keep"
|
||||
className="card-duplicate-dismiss"
|
||||
onClick={(e) => void handleDismissNearDuplicate(e)}
|
||||
title={t("tasks.keepTaskTitle", "Keep this task and dismiss duplicate warning")}
|
||||
aria-label={t("tasks.keepTaskTitle", "Keep this task and dismiss duplicate warning")}
|
||||
title={t("tasks.dismissDuplicateFlag", "Mark the duplicate flag for {{id}} as read", { id: String(task.sourceMetadata?.nearDuplicateOf) })}
|
||||
aria-label={t("tasks.dismissDuplicateFlag", "Mark the duplicate flag for {{id}} as read", { id: String(task.sourceMetadata?.nearDuplicateOf) })}
|
||||
>
|
||||
{t("tasks.keep", "Keep")}
|
||||
<X size={11} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
</span>
|
||||
)}
|
||||
{chipFarRight && (showTrackingIndicator || showLinkedIssueChipForImport) && githubTrackedIssue && (
|
||||
<a
|
||||
|
||||
@@ -3864,6 +3864,29 @@ Reuse the near-duplicate banner layout; replan-cap escalations use a stronger wa
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.detail-near-duplicate-banner__dismiss {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
padding: var(--space-xs);
|
||||
border: var(--btn-border-width) solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-warning);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.detail-near-duplicate-banner__dismiss:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.detail-near-duplicate-banner__dismiss:focus-visible {
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.detail-near-duplicate-banner__copy {
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
|
||||
@@ -1060,7 +1060,7 @@ export function TaskDetailContent({
|
||||
: undefined;
|
||||
/**
|
||||
* FNXC:NearDuplicateDetection 2026-06-14-12:00:
|
||||
* The Archive/Keep decision banner is actionable only while the referenced canonical exists and is active.
|
||||
* The duplicate banner is actionable only while the referenced canonical exists and is active.
|
||||
* Suppress the whole affordance for missing, archived, done, or soft-deleted canonicals so no empty banner shell or stale user-decision buttons remain.
|
||||
*/
|
||||
// FNXC:DuplicateIntake 2026-07-16-13:00: Issue #2225 reuses this linked banner for triage-marker Keep/Delete decisions.
|
||||
@@ -1114,6 +1114,11 @@ export function TaskDetailContent({
|
||||
A canonical the map does not cover yields `undefined`, which is the documented legacy fallback —
|
||||
strictly better than always-legacy, never a fabricated answer.
|
||||
*/
|
||||
/*
|
||||
FNXC:NearDuplicateDetection 2026-08-23-04:10:
|
||||
FN-173 makes the duplicate flag acknowledgeable rather than an opaque decision. The actions row only
|
||||
exists for Delete or Archive so ordinary cards without archive access do not leave an empty shell.
|
||||
*/
|
||||
const showNearDuplicateWarning = Boolean(nearDuplicateOf)
|
||||
&& workingTask.sourceMetadata?.nearDuplicateDismissed !== true
|
||||
&& task.column !== "archived"
|
||||
@@ -3486,7 +3491,7 @@ export function TaskDetailContent({
|
||||
try {
|
||||
const updatedTask = await updateTask(task.id, { dismissNearDuplicate: true }, projectId);
|
||||
onTaskUpdated?.(updatedTask);
|
||||
addToast(t("taskDetail.nearDuplicate.kept", "Kept {{id}} and dismissed duplicate warning", { id: task.id }), "success");
|
||||
addToast(t("taskDetail.nearDuplicate.dismissed", "Duplicate flag cleared for {{id}}", { id: task.id }), "success");
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err), "error");
|
||||
}
|
||||
@@ -3513,7 +3518,7 @@ export function TaskDetailContent({
|
||||
|
||||
/*
|
||||
* FNXC:DuplicateIntake 2026-07-16-14:00:
|
||||
* Issue #2225 requires triage-marker duplicates to offer a real Keep/Delete decision.
|
||||
* Issue #2225 requires triage-marker duplicates to offer a real clear-or-delete decision.
|
||||
* Unlike the ordinary near-duplicate Archive action, Delete calls the existing soft-delete
|
||||
* API and clears incoming lineage references so the confirmed duplicate is actually removed.
|
||||
*/
|
||||
@@ -5063,6 +5068,15 @@ export function TaskDetailContent({
|
||||
<div className="detail-near-duplicate-banner__header">
|
||||
<AlertTriangle aria-hidden="true" />
|
||||
<span className="detail-near-duplicate-banner__headline">{t("taskDetail.nearDuplicate.headline", "Potential duplicate detected")}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="detail-near-duplicate-banner__dismiss"
|
||||
onClick={() => void handleDismissNearDuplicate()}
|
||||
title={t("taskDetail.nearDuplicate.dismissBtn", "Mark the duplicate flag for {{id}} as read", { id: nearDuplicateOf })}
|
||||
aria-label={t("taskDetail.nearDuplicate.dismissBtn", "Mark the duplicate flag for {{id}} as read", { id: nearDuplicateOf })}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="detail-near-duplicate-banner__copy">
|
||||
{t("taskDetail.nearDuplicate.copy", "This task appears to be a near-duplicate of")}{" "}
|
||||
@@ -5078,23 +5092,22 @@ export function TaskDetailContent({
|
||||
{nearDuplicateOf}
|
||||
</button>
|
||||
{". "}{isTriageMarkerDuplicate
|
||||
? t("taskDetail.nearDuplicate.triageActions", "Choose Delete to remove this duplicate, or Keep to continue anyway.")
|
||||
: t("taskDetail.nearDuplicate.actions", "Choose Archive to move this task to archived, or Keep to continue with this task.")}
|
||||
? t("taskDetail.nearDuplicate.triageActions", "This task stays paused until you clear this flag or delete it. Delete it if the work is already covered.")
|
||||
: t("taskDetail.nearDuplicate.actions", "This task continues normally. Archive it if the work is already covered, or clear this flag once you have read it.")}
|
||||
</p>
|
||||
<div className="detail-near-duplicate-banner__actions">
|
||||
{isTriageMarkerDuplicate ? (
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => void handleDeleteTriageDuplicate()}>
|
||||
{t("taskDetail.nearDuplicate.deleteBtn", "Delete")}
|
||||
</button>
|
||||
) : onArchiveTask ? (
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => void handleArchiveNearDuplicate()}>
|
||||
{t("taskDetail.nearDuplicate.archiveBtn", "Archive")}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="btn btn-sm" onClick={() => void handleDismissNearDuplicate()}>
|
||||
{t("taskDetail.nearDuplicate.keepBtn", "Keep")}
|
||||
</button>
|
||||
</div>
|
||||
{(isTriageMarkerDuplicate || onArchiveTask) && (
|
||||
<div className="detail-near-duplicate-banner__actions">
|
||||
{isTriageMarkerDuplicate ? (
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => void handleDeleteTriageDuplicate()}>
|
||||
{t("taskDetail.nearDuplicate.deleteBtn", "Delete")}
|
||||
</button>
|
||||
) : onArchiveTask ? (
|
||||
<button type="button" className="btn btn-danger btn-sm" onClick={() => void handleArchiveNearDuplicate()}>
|
||||
{t("taskDetail.nearDuplicate.archiveBtn", "Archive")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/*
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React from "react";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { TaskCard } from "../TaskCard";
|
||||
import { loadAllAppCss, loadComponentCss } from "../../test/cssFixture";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
const addToast = vi.fn();
|
||||
vi.mock("../../hooks/useToast", () => ({
|
||||
useOptionalToast: () => null,
|
||||
useToast: () => ({ addToast, removeToast: vi.fn(), toasts: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
Link: () => null, Clock: () => null, Layers: () => null, Pencil: () => null,
|
||||
ChevronDown: () => null, Folder: () => null, Target: () => null, Bot: () => null,
|
||||
Trash2: () => null, RotateCw: () => null, Zap: () => null, GitBranch: () => null,
|
||||
GitPullRequest: () => null, AlertTriangle: () => null, ArrowUpRight: () => null,
|
||||
ArrowDown: () => null, Flag: () => null, ArrowUp: () => null, TriangleAlert: () => null,
|
||||
Eye: () => null, MoreHorizontal: () => null, Sparkles: () => null,
|
||||
X: () => <svg data-testid="icon-x" />,
|
||||
}));
|
||||
|
||||
vi.mock("../ProviderIcon", () => ({ ProviderIcon: () => null }));
|
||||
vi.mock("../PrCreateModal", () => ({ PrCreateModal: () => null }));
|
||||
vi.mock("../../hooks/useTaskDiffStats", () => ({ useTaskDiffStats: () => ({ stats: null, loading: false }) }));
|
||||
vi.mock("../../hooks/useBadgeWebSocket", () => ({ useBadgeWebSocket: () => ({ badgeUpdates: new Map(), isConnected: true, subscribeToBadge: vi.fn(), unsubscribeFromBadge: vi.fn() }) }));
|
||||
vi.mock("../../hooks/useBatchBadgeFetch", () => ({ getFreshBatchData: vi.fn(() => null) }));
|
||||
vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: vi.fn(), confirmWithChoice: vi.fn() }) }));
|
||||
vi.mock("../../api", () => ({ fetchWorkflowSettingValues: vi.fn().mockResolvedValue({ stored: {}, effective: {}, orphaned: [] }) }));
|
||||
|
||||
function makeTask(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: "FN-001",
|
||||
title: "Test task",
|
||||
column: "in-progress",
|
||||
description: "",
|
||||
steps: [],
|
||||
dependencies: [],
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
const readSource = (relativePath: string) => readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8");
|
||||
|
||||
function renderCard(task: Task, onUpdateTask?: (id: string, patch: { dismissNearDuplicate: boolean }) => Promise<unknown> | unknown) {
|
||||
return render(<TaskCard task={task} onOpenDetail={noop} addToast={addToast} onUpdateTask={onUpdateTask} />);
|
||||
}
|
||||
|
||||
describe("TaskCard duplicate tag (FN-173)", () => {
|
||||
it("clears the duplicate flag through the existing update seam", async () => {
|
||||
const onUpdateTask = vi.fn().mockResolvedValue(undefined);
|
||||
renderCard(makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } }), onUpdateTask);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mark the duplicate flag for FN-1234 as read" }));
|
||||
|
||||
await waitFor(() => expect(onUpdateTask).toHaveBeenCalledWith("FN-001", { dismissNearDuplicate: true }));
|
||||
expect(screen.queryByRole("button", { name: /keep/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not leave a duplicate group shell without a duplicate", () => {
|
||||
const { container } = renderCard(makeTask());
|
||||
|
||||
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /mark the duplicate flag/i })).toBeNull();
|
||||
expect(container.querySelector(".card-duplicate-chip-group")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /keep/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("explains that a held triage duplicate stays paused until dismissed or deleted", () => {
|
||||
renderCard(makeTask({
|
||||
paused: true,
|
||||
pausedReason: "duplicate-decision-required",
|
||||
sourceMetadata: { nearDuplicateOf: "FN-1234", duplicateSource: "triage-marker" },
|
||||
}), vi.fn());
|
||||
|
||||
const chip = screen.getByText("Duplicate of FN-1234").closest(".card-duplicate-chip");
|
||||
expect(chip).toHaveAttribute("title", expect.stringContaining("stays paused until you clear this flag or delete it"));
|
||||
expect(chip).toHaveAttribute("aria-label", expect.stringContaining("stays paused until you clear this flag or delete it"));
|
||||
expect(screen.getByTestId("card-needs-user-feedback-FN-001")).toHaveTextContent("Needs your decision");
|
||||
expect(screen.queryByRole("button", { name: /keep/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("explains that an ordinary near-duplicate continues normally", () => {
|
||||
renderCard(makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } }), vi.fn());
|
||||
|
||||
expect(screen.getByText("Duplicate of FN-1234").closest(".card-duplicate-chip")).toHaveAttribute("title", expect.stringContaining("task continues normally"));
|
||||
expect(screen.queryByRole("button", { name: /keep/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the tag visible and reports a failed dismissal", async () => {
|
||||
const failure = new Error("request rejected");
|
||||
const onUpdateTask = vi.fn().mockRejectedValue(failure);
|
||||
renderCard(makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } }), onUpdateTask);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mark the duplicate flag for FN-1234 as read" }));
|
||||
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith(expect.stringContaining("Failed to clear the duplicate flag"), "error"));
|
||||
expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /keep/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("passes onUpdateTask inside every TaskCard host element", () => {
|
||||
for (const relativePath of ["../Column.tsx", "../DockTaskList.tsx", "../useRightDockController.tsx", "../WorktreeGroup.tsx", "../dashboard/MainContent.tsx"]) {
|
||||
const source = readSource(relativePath);
|
||||
const taskCardElements = source.match(/<TaskCard\b[\s\S]*?\/>/g) ?? [];
|
||||
expect(taskCardElements.length, relativePath).toBeGreaterThan(0);
|
||||
expect(taskCardElements.some((element) => /\bonUpdateTask\b/.test(element)), relativePath).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the informational chip but no button when updates are unavailable", () => {
|
||||
const { container } = renderCard(makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } }));
|
||||
|
||||
expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument();
|
||||
expect(container.querySelector(".card-duplicate-chip-group button")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /keep/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps duplicate dismissal styles responsive and removes legacy styles", () => {
|
||||
const cardCss = loadComponentCss("TaskCard.css");
|
||||
const appCss = loadAllAppCss();
|
||||
const detailCss = loadComponentCss("TaskDetailModal.css");
|
||||
|
||||
expect(appCss).not.toContain("card-duplicate-keep");
|
||||
expect(cardCss).toMatch(/\.card-duplicate-dismiss\s*\{[\s\S]*?height:\s*var\(--card-chip-height\)/);
|
||||
expect(cardCss).toMatch(/\.card-duplicate-dismiss:focus-visible\s*\{[\s\S]*?var\(--focus-ring-strong\)/);
|
||||
expect(cardCss).toContain(".card-duplicate-dismiss::after");
|
||||
expect(cardCss).toMatch(/@media \(max-width: 768px\)\s*\{[\s\S]*?\.card-duplicate-chip-group,[\s\S]*?\.card-duplicate-dismiss/);
|
||||
expect(detailCss).toContain(".detail-near-duplicate-banner__dismiss");
|
||||
expect(detailCss).toMatch(/@media \(max-width: 768px\)\s*\{\s*\.detail-near-duplicate-banner__actions/);
|
||||
});
|
||||
|
||||
it("removes obsolete duplicate keys from the catalog and generated declarations", () => {
|
||||
const catalog = readSource("../../../../i18n/locales/en/app.json");
|
||||
const declarations = readSource("../../../../i18n/src/resources.d.ts");
|
||||
|
||||
for (const removedKey of ["keepTaskTitle", "keepFailed", "keepBtn"]) {
|
||||
expect(catalog).not.toContain(removedKey);
|
||||
expect(declarations).not.toContain(removedKey);
|
||||
}
|
||||
for (const retainedKey of ["dismissDuplicateFlag", "nearDuplicateHeldTitle"]) {
|
||||
expect(catalog).toContain(retainedKey);
|
||||
expect(declarations).toContain(retainedKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -54,6 +54,8 @@ vi.mock("lucide-react", () => ({
|
||||
Eye: () => <svg data-testid="icon-eye" />,
|
||||
// FNXC:TaskCardMenu 2026-07-10-12:00: visible ⋯ card-actions button icon.
|
||||
MoreHorizontal: () => <svg data-testid="icon-more-horizontal" />,
|
||||
// FNXC:NearDuplicateDetection 2026-08-23-04:53: This mock factory is closed-world; every icon imported by TaskCard must be declared here or conditional branches resolve it to undefined and React throws only when they render.
|
||||
X: () => <svg data-testid="icon-x" />,
|
||||
}));
|
||||
|
||||
vi.mock("../ProviderIcon", () => ({
|
||||
@@ -7359,7 +7361,8 @@ describe("TaskCard near-duplicate chip", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText("Duplicate of FN-1234")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Keep this task and dismiss duplicate warning" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Mark the duplicate flag for FN-1234 as read" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /keep/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides duplicate chip when nearDuplicateDismissed is true", () => {
|
||||
@@ -7386,6 +7389,7 @@ describe("TaskCard near-duplicate chip", () => {
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Mark the duplicate flag for FN-1234 as read" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders duplicate chip when canonical activity is unknown", () => {
|
||||
@@ -7426,7 +7430,7 @@ describe("TaskCard near-duplicate chip", () => {
|
||||
expect(screen.queryByText("Duplicate of FN-1234")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking Keep calls updateTask dismissNearDuplicate", async () => {
|
||||
it("clearing the duplicate flag calls updateTask dismissNearDuplicate", async () => {
|
||||
const onUpdateTask = vi.fn().mockResolvedValue(makeTask());
|
||||
|
||||
render(
|
||||
@@ -7438,7 +7442,7 @@ describe("TaskCard near-duplicate chip", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Keep this task and dismiss duplicate warning" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Mark the duplicate flag for FN-1234 as read" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onUpdateTask).toHaveBeenCalledWith("FN-001", { dismissNearDuplicate: true });
|
||||
|
||||
@@ -3191,7 +3191,7 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows near-duplicate banner and keeps warning on Keep click", async () => {
|
||||
it("clears the duplicate flag from the near-duplicate banner", async () => {
|
||||
const { updateTask } = await import("../../api");
|
||||
const mockUpdateTask = vi.mocked(updateTask);
|
||||
mockUpdateTask.mockResolvedValueOnce(makeTask({
|
||||
@@ -3214,13 +3214,34 @@ describe("TaskDetailModal", () => {
|
||||
);
|
||||
|
||||
expect(screen.getByText("Potential duplicate detected")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Keep" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Mark the duplicate flag for FN-1234 as read" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-099", { dismissNearDuplicate: true }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("omits an empty near-duplicate actions row without archive support", () => {
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
initialTab="definition"
|
||||
task={makeTask({ sourceMetadata: { nearDuplicateOf: "FN-1234" } })}
|
||||
tasks={[makeTask({ id: "FN-1234" })]}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Potential duplicate detected")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Mark the duplicate flag for FN-1234 as read" })).toBeInTheDocument();
|
||||
expect(container.querySelector(".detail-near-duplicate-banner__actions")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /keep/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("hides near-duplicate banner once dismissed", () => {
|
||||
render(
|
||||
<TaskDetailModal
|
||||
@@ -3260,7 +3281,8 @@ describe("TaskDetailModal", () => {
|
||||
|
||||
expect(screen.queryByText("Potential duplicate detected")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Archive" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Keep" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /keep/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Mark the duplicate flag for FN-1234 as read" })).toBeNull();
|
||||
});
|
||||
|
||||
it("archives from near-duplicate banner when confirmed", async () => {
|
||||
@@ -3307,7 +3329,7 @@ describe("TaskDetailModal", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Choose Delete to remove this duplicate, or Keep to continue anyway.");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("This task stays paused until you clear this flag or delete it.");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -438,6 +438,7 @@ export function MainContent({
|
||||
openTaskDetail: openPluginTaskDetail,
|
||||
openFile: openFileInBrowser,
|
||||
beginNativeStructureDrag: attachNativeStructureRefToDrag,
|
||||
/* FNXC:NearDuplicateDetection 2026-08-23-04:53: Plugin-rendered cards share the duplicate tag contract, so forward the board update seam and keep the mark-as-read control available on this host. */
|
||||
renderTaskCard: (task: Task | TaskDetail) => (
|
||||
<TaskCard
|
||||
task={task}
|
||||
@@ -447,6 +448,7 @@ export function MainContent({
|
||||
projectId={currentProject?.id}
|
||||
onOpenDetail={openPluginTaskDetail}
|
||||
addToast={addToast}
|
||||
onUpdateTask={updateTask}
|
||||
prAuthAvailable={prAuthAvailable}
|
||||
autoMergeEnabled={autoMerge}
|
||||
nearDuplicateCanonicalInactive={typeof task.sourceMetadata?.nearDuplicateOf === "string"
|
||||
|
||||
@@ -83,6 +83,7 @@ export interface OverflowViewRenderProps {
|
||||
onOpenSessionInNewWindow?: (session: ChatSessionInfo) => void;
|
||||
/** Opens New Task with a reverted source task's original description. */
|
||||
onReviseTask?: (task: Task | TaskDetail) => void;
|
||||
onUpdateTask?: (id: string, updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean; githubTracking?: { enabled?: boolean } }) => Promise<Task>;
|
||||
onDeleteTask?: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; allowResurrection?: boolean }) => Promise<Task>;
|
||||
onOpenDetail?: (task: Task | TaskDetail, initialTab?: DetailTaskTab) => void;
|
||||
onSendSelectionToTask?: (description: string) => void;
|
||||
@@ -163,6 +164,7 @@ export const STATIC_OVERFLOW_VIEW_ENTRIES: readonly OverflowViewEntry[] = [
|
||||
projectId={props.projectId}
|
||||
onOpenTask={props.onOpenTaskInDock}
|
||||
onReviseTask={props.onReviseTask}
|
||||
onUpdateTask={props.onUpdateTask}
|
||||
onDeleteTask={props.onDeleteTask}
|
||||
addToast={props.addToast}
|
||||
prAuthAvailable={false}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface RightDockControllerInput {
|
||||
openMobileTasksInPopup: boolean;
|
||||
openFileInBrowser: (path: string, opts?: { workspace?: string; line?: number; col?: number }) => void;
|
||||
onMoveTask: (id: string, column: ColumnId, optionsOrPosition?: { preserveProgress?: boolean } | number) => Promise<Task>;
|
||||
onUpdateTask?: (id: string, updates: { title?: string; description?: string; dependencies?: string[]; dismissNearDuplicate?: boolean; githubTracking?: { enabled?: boolean } }) => Promise<Task>;
|
||||
onDeleteTask: (id: string, options?: { removeDependencyReferences?: boolean; removeLineageReferences?: boolean; githubIssueAction?: GithubIssueAction; allowResurrection?: boolean }) => Promise<Task>;
|
||||
onArchiveTask?: (id: string, options?: { removeLineageReferences?: boolean }) => Promise<Task>;
|
||||
/* FNXC:TaskRevert 2026-07-05-00:00 (FN-7525): threaded alongside onArchiveTask; never mutates the source task's column. */
|
||||
@@ -187,6 +188,7 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
|
||||
projectId={input.projectId}
|
||||
onOpenDetail={(value: Task | TaskDetail) => input.openDetailTask(value)}
|
||||
onDeleteTask={input.onDeleteTask}
|
||||
onUpdateTask={input.onUpdateTask}
|
||||
addToast={input.addToast}
|
||||
prAuthAvailable={input.prAuthAvailable}
|
||||
autoMergeEnabled={input.autoMerge}
|
||||
@@ -209,6 +211,7 @@ export function useRightDockController(input: RightDockControllerInput): RightDo
|
||||
anchorGoalId: input.goalAnchorId,
|
||||
tasks: input.tasks,
|
||||
columnFlagsByTaskId: input.columnFlagsByTaskId,
|
||||
onUpdateTask: input.onUpdateTask,
|
||||
workflowSteps: input.workflowSteps,
|
||||
pluginContext: {
|
||||
projectId: input.projectId,
|
||||
|
||||
@@ -178,6 +178,37 @@ describe("NotificationService deferred failure notifications", () => {
|
||||
it. No delivery or durable wedge claim is allowed until the writer clears that
|
||||
ownership at exhaustion, when the existing once-per-episode seam must alert.
|
||||
*/
|
||||
it("writes clear-or-delete guidance for a held triage duplicate", async () => {
|
||||
const store = createStore();
|
||||
const sendMessageOnce = vi.fn(async () => ({ message: {} as any, inserted: true }));
|
||||
const service = new NotificationService(store as any, {
|
||||
messageStore: { on: () => undefined, sendMessageOnce } as any,
|
||||
});
|
||||
await service.start();
|
||||
|
||||
const duplicate = task({
|
||||
id: "FN-duplicate",
|
||||
paused: true,
|
||||
pausedReason: "duplicate-decision-required",
|
||||
sourceMetadata: { duplicateSource: "triage-marker", nearDuplicateOf: "FN-1234" },
|
||||
});
|
||||
store.emit("task:updated", duplicate);
|
||||
await flushAsyncHandlers();
|
||||
|
||||
expect(sendMessageOnce).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
content: expect.stringContaining("Clear the duplicate flag"),
|
||||
metadata: expect.objectContaining({ kind: "triage-duplicate-decision" }),
|
||||
}),
|
||||
"triage-duplicate-decision:FN-duplicate",
|
||||
);
|
||||
const payload = (sendMessageOnce.mock.calls as unknown as Array<[{ content: string }]>)[0][0];
|
||||
expect(payload.content).not.toMatch(/keep it/i);
|
||||
expect(payload.content).toMatch(/delete the task/i);
|
||||
|
||||
await service.stop();
|
||||
});
|
||||
|
||||
it("suppresses recovery-owned failed snapshots and alerts once after exhaustion", async () => {
|
||||
const store = createStore();
|
||||
const sendMessageOnce = vi.fn(async () => ({ message: {} as any, inserted: true }));
|
||||
|
||||
@@ -1001,7 +1001,8 @@ export class NotificationService {
|
||||
const content = [
|
||||
`**${formatTaskIdentifier(task)} needs your decision**`,
|
||||
"",
|
||||
`It was flagged as a duplicate of ${canonicalTaskId}. Keep it to continue planning, or delete it if the work is already covered.`,
|
||||
/* FNXC:NearDuplicateDetection 2026-08-23-04:10: FN-173 removed Keep, so operator prompts name the clear-the-flag action. */
|
||||
`It was flagged as a duplicate of ${canonicalTaskId}. Clear the duplicate flag to continue planning, or delete the task if the work is already covered.`,
|
||||
...(link ? ["", `[Open ${task.id}](${link})`] : []),
|
||||
].join("\n");
|
||||
await messageStore.sendMessageOnce({
|
||||
|
||||
@@ -8089,7 +8089,7 @@
|
||||
"resetProgressTitle": "Reset Progress?"
|
||||
},
|
||||
"nearDuplicate": {
|
||||
"actions": "Choose Archive to move this task to archived, or Keep to continue with this task.",
|
||||
"actions": "This task continues normally. Archive it if the work is already covered, or clear this flag once you have read it.",
|
||||
"archiveBtn": "Archive",
|
||||
"archiveConfirm": "Archive",
|
||||
"archived": "Archived {{id}}",
|
||||
@@ -8102,9 +8102,9 @@
|
||||
"deleteMessage": "Delete {{id}} as a duplicate of {{duplicateOf}}?",
|
||||
"deleteTitle": "Delete duplicate task",
|
||||
"headline": "Potential duplicate detected",
|
||||
"keepBtn": "Keep",
|
||||
"kept": "Kept {{id}} and dismissed duplicate warning",
|
||||
"triageActions": "Choose Delete to remove this duplicate, or Keep to continue anyway."
|
||||
"triageActions": "This task stays paused until you clear this flag or delete it. Delete it if the work is already covered.",
|
||||
"dismissBtn": "Mark the duplicate flag for {{id}} as read",
|
||||
"dismissed": "Duplicate flag cleared for {{id}}"
|
||||
},
|
||||
"nextRecoveryAt": "Next recovery at {{time}}",
|
||||
"no": "No",
|
||||
@@ -8677,7 +8677,7 @@
|
||||
"descriptionRefined": "Description refined with AI",
|
||||
"doneNoMerge": "Done (no merge)",
|
||||
"duplicateCheckFailed": "Duplicate check failed; creating task anyway.",
|
||||
"duplicateDismissed": "Kept {{taskId}}; duplicate warning dismissed",
|
||||
"duplicateDismissed": "Duplicate flag cleared for {{taskId}}",
|
||||
"duplicateOf": "Duplicate of {{id}}",
|
||||
"editTask": "Edit task",
|
||||
"enterDescriptionFirst": "Enter a description first",
|
||||
@@ -8713,10 +8713,7 @@
|
||||
"issueClosed": "closed",
|
||||
"issueDeleted": "deleted",
|
||||
"issueLeft": "left",
|
||||
"keep": "Keep",
|
||||
"keepFailed": "Failed to keep {{taskId}}: {{error}}",
|
||||
"keepProgress": "Keep Progress",
|
||||
"keepTaskTitle": "Keep this task and dismiss duplicate warning",
|
||||
"leaveUnchanged": "Leave Unchanged",
|
||||
"lineageConflict": "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nDelete anyway by unlinking these references first?",
|
||||
"linkedIssueChipAriaLabel": "Linked GitHub issue #{{number}}",
|
||||
@@ -8740,7 +8737,7 @@
|
||||
"moved": "Moved {{taskId}} to {{column}}",
|
||||
"moveFailed": "Failed to move {{taskId}}: {{error}}",
|
||||
"moveTask": "Move task",
|
||||
"nearDuplicateTitle": "Potential near-duplicate of {{id}}",
|
||||
"nearDuplicateTitle": "Flagged as a possible duplicate of {{id}}. This task continues normally; clear the flag once you have read it.",
|
||||
"needsInput": "Needs input",
|
||||
"noAgentsAvailable": "No agents available",
|
||||
"node": "Node",
|
||||
@@ -8836,7 +8833,10 @@
|
||||
"workflow": "workflow",
|
||||
"workflowCheck": "Workflow check",
|
||||
"costBadgeTitle": "Estimated cost {{amount}}",
|
||||
"costBadgeAriaLabel": "Estimated cost {{amount}}"
|
||||
"costBadgeAriaLabel": "Estimated cost {{amount}}",
|
||||
"dismissDuplicateFlag": "Mark the duplicate flag for {{id}} as read",
|
||||
"nearDuplicateHeldTitle": "Flagged as a duplicate of {{id}}. This task stays paused until you clear this flag or delete it.",
|
||||
"duplicateDismissFailed": "Failed to clear the duplicate flag for {{taskId}}: {{error}}"
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "Terminal arrow keys",
|
||||
|
||||
@@ -8050,7 +8050,7 @@
|
||||
"resetProgressTitle": "¿Restablecer el progreso?"
|
||||
},
|
||||
"nearDuplicate": {
|
||||
"actions": "Elige Archivar para mover esta tarea a archivadas, o Conservar para continuar con esta tarea.",
|
||||
"actions": "This task continues normally. Archive it if the work is already covered, or clear this flag once you have read it.",
|
||||
"archiveBtn": "Archivar",
|
||||
"archiveConfirm": "Archivar",
|
||||
"archived": "{{id}} archivado",
|
||||
@@ -8058,14 +8058,14 @@
|
||||
"archiveTitle": "Archivar tarea casi duplicada",
|
||||
"copy": "Esta tarea parece ser un casi-duplicado de",
|
||||
"headline": "Posible duplicado detectado",
|
||||
"keepBtn": "Conservar",
|
||||
"kept": "Se conservó {{id}} y se descartó la advertencia de duplicado",
|
||||
"deleteBtn": "Eliminar",
|
||||
"deleteConfirm": "Eliminar",
|
||||
"deleted": "Eliminado {{id}}",
|
||||
"deleteMessage": "¿Eliminar {{id}} como duplicado de {{duplicateOf}}?",
|
||||
"deleteTitle": "Eliminar tarea duplicada",
|
||||
"triageActions": "Elige Eliminar para quitar este duplicado, o Mantener para continuar de todos modos."
|
||||
"triageActions": "This task stays paused until you clear this flag or delete it. Delete it if the work is already covered.",
|
||||
"dismissBtn": "Mark the duplicate flag for {{id}} as read",
|
||||
"dismissed": "Duplicate flag cleared for {{id}}"
|
||||
},
|
||||
"nextRecoveryAt": "Próxima recuperación a las {{time}}",
|
||||
"no": "No",
|
||||
@@ -8658,7 +8658,7 @@
|
||||
"descriptionRefined": "Descripción refinada con IA",
|
||||
"doneNoMerge": "Listo (sin fusión)",
|
||||
"duplicateCheckFailed": "Falló la comprobación de duplicados; creando la tarea de todos modos.",
|
||||
"duplicateDismissed": "Se conservó {{taskId}}; aviso de duplicado descartado",
|
||||
"duplicateDismissed": "Duplicate flag cleared for {{taskId}}",
|
||||
"duplicateOf": "Duplicado de {{id}}",
|
||||
"editTask": "Editar tarea",
|
||||
"enterDescriptionFirst": "Introduce primero una descripción",
|
||||
@@ -8694,10 +8694,7 @@
|
||||
"issueClosed": "cerrado",
|
||||
"issueDeleted": "eliminado",
|
||||
"issueLeft": "dejado",
|
||||
"keep": "Conservar",
|
||||
"keepFailed": "Error al conservar {{taskId}}: {{error}}",
|
||||
"keepProgress": "Conservar progreso",
|
||||
"keepTaskTitle": "Conservar esta tarea y descartar la advertencia de duplicado",
|
||||
"leaveUnchanged": "Dejar sin cambios",
|
||||
"lineageConflict": "{{taskId}} tiene elementos secundarios de linaje ({{children}}) que lo referencian como padre de origen.\n\n¿Eliminar de todos modos desvinculando primero estas referencias?",
|
||||
"linkedIssueChipAriaLabel": "Issue de GitHub vinculado #{{number}}",
|
||||
@@ -8721,7 +8718,7 @@
|
||||
"moved": "{{taskId}} movido a {{column}}",
|
||||
"moveFailed": "Error al mover {{taskId}}: {{error}}",
|
||||
"moveTask": "Mover tarea",
|
||||
"nearDuplicateTitle": "Posible duplicado aproximado de {{id}}",
|
||||
"nearDuplicateTitle": "Flagged as a possible duplicate of {{id}}. This task continues normally; clear the flag once you have read it.",
|
||||
"needsInput": "",
|
||||
"noAgentsAvailable": "No hay agentes disponibles",
|
||||
"node": "Nodo",
|
||||
@@ -8826,7 +8823,10 @@
|
||||
"refinesOfTitle": "",
|
||||
"revertedTasks": "",
|
||||
"revertedResolutionActions": "",
|
||||
"revise": ""
|
||||
"revise": "",
|
||||
"dismissDuplicateFlag": "Mark the duplicate flag for {{id}} as read",
|
||||
"nearDuplicateHeldTitle": "Flagged as a duplicate of {{id}}. This task stays paused until you clear this flag or delete it.",
|
||||
"duplicateDismissFailed": "Failed to clear the duplicate flag for {{taskId}}: {{error}}"
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
@@ -8050,7 +8050,7 @@
|
||||
"resetProgressTitle": "Réinitialiser la progression ?"
|
||||
},
|
||||
"nearDuplicate": {
|
||||
"actions": "Choisissez Archiver pour déplacer cette tâche vers les archives, ou Conserver pour continuer avec cette tâche.",
|
||||
"actions": "This task continues normally. Archive it if the work is already covered, or clear this flag once you have read it.",
|
||||
"archiveBtn": "Archiver",
|
||||
"archiveConfirm": "Archiver",
|
||||
"archived": "{{id}} archivé",
|
||||
@@ -8058,14 +8058,14 @@
|
||||
"archiveTitle": "Archiver la tâche quasi-dupliquée",
|
||||
"copy": "Cette tâche semble être un quasi-doublon de",
|
||||
"headline": "Doublon potentiel détecté",
|
||||
"keepBtn": "Conserver",
|
||||
"kept": "{{id}} conservé et avertissement de doublon ignoré",
|
||||
"deleteBtn": "Supprimer",
|
||||
"deleteConfirm": "Supprimer",
|
||||
"deleted": "Supprimé {{id}}",
|
||||
"deleteMessage": "Supprimer {{id}} comme doublon de {{duplicateOf}} ?",
|
||||
"deleteTitle": "Supprimer la tâche en double",
|
||||
"triageActions": "Choisissez Supprimer pour retirer ce doublon, ou Conserver pour continuer."
|
||||
"triageActions": "This task stays paused until you clear this flag or delete it. Delete it if the work is already covered.",
|
||||
"dismissBtn": "Mark the duplicate flag for {{id}} as read",
|
||||
"dismissed": "Duplicate flag cleared for {{id}}"
|
||||
},
|
||||
"nextRecoveryAt": "Prochaine récupération à {{time}}",
|
||||
"no": "Non",
|
||||
@@ -8658,7 +8658,7 @@
|
||||
"descriptionRefined": "Description affinée par l'IA",
|
||||
"doneNoMerge": "Terminé (sans fusion)",
|
||||
"duplicateCheckFailed": "Vérification des doublons échouée ; création de la tâche quand même.",
|
||||
"duplicateDismissed": "{{taskId}} conservé ; avertissement de doublon ignoré",
|
||||
"duplicateDismissed": "Duplicate flag cleared for {{taskId}}",
|
||||
"duplicateOf": "Doublon de {{id}}",
|
||||
"editTask": "Modifier la tâche",
|
||||
"enterDescriptionFirst": "Saisir d'abord une description",
|
||||
@@ -8694,10 +8694,7 @@
|
||||
"issueClosed": "fermé",
|
||||
"issueDeleted": "supprimé",
|
||||
"issueLeft": "laissé",
|
||||
"keep": "Conserver",
|
||||
"keepFailed": "Impossible de conserver {{taskId}} : {{error}}",
|
||||
"keepProgress": "Conserver la progression",
|
||||
"keepTaskTitle": "Conserver cette tâche et ignorer l'avertissement de doublon",
|
||||
"leaveUnchanged": "Laisser intact",
|
||||
"lineageConflict": "{{taskId}} a des enfants de lignée ({{children}}) qui le référencent comme parent source.\n\nSupprimer quand même en dissociant d'abord ces références ?",
|
||||
"linkedIssueChipAriaLabel": "Issue GitHub liée #{{number}}",
|
||||
@@ -8721,7 +8718,7 @@
|
||||
"moved": "{{taskId}} déplacé vers {{column}}",
|
||||
"moveFailed": "Impossible de déplacer {{taskId}} : {{error}}",
|
||||
"moveTask": "Déplacer la tâche",
|
||||
"nearDuplicateTitle": "Potentiel quasi-doublon de {{id}}",
|
||||
"nearDuplicateTitle": "Flagged as a possible duplicate of {{id}}. This task continues normally; clear the flag once you have read it.",
|
||||
"needsInput": "",
|
||||
"noAgentsAvailable": "Aucun agent disponible",
|
||||
"node": "Nœud",
|
||||
@@ -8826,7 +8823,10 @@
|
||||
"refinesOfTitle": "",
|
||||
"revertedTasks": "",
|
||||
"revertedResolutionActions": "",
|
||||
"revise": ""
|
||||
"revise": "",
|
||||
"dismissDuplicateFlag": "Mark the duplicate flag for {{id}} as read",
|
||||
"nearDuplicateHeldTitle": "Flagged as a duplicate of {{id}}. This task stays paused until you clear this flag or delete it.",
|
||||
"duplicateDismissFailed": "Failed to clear the duplicate flag for {{taskId}}: {{error}}"
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
@@ -8050,7 +8050,7 @@
|
||||
"resetProgressTitle": "진행 상태 초기화?"
|
||||
},
|
||||
"nearDuplicate": {
|
||||
"actions": "보관을 선택하면 이 작업이 보관 상태로 이동하고, 유지를 선택하면 이 작업을 계속합니다.",
|
||||
"actions": "This task continues normally. Archive it if the work is already covered, or clear this flag once you have read it.",
|
||||
"archiveBtn": "보관",
|
||||
"archiveConfirm": "보관",
|
||||
"archived": "{{id}} 보관됨",
|
||||
@@ -8058,14 +8058,14 @@
|
||||
"archiveTitle": "근사 중복 작업 보관",
|
||||
"copy": "이 작업은 다음의 근사 중복으로 보입니다",
|
||||
"headline": "잠재적 중복 감지됨",
|
||||
"keepBtn": "유지",
|
||||
"kept": "{{id}} 유지 및 중복 경고 무시됨",
|
||||
"deleteBtn": "삭제",
|
||||
"deleteConfirm": "삭제",
|
||||
"deleted": "{{id}} 삭제됨",
|
||||
"deleteMessage": "{{duplicateOf}}의 중복으로 {{id}}를 삭제할까요?",
|
||||
"deleteTitle": "중복 작업 삭제",
|
||||
"triageActions": "이 중복을 제거하려면 삭제를, 계속하려면 유지를 선택하세요."
|
||||
"triageActions": "This task stays paused until you clear this flag or delete it. Delete it if the work is already covered.",
|
||||
"dismissBtn": "Mark the duplicate flag for {{id}} as read",
|
||||
"dismissed": "Duplicate flag cleared for {{id}}"
|
||||
},
|
||||
"nextRecoveryAt": "다음 복구 시각: {{time}}",
|
||||
"no": "아니요",
|
||||
@@ -8658,7 +8658,7 @@
|
||||
"descriptionRefined": "AI로 설명이 다듬어졌습니다",
|
||||
"doneNoMerge": "완료 (병합 없음)",
|
||||
"duplicateCheckFailed": "중복 확인에 실패했습니다. 작업을 그대로 생성합니다.",
|
||||
"duplicateDismissed": "{{taskId}}을(를) 유지했습니다. 중복 경고가 해제되었습니다",
|
||||
"duplicateDismissed": "Duplicate flag cleared for {{taskId}}",
|
||||
"duplicateOf": "{{id}}의 중복",
|
||||
"editTask": "작업 편집",
|
||||
"enterDescriptionFirst": "먼저 설명을 입력해 주세요",
|
||||
@@ -8694,10 +8694,7 @@
|
||||
"issueClosed": "닫힘",
|
||||
"issueDeleted": "삭제됨",
|
||||
"issueLeft": "그대로 둠",
|
||||
"keep": "유지",
|
||||
"keepFailed": "{{taskId}} 유지에 실패했습니다: {{error}}",
|
||||
"keepProgress": "진행 상황 유지",
|
||||
"keepTaskTitle": "이 작업을 유지하고 중복 경고 해제",
|
||||
"leaveUnchanged": "그대로 두기",
|
||||
"lineageConflict": "{{taskId}}에는 소스 부모로 참조하는 계보 자식({{children}})이 있습니다.\n\n이 참조를 먼저 해제하고 삭제하시겠습니까?",
|
||||
"linkedIssueChipAriaLabel": "연결된 GitHub 이슈 #{{number}}",
|
||||
@@ -8721,7 +8718,7 @@
|
||||
"moved": "{{taskId}}을(를) {{column}}으로 이동했습니다",
|
||||
"moveFailed": "{{taskId}} 이동에 실패했습니다: {{error}}",
|
||||
"moveTask": "작업 이동",
|
||||
"nearDuplicateTitle": "{{id}}의 잠재적 유사 중복",
|
||||
"nearDuplicateTitle": "Flagged as a possible duplicate of {{id}}. This task continues normally; clear the flag once you have read it.",
|
||||
"needsInput": "",
|
||||
"noAgentsAvailable": "사용 가능한 에이전트가 없습니다",
|
||||
"node": "노드",
|
||||
@@ -8826,7 +8823,10 @@
|
||||
"refinesOfTitle": "",
|
||||
"revertedTasks": "",
|
||||
"revertedResolutionActions": "",
|
||||
"revise": ""
|
||||
"revise": "",
|
||||
"dismissDuplicateFlag": "Mark the duplicate flag for {{id}} as read",
|
||||
"nearDuplicateHeldTitle": "Flagged as a duplicate of {{id}}. This task stays paused until you clear this flag or delete it.",
|
||||
"duplicateDismissFailed": "Failed to clear the duplicate flag for {{taskId}}: {{error}}"
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
@@ -8089,7 +8089,7 @@
|
||||
"resetProgressTitle": "Redefinir progresso?"
|
||||
},
|
||||
"nearDuplicate": {
|
||||
"actions": "Escolha Arquivar para mover esta tarefa para arquivadas, ou Manter para continuar com esta tarefa.",
|
||||
"actions": "This task continues normally. Archive it if the work is already covered, or clear this flag once you have read it.",
|
||||
"archiveBtn": "Arquivar",
|
||||
"archiveConfirm": "Arquivar",
|
||||
"archived": "{{id}} arquivada",
|
||||
@@ -8102,9 +8102,9 @@
|
||||
"deleteMessage": "Excluir {{id}} como duplicado de {{duplicateOf}}?",
|
||||
"deleteTitle": "Excluir tarefa duplicada",
|
||||
"headline": "Possível duplicado detectado",
|
||||
"keepBtn": "Manter",
|
||||
"kept": "{{id}} mantida e aviso de duplicado dispensado",
|
||||
"triageActions": "Escolha Excluir para remover este duplicado, ou Manter para continuar mesmo assim."
|
||||
"triageActions": "This task stays paused until you clear this flag or delete it. Delete it if the work is already covered.",
|
||||
"dismissBtn": "Mark the duplicate flag for {{id}} as read",
|
||||
"dismissed": "Duplicate flag cleared for {{id}}"
|
||||
},
|
||||
"nextRecoveryAt": "Próxima recuperação às {{time}}",
|
||||
"no": "Não",
|
||||
@@ -8677,7 +8677,7 @@
|
||||
"descriptionRefined": "Descrição refinada com IA",
|
||||
"doneNoMerge": "Concluída (sem merge)",
|
||||
"duplicateCheckFailed": "Falha na verificação de duplicados; criando a tarefa mesmo assim.",
|
||||
"duplicateDismissed": "{{taskId}} mantida; aviso de duplicado descartado",
|
||||
"duplicateDismissed": "Duplicate flag cleared for {{taskId}}",
|
||||
"duplicateOf": "Duplicada de {{id}}",
|
||||
"editTask": "Editar tarefa",
|
||||
"enterDescriptionFirst": "Insira uma descrição primeiro",
|
||||
@@ -8713,10 +8713,7 @@
|
||||
"issueClosed": "fechado",
|
||||
"issueDeleted": "excluído",
|
||||
"issueLeft": "mantido",
|
||||
"keep": "Manter",
|
||||
"keepFailed": "Falha ao manter {{taskId}}: {{error}}",
|
||||
"keepProgress": "Manter progresso",
|
||||
"keepTaskTitle": "Manter esta tarefa e descartar o aviso de duplicado",
|
||||
"leaveUnchanged": "Deixar sem alterações",
|
||||
"lineageConflict": "{{taskId}} possui tarefas descendentes de linhagem ({{children}}) que a referenciam como tarefa de origem.\n\nExcluir mesmo assim, desvinculando essas referências primeiro?",
|
||||
"linkedIssueChipAriaLabel": "Issue do GitHub vinculado #{{number}}",
|
||||
@@ -8740,7 +8737,7 @@
|
||||
"moved": "{{taskId}} movida para {{column}}",
|
||||
"moveFailed": "Falha ao mover {{taskId}}: {{error}}",
|
||||
"moveTask": "Mover tarefa",
|
||||
"nearDuplicateTitle": "Possível quase duplicata de {{id}}",
|
||||
"nearDuplicateTitle": "Flagged as a possible duplicate of {{id}}. This task continues normally; clear the flag once you have read it.",
|
||||
"needsInput": "Precisa de entrada",
|
||||
"noAgentsAvailable": "Nenhum agente disponível",
|
||||
"node": "Nó",
|
||||
@@ -8836,7 +8833,10 @@
|
||||
"workflow": "fluxo de trabalho",
|
||||
"workflowCheck": "Verificação de fluxo de trabalho",
|
||||
"costBadgeTitle": "Custo estimado {{amount}}",
|
||||
"costBadgeAriaLabel": "Custo estimado {{amount}}"
|
||||
"costBadgeAriaLabel": "Custo estimado {{amount}}",
|
||||
"dismissDuplicateFlag": "Mark the duplicate flag for {{id}} as read",
|
||||
"nearDuplicateHeldTitle": "Flagged as a duplicate of {{id}}. This task stays paused until you clear this flag or delete it.",
|
||||
"duplicateDismissFailed": "Failed to clear the duplicate flag for {{taskId}}: {{error}}"
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "Teclas de seta do terminal",
|
||||
|
||||
@@ -8050,7 +8050,7 @@
|
||||
"resetProgressTitle": "重置进度?"
|
||||
},
|
||||
"nearDuplicate": {
|
||||
"actions": "选择「归档」将此任务归档,或选择「保留」继续使用此任务。",
|
||||
"actions": "This task continues normally. Archive it if the work is already covered, or clear this flag once you have read it.",
|
||||
"archiveBtn": "归档",
|
||||
"archiveConfirm": "归档",
|
||||
"archived": "已归档 {{id}}",
|
||||
@@ -8058,14 +8058,14 @@
|
||||
"archiveTitle": "归档近似重复任务",
|
||||
"copy": "此任务看起来是以下任务的近似重复:",
|
||||
"headline": "检测到潜在重复",
|
||||
"keepBtn": "保留",
|
||||
"kept": "保留了 {{id}} 并关闭了重复警告",
|
||||
"deleteBtn": "删除",
|
||||
"deleteConfirm": "删除",
|
||||
"deleted": "已删除 {{id}}",
|
||||
"deleteMessage": "将 {{id}} 作为 {{duplicateOf}} 的重复项删除?",
|
||||
"deleteTitle": "删除重复任务",
|
||||
"triageActions": "选择删除以移除此重复项,或选择保留以继续。"
|
||||
"triageActions": "This task stays paused until you clear this flag or delete it. Delete it if the work is already covered.",
|
||||
"dismissBtn": "Mark the duplicate flag for {{id}} as read",
|
||||
"dismissed": "Duplicate flag cleared for {{id}}"
|
||||
},
|
||||
"nextRecoveryAt": "下次恢复时间:{{time}}",
|
||||
"no": "否",
|
||||
@@ -8658,7 +8658,7 @@
|
||||
"descriptionRefined": "已用 AI 优化描述",
|
||||
"doneNoMerge": "完成(不合并)",
|
||||
"duplicateCheckFailed": "重复检查失败,仍然创建任务。",
|
||||
"duplicateDismissed": "已保留 {{taskId}};重复警告已忽略",
|
||||
"duplicateDismissed": "Duplicate flag cleared for {{taskId}}",
|
||||
"duplicateOf": "{{id}} 的重复项",
|
||||
"editTask": "编辑任务",
|
||||
"enterDescriptionFirst": "请先输入描述",
|
||||
@@ -8694,10 +8694,7 @@
|
||||
"issueClosed": "已关闭",
|
||||
"issueDeleted": "已删除",
|
||||
"issueLeft": "保留",
|
||||
"keep": "保留",
|
||||
"keepFailed": "保留 {{taskId}} 失败:{{error}}",
|
||||
"keepProgress": "保留进度",
|
||||
"keepTaskTitle": "保留此任务并忽略重复警告",
|
||||
"leaveUnchanged": "保持不变",
|
||||
"lineageConflict": "{{taskId}} 有子项({{children}})将其引用为来源父项。\n\n仍要先解除这些引用并删除吗?",
|
||||
"linkedIssueChipAriaLabel": "关联的 GitHub Issue #{{number}}",
|
||||
@@ -8721,7 +8718,7 @@
|
||||
"moved": "已将 {{taskId}} 移动到 {{column}}",
|
||||
"moveFailed": "移动 {{taskId}} 失败:{{error}}",
|
||||
"moveTask": "移动任务",
|
||||
"nearDuplicateTitle": "疑似与 {{id}} 重复",
|
||||
"nearDuplicateTitle": "Flagged as a possible duplicate of {{id}}. This task continues normally; clear the flag once you have read it.",
|
||||
"needsInput": "",
|
||||
"noAgentsAvailable": "无可用智能体",
|
||||
"node": "节点",
|
||||
@@ -8826,7 +8823,10 @@
|
||||
"refinesOfTitle": "",
|
||||
"revertedTasks": "",
|
||||
"revertedResolutionActions": "",
|
||||
"revise": ""
|
||||
"revise": "",
|
||||
"dismissDuplicateFlag": "Mark the duplicate flag for {{id}} as read",
|
||||
"nearDuplicateHeldTitle": "Flagged as a duplicate of {{id}}. This task stays paused until you clear this flag or delete it.",
|
||||
"duplicateDismissFailed": "Failed to clear the duplicate flag for {{taskId}}: {{error}}"
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
@@ -8050,7 +8050,7 @@
|
||||
"resetProgressTitle": "重置進度?"
|
||||
},
|
||||
"nearDuplicate": {
|
||||
"actions": "選擇「封存」將此任務封存,或選擇「保留」繼續使用此任務。",
|
||||
"actions": "This task continues normally. Archive it if the work is already covered, or clear this flag once you have read it.",
|
||||
"archiveBtn": "封存",
|
||||
"archiveConfirm": "封存",
|
||||
"archived": "已封存 {{id}}",
|
||||
@@ -8058,14 +8058,14 @@
|
||||
"archiveTitle": "封存近似重複任務",
|
||||
"copy": "此任務看起來是以下任務的近似重複:",
|
||||
"headline": "檢測到潛在重複",
|
||||
"keepBtn": "保留",
|
||||
"kept": "保留了 {{id}} 並關閉了重複警告",
|
||||
"deleteBtn": "刪除",
|
||||
"deleteConfirm": "刪除",
|
||||
"deleted": "已刪除 {{id}}",
|
||||
"deleteMessage": "將 {{id}} 作為 {{duplicateOf}} 的重複項刪除?",
|
||||
"deleteTitle": "刪除重複任務",
|
||||
"triageActions": "選擇刪除以移除此重複項,或選擇保留以繼續。"
|
||||
"triageActions": "This task stays paused until you clear this flag or delete it. Delete it if the work is already covered.",
|
||||
"dismissBtn": "Mark the duplicate flag for {{id}} as read",
|
||||
"dismissed": "Duplicate flag cleared for {{id}}"
|
||||
},
|
||||
"nextRecoveryAt": "下次恢復時間:{{time}}",
|
||||
"no": "否",
|
||||
@@ -8658,7 +8658,7 @@
|
||||
"descriptionRefined": "已用 AI 優化說明",
|
||||
"doneNoMerge": "完成(不合併)",
|
||||
"duplicateCheckFailed": "重複檢查失敗,仍然建立任務。",
|
||||
"duplicateDismissed": "已保留 {{taskId}};重複警告已忽略",
|
||||
"duplicateDismissed": "Duplicate flag cleared for {{taskId}}",
|
||||
"duplicateOf": "{{id}} 的重複項目",
|
||||
"editTask": "編輯任務",
|
||||
"enterDescriptionFirst": "請先輸入說明",
|
||||
@@ -8694,10 +8694,7 @@
|
||||
"issueClosed": "已關閉",
|
||||
"issueDeleted": "已刪除",
|
||||
"issueLeft": "保留",
|
||||
"keep": "保留",
|
||||
"keepFailed": "保留 {{taskId}} 失敗:{{error}}",
|
||||
"keepProgress": "保留進度",
|
||||
"keepTaskTitle": "保留此任務並忽略重複警告",
|
||||
"leaveUnchanged": "保持不變",
|
||||
"lineageConflict": "{{taskId}} 有子項目({{children}})將其參照為來源父項。\n\n仍要先解除這些參照並刪除嗎?",
|
||||
"linkedIssueChipAriaLabel": "關聯的 GitHub Issue #{{number}}",
|
||||
@@ -8721,7 +8718,7 @@
|
||||
"moved": "已將 {{taskId}} 移動至 {{column}}",
|
||||
"moveFailed": "移動 {{taskId}} 失敗:{{error}}",
|
||||
"moveTask": "移動任務",
|
||||
"nearDuplicateTitle": "疑似與 {{id}} 重複",
|
||||
"nearDuplicateTitle": "Flagged as a possible duplicate of {{id}}. This task continues normally; clear the flag once you have read it.",
|
||||
"needsInput": "",
|
||||
"noAgentsAvailable": "目前沒有可用的代理程式",
|
||||
"node": "節點",
|
||||
@@ -8826,7 +8823,10 @@
|
||||
"refinesOfTitle": "",
|
||||
"revertedTasks": "",
|
||||
"revertedResolutionActions": "",
|
||||
"revise": ""
|
||||
"revise": "",
|
||||
"dismissDuplicateFlag": "Mark the duplicate flag for {{id}} as read",
|
||||
"nearDuplicateHeldTitle": "Flagged as a duplicate of {{id}}. This task stays paused until you clear this flag or delete it.",
|
||||
"duplicateDismissFailed": "Failed to clear the duplicate flag for {{taskId}}: {{error}}"
|
||||
},
|
||||
"terminal": {
|
||||
"arrowKeysLabel": "",
|
||||
|
||||
18
packages/i18n/src/resources.d.ts
vendored
18
packages/i18n/src/resources.d.ts
vendored
@@ -8277,7 +8277,7 @@ export default interface Resources {
|
||||
"resetProgressTitle": "Reset Progress?"
|
||||
},
|
||||
"nearDuplicate": {
|
||||
"actions": "Choose Archive to move this task to archived, or Keep to continue with this task.",
|
||||
"actions": "This task continues normally. Archive it if the work is already covered, or clear this flag once you have read it.",
|
||||
"archiveBtn": "Archive",
|
||||
"archiveConfirm": "Archive",
|
||||
"archiveMessage": "Archive {{id}} as a duplicate of {{duplicateOf}}?",
|
||||
@@ -8289,10 +8289,10 @@ export default interface Resources {
|
||||
"deleteMessage": "Delete {{id}} as a duplicate of {{duplicateOf}}?",
|
||||
"deleteTitle": "Delete duplicate task",
|
||||
"deleted": "Deleted {{id}}",
|
||||
"dismissBtn": "Mark the duplicate flag for {{id}} as read",
|
||||
"dismissed": "Duplicate flag cleared for {{id}}",
|
||||
"headline": "Potential duplicate detected",
|
||||
"keepBtn": "Keep",
|
||||
"kept": "Kept {{id}} and dismissed duplicate warning",
|
||||
"triageActions": "Choose Delete to remove this duplicate, or Keep to continue anyway."
|
||||
"triageActions": "This task stays paused until you clear this flag or delete it. Delete it if the work is already covered."
|
||||
},
|
||||
"nextRecoveryAt": "Next recovery at {{time}}",
|
||||
"no": "No",
|
||||
@@ -8856,9 +8856,11 @@ export default interface Resources {
|
||||
"depsCount_other": "{{count}} deps",
|
||||
"descriptionPlaceholder": "Task description",
|
||||
"descriptionRefined": "Description refined with AI",
|
||||
"dismissDuplicateFlag": "Mark the duplicate flag for {{id}} as read",
|
||||
"doneNoMerge": "Done (no merge)",
|
||||
"duplicateCheckFailed": "Duplicate check failed; creating task anyway.",
|
||||
"duplicateDismissed": "Kept {{taskId}}; duplicate warning dismissed",
|
||||
"duplicateDismissFailed": "Failed to clear the duplicate flag for {{taskId}}: {{error}}",
|
||||
"duplicateDismissed": "Duplicate flag cleared for {{taskId}}",
|
||||
"duplicateOf": "Duplicate of {{id}}",
|
||||
"editTask": "Edit task",
|
||||
"enterDescriptionFirst": "Enter a description first",
|
||||
@@ -8894,10 +8896,7 @@ export default interface Resources {
|
||||
"issueClosed": "closed",
|
||||
"issueDeleted": "deleted",
|
||||
"issueLeft": "left",
|
||||
"keep": "Keep",
|
||||
"keepFailed": "Failed to keep {{taskId}}: {{error}}",
|
||||
"keepProgress": "Keep Progress",
|
||||
"keepTaskTitle": "Keep this task and dismiss duplicate warning",
|
||||
"leaveUnchanged": "Leave Unchanged",
|
||||
"lineageConflict": "{{taskId}} has lineage children ({{children}}) that reference it as a source parent.\n\nDelete anyway by unlinking these references first?",
|
||||
"linkedIssueChipAriaLabel": "Linked GitHub issue #{{number}}",
|
||||
@@ -8921,7 +8920,8 @@ export default interface Resources {
|
||||
"moveFailed": "Failed to move {{taskId}}: {{error}}",
|
||||
"moveTask": "Move task",
|
||||
"moved": "Moved {{taskId}} to {{column}}",
|
||||
"nearDuplicateTitle": "Potential near-duplicate of {{id}}",
|
||||
"nearDuplicateHeldTitle": "Flagged as a duplicate of {{id}}. This task stays paused until you clear this flag or delete it.",
|
||||
"nearDuplicateTitle": "Flagged as a possible duplicate of {{id}}. This task continues normally; clear the flag once you have read it.",
|
||||
"needsInput": "Needs input",
|
||||
"noAgentsAvailable": "No agents available",
|
||||
"noExistingTasks": "No existing tasks",
|
||||
|
||||
Reference in New Issue
Block a user