@agents24/chat-react 0.5.5 → 0.5.6

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents24/chat-react",
3
- "version": "0.5.5",
3
+ "version": "0.5.6",
4
4
  "description": "Accessible web UI and complete default layout for Agents24 chat applications.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -58,8 +58,9 @@
58
58
  "prepack": "pnpm run build && pnpm run check:boundaries"
59
59
  },
60
60
  "dependencies": {
61
- "@agents24/client": "0.2.3",
62
- "@agents24/react": "0.2.3",
61
+ "@untitledui/file-icons": "0.0.9",
62
+ "@agents24/client": "0.2.4",
63
+ "@agents24/react": "0.2.4",
63
64
  "@fontsource-variable/inter": "^5.3.0",
64
65
  "@fontsource-variable/jetbrains-mono": "^5.3.0",
65
66
  "@fontsource-variable/merriweather": "^5.3.0",
@@ -1,6 +1,13 @@
1
- import { ArrowUp, ChevronDown, LoaderCircle, Mic, Square, X } from "lucide-react"
1
+ import { ArrowUp, ChevronDown, LoaderCircle, Mic, Square } from "lucide-react"
2
2
  import * as React from "react"
3
- import { AgentChatContextStatus, useAudioRecorder } from "@agents24/chat-react/ui"
3
+ import {
4
+ AgentChatContextStatus,
5
+ ChatAttachmentRows,
6
+ createAgentChatComposerFile,
7
+ revokeAgentChatComposerFiles,
8
+ useAudioRecorder,
9
+ type AgentChatComposerFile,
10
+ } from "@agents24/chat-react/ui"
4
11
  import { canonicalInputMimeType, inputMimeTypes, validateAgentChatSubmission, type AgentChatInputContract } from "@agents24/chat-react/runtime"
5
12
  import type { ContextWindow } from "@agents24/client/protocol"
6
13
 
@@ -29,7 +36,7 @@ type ChatComposerProps = {
29
36
  onModelChange: (modelId: string) => void
30
37
  onAgentChange: (agentId: string) => void
31
38
  onStop: () => void
32
- onSubmit: (text: string, files: File[]) => Promise<void>
39
+ onSubmit: (text: string, files: AgentChatComposerFile[]) => Promise<void>
33
40
  utilityRow?: "inline" | "below"
34
41
  }
35
42
 
@@ -52,15 +59,24 @@ export function ChatComposer({
52
59
  utilityRow = "inline",
53
60
  }: ChatComposerProps) {
54
61
  const [text, setText] = React.useState("")
55
- const [files, setFiles] = React.useState<File[]>([])
62
+ const [files, setFiles] = React.useState<AgentChatComposerFile[]>([])
56
63
  const [attachmentError, setAttachmentError] = React.useState<string | null>(null)
57
64
  const inputRef = React.useRef<HTMLInputElement>(null)
58
65
  const textareaRef = React.useRef<HTMLTextAreaElement>(null)
66
+ const filesRef = React.useRef<AgentChatComposerFile[]>([])
67
+ const inFlightFilesRef = React.useRef<AgentChatComposerFile[]>([])
59
68
  const submittingRef = React.useRef(false)
60
69
  const draftRevisionRef = React.useRef(0)
61
70
  const attachmentMimeTypes = inputMimeTypes(inputContract)
62
71
  const allowAttachments = attachmentMimeTypes.length > 0
63
72
  const recordingAllowed = inputContract.modalities.audio.recording_enabled
73
+ React.useEffect(() => {
74
+ filesRef.current = files
75
+ }, [files])
76
+ React.useEffect(() => () => {
77
+ revokeAgentChatComposerFiles(filesRef.current)
78
+ revokeAgentChatComposerFiles(inFlightFilesRef.current)
79
+ }, [])
64
80
  React.useEffect(() => {
65
81
  if (disabled || isRunning) return
66
82
  const frame = window.requestAnimationFrame(() => textareaRef.current?.focus({ preventScroll: true }))
@@ -68,14 +84,14 @@ export function ChatComposer({
68
84
  }, [disabled, focusKey, isRunning])
69
85
  const addRecording = React.useCallback((file: File) => {
70
86
  draftRevisionRef.current += 1
71
- setFiles((current) => [...current, file])
87
+ setFiles((current) => [...current, createAgentChatComposerFile(file)])
72
88
  }, [])
73
89
  const recorder = useAudioRecorder(addRecording)
74
90
  const isExpanded = forceExpanded || text.includes("\n") || text.length > 62
75
91
  const validationError = validateAgentChatSubmission(
76
92
  inputContract,
77
93
  text,
78
- files.map((file) => ({ data: file, mediaType: file.type })),
94
+ files.map((file) => ({ data: file.source, mediaType: file.mediaType })),
79
95
  )
80
96
 
81
97
  const selectedAgent = agents.find((item) => item.id === agentId)
@@ -143,16 +159,23 @@ export function ChatComposer({
143
159
  const clearedRevision = draftRevisionRef.current + 1
144
160
  draftRevisionRef.current = clearedRevision
145
161
  submittingRef.current = true
162
+ inFlightFilesRef.current = selectedFiles
146
163
  setText("")
147
164
  setFiles([])
148
165
  textareaRef.current?.focus()
149
166
  try {
150
167
  await onSubmit(value, selectedFiles)
168
+ revokeAgentChatComposerFiles(selectedFiles)
169
+ inFlightFilesRef.current = []
151
170
  } catch {
152
171
  if (draftRevisionRef.current === clearedRevision) {
153
172
  draftRevisionRef.current += 1
154
173
  setText(value)
155
174
  setFiles(selectedFiles)
175
+ inFlightFilesRef.current = []
176
+ } else {
177
+ revokeAgentChatComposerFiles(selectedFiles)
178
+ inFlightFilesRef.current = []
156
179
  }
157
180
  } finally {
158
181
  submittingRef.current = false
@@ -165,18 +188,19 @@ export function ChatComposer({
165
188
  data-agents24-chat-composer=""
166
189
  >
167
190
  {allowAttachments && files.length > 0 && (
168
- <div className="mb-2 flex min-w-0 gap-2 overflow-x-auto">
169
- {files.map((file, index) => (
170
- <div key={`${file.name}-${index}`} className="flex max-w-48 items-center gap-2 border bg-card px-2 py-1 text-xs">
171
- <span className="truncate">{file.name}</span>
172
- <Button variant="ghost" size="icon-xs" aria-label={`Remove ${file.name}`} onClick={() => {
173
- draftRevisionRef.current += 1
174
- setFiles((current) => current.filter((_, itemIndex) => itemIndex !== index))
175
- }}>
176
- <X />
177
- </Button>
178
- </div>
179
- ))}
191
+ <div className="mb-2 min-w-0">
192
+ <ChatAttachmentRows
193
+ attachments={files}
194
+ onRemove={(attachment) => {
195
+ if (!attachment.id) return
196
+ draftRevisionRef.current += 1
197
+ setFiles((current) => {
198
+ const removed = current.find((file) => file.id === attachment.id)
199
+ if (removed) revokeAgentChatComposerFiles([removed])
200
+ return current.filter((file) => file.id !== attachment.id)
201
+ })
202
+ }}
203
+ />
180
204
  </div>
181
205
  )}
182
206
  {attachmentError || recorder.error ? <p className="mb-2 text-xs text-destructive" role="alert">{attachmentError || recorder.error}</p> : null}
@@ -201,7 +225,7 @@ export function ChatComposer({
201
225
  attachmentMimeTypes.includes(canonicalInputMimeType(file.type))
202
226
  ))
203
227
  draftRevisionRef.current += 1
204
- setFiles((current) => [...current, ...accepted])
228
+ setFiles((current) => [...current, ...accepted.map(createAgentChatComposerFile)])
205
229
  setAttachmentError(
206
230
  accepted.length === selected.length
207
231
  ? null
@@ -1,7 +1,7 @@
1
1
  import type { ChatHitlPart, ChatMessage as ChatMessageModel } from "@agents24/react"
2
2
  import type { FeedbackReason, HitlResponse } from "@agents24/client"
3
3
  import type { AgentChatRuntimeHitlActionState } from "@agents24/chat-react/runtime"
4
- import { AgentChatDefaultActions, ChatAttachment, ChatAttachments } from "@agents24/chat-react/ui"
4
+ import { AgentChatDefaultActions, ChatAttachmentRows, type ChatAttachmentContentResolver } from "@agents24/chat-react/ui"
5
5
  import * as React from "react"
6
6
 
7
7
  import { MessageParts } from "./message-parts"
@@ -20,6 +20,7 @@ type Props = {
20
20
  input: { rating: "like" | "dislike" | null; reason?: FeedbackReason | null; comment?: string | null },
21
21
  ) => Promise<void>
22
22
  Timeline?: React.ComponentType<Agents24ChatTimelineSlotProps>
23
+ resolveAttachmentContent?: ChatAttachmentContentResolver
23
24
  }
24
25
 
25
26
  export function ChatMessage({
@@ -30,6 +31,7 @@ export function ChatMessage({
30
31
  onHitlAction,
31
32
  onRegenerate,
32
33
  onSetFeedback,
34
+ resolveAttachmentContent,
33
35
  Timeline,
34
36
  }: Props) {
35
37
  const isUser = message.role === "user"
@@ -37,14 +39,11 @@ export function ChatMessage({
37
39
  <Message align={isUser ? "end" : "start"}>
38
40
  <MessageContent>
39
41
  {isUser && message.attachments?.length ? (
40
- <ChatAttachments className="mb-2 justify-end">
41
- {message.attachments.map((attachment, index) => (
42
- <ChatAttachment
43
- data={attachment}
44
- key={String(attachment.id || attachment.url || attachment.filename || index)}
45
- />
46
- ))}
47
- </ChatAttachments>
42
+ <ChatAttachmentRows
43
+ attachments={message.attachments}
44
+ className="mb-2"
45
+ resolveContent={resolveAttachmentContent}
46
+ />
48
47
  ) : null}
49
48
  <Bubble variant={isUser ? "secondary" : "ghost"} align={isUser ? "end" : "start"} className={isUser ? "max-w-[min(75%,42rem)]" : "max-w-full overflow-visible"}>
50
49
  <BubbleContent className={isUser ? "px-4 py-2.5" : "w-full overflow-visible"}>
@@ -100,7 +100,7 @@ export function ChatShell({
100
100
  || controller.runState === "reconnecting"
101
101
  || controller.runState === "paused"
102
102
  || controller.runState === "cancelling"
103
- || runtime.isSubmitting
103
+ const isUploading = controller.runState === "uploading" || runtime.isUploading
104
104
  const isCancelling = controller.runState === "cancelling"
105
105
  const activeTitle = controller.threads.find((thread) => thread.id === controller.activeThreadId)?.title
106
106
  const hasBlockingError = Boolean(runtime.fatalError && controller.messages.length === 0)
@@ -151,7 +151,7 @@ export function ChatShell({
151
151
  <ChatComposer
152
152
  inputContract={runtime.inputContract || LOADING_INPUT_CONTRACT}
153
153
  contextWindow={runtime.contextWindow}
154
- disabled={runtime.isBootstrapping || runtime.isChangingAgent}
154
+ disabled={runtime.isBootstrapping || runtime.isChangingAgent || isUploading}
155
155
  focusKey={controller.activeThreadId}
156
156
  forceExpanded
157
157
  isCancelling={isCancelling}
@@ -166,10 +166,11 @@ export function ChatShell({
166
166
  onSubmit={(text, files) => runtime.submit({
167
167
  text,
168
168
  files: files.map((file) => ({
169
- data: file,
170
- mediaType: file.type || "application/octet-stream",
171
- name: file.name,
169
+ data: file.source,
170
+ mediaType: file.mediaType,
171
+ name: file.filename,
172
172
  })),
173
+ attachments: files,
173
174
  })}
174
175
  />
175
176
  </div>
@@ -222,6 +223,7 @@ export function ChatShell({
222
223
  onHitlAction={runtime.onHitlAction}
223
224
  onRegenerate={() => controller.regenerate(message)}
224
225
  onSetFeedback={(input) => controller.setFeedback(message, input)}
226
+ resolveAttachmentContent={runtime.resolveAttachmentContent}
225
227
  Timeline={components?.Timeline}
226
228
  />
227
229
  </MessageScrollerItem>
@@ -236,7 +238,7 @@ export function ChatShell({
236
238
  <ChatComposer
237
239
  inputContract={runtime.inputContract || LOADING_INPUT_CONTRACT}
238
240
  contextWindow={runtime.contextWindow}
239
- disabled={runtime.isBootstrapping || runtime.isChangingAgent || hasBlockingError}
241
+ disabled={runtime.isBootstrapping || runtime.isChangingAgent || hasBlockingError || isUploading}
240
242
  focusKey={controller.activeThreadId}
241
243
  isCancelling={isCancelling}
242
244
  isRunning={isRunning}
@@ -250,10 +252,11 @@ export function ChatShell({
250
252
  onSubmit={(text, files) => runtime.submit({
251
253
  text,
252
254
  files: files.map((file) => ({
253
- data: file,
254
- mediaType: file.type || "application/octet-stream",
255
- name: file.name,
255
+ data: file.source,
256
+ mediaType: file.mediaType,
257
+ name: file.filename,
256
258
  })),
259
+ attachments: files,
257
260
  })}
258
261
  utilityRow="below"
259
262
  />