Files
PaperClipAI/ui/src/components/task-chat/TaskChatMarker.tsx
T
DottaandPaperclip b11010b0d4 Simplify failure banners and show only the latest error (#16982)
Remove dashed failure dividers and show only the latest adjacent run or recovery error. Preserve permitted retries, pending state and unrelated notice boundaries. Add production-component Storybook examples and browser regression coverage.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-20 14:29:39 -05:00

190 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useId, useState } from "react";
import { cn } from "@/lib/utils";
import { ChevronDown, CircleDot, OctagonX, Square, Flag, KeyRound } from "lucide-react";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import type { TaskChatMarkerItem } from "./task-chat-model";
import { Button } from "@/components/ui/button";
import { Link } from "@/lib/router";
import { timeAgo } from "@/lib/timeAgo";
const VARIANT_ICON = {
session_start: CircleDot,
interrupted: OctagonX,
turn_boundary: Flag,
} as const;
/**
* Lifecycle divider — renders a state boundary (session start, interruption,
* turn boundary) as a recessed rule. Interruptions use a compact row without
* divider rules, including older items that did not set `collapsible`.
*/
export function TaskChatMarker({
item,
onTryAgain,
tryAgainPending = false,
}: {
item: TaskChatMarkerItem;
onTryAgain?: () => Promise<void> | void;
tryAgainPending?: boolean;
}) {
const [open, setOpen] = useState(false);
const detailsId = useId();
const streamlined = useStreamlinedTaskChatPresentation();
const Icon = item.tone === "neutral" ? Square : VARIANT_ICON[item.variant];
const interrupted = item.variant === "interrupted" && item.tone !== "neutral";
const relative = item.createdAtIso ? timeAgo(item.createdAtIso) : undefined;
const handleTryAgain = () => {
void Promise.resolve()
.then(() => onTryAgain?.())
// The parent mutation owns visible error feedback.
.catch(() => undefined);
};
if (item.credentialAccess) {
const { agentName, credentialName, deniedUser, settingsHref } = item.credentialAccess;
return (
<div role="alert" data-testid="task-chat-credential-access-notice"
className="tc-enter-marker flex w-full items-start gap-3 rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm">
<KeyRound className="mt-0.5 size-5 shrink-0 text-destructive" aria-hidden />
<div className="flex min-w-0 flex-1 flex-col gap-2">
<p className="font-semibold text-destructive">{agentName} couldn’t start</p>
<p className="break-words font-medium text-foreground">
{agentName} is configured to use {credentialName || "an AI credential"}, but {deniedUser === "you" ? "you don’t" : `${deniedUser} doesn’t`} have access to that credential.
</p>
<p className="text-muted-foreground">
Choose an AI connection {deniedUser === "you" ? "you can" : `${deniedUser} can`} use, or ask an administrator to update {agentName}’s connection. Then try again.
</p>
<div className="flex flex-wrap items-center gap-2 pt-1">
<Button asChild variant="outline" size="sm"><Link to={settingsHref}>Change AI connection</Link></Button>
{item.runHref && <Button asChild variant="ghost" size="sm"><Link to={item.runHref}>View run</Link></Button>}
</div>
</div>
</div>
);
}
if (item.collapsible) {
return (
<div
className="tc-enter-marker flex flex-col items-start py-1 text-xs text-muted-foreground"
data-testid="task-chat-collapsible-marker"
>
<div className="flex max-w-(--pct-85) items-center gap-1.5">
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls={detailsId}
className={cn(
"flex min-w-0 items-center gap-1.5 rounded-md px-2 py-0.5",
interrupted ? "text-destructive" : "text-muted-foreground",
"hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="truncate font-medium">{item.label}</span>
{relative ? (
<span className="shrink-0 text-muted-foreground">
· {relative}
</span>
) : null}
<ChevronDown
className={cn(
"tc-notice-chevron h-3.5 w-3.5 shrink-0 transition-transform",
open && "rotate-180",
)}
aria-hidden
/>
</button>
{onTryAgain && !open ? (
<Button
type="button"
variant="outline"
size="xs"
onClick={handleTryAgain}
disabled={tryAgainPending}
data-testid="task-chat-run-failed-try-again"
>
{tryAgainPending ? "Trying again..." : "Try again"}
</Button>
) : null}
</div>
{open ? (
<div
id={detailsId}
data-testid="task-chat-collapsible-marker-details"
className="mt-1 w-full max-w-(--pct-85) overflow-hidden rounded-lg border border-border bg-muted/25 text-left text-sm dark:bg-muted/15"
>
{item.detail ? (
<div className="px-3 py-2.5 text-foreground/90">
{item.detail}
</div>
) : null}
{item.runHref || item.planHref || onTryAgain ? (
<div className="flex items-center justify-end gap-2 border-t border-border/70 bg-background/50 px-3 py-2 dark:bg-background/30">
{item.planHref ? (
<Button asChild variant="ghost" size="xs">
<Link to={item.planHref}>View saved plan</Link>
</Button>
) : null}
{item.runHref ? (
<Button asChild variant="ghost" size="xs">
<Link to={item.runHref}>View run</Link>
</Button>
) : null}
{onTryAgain ? (
<Button
type="button"
size="xs"
onClick={handleTryAgain}
disabled={tryAgainPending}
data-testid="task-chat-run-failed-try-again"
>
{tryAgainPending ? "Trying again..." : "Try again"}
</Button>
) : null}
</div>
) : null}
</div>
) : null}
</div>
);
}
return (
<div
className="tc-enter-marker flex items-center gap-2 py-1 text-xs text-muted-foreground"
role={streamlined && item.variant !== "interrupted" ? "separator" : undefined}
aria-label={streamlined && item.variant !== "interrupted" ? item.label : undefined}
>
{item.variant !== "interrupted" ? <span className="h-px flex-1 bg-border" /> : null}
<span
className={cn(
"flex min-w-0 flex-wrap items-center gap-1.5",
interrupted && "text-destructive",
)}
>
<Icon className="h-3.5 w-3.5" />
<span className="font-medium">{item.label}</span>
{item.detail ? (
<span className="text-muted-foreground">· {item.detail}</span>
) : null}
{onTryAgain ? (
<Button
type="button"
variant="outline"
size="xs"
className="text-foreground"
onClick={handleTryAgain}
disabled={tryAgainPending}
data-testid="task-chat-run-failed-try-again"
>
{tryAgainPending ? "Trying again..." : "Try again"}
</Button>
) : null}
</span>
{item.variant !== "interrupted" ? <span className="h-px flex-1 bg-border" /> : null}
</div>
);
}