mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 18:13:27 +02:00
* 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>
136 lines
4.5 KiB
TypeScript
136 lines
4.5 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
import { Input } from "@multica/ui/components/ui/input";
|
|
import { Label } from "@multica/ui/components/ui/label";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
|
import { useCreateWorkspace } from "@multica/core/workspace/mutations";
|
|
import type { Workspace } from "@multica/core/types";
|
|
import { isImeComposing } from "@multica/core/utils";
|
|
import {
|
|
WORKSPACE_SLUG_REGEX,
|
|
isWorkspaceSlugConflict,
|
|
nameToWorkspaceSlug,
|
|
} from "./slug";
|
|
import { useT } from "../i18n";
|
|
import { isReservedSlug } from "@multica/core/paths";
|
|
|
|
export interface CreateWorkspaceFormProps {
|
|
onSuccess: (workspace: Workspace) => void | Promise<void>;
|
|
}
|
|
|
|
export function CreateWorkspaceForm({ onSuccess }: CreateWorkspaceFormProps) {
|
|
const { t } = useT("workspace");
|
|
const createWorkspace = useCreateWorkspace();
|
|
const [name, setName] = useState("");
|
|
const [slug, setSlug] = useState("");
|
|
const [slugServerError, setSlugServerError] = useState<string | null>(null);
|
|
const slugTouched = useRef(false);
|
|
|
|
const slugValidationError =
|
|
slug.length > 0 && !WORKSPACE_SLUG_REGEX.test(slug)
|
|
? t(($) => $.create_form.errors.slug_format)
|
|
: null;
|
|
const slugReservedError =
|
|
slug.length > 0 && isReservedSlug(slug)
|
|
? t(($) => $.create_form.errors.slug_reserved)
|
|
: null;
|
|
const slugError = slugValidationError ?? slugReservedError ?? slugServerError;
|
|
const canSubmit =
|
|
name.trim().length > 0 && slug.trim().length > 0 && !slugError;
|
|
|
|
const handleNameChange = (value: string) => {
|
|
setName(value);
|
|
if (!slugTouched.current) {
|
|
setSlug(nameToWorkspaceSlug(value));
|
|
setSlugServerError(null);
|
|
}
|
|
};
|
|
|
|
const handleSlugChange = (value: string) => {
|
|
slugTouched.current = true;
|
|
setSlug(value);
|
|
setSlugServerError(null);
|
|
};
|
|
|
|
const handleCreate = () => {
|
|
if (!canSubmit) return;
|
|
createWorkspace.mutate(
|
|
{ name: name.trim(), slug: slug.trim() },
|
|
{
|
|
onSuccess,
|
|
onError: (error) => {
|
|
if (isWorkspaceSlugConflict(error)) {
|
|
setSlugServerError(t(($) => $.create_form.errors.slug_taken));
|
|
toast.error(t(($) => $.create_form.errors.slug_conflict_toast));
|
|
return;
|
|
}
|
|
toast.error(
|
|
error instanceof Error && error.message
|
|
? error.message
|
|
: t(($) => $.create_form.errors.create_failed),
|
|
);
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Card className="w-full">
|
|
<CardContent className="space-y-4 pt-6">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="ws-name">{t(($) => $.create_form.name_label)}</Label>
|
|
<Input
|
|
id="ws-name"
|
|
autoFocus
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => handleNameChange(e.target.value)}
|
|
placeholder={t(($) => $.create_form.name_placeholder)}
|
|
onKeyDown={(e) => {
|
|
if (isImeComposing(e)) return;
|
|
if (e.key === "Enter") handleCreate();
|
|
}}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="ws-slug">{t(($) => $.create_form.url_label)}</Label>
|
|
<div className="flex items-center gap-0 rounded-md border bg-background focus-within:ring-2 focus-within:ring-ring">
|
|
{/* eslint-disable-next-line i18next/no-literal-string -- brand URL prefix, not translatable */}
|
|
<span className="pl-3 text-sm text-muted-foreground select-none">
|
|
multica.ai/
|
|
</span>
|
|
<Input
|
|
id="ws-slug"
|
|
type="text"
|
|
value={slug}
|
|
onChange={(e) => handleSlugChange(e.target.value)}
|
|
placeholder={t(($) => $.create_form.url_placeholder)}
|
|
className="border-0 shadow-none focus-visible:ring-0"
|
|
onKeyDown={(e) => {
|
|
if (isImeComposing(e)) return;
|
|
if (e.key === "Enter") handleCreate();
|
|
}}
|
|
/>
|
|
</div>
|
|
{slugError && (
|
|
<p className="text-xs text-destructive">{slugError}</p>
|
|
)}
|
|
</div>
|
|
<Button
|
|
className="w-full"
|
|
size="lg"
|
|
onClick={handleCreate}
|
|
disabled={createWorkspace.isPending || !canSubmit}
|
|
>
|
|
{createWorkspace.isPending
|
|
? t(($) => $.create_form.submitting)
|
|
: t(($) => $.create_form.submit)}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|