mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-29 06:28:23 +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>
154 lines
5.4 KiB
TypeScript
154 lines
5.4 KiB
TypeScript
/**
|
|
* Standalone attachment list for comment cards.
|
|
*
|
|
* Mirrors the design of web's `AttachmentList` in
|
|
* `packages/views/issues/components/comment-card.tsx:121-159` — renders
|
|
* any attachment whose URL the markdown content didn't already reference,
|
|
* with same-file dedup so a duplicate upload referenced inline doesn't
|
|
* also appear below.
|
|
*
|
|
* The data-contract parity goal: a comment authored on mobile (which has
|
|
* no inline-insert path — see `inline-comment-composer.tsx`) carries its
|
|
* attachments via the `attachments` field only, with no `` in
|
|
* `content`. Web reads it back and `AttachmentList` puts the attachments
|
|
* below the body. Mobile reads it back here and does the same. A comment
|
|
* authored on web with inline images already inside the markdown renders
|
|
* inline on both clients via `MarkdownImage`, and this list returns null
|
|
* because there's nothing "leftover" to show.
|
|
*
|
|
* For v1 we render images via the same `MarkdownImage` used by inline
|
|
* markdown rendering (consistent aspect-ratio + lightbox behavior). Non-
|
|
* image attachments render as a tappable file card showing 📎 + filename
|
|
* + size hint, opening the canonical download URL on tap.
|
|
*/
|
|
import { useMemo } from "react";
|
|
import { Linking, Pressable, View } from "react-native";
|
|
import { Ionicons } from "@expo/vector-icons";
|
|
import type { Attachment } from "@multica/core/types";
|
|
import { standaloneAttachments } from "@/lib/attachment-dedup";
|
|
import { MarkdownImage } from "@/lib/markdown/markdown-image";
|
|
import { resolveAttachmentUrl } from "@/lib/attachment-url";
|
|
import { useColorScheme } from "@/lib/use-color-scheme";
|
|
import { THEME } from "@/lib/theme";
|
|
import { Text } from "@/components/ui/text";
|
|
|
|
interface Props {
|
|
attachments?: Attachment[];
|
|
/** The comment's markdown content. Attachments referenced inside it via
|
|
* `` or `[name](url)` are skipped so they aren't double-rendered.
|
|
* Pass `undefined` (not just an empty string) when the comment has no
|
|
* body — that disables the inline-reference filter and renders all
|
|
* supplied attachments. */
|
|
content?: string;
|
|
}
|
|
|
|
export function CommentAttachmentList({ attachments, content }: Props) {
|
|
const { colorScheme } = useColorScheme();
|
|
const theme = THEME[colorScheme];
|
|
|
|
// Only render attachments not already referenced inline in the body. The
|
|
// dedup lives in a pure helper (lib/attachment-dedup) so it can be unit
|
|
// tested; it matches every real URL form the server emits (stable path /
|
|
// url / download_url / markdown_url), mirroring web's AttachmentList.
|
|
const standalone = useMemo(
|
|
() => standaloneAttachments(attachments, content),
|
|
[attachments, content],
|
|
);
|
|
|
|
if (standalone.length === 0) return null;
|
|
|
|
return (
|
|
<View className="gap-1.5">
|
|
{standalone.map((attachment) => {
|
|
const isImage = attachment.content_type.startsWith("image/");
|
|
if (isImage) {
|
|
return (
|
|
<MarkdownImage
|
|
key={attachment.id}
|
|
uri={attachment.url}
|
|
alt={attachment.filename}
|
|
attachments={attachments}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<FileCard
|
|
key={attachment.id}
|
|
attachment={attachment}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function FileCard({
|
|
attachment,
|
|
theme,
|
|
}: {
|
|
attachment: Attachment;
|
|
theme: typeof THEME["light"];
|
|
}) {
|
|
const sizeLabel = formatBytes(attachment.size_bytes);
|
|
return (
|
|
<Pressable
|
|
onPress={() => {
|
|
// download_url is the canonical link — opening it hands off to
|
|
// Safari which handles auth-token-free download + previewing for
|
|
// common types (PDF, txt). Mirrors what the markdown link renderer
|
|
// does for `[name](url)`.
|
|
//
|
|
// The backend may return a server-relative URL like
|
|
// `/api/attachments/{id}/download` when no CloudFront signer is
|
|
// configured (MUL-2976). RN's `Linking.openURL` requires an
|
|
// absolute http(s) URL — it returns "Cannot open URL" otherwise —
|
|
// so resolve against `EXPO_PUBLIC_API_URL` first.
|
|
const target = resolveAttachmentUrl(attachment.download_url);
|
|
if (target) {
|
|
void Linking.openURL(target);
|
|
}
|
|
}}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={`Open ${attachment.filename}`}
|
|
className="flex-row items-center gap-2 px-3 py-2 rounded-md bg-secondary/60 active:opacity-80"
|
|
>
|
|
<Ionicons
|
|
name="document-outline"
|
|
size={20}
|
|
color={theme.mutedForeground}
|
|
/>
|
|
<View className="flex-1">
|
|
<Text
|
|
className="text-sm text-foreground"
|
|
numberOfLines={1}
|
|
>
|
|
{attachment.filename}
|
|
</Text>
|
|
{sizeLabel ? (
|
|
<Text className="text-xs text-muted-foreground">{sizeLabel}</Text>
|
|
) : null}
|
|
</View>
|
|
<Ionicons
|
|
name="download-outline"
|
|
size={18}
|
|
color={theme.mutedForeground}
|
|
/>
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function formatBytes(bytes: number): string | null {
|
|
if (!bytes || bytes <= 0) return null;
|
|
const units = ["B", "KB", "MB", "GB"];
|
|
let value = bytes;
|
|
let unitIndex = 0;
|
|
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
value /= 1024;
|
|
unitIndex++;
|
|
}
|
|
const formatted =
|
|
value < 10 ? value.toFixed(1) : Math.round(value).toString();
|
|
return `${formatted} ${units[unitIndex]}`;
|
|
}
|