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:
7
.changeset/fn-9194-mailbox-task-link-id.md
Normal file
7
.changeset/fn-9194-mailbox-task-link-id.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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("{{");
|
||||
});
|
||||
|
||||
});
|
||||
Reference in New Issue
Block a user