Files
multica/packages/views/settings/components/notifications-tab.tsx
Naiyuan Qing 20c2f45b4a fix(views): surface backend error messages on mutation failures (MUL-2317) (#2772)
* fix(views): surface backend error messages on mutation failures (MUL-2317)

Mutation toasts across the views package were swallowing the backend
`error` string and showing only a generic i18n fallback. This made it
impossible for users to see why an operation failed (most visibly:
creating an issue with a duplicate title produced a vague "Failed to
create issue" toast).

The fix has three pieces:

1. Create-issue duplicate branch (A段)
   - New schema `DuplicateIssueErrorBodySchema` in core/api/schemas.ts.
   - `create-issue.tsx` parses `ApiError.body` via `parseWithFallback`
     and renders a dedicated amber-toned toast with a "view existing"
     link when the server returns `{ code: "active_duplicate_issue",
     issue: {...} }`. Schema drift downgrades to the normal error toast.
   - Schema intentionally omits `issue.status` so the toast does not
     depend on `StatusIcon`, which has no fallback for unknown enums.

2. User-facing mutation failure toasts (B段)
   - 47 sites converted to `err instanceof Error && err.message ?
     err.message : <existing fallback>` — preserves all existing
     code-specific branches (slug conflict, agent_unavailable,
     daemon_version_unsupported) and i18n keys.
   - Covers Type 1 (onError) and Type 2 (catch block) patterns across
     issues, projects, autopilots, inbox, runtimes, squads, comments,
     batch actions, workspace create, and agent config tabs.

3. Autopilot partial-success (Type 3)
   - New i18n keys `toast_create_partial_with_reason` /
     `toast_update_partial_with_reason` (double-brace `{{reason}}`).
   - `autopilot-dialog.tsx` captures `err.message` in the schedule
     `catch` and routes to the `_with_reason` variant when present,
     preserving the partial-success semantic (autopilot saved, schedule
     failed) while exposing the actual reason.

Explicitly out of scope:
- `packages/core/` mutation hooks (no global onError, no UI dependency)
- No `toastApiError` helper (matches existing 14+ correct sites)
- Sub-issue link aggregate `Promise.allSettled` keeps count-based toast
  (N independent requests cannot collapse to one err.message); only
  added a dev-side `console.error` per rejection.
- Clipboard catches and `useUpdateChatSession` (not API mutation toasts)

Tests:
- `packages/core/api/schemas.test.ts` — schema contract (valid body,
  forward-compat fields, rename rejection, missing issue, wrong types).
- `packages/views/modals/create-issue.test.tsx` — duplicate toast +
  view link, schema-drift fallback, err.message surfacing, non-Error
  fallback (4 new cases).
- `packages/views/autopilots/components/autopilot-dialog-i18n.test.ts`
  — real i18next, asserts rendered text contains the reason verbatim
  (guards against `{reason}` vs `{{reason}}` regression).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(autopilots): unify rotate-token catch + cover dialog partial-success render

Address reviewer feedback on PR #2772:

1. webhook-token rotate (`autopilot-detail-page.tsx`) now follows the
   `err.message ?? fallback` ternary used by the sibling trigger
   delete/add paths, instead of swallowing the error.

2. Extract `formatSchedulePartialFailureToast` so the dialog's
   partial-success branches and the i18n test exercise the same
   helper. The test now drives the actual format function, so a
   variable-name typo at the call site (e.g. `{ msg }` instead of
   `{ reason }`) fails the substring assertion.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* test(modals): drop user.type for title in success path to dodge CI 5s timeout

The success-path test typed the 42-character title via userEvent which
triggers a controlled re-render per keystroke. On the slower CI runner
the whole test crept up to ~5s and intermittently tripped the default
vitest timeout. Setting the value in one shot via fireEvent.change cuts
the cost while leaving the submit + toast interactions on userEvent.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 13:44:10 +08:00

117 lines
4.0 KiB
TypeScript

"use client";
import { useQuery } from "@tanstack/react-query";
import { useWorkspaceId } from "@multica/core/hooks";
import { notificationPreferenceOptions } from "@multica/core/notification-preferences/queries";
import { useUpdateNotificationPreferences } from "@multica/core/notification-preferences/mutations";
import type { NotificationGroupKey, NotificationPreferences } from "@multica/core/types";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { Switch } from "@multica/ui/components/ui/switch";
import { toast } from "sonner";
import { useT } from "../../i18n";
// Inbox event groups rendered in the per-event toggle list. `system_notifications`
// is a sibling preference key but lives in its own section below.
const INBOX_GROUP_KEYS = [
"assignments",
"status_changes",
"comments",
"updates",
"agent_activity",
] as const;
type InboxGroupKey = (typeof INBOX_GROUP_KEYS)[number];
export function NotificationsTab() {
const { t } = useT("settings");
const wsId = useWorkspaceId();
const { data } = useQuery(notificationPreferenceOptions(wsId));
const mutation = useUpdateNotificationPreferences();
const preferences = data?.preferences ?? {};
const handleToggle = (key: NotificationGroupKey, enabled: boolean) => {
const updated: NotificationPreferences = {
...preferences,
[key]: enabled ? "all" : "muted",
};
// Remove keys set to "all" (default) to keep the object clean
if (enabled) {
delete updated[key];
}
mutation.mutate(updated, {
onError: (err) =>
toast.error(
err instanceof Error && err.message
? err.message
: t(($) => $.notifications.toast_failed),
),
});
};
const systemEnabled = preferences.system_notifications !== "muted";
return (
<div className="space-y-8">
<section className="space-y-4">
<div>
<h2 className="text-sm font-semibold">{t(($) => $.notifications.title)}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t(($) => $.notifications.description)}
</p>
</div>
<Card>
<CardContent className="divide-y">
{INBOX_GROUP_KEYS.map((key: InboxGroupKey) => {
const enabled = preferences[key] !== "muted";
return (
<div
key={key}
className="flex items-center justify-between py-3 first:pt-0 last:pb-0"
>
<div className="space-y-0.5 pr-4">
<p className="text-sm font-medium">{t(($) => $.notifications.groups[key].label)}</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.notifications.groups[key].description)}
</p>
</div>
<Switch
checked={enabled}
onCheckedChange={(checked) => handleToggle(key, checked)}
/>
</div>
);
})}
</CardContent>
</Card>
</section>
<section className="space-y-4">
<div>
<h2 className="text-sm font-semibold">{t(($) => $.notifications.system.title)}</h2>
<p className="text-sm text-muted-foreground mt-1">
{t(($) => $.notifications.system.description)}
</p>
</div>
<Card>
<CardContent>
<div className="flex items-center justify-between">
<div className="space-y-0.5 pr-4">
<p className="text-sm font-medium">{t(($) => $.notifications.system.label)}</p>
<p className="text-xs text-muted-foreground">
{t(($) => $.notifications.system.hint)}
</p>
</div>
<Switch
checked={systemEnabled}
onCheckedChange={(checked) => handleToggle("system_notifications", checked)}
/>
</div>
</CardContent>
</Card>
</section>
</div>
);
}