From 3f448f72922af0f281f987a1aa5d6e970f89bcfc Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 23 Aug 2026 01:16:35 -0700 Subject: [PATCH] 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) --- .changeset/fn-9194-mailbox-task-link-id.md | 7 + docs/dashboard-guide.md | 2 +- .../app/components/MailboxRelatedWorkLink.tsx | 9 +- .../__tests__/MailboxRelatedWorkLink.test.tsx | 50 +++++++ ...boxTaskLinkInterpolation.surfaces.test.tsx | 128 ++++++++++++++++++ 5 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 .changeset/fn-9194-mailbox-task-link-id.md create mode 100644 packages/dashboard/app/components/__tests__/MailboxTaskLinkInterpolation.surfaces.test.tsx diff --git a/.changeset/fn-9194-mailbox-task-link-id.md b/.changeset/fn-9194-mailbox-task-link-id.md new file mode 100644 index 0000000000..36f1d2afe4 --- /dev/null +++ b/.changeset/fn-9194-mailbox-task-link-id.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 0e77625611..605e720e68 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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: `) 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) diff --git a/packages/dashboard/app/components/MailboxRelatedWorkLink.tsx b/packages/dashboard/app/components/MailboxRelatedWorkLink.tsx index 7e77d53749..c4a96bc1b8 100644 --- a/packages/dashboard/app/components/MailboxRelatedWorkLink.tsx +++ b/packages/dashboard/app/components/MailboxRelatedWorkLink.tsx @@ -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> ); } diff --git a/packages/dashboard/app/components/__tests__/MailboxRelatedWorkLink.test.tsx b/packages/dashboard/app/components/__tests__/MailboxRelatedWorkLink.test.tsx index 5230e0bea6..0a2e4b1544 100644 --- a/packages/dashboard/app/components/__tests__/MailboxRelatedWorkLink.test.tsx +++ b/packages/dashboard/app/components/__tests__/MailboxRelatedWorkLink.test.tsx @@ -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(); diff --git a/packages/dashboard/app/components/__tests__/MailboxTaskLinkInterpolation.surfaces.test.tsx b/packages/dashboard/app/components/__tests__/MailboxTaskLinkInterpolation.surfaces.test.tsx new file mode 100644 index 0000000000..3f8863312c --- /dev/null +++ b/packages/dashboard/app/components/__tests__/MailboxTaskLinkInterpolation.surfaces.test.tsx @@ -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("{{"); + }); + +});