FN-9194: fix mailbox task ID interpolation

Show concrete destination IDs in mailbox task links instead of leaking untranslated placeholders.

- Pass the catalog's `id` variable to visible and accessible task-link labels.
- Cover shared links and all mailbox host, viewport, and conversation-layout surfaces with the shipping catalog.
- Document the task-link label and add a patch changeset.

Files changed:
 .changeset/fn-9194-mailbox-task-link-id.md         |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 .../app/components/MailboxRelatedWorkLink.tsx      |   9 +-
 .../__tests__/MailboxRelatedWorkLink.test.tsx      |  50 ++++++++
 .../MailboxTaskLinkInterpolation.surfaces.test.tsx | 128 +++++++++++++++++++++
 5 files changed, 193 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-9194

Fusion-Task-Lineage: d7afd16a-c951-40c4-aa12-d7b56f9cba7e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-23 01:16:35 -07:00
parent eaa1d47cf0
commit 3f448f7292
5 changed files with 193 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Mailbox task links now show the real task ID instead of a raw placeholder.
category: fix
dev: Aligns MailboxRelatedWorkLink with the mailbox.viewTask and mailbox.viewTaskAria {{id}} variable contract.

View File

@@ -917,7 +917,7 @@ Mailbox view shows inbox/outbox communication threads and unread state. When an
- Inbox renders one row per message (no sender-based collapsing)
- clicking a message in the Mail tab opens the task detail pane with full message content and conversation context
- reply rows in the mailbox modal can expand inline to show the replied-to message context for easier thread reading
- when an agent or dashboard chat session registers an artifact with `fn_artifact_register`, Fusion sends a best-effort `system` → user inbox message announcing the new artifact (for example, `New image artifact registered: <title>`) with metadata for `artifactId`, `artifactType`, `title`, optional `mimeType`, `authorId`, and optional `taskId`; notification delivery is informational and never blocks or rolls back the artifact registration. Artifact notifications are actionable in message detail views: image artifacts show an inline preview plus **Open artifact**, while video/audio/document/other artifacts show an **Open artifact** link to the managed media URL. When `taskId` metadata is present, the same artifact block also shows **View task** so users can open the producing task detail directly from the mailbox in the shared movable/resizable task-detail window.
- when an agent or dashboard chat session registers an artifact with `fn_artifact_register`, Fusion sends a best-effort `system` → user inbox message announcing the new artifact (for example, `New image artifact registered: <title>`) with metadata for `artifactId`, `artifactType`, `title`, optional `mimeType`, `authorId`, and optional `taskId`; notification delivery is informational and never blocks or rolls back the artifact registration. Artifact notifications are actionable in message detail views: image artifacts show an inline preview plus **Open artifact**, while video/audio/document/other artifacts show an **Open artifact** link to the managed media URL. When `taskId` metadata is present, the same artifact block also shows **View task FN-NNNN** so users can open the producing task detail directly from the mailbox in the shared movable/resizable task-detail window.
- on first engine startup under Fusion `0.59.x`, each project receives one best-effort `system` inbox notice about the upcoming embedded-Postgres storage migration with the Discord help link; `metadata.kind = "postgres-migration-notice"` prevents duplicates across restarts.
- mailbox now includes an **Approvals** tab with pending and history filters (`approved` / `denied` / `completed`), approval detail context, and inline approve/deny actions for pending requests
- for approvals gated by an agent's permission policy (permanent agents and task-worker heartbeats), the Approvals detail pane renders the gated action's real payload — tool name, shell command line or structured arguments, and working directory when present — instead of only a generic "Agent gated action for `<tool>`" summary; a stateless heartbeat retrying the same gated command reuses the existing pending approval instead of creating a duplicate (FN-7609)

View File

@@ -21,6 +21,11 @@ export function hasRelatedTaskLink(metadata: MessageMetadata | undefined, onOpen
* its task or planning session. Task metadata wins when both targets are present, and missing
* metadata or navigation handlers deliberately render no button so the detail never contains a
* dead affordance.
*
* FNXC:MailboxRelatedWork 2026-08-23-07:58:
* FN-9194 requires mailbox task links to display their destination task ID. The shared
* mailbox.viewTask entries interpolate {{id}}, so every consumer must pass { id }; a catalog hit
* outranks an inline default and a mismatched variable otherwise exposes the raw placeholder.
*/
export function MailboxRelatedWorkLink({
metadata,
@@ -36,11 +41,11 @@ export function MailboxRelatedWorkLink({
<button
type="button"
className="btn mailbox-related-work-link"
aria-label={t("mailbox.viewTaskAria", "View task: {{taskId}}", { taskId })}
aria-label={t("mailbox.viewTaskAria", "View task: {{id}}", { id: taskId })}
data-testid="mailbox-view-task"
onClick={() => onOpenTask(taskId)}
>
{t("mailbox.viewTask", "View task")}
{t("mailbox.viewTask", "View task {{id}}", { id: taskId })}
</button>
);
}

View File

@@ -1,7 +1,26 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { createInstance } from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import realEnApp from "../../../../i18n/locales/en/app.json";
import { MailboxRelatedWorkLink } from "../MailboxRelatedWorkLink";
async function createRealCatalogInstance() {
const instance = createInstance();
await instance.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
ns: ["app"],
defaultNS: "app",
returnNull: false,
returnEmptyString: false,
react: { useSuspense: false },
interpolation: { escapeValue: false },
resources: { en: { app: realEnApp } },
});
return instance;
}
describe("MailboxRelatedWorkLink", () => {
it("opens a task when task metadata and its handler are available", () => {
const onOpenTask = vi.fn();
@@ -11,6 +30,34 @@ describe("MailboxRelatedWorkLink", () => {
expect(onOpenTask).toHaveBeenCalledWith("FN-8428");
});
it("interpolates task and planning-session destinations against the shipping English catalog", async () => {
const instance = await createRealCatalogInstance();
const onOpenTask = vi.fn();
const onOpenPlanningSession = vi.fn();
const { rerender, container } = render(
<I18nextProvider i18n={instance}>
<MailboxRelatedWorkLink metadata={{ taskId: "FN-8428" }} onOpenTask={onOpenTask} />
</I18nextProvider>,
);
const taskLink = screen.getByTestId("mailbox-view-task");
expect(taskLink).toHaveTextContent("View task FN-8428");
expect(taskLink).toHaveAccessibleName("View task: FN-8428");
expect(container.textContent).not.toContain("{{");
fireEvent.click(taskLink);
expect(onOpenTask).toHaveBeenCalledWith("FN-8428");
rerender(
<I18nextProvider i18n={instance}>
<MailboxRelatedWorkLink
metadata={{ kind: "planning-clarification", sessionId: "planning-8428" }}
onOpenPlanningSession={onOpenPlanningSession}
/>
</I18nextProvider>,
);
expect(screen.getByTestId("mailbox-open-planning-session")).toHaveAccessibleName("Open planning session: planning-8428");
});
it("opens a planning clarification session when no task target is available", () => {
const onOpenPlanningSession = vi.fn();
render(
@@ -31,6 +78,9 @@ describe("MailboxRelatedWorkLink", () => {
rerender(<MailboxRelatedWorkLink metadata={{ kind: "planning-clarification", sessionId: "planning-8428" }} />);
expect(screen.queryByTestId("mailbox-open-planning-session")).toBeNull();
rerender(<MailboxRelatedWorkLink metadata={{ taskId: " " }} onOpenTask={vi.fn()} />);
expect(screen.queryByTestId("mailbox-view-task")).toBeNull();
rerender(<MailboxRelatedWorkLink metadata={{ kind: "ordinary" }} onOpenTask={vi.fn()} onOpenPlanningSession={vi.fn()} />);
expect(screen.queryByTestId("mailbox-view-task")).toBeNull();
expect(screen.queryByTestId("mailbox-open-planning-session")).toBeNull();

View File

@@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { createInstance } from "i18next";
import { I18nextProvider, initReactI18next } from "react-i18next";
import type { Message } from "@fusion/core";
import realEnApp from "../../../../i18n/locales/en/app.json";
import { MailboxView } from "../MailboxView";
import { MailboxModal } from "../MailboxModal";
import { useViewportMode } from "../../hooks/useViewportMode";
import { useViewportMode as useHeaderViewportMode } from "../Header";
vi.mock("../../api", () => ({
fetchInbox: vi.fn(), fetchOutbox: vi.fn(), fetchUnreadCount: vi.fn(), fetchAgentMailbox: vi.fn(), fetchAllAgentMailbox: vi.fn(),
markMessageRead: vi.fn(), markAllMessagesRead: vi.fn(), deleteMessage: vi.fn(), fetchConversation: vi.fn(), fetchMessage: vi.fn(),
sendMessage: vi.fn(), fetchAgents: vi.fn(), fetchApprovals: vi.fn(), fetchApprovalDetail: vi.fn(), decideApproval: vi.fn(),
artifactMediaUrlWithToken: vi.fn(), fetchNativeStructurePreview: vi.fn(),
}));
vi.mock("../../hooks/useViewportMode", () => ({
useViewportMode: vi.fn(() => "desktop"), isMobileViewport: () => false, isFullScreenSheetViewport: () => false,
isShortViewport: () => false, getViewportMode: () => "desktop", isTabletTouchViewport: () => false,
}));
vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: vi.fn(() => ({ keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, keyboardOpen: false })) }));
vi.mock("../../sse-bus", () => ({ subscribeSse: vi.fn(() => () => {}) }));
vi.mock("../Header", () => ({ useViewportMode: vi.fn(() => "desktop") }));
vi.mock("../ComposeChatPanel", () => ({ ComposeChatPanel: () => null }));
vi.mock("lucide-react", () => ({
Mail: () => null, Send: () => null, Inbox: () => null, Bot: () => null, Trash2: () => null,
CheckCheck: () => null, Loader2: () => null, RefreshCw: () => null, MessageSquare: () => null,
User: () => null, X: () => null, Check: () => null, ChevronRight: () => null, ChevronDown: () => null,
AlertCircle: () => null, Map: () => null, Flag: () => null, Lightbulb: () => null, BarChart3: () => null,
Target: () => null, CircleAlert: () => null, Archive: () => null,
}));
import * as api from "../../api";
const agents = [{ id: "agent-1", name: "Agent", role: "executor", state: "idle", createdAt: "2026-08-23T00:00:00.000Z", updatedAt: "2026-08-23T00:00:00.000Z", metadata: {} }];
function message(id: string, metadata: Message["metadata"]): Message {
return {
id,
fromId: "agent-1",
fromType: "agent",
toId: "dashboard",
toType: "user",
type: "agent-to-user",
read: true,
archived: false,
content: `Mailbox message ${id}`,
metadata,
createdAt: "2026-08-23T00:00:00.000Z",
updatedAt: "2026-08-23T00:00:00.000Z",
};
}
async function createRealCatalogInstance() {
const instance = createInstance();
await instance.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
ns: ["app"],
defaultNS: "app",
returnNull: false,
returnEmptyString: false,
react: { useSuspense: false },
interpolation: { escapeValue: false },
resources: { en: { app: realEnApp } },
});
return instance;
}
function Host({ kind, ...props }: { kind: "view" | "modal" } & Record<string, unknown>) {
return kind === "view"
? <MailboxView {...props as any} />
: <MailboxModal isOpen onClose={vi.fn()} agents={agents as any} {...props as any} />;
}
function renderHost(kind: "view" | "modal", instance: Awaited<ReturnType<typeof createRealCatalogInstance>>, onOpenTask = vi.fn(), onOpenPlanningSession = vi.fn()) {
return render(
<I18nextProvider i18n={instance}>
<Host kind={kind} addToast={vi.fn()} onOpenTask={onOpenTask} onOpenPlanningSession={onOpenPlanningSession} onOpenNativeStructure={vi.fn()} nativeStructureCandidates={[]} />
</I18nextProvider>,
);
}
/*
* FNXC:MailboxRelatedWork 2026-08-23-07:58:
* FN-9194 requires production mailbox hosts to resolve task-link copy through the shipping
* English catalog. The matrix preserves the visible and accessible task ID across viewport and
* conversation layouts, where a catalog placeholder mismatch would otherwise reach users.
*/
describe("mailbox task-link interpolation production surfaces", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: vi.fn() });
vi.mocked(api.fetchOutbox).mockResolvedValue({ messages: [], total: 0 });
vi.mocked(api.fetchUnreadCount).mockResolvedValue({ unreadCount: 0 });
vi.mocked(api.fetchAgents).mockResolvedValue(agents as any);
vi.mocked(api.fetchAllAgentMailbox).mockResolvedValue({ messages: [], total: 0, unreadCount: 0 });
});
afterEach(() => {
window.history.replaceState({}, "", "/");
});
it.each([
["view", "desktop", "single"], ["view", "desktop", "threaded"], ["view", "mobile", "single"], ["view", "mobile", "threaded"],
["modal", "desktop", "single"], ["modal", "desktop", "threaded"], ["modal", "mobile", "single"], ["modal", "mobile", "threaded"],
] as const)("renders the concrete task ID in %s %s %s detail", async (kind, viewport, layout) => {
vi.mocked(useViewportMode).mockReturnValue(viewport);
vi.mocked(useHeaderViewportMode).mockReturnValue(viewport);
const selected = message("task-link", { taskId: "FN-1234" });
const thread = layout === "threaded"
? [message("task-link-parent", {}), { ...selected, metadata: { ...selected.metadata, replyTo: { messageId: "task-link-parent" } } }]
: [];
vi.mocked(api.fetchInbox).mockResolvedValue({ messages: [selected], total: 1, unreadCount: 1 });
vi.mocked(api.fetchConversation).mockResolvedValue(thread as any);
const instance = await createRealCatalogInstance();
const onOpenTask = vi.fn();
const { container } = renderHost(kind, instance, onOpenTask);
fireEvent.click(await screen.findByTestId("mailbox-item-task-link"));
const taskLink = await screen.findByTestId("mailbox-view-task");
expect(taskLink).toHaveTextContent("View task FN-1234");
expect(taskLink).toHaveAccessibleName("View task: FN-1234");
expect(container.textContent).not.toContain("{{");
});
});