iris-chatbot 5.3.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -137,8 +137,7 @@ button:focus-visible {
137
137
  }
138
138
 
139
139
  .message-card.user .message-content {
140
- display: flex;
141
- align-items: center;
140
+ display: block;
142
141
  min-height: 1.4em;
143
142
  line-height: 1.45;
144
143
  }
@@ -147,6 +146,43 @@ button:focus-visible {
147
146
  margin: 0;
148
147
  }
149
148
 
149
+ .user-message-content {
150
+ display: flex;
151
+ flex-direction: column;
152
+ gap: 8px;
153
+ }
154
+
155
+ .user-attachment-row {
156
+ display: flex;
157
+ flex-wrap: wrap;
158
+ gap: 6px;
159
+ }
160
+
161
+ .user-attachment-pill {
162
+ display: inline-flex;
163
+ align-items: center;
164
+ gap: 6px;
165
+ max-width: 240px;
166
+ border-radius: 999px;
167
+ border: 1px solid rgba(255, 255, 255, 0.34);
168
+ background: rgba(255, 255, 255, 0.15);
169
+ color: #ffffff;
170
+ padding: 4px 9px;
171
+ font-size: 11px;
172
+ line-height: 1;
173
+ }
174
+
175
+ .user-attachment-kind {
176
+ font-weight: 700;
177
+ opacity: 0.9;
178
+ }
179
+
180
+ .user-attachment-title {
181
+ overflow: hidden;
182
+ text-overflow: ellipsis;
183
+ white-space: nowrap;
184
+ }
185
+
150
186
  .message-row.user .message-content> :first-child,
151
187
  .message-row.user .message-content> :last-child {
152
188
  margin-top: 0;
@@ -917,7 +953,7 @@ button:focus-visible {
917
953
  line-height: 1.4;
918
954
  padding-top: 7px;
919
955
  padding-bottom: 9px;
920
- padding-left: 6px;
956
+ padding-left: 0;
921
957
  /* One line by default; height grows with content in Composer.tsx */
922
958
  min-height: calc(1.4em + 7px + 9px);
923
959
  }
@@ -956,6 +992,23 @@ button:focus-visible {
956
992
  border-color: transparent;
957
993
  }
958
994
 
995
+ .send-button:disabled {
996
+ opacity: 0.65;
997
+ cursor: not-allowed;
998
+ }
999
+
1000
+ .composer-attach-button {
1001
+ background: transparent;
1002
+ border-color: transparent;
1003
+ color: var(--text-secondary);
1004
+ }
1005
+
1006
+ .composer-attach-button:hover {
1007
+ background: transparent;
1008
+ color: var(--text-primary);
1009
+ border-color: transparent;
1010
+ }
1011
+
959
1012
  .mic-button {
960
1013
  background: transparent;
961
1014
  color: var(--text-secondary);
@@ -1223,6 +1276,55 @@ button:focus-visible {
1223
1276
  gap: 0;
1224
1277
  }
1225
1278
 
1279
+ .composer-attachments {
1280
+ display: flex;
1281
+ flex-wrap: wrap;
1282
+ gap: 8px;
1283
+ padding: 0 2px 10px;
1284
+ }
1285
+
1286
+ .composer-attachment-chip {
1287
+ display: inline-flex;
1288
+ align-items: center;
1289
+ gap: 6px;
1290
+ max-width: 260px;
1291
+ border: 1px solid var(--border);
1292
+ background: var(--panel-2);
1293
+ border-radius: 999px;
1294
+ padding: 4px 8px;
1295
+ font-size: 12px;
1296
+ color: var(--text-secondary);
1297
+ }
1298
+
1299
+ .composer-attachment-icon {
1300
+ display: inline-flex;
1301
+ align-items: center;
1302
+ justify-content: center;
1303
+ color: var(--text-muted);
1304
+ }
1305
+
1306
+ .composer-attachment-name {
1307
+ overflow: hidden;
1308
+ text-overflow: ellipsis;
1309
+ white-space: nowrap;
1310
+ }
1311
+
1312
+ .composer-attachment-remove {
1313
+ display: inline-flex;
1314
+ align-items: center;
1315
+ justify-content: center;
1316
+ border: none;
1317
+ background: transparent;
1318
+ color: var(--text-muted);
1319
+ width: 16px;
1320
+ height: 16px;
1321
+ border-radius: 999px;
1322
+ }
1323
+
1324
+ .composer-attachment-remove:hover {
1325
+ color: var(--text-primary);
1326
+ }
1327
+
1226
1328
  .quoted-context-container {
1227
1329
  display: flex;
1228
1330
  align-items: flex-start;
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react";
4
+ import { nanoid } from "nanoid";
4
5
  import type {
6
+ ChatAttachment,
5
7
  ChatCitationSource,
6
8
  ModelConnection,
7
9
  ChatStreamChunk,
@@ -17,6 +19,12 @@ import {
17
19
  createThreadFromMessage,
18
20
  deleteThread as deleteThreadById,
19
21
  } from "../lib/data";
22
+ import {
23
+ getAttachmentProviderForConnection,
24
+ MAX_ATTACHMENTS_PER_MESSAGE,
25
+ normalizeChatAttachments,
26
+ validateAttachmentCandidate,
27
+ } from "../lib/attachments";
20
28
  import { buildPath, embedSourcesInContent, isLikelyGreeting, splitContentAndSources } from "../lib/utils";
21
29
  import { db } from "../lib/db";
22
30
  import {
@@ -355,6 +363,11 @@ function isLikelyDraftEditFollowup(input: string): boolean {
355
363
 
356
364
  type MicMode = "off" | "dictation";
357
365
  type MicState = "idle" | "listening" | "processing";
366
+ type PendingAttachment = {
367
+ id: string;
368
+ file: File;
369
+ kind: ChatAttachment["kind"];
370
+ };
358
371
 
359
372
  export default function ChatView({
360
373
  thread,
@@ -389,6 +402,8 @@ export default function ChatView({
389
402
  const [showJumpToBottom, setShowJumpToBottom] = useState(false);
390
403
  const [micMode, setMicMode] = useState<MicMode>("off");
391
404
  const [micState, setMicState] = useState<MicState>("idle");
405
+ const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
406
+ const [isUploadingAttachments, setIsUploadingAttachments] = useState(false);
392
407
  const [quotedContext, setQuotedContext] = useState<string | null>(null);
393
408
  const [askButtonPos, setAskButtonPos] = useState<{ top: number; left: number } | null>(null);
394
409
  const [pendingSelection, setPendingSelection] = useState<string | null>(null);
@@ -1060,6 +1075,78 @@ export default function ChatView({
1060
1075
  setQuotedContext(null);
1061
1076
  }, []);
1062
1077
 
1078
+ const pendingAttachmentChips = useMemo(
1079
+ () =>
1080
+ pendingAttachments.map((attachment) => ({
1081
+ id: attachment.id,
1082
+ name: attachment.file.name,
1083
+ kind: attachment.kind,
1084
+ })),
1085
+ [pendingAttachments],
1086
+ );
1087
+
1088
+ const removePendingAttachment = useCallback((attachmentId: string) => {
1089
+ setPendingAttachments((current) =>
1090
+ current.filter((attachment) => attachment.id !== attachmentId),
1091
+ );
1092
+ }, []);
1093
+
1094
+ const attachFilesToComposer = useCallback((files: File[]) => {
1095
+ if (files.length === 0) {
1096
+ return;
1097
+ }
1098
+
1099
+ const nextAttachments = [...pendingAttachments];
1100
+ const dedupe = new Set(
1101
+ nextAttachments.map(
1102
+ (attachment) =>
1103
+ `${attachment.file.name}:${attachment.file.size}:${attachment.file.lastModified}`,
1104
+ ),
1105
+ );
1106
+ let nextError: string | null = null;
1107
+ let addedAny = false;
1108
+ for (const file of files) {
1109
+ if (nextAttachments.length >= MAX_ATTACHMENTS_PER_MESSAGE) {
1110
+ nextError = `You can upload up to ${MAX_ATTACHMENTS_PER_MESSAGE} files per message.`;
1111
+ break;
1112
+ }
1113
+
1114
+ const validation = validateAttachmentCandidate({
1115
+ name: file.name,
1116
+ mimeType: file.type,
1117
+ sizeBytes: file.size,
1118
+ });
1119
+ if (!validation.ok) {
1120
+ nextError = nextError ?? validation.error;
1121
+ continue;
1122
+ }
1123
+
1124
+ const key = `${file.name}:${file.size}:${file.lastModified}`;
1125
+ if (dedupe.has(key)) {
1126
+ continue;
1127
+ }
1128
+ dedupe.add(key);
1129
+ nextAttachments.push({
1130
+ id: nanoid(),
1131
+ file,
1132
+ kind: validation.kind,
1133
+ });
1134
+ addedAny = true;
1135
+ }
1136
+
1137
+ if (addedAny) {
1138
+ setPendingAttachments(nextAttachments);
1139
+ }
1140
+
1141
+ if (nextError) {
1142
+ setError(nextError);
1143
+ return;
1144
+ }
1145
+ if (addedAny) {
1146
+ setError(null);
1147
+ }
1148
+ }, [pendingAttachments]);
1149
+
1063
1150
  useEffect(() => {
1064
1151
  const onMouseUp = () => {
1065
1152
  // Small delay so the selection is finalized
@@ -1094,7 +1181,10 @@ export default function ChatView({
1094
1181
  if (!resolvedThread) return;
1095
1182
  const trimmed = (overrideInput ?? input).trim();
1096
1183
  const hasQuoted = Boolean(quotedContext);
1097
- if (!trimmed && !hasQuoted) return;
1184
+ const pendingComposerAttachments = pendingAttachments;
1185
+ const hasPendingAttachments = pendingComposerAttachments.length > 0;
1186
+ if (!trimmed && !hasQuoted && !hasPendingAttachments) return;
1187
+ if (isUploadingAttachments) return;
1098
1188
  let draftEditContextForRequest: PendingDraftContext | null = null;
1099
1189
  const sendTriggeredByMic =
1100
1190
  typeof overrideInput === "string" || micModeRef.current !== "off";
@@ -1131,6 +1221,34 @@ export default function ChatView({
1131
1221
  return;
1132
1222
  }
1133
1223
 
1224
+ const connectionAttachmentProvider = getAttachmentProviderForConnection(connectionPayload);
1225
+ const currentPathMessages = buildPath(resolvedThread.headMessageId, messageMap);
1226
+ const existingAttachmentProviders = new Set<ChatAttachment["provider"]>();
1227
+ for (const message of currentPathMessages) {
1228
+ const attachments = normalizeChatAttachments(message.attachments);
1229
+ for (const attachment of attachments) {
1230
+ existingAttachmentProviders.add(attachment.provider);
1231
+ }
1232
+ }
1233
+ if (existingAttachmentProviders.size > 1) {
1234
+ setError("This thread has mixed attachment providers. Start a new chat to continue.");
1235
+ stopMicAfterValidationFailure();
1236
+ return;
1237
+ }
1238
+ const existingProvider = [...existingAttachmentProviders][0] ?? null;
1239
+ if (existingProvider && connectionAttachmentProvider !== existingProvider) {
1240
+ setError(
1241
+ `This thread has ${existingProvider} attachments. Switch back to that provider or start a new chat.`,
1242
+ );
1243
+ stopMicAfterValidationFailure();
1244
+ return;
1245
+ }
1246
+ if (hasPendingAttachments && !connectionAttachmentProvider) {
1247
+ setError("Attachments are only supported on OpenAI/OpenAI-compatible and Anthropic connections.");
1248
+ stopMicAfterValidationFailure();
1249
+ return;
1250
+ }
1251
+
1134
1252
  if (!model) {
1135
1253
  setError("Select a model before sending a message.");
1136
1254
  stopMicAfterValidationFailure();
@@ -1187,6 +1305,50 @@ export default function ChatView({
1187
1305
  return;
1188
1306
  }
1189
1307
 
1308
+ let uploadedAttachments: ChatAttachment[] = [];
1309
+ if (pendingComposerAttachments.length > 0) {
1310
+ setIsUploadingAttachments(true);
1311
+ try {
1312
+ const formData = new FormData();
1313
+ formData.set("connection", JSON.stringify(connectionPayload));
1314
+ for (const attachment of pendingComposerAttachments) {
1315
+ formData.append("files", attachment.file, attachment.file.name);
1316
+ }
1317
+
1318
+ const uploadResponse = await fetch("/api/chat/uploads", {
1319
+ method: "POST",
1320
+ body: formData,
1321
+ });
1322
+ const uploadPayload = (await uploadResponse.json().catch(() => ({}))) as {
1323
+ error?: string;
1324
+ attachments?: unknown;
1325
+ };
1326
+ if (!uploadResponse.ok) {
1327
+ throw new Error(uploadPayload.error || "Failed to upload attachments.");
1328
+ }
1329
+ uploadedAttachments = normalizeChatAttachments(uploadPayload.attachments);
1330
+ if (uploadedAttachments.length !== pendingComposerAttachments.length) {
1331
+ throw new Error("One or more attachments were rejected during upload.");
1332
+ }
1333
+ if (
1334
+ connectionAttachmentProvider &&
1335
+ uploadedAttachments.some(
1336
+ (attachment) => attachment.provider !== connectionAttachmentProvider,
1337
+ )
1338
+ ) {
1339
+ throw new Error("Uploaded attachment provider does not match the selected connection.");
1340
+ }
1341
+ } catch (uploadError) {
1342
+ const uploadMessage =
1343
+ uploadError instanceof Error ? uploadError.message : "Failed to upload attachments.";
1344
+ setError(uploadMessage);
1345
+ stopMicAfterValidationFailure();
1346
+ return;
1347
+ } finally {
1348
+ setIsUploadingAttachments(false);
1349
+ }
1350
+ }
1351
+
1190
1352
  inflightThreadIdsRef.current.set(
1191
1353
  resolvedThread.id,
1192
1354
  (inflightThreadIdsRef.current.get(resolvedThread.id) ?? 0) + 1,
@@ -1214,12 +1376,21 @@ export default function ChatView({
1214
1376
  const { userMessage, assistantMessage } = await appendUserAndAssistant({
1215
1377
  thread: resolvedThread,
1216
1378
  content: contentToSend,
1379
+ attachments: uploadedAttachments,
1217
1380
  provider: connection.kind === "builtin" ? connection.provider ?? connection.id : connection.id,
1218
1381
  model,
1219
1382
  });
1383
+ if (pendingComposerAttachments.length > 0) {
1384
+ const sentAttachmentIds = new Set(
1385
+ pendingComposerAttachments.map((attachment) => attachment.id),
1386
+ );
1387
+ setPendingAttachments((current) =>
1388
+ current.filter((attachment) => !sentAttachmentIds.has(attachment.id)),
1389
+ );
1390
+ }
1220
1391
 
1221
1392
  if (isFirstMessage) {
1222
- if (isLikelyGreeting(trimmed)) {
1393
+ if (!contentToSend.trim() || isLikelyGreeting(trimmed)) {
1223
1394
  await db.threads.update(resolvedThread.id, { title: "New chat" });
1224
1395
  } else {
1225
1396
  void (async () => {
@@ -1292,7 +1463,11 @@ export default function ChatView({
1292
1463
  map.set(userMessage.id, userMessage);
1293
1464
  map.set(assistantMessage.id, assistantMessage);
1294
1465
  const history = buildPath(userMessage.id, map);
1295
- const payloadMessages: Array<{ role: "system" | "user" | "assistant"; content: string }> = [];
1466
+ const payloadMessages: Array<{
1467
+ role: "system" | "user" | "assistant";
1468
+ content: string;
1469
+ attachments?: ChatAttachment[];
1470
+ }> = [];
1296
1471
  let memoryPayload: MemoryContextPayload | undefined;
1297
1472
 
1298
1473
  if (memoryEnabled) {
@@ -1379,6 +1554,15 @@ export default function ChatView({
1379
1554
  }
1380
1555
  }
1381
1556
 
1557
+ const attachments =
1558
+ message.role === "user"
1559
+ ? normalizeChatAttachments(message.attachments)
1560
+ : [];
1561
+
1562
+ if (attachments.length > 0) {
1563
+ return { role: message.role, content, attachments };
1564
+ }
1565
+
1382
1566
  return { role: message.role, content };
1383
1567
  }),
1384
1568
  );
@@ -1776,6 +1960,8 @@ export default function ChatView({
1776
1960
  startMicRecognitionSession,
1777
1961
  stopMicMode,
1778
1962
  quotedContext,
1963
+ pendingAttachments,
1964
+ isUploadingAttachments,
1779
1965
  messageMap,
1780
1966
  toolEventsByMessage,
1781
1967
  ]);
@@ -1784,6 +1970,11 @@ export default function ChatView({
1784
1970
  handleSendRef.current = handleSend;
1785
1971
  }, [handleSend]);
1786
1972
 
1973
+ useEffect(() => {
1974
+ setPendingAttachments([]);
1975
+ setIsUploadingAttachments(false);
1976
+ }, [thread?.id]);
1977
+
1787
1978
  useEffect(() => {
1788
1979
  const pendingThreadId = pendingSendScrollThreadIdRef.current;
1789
1980
  if (!pendingThreadId || pendingThreadId !== thread?.id) {
@@ -1910,9 +2101,16 @@ export default function ChatView({
1910
2101
  showStopWhenStreaming={!isAwaitingDraftApproval}
1911
2102
  onMicToggle={handleMicToggle}
1912
2103
  micState={micState}
1913
- micDisabled={Boolean(activeStreamingMessageId) && !isAwaitingDraftApproval}
2104
+ micDisabled={
2105
+ (Boolean(activeStreamingMessageId) && !isAwaitingDraftApproval) ||
2106
+ isUploadingAttachments
2107
+ }
1914
2108
  quotedContext={quotedContext}
1915
2109
  onClearQuotedContext={handleClearQuotedContext}
2110
+ pendingAttachments={pendingAttachmentChips}
2111
+ onAttachFiles={attachFilesToComposer}
2112
+ onRemovePendingAttachment={removePendingAttachment}
2113
+ isUploadingAttachments={isUploadingAttachments}
1916
2114
  />
1917
2115
  </div>
1918
2116
  </div>
@@ -1976,9 +2174,16 @@ export default function ChatView({
1976
2174
  showStopWhenStreaming={!isAwaitingDraftApproval}
1977
2175
  onMicToggle={handleMicToggle}
1978
2176
  micState={micState}
1979
- micDisabled={Boolean(activeStreamingMessageId) && !isAwaitingDraftApproval}
2177
+ micDisabled={
2178
+ (Boolean(activeStreamingMessageId) && !isAwaitingDraftApproval) ||
2179
+ isUploadingAttachments
2180
+ }
1980
2181
  quotedContext={quotedContext}
1981
2182
  onClearQuotedContext={handleClearQuotedContext}
2183
+ pendingAttachments={pendingAttachmentChips}
2184
+ onAttachFiles={attachFilesToComposer}
2185
+ onRemovePendingAttachment={removePendingAttachment}
2186
+ isUploadingAttachments={isUploadingAttachments}
1982
2187
  />
1983
2188
  </div>
1984
2189
  </div>
@@ -1,7 +1,20 @@
1
1
  "use client";
2
2
 
3
3
  import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
4
- import { ArrowUp, CornerDownRight, Loader2, Mic, Square, X } from "lucide-react";
4
+ import {
5
+ ArrowUp,
6
+ CornerDownRight,
7
+ File as FileIcon,
8
+ FileText,
9
+ Image as ImageIcon,
10
+ Loader2,
11
+ Mic,
12
+ Plus,
13
+ Square,
14
+ X,
15
+ } from "lucide-react";
16
+ import { COMPOSER_FILE_ACCEPT } from "../lib/attachments";
17
+ import type { ChatAttachment } from "../lib/types";
5
18
 
6
19
  function normalizePastedText(input: string): string {
7
20
  return input
@@ -24,6 +37,10 @@ export default function Composer({
24
37
  micDisabled = false,
25
38
  quotedContext = null,
26
39
  onClearQuotedContext,
40
+ pendingAttachments = [],
41
+ onAttachFiles,
42
+ onRemovePendingAttachment,
43
+ isUploadingAttachments = false,
27
44
  }: {
28
45
  value: string;
29
46
  onChange: (value: string) => void;
@@ -36,11 +53,22 @@ export default function Composer({
36
53
  micDisabled?: boolean;
37
54
  quotedContext?: string | null;
38
55
  onClearQuotedContext?: () => void;
56
+ pendingAttachments?: Array<{
57
+ id: string;
58
+ name: string;
59
+ kind: ChatAttachment["kind"];
60
+ }>;
61
+ onAttachFiles?: (files: File[]) => void;
62
+ onRemovePendingAttachment?: (id: string) => void;
63
+ isUploadingAttachments?: boolean;
39
64
  }) {
40
65
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
66
+ const fileInputRef = useRef<HTMLInputElement | null>(null);
41
67
  const minRowsRef = useRef(1);
42
68
  const maxRowsRef = useRef(8);
43
69
  const hasText = value.trim().length > 0;
70
+ const hasPendingAttachments = pendingAttachments.length > 0;
71
+ const canSend = hasText || Boolean(quotedContext) || hasPendingAttachments;
44
72
  const micButtonTitle =
45
73
  micState === "listening"
46
74
  ? "Stop dictation"
@@ -140,7 +168,68 @@ export default function Composer({
140
168
  </button>
141
169
  </div>
142
170
  ) : null}
171
+ {pendingAttachments.length > 0 ? (
172
+ <div className="composer-attachments">
173
+ {pendingAttachments.map((attachment) => {
174
+ const icon =
175
+ attachment.kind === "image" ? (
176
+ <ImageIcon className="h-3.5 w-3.5" />
177
+ ) : attachment.kind === "pdf" ? (
178
+ <FileText className="h-3.5 w-3.5" />
179
+ ) : (
180
+ <FileIcon className="h-3.5 w-3.5" />
181
+ );
182
+ return (
183
+ <span key={attachment.id} className="composer-attachment-chip">
184
+ <span className="composer-attachment-icon">{icon}</span>
185
+ <span className="composer-attachment-name">{attachment.name}</span>
186
+ <button
187
+ type="button"
188
+ className="composer-attachment-remove"
189
+ onClick={() => onRemovePendingAttachment?.(attachment.id)}
190
+ aria-label={`Remove ${attachment.name}`}
191
+ >
192
+ <X className="h-3 w-3" />
193
+ </button>
194
+ </span>
195
+ );
196
+ })}
197
+ </div>
198
+ ) : null}
143
199
  <div className="composer flex items-end gap-3">
200
+ {onAttachFiles ? (
201
+ <>
202
+ <input
203
+ ref={fileInputRef}
204
+ type="file"
205
+ accept={COMPOSER_FILE_ACCEPT}
206
+ multiple
207
+ className="hidden"
208
+ onChange={(event) => {
209
+ const selectedFiles = event.currentTarget.files;
210
+ if (!selectedFiles || selectedFiles.length === 0) {
211
+ return;
212
+ }
213
+ onAttachFiles([...selectedFiles]);
214
+ event.currentTarget.value = "";
215
+ }}
216
+ />
217
+ <button
218
+ type="button"
219
+ className="send-button composer-attach-button shrink-0"
220
+ title={isUploadingAttachments ? "Uploading attachments" : "Add files"}
221
+ aria-label={isUploadingAttachments ? "Uploading attachments" : "Add files"}
222
+ disabled={isStreaming || isUploadingAttachments}
223
+ onClick={() => fileInputRef.current?.click()}
224
+ >
225
+ {isUploadingAttachments ? (
226
+ <Loader2 className="h-4 w-4 animate-spin" />
227
+ ) : (
228
+ <Plus className="h-4 w-4" />
229
+ )}
230
+ </button>
231
+ </>
232
+ ) : null}
144
233
  <textarea
145
234
  ref={textareaRef}
146
235
  rows={1}
@@ -159,7 +248,7 @@ export default function Composer({
159
248
  onKeyDown={(event) => {
160
249
  if (event.key === "Enter" && !event.shiftKey) {
161
250
  event.preventDefault();
162
- if (!isStreaming) onSend();
251
+ if (!isStreaming && !isUploadingAttachments) onSend();
163
252
  }
164
253
  }}
165
254
  />
@@ -191,7 +280,8 @@ export default function Composer({
191
280
  ) : (
192
281
  <button
193
282
  onClick={onSend}
194
- className={`send-button shrink-0 ${hasText || quotedContext ? "active" : ""}`}
283
+ className={`send-button shrink-0 ${canSend ? "active" : ""}`}
284
+ disabled={isUploadingAttachments}
195
285
  >
196
286
  <ArrowUp className="h-4 w-4" />
197
287
  </button>
@@ -22,6 +22,7 @@ import type {
22
22
  ToolApproval,
23
23
  ToolEvent,
24
24
  } from "../lib/types";
25
+ import { normalizeChatAttachments } from "../lib/attachments";
25
26
  import { splitContentAndSources } from "../lib/utils";
26
27
 
27
28
  const MAX_VISIBLE_TOOL_ITEMS = 8;
@@ -1279,6 +1280,10 @@ function MessageCard({
1279
1280
  () => splitContentAndSources(message.content || ""),
1280
1281
  [message.content],
1281
1282
  );
1283
+ const userAttachments = useMemo(
1284
+ () => (message.role === "user" ? normalizeChatAttachments(message.attachments) : []),
1285
+ [message.role, message.attachments],
1286
+ );
1282
1287
  const sourcesOrderedByCitation = useMemo(
1283
1288
  () => reorderSourcesByCitationOrder(messageTextContent, messageSources),
1284
1289
  [messageTextContent, messageSources],
@@ -1415,7 +1420,29 @@ function MessageCard({
1415
1420
  </ReactMarkdown>
1416
1421
  )
1417
1422
  ) : (
1418
- <p>{messageTextContent}</p>
1423
+ <div className="user-message-content">
1424
+ {userAttachments.length > 0 ? (
1425
+ <div className="user-attachment-row" aria-label="Attachments">
1426
+ {userAttachments.map((attachment, index) => (
1427
+ <span
1428
+ key={`${attachment.providerFileId}-${index}`}
1429
+ className="user-attachment-pill"
1430
+ title={attachment.name}
1431
+ >
1432
+ <span className="user-attachment-kind">
1433
+ {attachment.kind === "image"
1434
+ ? "IMG"
1435
+ : attachment.kind === "pdf"
1436
+ ? "PDF"
1437
+ : "DOC"}
1438
+ </span>
1439
+ <span className="user-attachment-title">{attachment.name}</span>
1440
+ </span>
1441
+ ))}
1442
+ </div>
1443
+ ) : null}
1444
+ {messageTextContent ? <p>{messageTextContent}</p> : null}
1445
+ </div>
1419
1446
  )}
1420
1447
 
1421
1448
  {message.role === "assistant" && !assistantCollapsed && sourcesOrderedByCitation.length > 0 ? (