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:
Fusion Agent
2026-08-23 05:19:45 +00:00
parent 14a700842c
commit 37124bb7f9
25 changed files with 431 additions and 140 deletions

View 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.

View File

@@ -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`

View File

@@ -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.

View File

@@ -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:

View File

@@ -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}

View File

@@ -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%;
}

View File

@@ -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

View File

@@ -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);

View File

@@ -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>
)}
{/*

View File

@@ -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);
}
});
});

View File

@@ -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 });

View File

@@ -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(() => {

View File

@@ -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"

View File

@@ -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}

View File

@@ -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,

View File

@@ -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 }));

View File

@@ -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({

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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",