mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-10 14:58:25 +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>
273 lines
9.0 KiB
TypeScript
273 lines
9.0 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { ChevronRight, FolderGit, Plus, Trash2 } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
projectResourcesOptions,
|
|
useCreateProjectResource,
|
|
useDeleteProjectResource,
|
|
} from "@multica/core/projects";
|
|
import { useWorkspaceId } from "@multica/core/hooks";
|
|
import { useCurrentWorkspace } from "@multica/core/paths";
|
|
import type {
|
|
GithubRepoResourceRef,
|
|
ProjectResource,
|
|
} from "@multica/core/types";
|
|
import { Button } from "@multica/ui/components/ui/button";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@multica/ui/components/ui/popover";
|
|
import {
|
|
Tooltip,
|
|
TooltipTrigger,
|
|
TooltipContent,
|
|
} from "@multica/ui/components/ui/tooltip";
|
|
import { useT } from "../../i18n";
|
|
|
|
// Project Resources sidebar section.
|
|
//
|
|
// Today only renders github_repo, but the rendering layer is type-dispatched
|
|
// so adding a new type means: (1) extend the API validator, (2) add a render
|
|
// case here. No changes to the schema or query layer.
|
|
export function ProjectResourcesSection({ projectId }: { projectId: string }) {
|
|
const { t } = useT("projects");
|
|
const wsId = useWorkspaceId();
|
|
const workspace = useCurrentWorkspace();
|
|
const [open, setOpen] = useState(true);
|
|
const [addOpen, setAddOpen] = useState(false);
|
|
|
|
const { data: resources = [] } = useQuery(
|
|
projectResourcesOptions(wsId, projectId),
|
|
);
|
|
const createResource = useCreateProjectResource(wsId, projectId);
|
|
const deleteResource = useDeleteProjectResource(wsId, projectId);
|
|
|
|
const attachedUrls = new Set(
|
|
resources
|
|
.filter((r) => r.resource_type === "github_repo")
|
|
.map((r) => (r.resource_ref as GithubRepoResourceRef).url),
|
|
);
|
|
|
|
const handleAttach = async (url: string) => {
|
|
try {
|
|
await createResource.mutateAsync({
|
|
resource_type: "github_repo",
|
|
resource_ref: { url },
|
|
});
|
|
toast.success(t(($) => $.resources.toast_attached));
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : t(($) => $.resources.toast_attach_failed);
|
|
toast.error(msg);
|
|
}
|
|
};
|
|
|
|
const handleRemove = async (resource: ProjectResource) => {
|
|
try {
|
|
await deleteResource.mutateAsync(resource.id);
|
|
toast.success(t(($) => $.resources.toast_removed));
|
|
} catch (err) {
|
|
toast.error(
|
|
err instanceof Error && err.message
|
|
? err.message
|
|
: t(($) => $.resources.toast_remove_failed),
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<button
|
|
className={`flex w-full items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors mb-2 hover:bg-accent/70 ${open ? "" : "text-muted-foreground hover:text-foreground"}`}
|
|
onClick={() => setOpen(!open)}
|
|
>
|
|
{t(($) => $.resources.section_header)}
|
|
<ChevronRight
|
|
className={`!size-3 shrink-0 stroke-[2.5] text-muted-foreground transition-transform ${open ? "rotate-90" : ""}`}
|
|
/>
|
|
</button>
|
|
{open && (
|
|
<div className="pl-2 space-y-1.5">
|
|
{resources.length === 0 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t(($) => $.resources.empty)}
|
|
</p>
|
|
)}
|
|
{resources.map((resource) => (
|
|
<ResourceRow
|
|
key={resource.id}
|
|
resource={resource}
|
|
onRemove={() => handleRemove(resource)}
|
|
/>
|
|
))}
|
|
<Popover open={addOpen} onOpenChange={setAddOpen}>
|
|
<PopoverTrigger
|
|
render={
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
<Plus className="size-3" />
|
|
{t(($) => $.resources.add_button)}
|
|
</Button>
|
|
}
|
|
/>
|
|
<PopoverContent align="start" className="w-72 p-2 space-y-2">
|
|
<div className="text-xs font-medium text-muted-foreground">
|
|
{t(($) => $.resources.popover_title)}
|
|
</div>
|
|
{workspace?.repos && workspace.repos.length > 0 && (
|
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
|
{workspace.repos.map((repo) => {
|
|
const isAttached = attachedUrls.has(repo.url);
|
|
const isDisabled = isAttached || createResource.isPending;
|
|
return (
|
|
// Use aria-disabled instead of the native `disabled` attribute so
|
|
// hover events still reach the tooltip trigger on attached rows
|
|
// (browsers suppress pointer events on disabled form controls).
|
|
<button
|
|
key={repo.url}
|
|
type="button"
|
|
aria-disabled={isDisabled}
|
|
onClick={async () => {
|
|
if (isDisabled) return;
|
|
await handleAttach(repo.url);
|
|
setAddOpen(false);
|
|
}}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs text-left hover:bg-accent transition-colors aria-disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:hover:bg-transparent"
|
|
>
|
|
<FolderGit className="size-3.5" />
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<span className="truncate flex-1">{repo.url}</span>
|
|
}
|
|
/>
|
|
<TooltipContent side="top">{repo.url}</TooltipContent>
|
|
</Tooltip>
|
|
{isAttached && (
|
|
<span className="text-[10px] text-muted-foreground">
|
|
{t(($) => $.resources.attached_badge)}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<CustomRepoForm
|
|
onSubmit={async (url) => {
|
|
await handleAttach(url);
|
|
setAddOpen(false);
|
|
}}
|
|
/>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ResourceRow({
|
|
resource,
|
|
onRemove,
|
|
}: {
|
|
resource: ProjectResource;
|
|
onRemove: () => void;
|
|
}) {
|
|
const { t } = useT("projects");
|
|
if (resource.resource_type === "github_repo") {
|
|
const ref = resource.resource_ref as GithubRepoResourceRef;
|
|
return (
|
|
<div className="flex items-center gap-2 text-xs group">
|
|
<FolderGit className="size-3.5 text-muted-foreground shrink-0" />
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<a
|
|
href={ref.url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="truncate flex-1 hover:underline"
|
|
>
|
|
{resource.label || ref.url}
|
|
</a>
|
|
}
|
|
/>
|
|
<TooltipContent side="top">{ref.url}</TooltipContent>
|
|
</Tooltip>
|
|
<button
|
|
type="button"
|
|
onClick={onRemove}
|
|
className="opacity-0 group-hover:opacity-100 transition-opacity rounded-sm p-0.5 hover:bg-accent"
|
|
title={t(($) => $.resources.remove_tooltip)}
|
|
>
|
|
<Trash2 className="size-3 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span className="truncate flex-1">
|
|
{resource.label || resource.resource_type}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={onRemove}
|
|
className="rounded-sm p-0.5 hover:bg-accent"
|
|
title={t(($) => $.resources.remove_tooltip)}
|
|
>
|
|
<Trash2 className="size-3" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CustomRepoForm({
|
|
onSubmit,
|
|
}: {
|
|
onSubmit: (url: string) => Promise<void> | void;
|
|
}) {
|
|
const { t } = useT("projects");
|
|
const [url, setUrl] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const handle = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const trimmed = url.trim();
|
|
if (!trimmed) return;
|
|
setSubmitting(true);
|
|
try {
|
|
await onSubmit(trimmed);
|
|
setUrl("");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
return (
|
|
<form onSubmit={handle} className="flex items-center gap-1.5 pt-1 border-t">
|
|
<input
|
|
type="text"
|
|
value={url}
|
|
onChange={(e) => setUrl(e.target.value)}
|
|
placeholder={t(($) => $.resources.url_placeholder)}
|
|
className="flex-1 bg-transparent text-xs px-2 py-1 outline-none placeholder:text-muted-foreground"
|
|
/>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
variant="ghost"
|
|
className="h-6 px-2 text-xs"
|
|
disabled={!url.trim() || submitting}
|
|
>
|
|
{t(($) => $.resources.url_submit)}
|
|
</Button>
|
|
</form>
|
|
);
|
|
}
|