mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* feat(chat): support images/files in agent chat replies (MUL-4287) Agents can now attach images/files to their chat replies, matching how comment attachments already work. The write-side gap was that the assistant chat_message is synthesized server-side from the completion callback's text output and never bound any attachments. Backend: - migration 150: nullable attachment.task_id (+ partial index), the transient handle that ties an agent's in-run upload to the reply it produces. - POST /api/upload-file accepts task_id: gated to the task's own agent, in this workspace, on a chat task; tags the row with task_id + chat_session_id. - CompleteTask (chat branch) binds the task's still-unclaimed attachments to the assistant message via BindChatAttachmentsToMessage (rejects rows already owned by an issue/comment/chat_message). An empty-output reply that produced files still creates a message so the images have an owner. FailTask binds nothing. CLI: - `multica attachment upload <path>` uploads a file for the current chat task (task from MULTICA_TASK_ID or --task) and prints id / markdown_url / a ready-to-paste markdown snippet. Prompt: - web/mobile chat prompt tells the agent how to attach a file to its reply. Mobile: - chat:done handler now always invalidates the messages list so attachments (absent from the event payload) refetch; mirrors web's self-heal. - chat bubbles render standalone attachment cards via the existing CommentAttachmentList (dedup vs inline references), matching web. Web/desktop needed no change — they already render message.attachments inline and via AttachmentList, and self-heal on chat:done. Tests: upload permission/isolation, bind-on-complete, empty-output+attachments, FailTask no-bind, null task_id untouched, already-owned not stolen, CLI output contract, mobile refetch-on-done. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): address review blockers on chat reply attachments (MUL-4287) Two final-review blockers on PR #5164: 1. Mobile inline dedup only checked raw `url`, so an attachment referenced inline via `markdown_url` (exactly what the CLI snippet emits) rendered twice — once inline, once as a standalone card. Reuse the core `contentReferencesAttachment` helper so dedup covers every real reference form (stable /api/attachments/<id>/download path, url, download_url, markdown_url), matching web's AttachmentList. Extracted the filter into a pure `lib/attachment-dedup.ts` so it is unit-testable, and added a regression test covering `content` containing `attachment.markdown_url` (plus the other URL forms and same-identity sibling dedup). 2. CLI `attachment upload` emitted `![...]` image markdown for every file, producing a broken-image snippet for non-images. Emit image markdown only for image/* content types and a plain link otherwise, with a CLI contract test for both. Approved scope otherwise unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): renumber attachment_task_id migration 150 -> 157 after main merge (MUL-4287) Merged latest main; main renumbered its migrations and now occupies 150-156, so 150_attachment_task_id collided with 150_agent_task_coalesced_comments and would fail TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Renamed to the next unique prefix (157). No content change; migrate up applies cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(chat): render agent-produced files as attachment cards, not raw links The chat upload command handed the agent a bare `[name](url)` markdown snippet. Pasted mid-sentence it renders as a plain text link (not a card), and the referenced URL hides the auto-bound standalone attachment — so a file the agent produced could end up showing as nothing. Return the block-level `!file[name](url)` card syntax instead (images keep `` inline), and markdown-escape the filename so names with `[`/`]` don't truncate the label. The prompt and CLI help now state the file auto-attaches below the reply and the snippet is optional, only for placement. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): soften message-list scroll fade (32px → 16px) The 32px edge fade washed out full-bleed content (HTML / image previews) at the list edges. Halve the fade distance so it barely grazes previews while still hinting at more content above/below. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): renumber attachment_task_id migration 157 -> 158 main landed 157_agent_task_delivered_comments while this branch was open, colliding on prefix 157 and failing TestMigrationNumericPrefixesStayUniqueAfterLegacySet. Bump this PR's migration to the next free prefix (158). Rename only; the migration body (nullable attachment.task_id + partial index) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): pin attachment upload to the token's task; build index concurrently Two code-review findings on the chat-attachment path (MUL-4287): - Isolation/privacy: POST /api/upload-file only checked the form task_id belonged to the caller's agent, not that it matched the task-scoped token's authoritative X-Task-ID. A run authorized for task A could tag an attachment onto task B (another chat task of the same agent, possibly another user's session), binding it into that reply on completion. Require the form task_id to equal the server-set X-Task-ID; add a same-agent/other-task 403 regression. - Migration: split the task_id lookup index into its own migration (159) built with CREATE INDEX CONCURRENTLY (repo convention) — it cannot share a multi-command file with the ADD COLUMN in 158. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(chat): enforce task-token source on attachment upload; drop transient task_id FK (MUL-4287) Addresses the two remaining Preflight BLOCKERs on PR #5164. Security (file.go): the task_id upload path compared the form task_id to X-Task-ID but did not require X-Actor-Source=task_token. A normal JWT/mul_ PAT leaves that header empty and the middleware does NOT strip a client-forged X-Task-ID; resolveActor's fallback accepts a valid X-Agent-ID+X-Task-ID pair. So a member who learned a task ID could forge both and inject an attachment onto another chat task's assistant reply (cross-session/privacy leak). Now the branch requires X-Actor-Source=task_token first (mirrors chat_history.go's load-bearing boundary), then pins to the middleware-injected X-Task-ID. Tests now go through the real task-token headers and add a forged-JWT-403 regression. Migration (158): task_id is a transient binding handle (written once at upload against an already-validated task, read only during that task's own completion; durable owner is chat_message_id). There is no app-layer path that hard-deletes agent_task_queue rows, and orphan uploads are already reaped by attachment.chat_session_id's ON DELETE CASCADE — so an FK here would only add a cascade dependency the app never relies on plus write overhead on the hot attachment table. Drop the FK; task_id is now a plain UUID column. Added a regression test that an unbound task-tagged upload is reaped on chat_session delete. Index (159, CONCURRENTLY) unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(mobile): align !file card preprocess with web parser + CLI escaped labels (MUL-4287) Howard final-review blocker: mobile's `!file[...]` preprocess didn't keep up with the CLI's file-card output, so agent-produced non-image files rendered nowhere on mobile. - `FILE_LINE_RE` used `[^\]]+` for the label, so the CLI's escaped-bracket output `!file[a\]b.pdf](url)` (cmd_attachment.go escapeMarkdownLabel) never matched — the line stayed literal AND `standaloneAttachments` still hid the fallback card (the URL is in `content`), so the file showed nowhere. - Align the matcher with web's `packages/ui/markdown/file-cards.ts`: label allows backslash-escaped metacharacters (ReDoS-safe class), and the URL is restricted to the same allowlist (site-relative /uploads + /api/attachments/ <UUID>/download, plus absolute http(s)); disallowed schemes stay plain text. - Unescape the label to the real filename, then re-escape only the chars that would break a markdown LINK label (mobile emits `[📎 name](url)`, re-parsed by the renderer — unlike web's HTML data-filename), so a raw `]` never truncates the link text. No dedup change: once the inline `!file` renders, hiding the standalone card is correct. Added focused unit tests covering the escaped-label case, parens/ backslash unescape, the site-relative URL form, and disallowed-scheme rejection. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
474 lines
18 KiB
TypeScript
474 lines
18 KiB
TypeScript
/**
|
|
* Chat message list — user / assistant bubbles, oldest at top, newest at
|
|
* bottom. Initial render lands at the bottom; new arrivals auto-scroll
|
|
* when the user is anchored near the bottom; reading history is never
|
|
* yanked down.
|
|
*
|
|
* Behavioral parity (apps/mobile/CLAUDE.md):
|
|
* - Render ALL message roles. Unknown role values are downgraded to
|
|
* "assistant" by ChatMessageSchema's `.catch()`, so this list never
|
|
* needs to silently drop a row.
|
|
* - Render `failure_reason` messages with destructive styling — same
|
|
* boolean as web's destructive bubble + failureReasonLabel().
|
|
*
|
|
* v1 simplifications:
|
|
* - No "Replied in Ns" badge under assistant bubbles (elapsed_ms is
|
|
* parsed but not displayed). Easy v2 add — show below the bubble.
|
|
* - Attachments bound to a message but NOT referenced inline in `content`
|
|
* render as standalone cards below the bubble via `CommentAttachmentList`
|
|
* (same component the comment thread uses; mirrors web reusing
|
|
* `AttachmentList` in chat). Inline `` / `[name](url)` still flow
|
|
* through the markdown renderer and are de-duped out of the card list.
|
|
*
|
|
* Interaction: long-press inside a bubble fires a native iOS
|
|
* `ActionSheetIOS` (Copy / Select Text / Cancel). While the sheet is on
|
|
* screen the targeted bubble's border highlights. The assistant branch
|
|
* has no border baseline because its bubble has no shell — adding a 2px
|
|
* baseline would shift layout per message. See `useChatMessageLongPress`
|
|
* in `./message-long-press.tsx`.
|
|
*
|
|
* List engine: FlashList v2 (Shopify). FlatList was the original choice
|
|
* (per the now-outdated "no FlashList" baseline in apps/mobile/CLAUDE.md
|
|
* — written before FlashList v2 stabilised). FlatList's `scrollToEnd` is
|
|
* janky on variable-height lists by RN's own docs admission, and our
|
|
* markdown bubbles render in multiple async passes (Shiki highlight,
|
|
* image natural-size, lightbox provider injection) — each pass used to
|
|
* fire onContentSizeChange and trigger another forced scroll, causing
|
|
* the "open chat → feels stuck" jank. FlashList v2 replaces the manual
|
|
* scroll dance with `maintainVisibleContentPosition`
|
|
* (default-on; locks visible item across content changes) +
|
|
* `startRenderingFromBottom` (initial paint at bottom, no setTimeout
|
|
* hacks). Cell recycling also keeps scroll-up smooth.
|
|
*/
|
|
import { ActivityIndicator, Pressable, View } from "react-native";
|
|
import { FlashList } from "@shopify/flash-list";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import type {
|
|
ChatMessage,
|
|
ChatPendingTask,
|
|
TaskMessagePayload,
|
|
} from "@multica/core/types";
|
|
import type { AgentAvailability } from "@multica/core/agents";
|
|
import { taskMessagesOptions } from "@/data/queries/chat";
|
|
import { Text } from "@/components/ui/text";
|
|
import { Markdown } from "@/lib/markdown";
|
|
import { failureReasonLabel } from "@/lib/failure-reason-label";
|
|
import { formatElapsedMs } from "@/lib/format-elapsed";
|
|
import { cn } from "@/lib/utils";
|
|
import { useChatSelectStore } from "@/data/chat-select-store";
|
|
import { useChatMessageLongPress } from "./message-long-press";
|
|
import { ChatEmptyState } from "./chat-empty-state";
|
|
import { ChatTimeline } from "./chat-timeline";
|
|
// Reuse the comment thread's standalone attachment list — same design web
|
|
// reuses in chat (AttachmentList). Renders any bound attachment not already
|
|
// referenced inline in the message content, with same-file dedup.
|
|
import { CommentAttachmentList } from "@/components/issue/comment-attachment-list";
|
|
import { StatusPill } from "./status-pill";
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from "@/components/ui/collapsible";
|
|
|
|
interface Props {
|
|
messages: ChatMessage[];
|
|
loading: boolean;
|
|
/** Has the workspace ever started a chat? Drives empty-state copy. */
|
|
hasSessions: boolean;
|
|
/** Currently picked / inherited agent's display name. */
|
|
agentName?: string;
|
|
/** Receive a starter-prompt tap. Caller writes into the draft store
|
|
* (or focuses the composer with the text) — empty state stays neutral
|
|
* about send vs. preview. */
|
|
onPickPrompt: (text: string) => void;
|
|
/** Server-authoritative pending-task snapshot for the active session.
|
|
* Used to render the live timeline + status line as the last item in
|
|
* the message stream, mirroring web's
|
|
* `packages/views/chat/components/chat-message-list.tsx` placement. */
|
|
pendingTask?: ChatPendingTask | null;
|
|
/** Live timeline rows for the in-flight task. Already fetched by the
|
|
* parent so this list doesn't have to manage its own subscription. */
|
|
liveTaskMessages?: TaskMessagePayload[];
|
|
/** Resolved availability — drives the StatusPill's "Offline" /
|
|
* "Reconnecting" stages. Pass `undefined` while loading. */
|
|
availability?: AgentAvailability;
|
|
}
|
|
|
|
export function ChatMessageList({
|
|
messages,
|
|
loading,
|
|
hasSessions,
|
|
agentName,
|
|
onPickPrompt,
|
|
pendingTask,
|
|
liveTaskMessages,
|
|
availability,
|
|
}: Props) {
|
|
// Top-level selection subscription gates the outer "tap-outside-to-dismiss"
|
|
// Pressable below. When null, the Pressable stays disabled and every tap
|
|
// passes through to the list cells / bubble long-press wrappers normally.
|
|
const selectingId = useChatSelectStore((s) => s.selectingId);
|
|
|
|
if (loading && messages.length === 0) {
|
|
return (
|
|
<View className="flex-1 items-center justify-center">
|
|
<ActivityIndicator />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (messages.length === 0) {
|
|
// Empty new-chat state. Lives here (rather than the parent screen) so
|
|
// the empty state and the rendered list share spacing/layout rules.
|
|
return (
|
|
<ChatEmptyState
|
|
hasSessions={hasSessions}
|
|
agentName={agentName}
|
|
onPickPrompt={onPickPrompt}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Show the live trace + status line until the persisted assistant
|
|
// message lands. Once chat:done writes the assistant row, AssistantRow's
|
|
// own timeline (read from the same cache entry) owns the render — no
|
|
// double-rendering.
|
|
const pendingTaskId = pendingTask?.task_id ?? null;
|
|
const pendingAlreadyPersisted =
|
|
!!pendingTaskId &&
|
|
messages.some(
|
|
(m) => m.role === "assistant" && m.task_id === pendingTaskId,
|
|
);
|
|
const showLiveSection = !!pendingTaskId && !pendingAlreadyPersisted;
|
|
const showLiveTimeline =
|
|
showLiveSection && (liveTaskMessages?.length ?? 0) > 0;
|
|
|
|
return (
|
|
// Outer Pressable owns the "tap anywhere outside the selected bubble
|
|
// to exit text-selection mode" gesture. Disabled when no message is
|
|
// selected, so it's a layout-only wrapper and every tap passes straight
|
|
// through to the FlashList cells. Active state captures any tap that
|
|
// didn't fire an inner Pressable — bubble cells in selecting mode
|
|
// render their body without a Pressable wrapper (see `MessageRow`'s
|
|
// `if (isSelecting) return body;`), so taps on the selected bubble
|
|
// also dismiss, matching iOS Notes / iMessage behaviour. Scroll
|
|
// gestures are unaffected (Pressable only intercepts non-drag taps).
|
|
<Pressable
|
|
onPress={
|
|
selectingId
|
|
? () => useChatSelectStore.getState().clear()
|
|
: undefined
|
|
}
|
|
disabled={!selectingId}
|
|
style={{ flex: 1 }}
|
|
>
|
|
{/* `key` on first message id forces remount on session switch so
|
|
`startRenderingFromBottom` re-fires and we land at the new
|
|
session's bottom (instead of inheriting the previous session's
|
|
scroll position). Cheap because sessions are switched, not
|
|
re-rendered every keystroke. */}
|
|
<FlashList
|
|
key={messages[0]?.id ?? "empty"}
|
|
data={messages}
|
|
keyExtractor={(m) => m.id}
|
|
renderItem={({ item }) => <MessageRow message={item} />}
|
|
ItemSeparatorComponent={MessageSeparator}
|
|
ListFooterComponent={
|
|
showLiveSection ? (
|
|
<View style={{ paddingTop: 12 }} className="gap-2">
|
|
{showLiveTimeline ? (
|
|
<ChatTimeline items={liveTaskMessages ?? []} isStreaming />
|
|
) : null}
|
|
<StatusPill
|
|
pendingTask={pendingTask}
|
|
taskMessages={liveTaskMessages}
|
|
availability={availability}
|
|
/>
|
|
</View>
|
|
) : null
|
|
}
|
|
// Outer padding mirrors web's max-w-4xl px-5 py-4 container at
|
|
// mobile scale. Vertical gap between bubbles handled by
|
|
// ItemSeparatorComponent (FlashList doesn't honour `gap-*` on
|
|
// contentContainer the way FlatList's gap-via-NativeWind did).
|
|
contentContainerStyle={{
|
|
paddingHorizontal: 16,
|
|
paddingTop: 12,
|
|
paddingBottom: 16,
|
|
}}
|
|
// Chat behavior: initial render at the bottom; when new messages
|
|
// arrive AND the user is within 20% of the bottom, auto-scroll.
|
|
// Reading history (further than 20% up) is preserved. This single
|
|
// prop replaces the entire FlatList-era guard ref dance.
|
|
maintainVisibleContentPosition={{
|
|
autoscrollToBottomThreshold: 0.2,
|
|
startRenderingFromBottom: true,
|
|
}}
|
|
// Any user-initiated scroll exits message text-selection mode —
|
|
// matches iMessage's behavior where scrolling implicitly commits /
|
|
// dismisses the selection caret. Hooks both drag-start and the
|
|
// momentum kick after a flick so a fast scroll can't escape.
|
|
onScrollBeginDrag={() => useChatSelectStore.getState().clear()}
|
|
onMomentumScrollBegin={() => useChatSelectStore.getState().clear()}
|
|
// iMessage-style keyboard dismissal: dragging the list pulls the
|
|
// keyboard down with the finger (iOS); tapping empty space between
|
|
// bubbles dismisses it. `handled` keeps Pressables inside bubbles
|
|
// (long-press action sheet etc.) firing normally.
|
|
keyboardDismissMode="interactive"
|
|
keyboardShouldPersistTaps="handled"
|
|
/>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function MessageSeparator() {
|
|
return <View style={{ height: 12 }} />;
|
|
}
|
|
|
|
function MessageRow({ message }: { message: ChatMessage }) {
|
|
const isUser = message.role === "user";
|
|
const isFailure = !!message.failure_reason;
|
|
const isSelecting = useChatSelectStore(
|
|
(s) => s.selectingId === message.id,
|
|
);
|
|
const longPress = useChatMessageLongPress(message);
|
|
|
|
if (isFailure) {
|
|
return (
|
|
<FailureBubble
|
|
reasonLabel={failureReasonLabel(message.failure_reason)}
|
|
rawError={message.content}
|
|
elapsedMs={message.elapsed_ms ?? null}
|
|
isSelecting={isSelecting}
|
|
longPress={longPress}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (isUser) {
|
|
// User bubble: same Markdown pipeline as assistant — `@mention`
|
|
// serialisation `[MUL-1](mention://issue/<id>)`, inline links, and
|
|
// inline code resolve identically to web's
|
|
// `packages/views/chat/components/chat-message-list.tsx` user branch.
|
|
// Width is capped at 80% so the bubble keeps the iMessage-style
|
|
// trailing alignment instead of stretching across the column.
|
|
const body = (
|
|
<View
|
|
className={cn(
|
|
"self-end max-w-[80%] gap-1.5 rounded-2xl border-2 px-3.5 py-2 transition-colors",
|
|
isSelecting
|
|
? "bg-primary/5 border-primary/30"
|
|
: longPress.isPressed
|
|
? "bg-muted border-primary/30"
|
|
: "bg-muted border-transparent",
|
|
)}
|
|
>
|
|
<Markdown
|
|
content={message.content}
|
|
attachments={message.attachments}
|
|
selectable={isSelecting}
|
|
compact
|
|
/>
|
|
<CommentAttachmentList
|
|
attachments={message.attachments}
|
|
content={message.content}
|
|
/>
|
|
</View>
|
|
);
|
|
if (isSelecting) return body;
|
|
return (
|
|
<Pressable
|
|
onLongPress={longPress.onLongPress}
|
|
delayLongPress={500}
|
|
>
|
|
{body}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
// Assistant: timeline fold + markdown + elapsed caption. See
|
|
// AssistantRow for why timeline is lifted into its own component.
|
|
return (
|
|
<AssistantRow
|
|
message={message}
|
|
isSelecting={isSelecting}
|
|
longPress={longPress}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Persisted assistant message. Renders:
|
|
*
|
|
* - Process-steps fold (from `task-messages` cache; same cache fed by
|
|
* the live timeline above, so completed runs keep their trace).
|
|
* - Markdown content (the model's final answer).
|
|
* - "Replied in Ns" caption when `elapsed_ms` is stamped.
|
|
*
|
|
* Web's equivalent is `AssistantMessage` in packages/views/chat/components/
|
|
* chat-message-list.tsx — same shape, simplified for RN (no inner Tooltip
|
|
* / Copy button — long-press already exposes Copy via the native action
|
|
* sheet, and selection mode owns the highlight, so a hover-only Copy
|
|
* affordance would be redundant on mobile).
|
|
*/
|
|
function AssistantRow({
|
|
message,
|
|
isSelecting,
|
|
longPress,
|
|
}: {
|
|
message: ChatMessage;
|
|
isSelecting: boolean;
|
|
longPress: ReturnType<typeof useChatMessageLongPress>;
|
|
}) {
|
|
// Read the cached timeline if any. `enabled` (in taskMessagesOptions) is
|
|
// gated on isTaskMessageTaskId — optimistic id prefixes never fetch, so
|
|
// freshly-sent messages don't spam the API while waiting for the real
|
|
// task_id to land. Cached cells (after live timeline finished) return
|
|
// synchronously with no network roundtrip.
|
|
const { data: timeline = [] } = useQuery(
|
|
taskMessagesOptions(message.task_id),
|
|
);
|
|
// no_response (MUL-4351, mirrors packages/views AssistantMessage): the agent
|
|
// completed this turn without text. Keep the tool timeline and show a notice
|
|
// instead of an empty Markdown block; caption reads "Finished in" not
|
|
// "Replied in".
|
|
const isNoResponse = message.message_kind === "no_response";
|
|
const body = (
|
|
<View className="gap-1.5">
|
|
{timeline.length > 0 ? (
|
|
<ChatTimeline items={timeline} />
|
|
) : null}
|
|
{isNoResponse ? (
|
|
<Text className="text-sm italic text-muted-foreground">
|
|
The agent finished this turn without a text reply.
|
|
</Text>
|
|
) : (
|
|
<Markdown
|
|
content={message.content}
|
|
attachments={message.attachments}
|
|
selectable={isSelecting}
|
|
/>
|
|
)}
|
|
{/* Standalone attachment cards for anything not referenced inline. An
|
|
image-only reply is a real ('message') outcome with empty content, so
|
|
it flows through the else-branch above (renders nothing) and the cards
|
|
here ARE the reply. */}
|
|
<CommentAttachmentList
|
|
attachments={message.attachments}
|
|
content={message.content}
|
|
/>
|
|
{message.elapsed_ms != null ? (
|
|
<ElapsedCaption
|
|
variant={isNoResponse ? "finished" : "replied"}
|
|
elapsedMs={message.elapsed_ms}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
);
|
|
if (isSelecting) return body;
|
|
return (
|
|
<Pressable onLongPress={longPress.onLongPress} delayLongPress={500}>
|
|
{body}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
// Persistent caption rendered under the assistant bubble / failure bubble
|
|
// once the server has written `elapsed_ms`. Server computes once at task
|
|
// completion, so this caption is identical across reloads and clients.
|
|
function ElapsedCaption({
|
|
variant,
|
|
elapsedMs,
|
|
}: {
|
|
variant: "replied" | "failed" | "finished";
|
|
elapsedMs: number;
|
|
}) {
|
|
const label =
|
|
variant === "replied"
|
|
? `Replied in ${formatElapsedMs(elapsedMs)}`
|
|
: variant === "finished"
|
|
? `Finished in ${formatElapsedMs(elapsedMs)}`
|
|
: `Failed after ${formatElapsedMs(elapsedMs)}`;
|
|
return (
|
|
<Text className="text-xs text-muted-foreground/80 mt-1">{label}</Text>
|
|
);
|
|
}
|
|
|
|
function FailureBubble({
|
|
reasonLabel,
|
|
rawError,
|
|
elapsedMs,
|
|
isSelecting,
|
|
longPress,
|
|
}: {
|
|
reasonLabel: string;
|
|
rawError: string;
|
|
elapsedMs: number | null;
|
|
isSelecting: boolean;
|
|
longPress: ReturnType<typeof useChatMessageLongPress>;
|
|
}) {
|
|
const hasRawError = rawError.trim().length > 0;
|
|
|
|
// B6: pass `selectable={isSelecting}` rather than hard-coding
|
|
// `selectable` — otherwise UIKit's text-selection gesture pre-empts
|
|
// our long-press handler and the action sheet never fires. Select-mode
|
|
// cue is the border-tint to primary; bg stays destructive so the
|
|
// failure signal is never lost.
|
|
const body = (
|
|
<View className="self-start max-w-[80%]">
|
|
<View
|
|
className={cn(
|
|
"rounded-2xl border-2 bg-destructive/10 px-3.5 py-2 transition-colors",
|
|
isSelecting || longPress.isPressed
|
|
? "border-primary/30"
|
|
: "border-destructive/30",
|
|
)}
|
|
>
|
|
<Text className="text-xs font-semibold text-destructive">
|
|
{reasonLabel}
|
|
</Text>
|
|
{hasRawError ? (
|
|
<Collapsible>
|
|
<CollapsibleTrigger asChild>
|
|
<View
|
|
accessibilityRole="button"
|
|
accessibilityLabel="Show error details"
|
|
className="mt-1 flex-row items-center gap-1 active:opacity-70"
|
|
>
|
|
<Ionicons
|
|
name="chevron-forward"
|
|
size={12}
|
|
color="#71717a"
|
|
/>
|
|
<Text className="text-xs text-muted-foreground">
|
|
Show details
|
|
</Text>
|
|
</View>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent>
|
|
<View className="mt-1 rounded bg-muted/40 px-2 py-1.5">
|
|
<Text
|
|
className="text-xs text-muted-foreground"
|
|
selectable={isSelecting}
|
|
>
|
|
{rawError}
|
|
</Text>
|
|
</View>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
) : null}
|
|
</View>
|
|
{elapsedMs != null ? (
|
|
<ElapsedCaption variant="failed" elapsedMs={elapsedMs} />
|
|
) : null}
|
|
</View>
|
|
);
|
|
if (isSelecting) return body;
|
|
return (
|
|
<Pressable onLongPress={longPress.onLongPress} delayLongPress={500}>
|
|
{body}
|
|
</Pressable>
|
|
);
|
|
}
|