diff --git a/packages/web/src/features/chat/attached-files-banner.tsx b/packages/web/src/features/chat/attached-files-banner.tsx
index 909df1b..88a8c53 100644
--- a/packages/web/src/features/chat/attached-files-banner.tsx
+++ b/packages/web/src/features/chat/attached-files-banner.tsx
@@ -3,8 +3,8 @@
* server appends aren't shown verbatim, they collapse into a single line reading
* "Attached files: a.pdf, b.csv" (paperclip icon + static text, no navigation — the files live
* in the session scratchpad and the model opens them by path); the body text around them is
- * rendered as usual by the caller. Same shape as SkillsBanner, so the two notices a message
- * can carry read as one family.
+ * rendered as usual by the caller. It keeps the same visual language as message-level notices;
+ * the caller owns its user-side alignment and timestamp footer.
*/
import { S } from "../../lib/strings";
import { attachmentFileName } from "../../lib/attachments";
@@ -22,7 +22,7 @@ export function AttachedFilesBanner({ files }: { files: string[] }) {
// into a paragraph-tall block above the message. The full list stays reachable as a title.
{label}
diff --git a/packages/web/src/features/chat/message-item.tsx b/packages/web/src/features/chat/message-item.tsx
index 2f3a196..209db82 100644
--- a/packages/web/src/features/chat/message-item.tsx
+++ b/packages/web/src/features/chat/message-item.tsx
@@ -217,10 +217,6 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
<>
{scheduled && }
{skills && }
- {/* Files uploaded with this message: named above the bubble, like the other
- message-level notices — the bytes live in the session scratchpad, the model
- opens them by path (goal mode never gets here: it takes text and images only). */}
- {files.length > 0 && }
{text && (
@@ -236,6 +232,19 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
/>
)}
+ {/* Files uploaded with this message: shown below the text in the same user-side
+ container and with the same timestamp footer as uploaded images. The bytes live in
+ the session scratchpad, where the model opens them by path (goal mode never gets
+ here: it takes text and images only). */}
+ {files.length > 0 && (
+
+ )}
{images.map((src, i) => (
@@ -262,8 +271,15 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
// Images sent with the message live inside the same chip: `item.images` on a vision
// model (delivered as image messages right behind the text), or restored from the
// [attached image: …] path lines without one — the same two shapes a user_text bubble
- // handles, so both render identically here.
- const { text: steerText, images: steerImages } = splitAttachments(item.text);
+ // handles, so both render identically here. Files ride steering only as
+ // [attached file: …] rows (there is no item.files channel) and collapse into the same
+ // banner a full prompt uses, kept inside the chip — a files-only steering would
+ // otherwise render as an empty chip with the filenames lost.
+ const {
+ text: steerText,
+ images: steerImages,
+ files: steerFiles,
+ } = splitAttachments(item.text);
const shown = [...steerImages, ...(item.images ?? [])];
return (
@@ -292,6 +308,11 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
))}
)}
+ {steerFiles.length > 0 && (
+
+ )}