Files
fusion/packages/dashboard/app/utils/taskRevert.ts
gsxdsm 986e249bc1 FN-7555: surface bidirectional undo/source task links in dashboard UI
Adds a client-side helper and UI affordances so AI-undo tasks and their source tasks link to each other in the dashboard, without any new API or backend changes.

- Add packages/dashboard/app/utils/taskRevert.ts with getRevertOfId() (resolves the revertOf marker written by createAiUndoTask, with a defensive sourceType==="recovery" fallback to sourceParentTaskId) and findOpenUndoTaskForSource() (reverse lookup for the most recent open undo task pointing at a source task)
- TaskCard and TaskDetailModal now render "Undo of <id>" / "Undo task: <id>" provenance links using the shared helper, keeping forward and reverse affordances consistent
- Add TaskCard.css styling for the new undo-link affordance
- Add/extend tests: TaskCard.test.tsx and TaskDetailModal.rendering.test.tsx cover the new undo/source link rendering
- Update docs/task-management.md and packages/i18n/locales/en/app.json for the new UI copy

Files changed:
 docs/task-management.md                            |   2 +-
 packages/dashboard/app/components/TaskCard.css     |  37 +++++
 packages/dashboard/app/components/TaskCard.tsx     |  29 +++-
 .../dashboard/app/components/TaskDetailModal.tsx   |  46 ++++++
 .../app/components/__tests__/TaskCard.test.tsx     |  46 ++++++
 .../__tests__/TaskDetailModal.rendering.test.tsx   | 160 +++++++++++++++++++++
 packages/dashboard/app/utils/taskRevert.ts         |  80 +++++++++++
 packages/i18n/locales/en/app.json                  |   7 +-
 8 files changed, 403 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-7555

Fusion-Task-Lineage: c3d2dbd2-4d86-40b5-afce-0ca7a6dfc73d

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-05 11:31:46 -07:00

81 lines
3.2 KiB
TypeScript

import type { Task } from "@fusion/core";
/**
* FNXC:TaskRevert 2026-07-04-00:00:
* FN-7524 stamps an AI-undo task with `sourceMetadata.revertOf = <sourceTaskId>`
* (see `REVERT_OF_METADATA_KEY` in `packages/engine/src/task-revert.ts`) — this is
* the sole authoritative undo→source pointer written by `createAiUndoTask`. The
* backend deliberately does NOT set `sourceParentTaskId` for undo tasks (that field
* is owned by refine/duplicate lineage and child-task counting), so this helper only
* falls back to `sourceParentTaskId` defensively for forward-compatibility with a
* future backend shape; today it always resolves via `revertOf`.
*
* The `sourceParentTaskId` fallback is gated to `sourceType === "recovery"` (the
* sourceType `createAiUndoTask` stamps). `task_refine`/`task_duplicate` tasks also
* set `sourceParentTaskId`, but for an UNRELATED lineage relationship that already
* renders its own "Created via Refinement/Duplicate of <id>" provenance clause
* (`getProvenanceLabel` in TaskDetailModal.tsx) — without this gate, an undo-of
* clause would double-render alongside it for the same id.
*
* FN-7555 (this task) surfaces this marker bi-directionally in the dashboard only —
* no new API, no backend changes. Both `TaskCard` and `TaskDetailModal` import this
* helper (and `findOpenUndoTaskForSource` below) so the forward/reverse affordances
* never disagree about what counts as "an undo relationship".
*/
export function getRevertOfId(
sourceMetadata: Task["sourceMetadata"] | undefined,
sourceParentTaskId?: string | null,
sourceType?: string,
): string | undefined {
const revertOf = sourceMetadata?.revertOf;
if (typeof revertOf === "string" && revertOf.trim().length > 0) {
return revertOf.trim();
}
if (
sourceType === "recovery"
&& typeof sourceParentTaskId === "string"
&& sourceParentTaskId.trim().length > 0
) {
return sourceParentTaskId.trim();
}
return undefined;
}
/**
* FNXC:TaskRevert 2026-07-04-00:00:
* Reverse lookup: given the full loaded `tasks` list and a source task id, find the
* most recently created OPEN undo task that points back at it via `revertOf`. This
* mirrors `TaskStore.findOpenRevertTaskForSource` (packages/core/src/store.ts)
* client-side: `done`/`archived`/soft-deleted undo tasks are intentionally excluded
* so a completed or discarded undo attempt never renders as an active "Undo task"
* link (no stale/leftover affordance). When multiple open undo tasks exist (should
* not normally happen given the route's own dedup guard, but the UI must stay
* defensive), the most recently created one wins.
*/
export function findOpenUndoTaskForSource(tasks: readonly Task[], sourceTaskId: string): Task | undefined {
const trimmedSourceId = sourceTaskId.trim();
if (trimmedSourceId.length === 0) {
return undefined;
}
let best: Task | undefined;
for (const candidate of tasks) {
if (candidate.deletedAt) {
continue;
}
if (candidate.column === "done" || candidate.column === "archived") {
continue;
}
if (getRevertOfId(candidate.sourceMetadata) !== trimmedSourceId) {
continue;
}
if (!best || new Date(candidate.createdAt).getTime() > new Date(best.createdAt).getTime()) {
best = candidate;
}
}
return best;
}