Files
multica/packages/views/chat/components/chat-message-list.tsx
Bohan Jiang cb87dd106b feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351) (#5195)
* feat(chat): task-owned direct-chat input batches + explicit no_response outcome (MUL-4351)

Direct (web/mobile) chat no longer uses the last-assistant-row as an implicit
input cursor. Each direct send now owns an immutable input batch:

- agent_task_queue.chat_input_task_id makes a task the owner of the user
  messages it must consume; the send path creates the task + user message +
  attachment bindings + session touch in one transaction, and the daemon is
  notified only after commit. A claim reads exactly that batch, so a message
  that arrives mid-run belongs to the next task and is never absorbed.
- Auto-retry inherits the root input owner and is queued at a bumped priority,
  created inside FailTask's transaction so no newer chat task can jump ahead.
- CompleteTask writes exactly one assistant outcome inside the completion
  transaction: a normal message, or a visible no_response outcome (with a
  non-empty English fallback) when the final output is empty. The write failing
  rolls the completion back and the handler returns 5xx so the daemon retries;
  the status CAS keeps it idempotent. chat:done carries message_kind.
- Web/desktop/mobile render no_response as a localized 'no text reply' state
  (keeping the tool timeline), suppress Copy, keep it unread, and keep the
  session-list preview non-blank.
- Legacy/channel tasks (chat_input_task_id NULL) keep the trailing-message
  selector, so a rolling deploy never replays Slack/Lark history.

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

* fix(chat): scope no_response to direct tasks; don't cancel task on input read error (MUL-4351)

Addresses PR review (Niko):
- writeChatCompletionOutcome only writes a no_response row for task-owned
  direct tasks (chat_input_task_id set). Legacy/channel (Slack/Lark) tasks keep
  the prior behavior: empty output writes no assistant row, so chat:done carries
  empty content and the channel outbound silently drops it — the no_response
  fallback body never reaches an external channel.
- The daemon claim distinguishes a genuine zero-input batch from a failed
  input read: on ListChatInputMessages / ListChatMessages error it returns 5xx
  and preserves the dispatched task for redelivery instead of cancelling a valid
  task on a transient DB error.

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

---------

Co-authored-by: J <j@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-10 16:22:42 +08:00

777 lines
28 KiB
TypeScript

"use client";
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import { Virtuoso, type Components } from "react-virtuoso";
import { cn } from "@multica/ui/lib/utils";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@multica/ui/components/ui/collapsible";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@multica/ui/components/ui/tooltip";
import { ChevronRight, ChevronDown, Brain, AlertCircle, AlertTriangle, Copy } from "lucide-react";
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { isTaskMessageTaskId, taskMessagesOptions } from "@multica/core/chat/queries";
import { MemoizedMarkdown } from "@multica/views/common/markdown";
import { copyText } from "@multica/ui/lib/clipboard";
import { AttachmentList } from "../../issues/components/comment-card";
import type { AgentAvailability } from "@multica/core/agents";
import type {
ChatMessage,
ChatPendingTask,
TaskFailureReason,
TaskMessagePayload,
} from "@multica/core/types";
import type { ChatTimelineItem } from "@multica/core/chat";
import { buildTimeline } from "../../common/task-transcript";
import { TaskStatusPill } from "./task-status-pill";
import { formatElapsedMs } from "../lib/format";
import { splitTimeline, extractCopyText } from "../lib/copy-text";
import { useT } from "../../i18n";
// ─── Public component ────────────────────────────────────────────────────
interface ChatMessageListProps {
messages: ChatMessage[];
/**
* Server-authoritative pending-task snapshot. `null` / undefined means
* no in-flight task — list renders without StatusPill.
*/
pendingTask: ChatPendingTask | null | undefined;
/** Resolved presence; pass `undefined` while loading to keep the pill copy neutral. */
availability: AgentAvailability | undefined;
firstItemIndex?: number;
hasOlderMessages?: boolean;
isFetchingOlderMessages?: boolean;
onLoadOlderMessages?: () => void;
}
// ─── Virtuoso chrome ─────────────────────────────────────────────────────
//
// Header/Footer MUST be stable component references (module scope), never
// inline arrows in the `components` prop: an inline `components={{ Footer:
// () => … }}` creates a new component *type* every render, so React unmounts
// and remounts the whole Header/Footer subtree each time. During task
// streaming that tore down and rebuilt the entire live timeline — every row
// and every Markdown parse — on every `task:message` event, freezing the
// renderer for seconds at a time (MUL-3960). Per-render data flows through
// Virtuoso's `context` prop instead, which reaches these components as an
// ordinary prop (re-render, not remount).
interface ChatListContext {
isFetchingOlderMessages: boolean;
hasLive: boolean;
liveTimeline: ChatTimelineItem[];
showStatusPill: boolean;
pendingTask: ChatPendingTask | null | undefined;
liveTaskMessages: readonly TaskMessagePayload[] | undefined;
availability: AgentAvailability | undefined;
}
function ChatListHeader({ context }: { context?: ChatListContext }) {
const { t } = useT("chat");
return (
<div className="mx-auto w-full max-w-4xl px-5 pt-4">
{context?.isFetchingOlderMessages && (
<div className="text-center text-xs text-muted-foreground">
{t(($) => $.message_list.loading_older)}
</div>
)}
</div>
);
}
function ChatListFooter({ context }: { context?: ChatListContext }) {
if (!context) return null;
return (
<div className="mx-auto w-full max-w-4xl px-5 pb-4 space-y-4">
{context.hasLive && (
<div className="w-full space-y-1.5">
<TimelineView items={context.liveTimeline} isStreaming />
</div>
)}
{context.showStatusPill && context.pendingTask && (
<TaskStatusPill
pendingTask={context.pendingTask}
taskMessages={context.liveTaskMessages ?? []}
availability={context.availability}
/>
)}
</div>
);
}
const LIST_COMPONENTS: Components<ChatMessage, ChatListContext> = {
Header: ChatListHeader,
Footer: ChatListFooter,
};
export function ChatMessageList({
messages,
pendingTask,
availability,
firstItemIndex = 0,
hasOlderMessages = false,
isFetchingOlderMessages = false,
onLoadOlderMessages,
}: ChatMessageListProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollContainerEl, setScrollContainerEl] = useState<HTMLDivElement | null>(null);
const [isNearBottom, setIsNearBottom] = useState(true);
const setScrollContainerRef = useCallback((node: HTMLDivElement | null) => {
scrollRef.current = node;
setScrollContainerEl(node);
}, []);
const fadeStyle = useScrollFade(scrollRef);
const pendingTaskId = pendingTask?.task_id ?? null;
// Once the assistant message for this pending task has landed in the
// messages list, AssistantMessage owns its rendering — suppress the live
// timeline (and pill) to avoid rendering the same content in two places
// during the invalidate → refetch window.
const pendingAlreadyPersisted = !!pendingTaskId && messages.some(
(m) => m.role === "assistant" && m.task_id === pendingTaskId,
);
// Live timeline for the in-flight task. useRealtimeSync keeps this cache
// current via setQueryData on task:message events.
const showLiveTimeline = !!pendingTaskId && !pendingAlreadyPersisted;
const canFetchLiveTimeline = isTaskMessageTaskId(pendingTaskId) && !pendingAlreadyPersisted;
const { data: liveTaskMessages } = useQuery({
...taskMessagesOptions(pendingTaskId ?? ""),
enabled: canFetchLiveTimeline,
});
// Memoized on the cache array identity: mergeTaskMessagesBySeq preserves
// the array reference when a duplicate event arrives, so this recomputes
// only when a genuinely new message lands — not on unrelated re-renders.
const liveTimeline: ChatTimelineItem[] = useMemo(
() => buildTimeline(liveTaskMessages ?? []),
[liveTaskMessages],
);
const hasLive = showLiveTimeline && liveTimeline.length > 0;
const showStatusPill = !!pendingTaskId && !pendingAlreadyPersisted && !!pendingTask;
const totalCount = messages.length + (hasLive || showStatusPill ? 1 : 0);
const firstIndex = totalCount > 0 ? firstItemIndex : 0;
const listContext: ChatListContext = {
isFetchingOlderMessages,
hasLive,
liveTimeline,
showStatusPill,
pendingTask,
liveTaskMessages,
availability,
};
return (
<div
ref={setScrollContainerRef}
data-tab-scroll-root
style={fadeStyle}
className="flex-1 overflow-y-auto"
>
{!scrollContainerEl ? (
<div className="mx-auto w-full max-w-4xl px-5 pt-4 space-y-3">
<ChatMessageSkeleton />
</div>
) : (
<Virtuoso
customScrollParent={scrollContainerEl}
data={messages}
firstItemIndex={firstIndex}
increaseViewportBy={{ top: 400, bottom: 600 }}
atBottomThreshold={120}
atBottomStateChange={setIsNearBottom}
followOutput={() => (!isFetchingOlderMessages && isNearBottom ? "smooth" : false)}
startReached={() => {
if (hasOlderMessages && !isFetchingOlderMessages) {
onLoadOlderMessages?.();
}
}}
computeItemKey={(_, msg) => msg.id}
context={listContext}
components={LIST_COMPONENTS}
itemContent={(_, msg) => (
<div className="mx-auto w-full max-w-4xl px-5 py-2">
<MessageBubble
message={msg}
isPending={!!pendingTaskId && msg.task_id === pendingTaskId}
/>
</div>
)}
/>
)}
</div>
);
}
/**
* Placeholder shown while `chat_message` for a session is being fetched
* (initial refresh, or switching to an un-cached session). Shape roughly
* mirrors an assistant → user → assistant exchange so the window doesn't
* shift under the user when real messages arrive.
*/
export function ChatMessageSkeleton() {
return (
<div className="flex-1 overflow-hidden">
<div className="mx-auto w-full max-w-4xl px-5 py-4 space-y-5">
<div className="space-y-2">
<Skeleton className="h-3.5 w-3/4" />
<Skeleton className="h-3.5 w-1/2" />
</div>
<div className="flex justify-end">
<Skeleton className="h-8 w-48 rounded-2xl" />
</div>
<div className="space-y-2">
<Skeleton className="h-3.5 w-2/3" />
<Skeleton className="h-3.5 w-5/6" />
<Skeleton className="h-3.5 w-1/3" />
</div>
</div>
</div>
);
}
// ─── Message bubbles ─────────────────────────────────────────────────────
// memo: every streamed task:message re-renders ChatMessageList, and with it
// every VISIBLE row via itemContent. Message objects are referentially
// stable for unchanged messages and isPending is a boolean, so a shallow
// memo skips reconciling rows the stream didn't touch — the persisted
// history stays inert while only the live footer updates.
const MessageBubble = memo(function MessageBubble({
message,
isPending,
}: {
message: ChatMessage;
isPending: boolean;
}) {
if (message.role === "user") {
return (
<div className="flex justify-end">
<div className="rounded-2xl bg-muted px-3.5 py-2 text-sm max-w-[80%] break-words">
{/* User messages are authored as markdown in ContentEditor, so
* render them through the same pipeline as assistant replies.
* Neutralise prose's leading/trailing margin so single-line
* bubbles stay as compact as the plain-text version used to. */}
<div className="prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<MemoizedMarkdown attachments={message.attachments}>{message.content}</MemoizedMarkdown>
</div>
<AttachmentList
attachments={message.attachments}
content={message.content}
className="mt-1.5"
/>
</div>
</div>
);
}
return <AssistantMessage message={message} isPending={isPending} />;
});
function AssistantMessage({
message,
isPending,
}: {
message: ChatMessage;
isPending: boolean;
}) {
const taskId = message.task_id;
const canFetchTaskMessages = isTaskMessageTaskId(taskId);
// Use the shared taskMessagesOptions so this cache entry is the same one
// seeded by useRealtimeSync during task execution — zero refetch when the
// task finishes, since WS already populated it.
const { data: taskMessages } = useQuery({
...taskMessagesOptions(taskId ?? ""),
enabled: canFetchTaskMessages,
});
// Same memoization rationale as the live timeline in ChatMessageList.
const timeline: ChatTimelineItem[] = useMemo(
() => buildTimeline(taskMessages ?? []),
[taskMessages],
);
// Failure bubble path: when the server's FailTask wrote a failure
// chat_message (failure_reason set), render a destructive bubble with the
// human-readable reason label + collapsible raw errMsg + the same timeline
// so the user can see exactly where the run broke.
if (message.failure_reason) {
return (
<FailureBubble
reason={message.failure_reason}
rawError={message.content}
timeline={timeline}
elapsedMs={message.elapsed_ms}
/>
);
}
// no_response path (MUL-4351): the agent completed this direct-chat turn
// without any text. Keep whatever tool/thinking timeline the run produced and
// show a localized "no text reply" notice instead of an empty markdown block.
const isNoResponse = message.message_kind === "no_response";
return (
<div className="w-full space-y-1.5">
{timeline.length > 0 && (
<TimelineView items={timeline} attachments={message.attachments} />
)}
{isNoResponse ? (
<NoResponseNotice />
) : timeline.length === 0 ? (
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<MemoizedMarkdown attachments={message.attachments}>{message.content}</MemoizedMarkdown>
</div>
) : null}
<AttachmentList
attachments={message.attachments}
content={message.content}
/>
<MessageFooter
message={message}
timeline={timeline}
isPending={isPending}
/>
</div>
);
}
// Muted, localized notice shown in place of assistant text when a turn
// completed with no reply (message_kind === "no_response"). Explains the empty
// turn instead of rendering a blank bubble (MUL-4351).
function NoResponseNotice() {
const { t } = useT("chat");
return (
<div className="text-sm italic text-muted-foreground">
{t(($) => $.message_list.no_response)}
</div>
);
}
// Inline footer row beneath the assistant reply: "Replied in 38s · [Copy]".
// Action icons live here (not as a hover-floating overlay) so they're
// discoverable on first read and don't shift content. Buttons stay quiet
// (muted) until hover. Copy is suppressed during streaming because the
// final text is still being appended.
function MessageFooter({
message,
timeline,
isPending,
}: {
message: ChatMessage;
timeline: ChatTimelineItem[];
isPending: boolean;
}) {
// A no_response turn has nothing to copy, and its caption uses a neutral
// "Finished in Xs" instead of "Replied in Xs" (MUL-4351).
const isNoResponse = message.message_kind === "no_response";
const showCopy = !isPending && !isNoResponse;
if (message.elapsed_ms == null && !showCopy) return null;
return (
<div className="flex items-center gap-1.5">
{message.elapsed_ms != null && (
<ElapsedCaption
variant={isNoResponse ? "finished" : "replied"}
elapsedMs={message.elapsed_ms}
/>
)}
{showCopy && <MessageCopyButton message={message} timeline={timeline} />}
</div>
);
}
function MessageCopyButton({
message,
timeline,
}: {
message: ChatMessage;
timeline: ChatTimelineItem[];
}) {
const { t } = useT("chat");
const handleCopy = async () => {
if (await copyText(extractCopyText(message, timeline))) {
toast.success(t(($) => $.message_list.copied_toast));
} else {
toast.error(t(($) => $.message_list.copy_failed_toast));
}
};
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground/70 hover:text-foreground"
onClick={handleCopy}
aria-label={t(($) => $.message_list.copy_action)}
/>
}
>
<Copy />
</TooltipTrigger>
<TooltipContent side="top">
{t(($) => $.message_list.copy_action)}
</TooltipContent>
</Tooltip>
);
}
// Persisted "Replied in 38s" / "Failed after 12s" line under the assistant
// bubble. Reads `elapsed_ms` straight off the chat_message — server computes
// it once at task completion, so this caption is identical across reloads
// and devices. Skipped silently when null (legacy messages predating
// migration 063 + user messages).
function ElapsedCaption({
variant,
elapsedMs,
className,
}: {
variant: "replied" | "failed" | "finished";
elapsedMs: number;
className?: string;
}) {
const { t } = useT("chat");
const elapsed = formatElapsedMs(elapsedMs);
const text =
variant === "replied"
? t(($) => $.message_list.replied_in, { elapsed })
: variant === "finished"
? t(($) => $.message_list.finished_in, { elapsed })
: t(($) => $.message_list.failed_after, { elapsed });
return (
<div className={cn("text-xs text-muted-foreground/80", className)}>
{text}
</div>
);
}
function FailureBubble({
reason,
rawError,
timeline,
elapsedMs,
}: {
reason: string;
rawError: string;
timeline: ChatTimelineItem[];
elapsedMs?: number | null;
}) {
const { t } = useT("chat");
const [open, setOpen] = useState(false);
// Chat gets its own friendly, reassuring copy per failure reason — plain
// language + a "try again" nudge — instead of the terse developer labels
// (`failureReasonLabel`) used on the agent-detail / execution-log surfaces.
// An unknown reason (a future enum value this build doesn't ship yet) falls
// back to a generic friendly line. The raw error stays tucked under the
// collapsible below for anyone who wants the technical detail.
const chatFailureCopy: Record<TaskFailureReason, string> = {
agent_error: t(($) => $.message_list.failure.agent_error),
timeout: t(($) => $.message_list.failure.timeout),
codex_semantic_inactivity: t(($) => $.message_list.failure.codex_semantic_inactivity),
runtime_offline: t(($) => $.message_list.failure.runtime_offline),
runtime_recovery: t(($) => $.message_list.failure.runtime_recovery),
manual: t(($) => $.message_list.failure.manual),
};
const label =
chatFailureCopy[reason as TaskFailureReason] ??
t(($) => $.message_list.failure.fallback);
return (
<div className="w-full space-y-1.5">
{/* Failure read as an inline, low-key note — not a destructive
* alert. Intentionally borderless / no background tint: a chat
* failure is informational ("this didn't work"), not a system
* error. The icon + muted destructive text are signal enough,
* the rest stays in the normal reply rhythm. */}
<div className="flex items-start gap-1.5 text-sm">
<AlertTriangle className="size-3.5 shrink-0 text-destructive/80 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="text-destructive/90">{label}</div>
{rawError.trim() && (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors">
{open ? (
<ChevronDown className="size-3" />
) : (
<ChevronRight className="size-3" />
)}
<span>{t(($) => $.message_list.show_details)}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-1 max-h-40 overflow-auto rounded bg-muted/40 p-2 text-xs text-muted-foreground whitespace-pre-wrap break-all">
{rawError}
</pre>
</CollapsibleContent>
</Collapsible>
)}
</div>
</div>
{timeline.length > 0 && <TimelineView items={timeline} />}
{elapsedMs != null && (
<ElapsedCaption variant="failed" elapsedMs={elapsedMs} />
)}
</div>
);
}
// ─── Timeline: outer process fold + final text (Conductor-style) ─────────
//
// splitTimeline (lib/copy-text.ts) carves the items into:
// preface — text before the first thinking/tool item
// middle — first → last non-text item (inclusive, may sandwich text)
// final — text after the last non-text item
//
// We render preface + final outside an outer Collapsible ("X steps") that
// wraps middle. The inner row Collapsibles (ThinkingRow / ToolCallRow /
// ToolResultRow) are unchanged — clicking them toggles independently of
// the outer fold. Copy mirrors what's visible when the outer fold is
// closed: preface + final, never middle. See extractCopyText for the
// authoritative copy logic.
function TimelineView({
items,
isStreaming,
attachments,
}: {
items: ChatTimelineItem[];
isStreaming?: boolean;
attachments?: import("@multica/core/types").Attachment[];
}) {
const { preface, middle, final } = splitTimeline(items);
return (
<>
{preface.length > 0 && (
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<MemoizedMarkdown attachments={attachments}>
{preface.map((t) => t.content ?? "").join("")}
</MemoizedMarkdown>
</div>
)}
{middle.length > 0 && (
<OuterProcessFold
items={middle}
defaultOpen={!!isStreaming}
attachments={attachments}
/>
)}
{final.length > 0 && (
<div className="text-sm leading-relaxed prose prose-sm dark:prose-invert max-w-none">
<MemoizedMarkdown attachments={attachments}>
{final.map((t) => t.content ?? "").join("")}
</MemoizedMarkdown>
</div>
)}
</>
);
}
function OuterProcessFold({
items,
defaultOpen,
attachments,
}: {
items: ChatTimelineItem[];
defaultOpen?: boolean;
attachments?: import("@multica/core/types").Attachment[];
}) {
const { t } = useT("chat");
// useState seeds once at mount — subsequent renders never overwrite the
// user's manual toggle. The streaming → completed transition unmounts
// the live <TimelineView> and mounts the persisted AssistantMessage's
// own <TimelineView>, so the persisted instance starts closed (default)
// even if the live one was open. That's the desired collapsed-default.
const [open, setOpen] = useState(defaultOpen ?? false);
const stepCount = items.length;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors">
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
<span>{t(($) => $.message_list.process_steps, { count: stepCount })}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-1 rounded-lg border bg-muted/20 p-2 space-y-0.5">
{items.map((item) =>
item.type === "text" ? (
<MiddleTextRow key={item.seq} item={item} attachments={attachments} />
) : (
<ItemRow key={item.seq} item={item} />
),
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
// Intermediate text segment rendered inside the outer fold. Visually
// down-shifted (xs / muted) so it reads as part of the agent's process,
// not the final answer — the final answer renders below the fold at full
// prose size.
function MiddleTextRow({
item,
attachments,
}: {
item: ChatTimelineItem;
attachments?: import("@multica/core/types").Attachment[];
}) {
return (
<div className="py-0.5 text-xs text-muted-foreground prose prose-sm dark:prose-invert max-w-none [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
<MemoizedMarkdown attachments={attachments}>{item.content ?? ""}</MemoizedMarkdown>
</div>
);
}
// ─── Individual item rows ────────────────────────────────────────────────
function ItemRow({ item }: { item: ChatTimelineItem }) {
switch (item.type) {
case "tool_use":
return <ToolCallRow item={item} />;
case "tool_result":
return <ToolResultRow item={item} />;
case "thinking":
return <ThinkingRow item={item} />;
case "error":
return <ErrorRow item={item} />;
default:
return null;
}
}
function shortenPath(p: string): string {
const parts = p.split("/");
if (parts.length <= 3) return p;
return ".../" + parts.slice(-2).join("/");
}
function getToolSummary(item: ChatTimelineItem): string {
if (!item.input) return "";
const inp = item.input as Record<string, string>;
if (inp.query) return inp.query;
if (inp.file_path) return shortenPath(inp.file_path);
if (inp.path) return shortenPath(inp.path);
if (inp.pattern) return inp.pattern;
if (inp.description) return String(inp.description);
if (inp.command) {
const cmd = String(inp.command);
return cmd.length > 100 ? cmd.slice(0, 100) + "..." : cmd;
}
if (inp.prompt) {
const p = String(inp.prompt);
return p.length > 100 ? p.slice(0, 100) + "..." : p;
}
if (inp.skill) return String(inp.skill);
for (const v of Object.values(inp)) {
if (typeof v === "string" && v.length > 0 && v.length < 120) return v;
}
return "";
}
function ToolCallRow({ item }: { item: ChatTimelineItem }) {
const [open, setOpen] = useState(false);
const summary = getToolSummary(item);
const hasInput = item.input && Object.keys(item.input).length > 0;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded px-1 -mx-1 py-0.5 text-xs hover:bg-accent/30 transition-colors">
<ChevronRight
className={cn(
"h-3 w-3 shrink-0 text-muted-foreground transition-transform",
open && "rotate-90",
!hasInput && "invisible",
)}
/>
<span className="font-medium text-foreground shrink-0">{item.tool}</span>
{summary && <span className="truncate text-muted-foreground">{summary}</span>}
</CollapsibleTrigger>
{hasInput && (
<CollapsibleContent>
<pre className="ml-[18px] mt-0.5 max-h-32 overflow-auto rounded bg-muted/50 p-2 text-xs text-muted-foreground whitespace-pre-wrap break-all">
{JSON.stringify(item.input, null, 2)}
</pre>
</CollapsibleContent>
)}
</Collapsible>
);
}
function ToolResultRow({ item }: { item: ChatTimelineItem }) {
const { t } = useT("chat");
const [open, setOpen] = useState(false);
const output = item.output ?? "";
if (!output) return null;
const preview = output.length > 120 ? output.slice(0, 120) + "..." : output;
const labelPrefix = item.tool
? t(($) => $.message_list.tool_result_named, { tool: item.tool })
: t(($) => $.message_list.tool_result_unnamed);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-start gap-1.5 rounded px-1 -mx-1 py-0.5 text-xs hover:bg-accent/30 transition-colors">
<ChevronRight
className={cn("h-3 w-3 shrink-0 text-muted-foreground transition-transform mt-0.5", open && "rotate-90")}
/>
<span className="text-muted-foreground/70 truncate">
{labelPrefix}{preview}
</span>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded bg-muted/50 p-2 text-xs text-muted-foreground whitespace-pre-wrap break-all">
{output.length > 4000 ? output.slice(0, 4000) + "\n... (truncated)" : output}
</pre>
</CollapsibleContent>
</Collapsible>
);
}
function ThinkingRow({ item }: { item: ChatTimelineItem }) {
const [open, setOpen] = useState(false);
const text = item.content ?? "";
if (!text) return null;
const preview = text.length > 150 ? text.slice(0, 150) + "..." : text;
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full items-start gap-1.5 rounded px-1 -mx-1 py-0.5 text-xs hover:bg-accent/30 transition-colors">
<Brain className="h-3 w-3 shrink-0 text-muted-foreground/60 mt-0.5" />
<span className="text-muted-foreground italic truncate">{preview}</span>
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="ml-[18px] mt-0.5 max-h-40 overflow-auto rounded bg-muted/30 p-2 text-xs text-muted-foreground whitespace-pre-wrap break-words">
{text}
</pre>
</CollapsibleContent>
</Collapsible>
);
}
function ErrorRow({ item }: { item: ChatTimelineItem }) {
return (
<div className="flex items-start gap-1.5 px-1 -mx-1 py-0.5 text-xs">
<AlertCircle className="h-3 w-3 shrink-0 text-destructive mt-0.5" />
<span className="text-destructive">{item.content}</span>
</div>
);
}
// ─── Shared ──────────────────────────────────────────────────────────────