Files
multica/apps/mobile/components/chat/chat-message-list.tsx
Naiyuan Qing a19e60a9e6 feat(chat): support images/files in agent chat replies (MUL-4287) (#5164)
* 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
`![name](url)` 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>
2026-07-13 09:15:36 +08:00

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 `![](url)` / `[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>
);
}