@agents24/chat-react 0.5.29 → 0.5.31

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.
Files changed (52) hide show
  1. package/README.md +28 -1
  2. package/dist/chunk-INO5DSSJ.js +86 -0
  3. package/dist/chunk-INO5DSSJ.js.map +1 -0
  4. package/dist/generated/delegation-types.d.ts +129 -0
  5. package/dist/registry/agents24-chat.json +267 -0
  6. package/dist/runtime/index.cjs +53 -1
  7. package/dist/runtime/index.cjs.map +1 -1
  8. package/dist/runtime/index.d.ts +2 -0
  9. package/dist/runtime/index.js +62 -78
  10. package/dist/runtime/index.js.map +1 -1
  11. package/dist/runtime/input-contract.d.ts +3 -1
  12. package/dist/runtime/use-delegation-tasks.d.ts +20 -0
  13. package/dist/scaffold/app.d.ts +3 -0
  14. package/dist/scaffold/components/chat/chat-composer.d.ts +5 -3
  15. package/dist/scaffold/components/chat/subagent-tasks.d.ts +3 -0
  16. package/dist/scaffold/components/chat/theme-menu.d.ts +1 -1
  17. package/dist/scaffold/components/theme-provider.d.ts +3 -1
  18. package/dist/scaffold/index.cjs +420 -333
  19. package/dist/scaffold/index.cjs.map +1 -1
  20. package/dist/scaffold/index.d.ts +5 -2
  21. package/dist/scaffold/index.js +415 -332
  22. package/dist/scaffold/index.js.map +1 -1
  23. package/dist/styles.css +14 -0
  24. package/dist/ui/adapters.d.ts +5 -2
  25. package/dist/ui/agent-chat-composer.d.ts +8 -2
  26. package/dist/ui/agent-chat-queue.d.ts +0 -2
  27. package/dist/ui/index.cjs +817 -623
  28. package/dist/ui/index.cjs.map +1 -1
  29. package/dist/ui/index.d.ts +1 -0
  30. package/dist/ui/index.js +776 -622
  31. package/dist/ui/index.js.map +1 -1
  32. package/dist/ui/tool-sessions.d.ts +10 -2
  33. package/dist/ui/use-agent-chat-composer.d.ts +47 -0
  34. package/dist/ui/use-audio-recorder.d.ts +5 -3
  35. package/package.json +11 -6
  36. package/scaffold/host-styles.css +3 -0
  37. package/scaffold/inherit.css +4 -0
  38. package/scaffold/manifest.json +2 -2
  39. package/scaffold/src/app.tsx +5 -0
  40. package/scaffold/src/chat.css +35 -1
  41. package/scaffold/src/components/chat/chat-composer.tsx +31 -97
  42. package/scaffold/src/components/chat/chat-shell.tsx +7 -5
  43. package/scaffold/src/components/chat/external-link-dialog.tsx +6 -5
  44. package/scaffold/src/components/chat/identity-control.tsx +0 -1
  45. package/scaffold/src/components/chat/subagent-tasks.tsx +109 -0
  46. package/scaffold/src/components/chat/theme-menu.tsx +3 -1
  47. package/scaffold/src/components/theme-provider.tsx +28 -23
  48. package/scaffold/src/components/ui/tooltip.tsx +3 -0
  49. package/scaffold/src/host.css +11 -0
  50. package/scaffold/src/index.css +1 -213
  51. package/scaffold/src/index.tsx +9 -2
  52. package/scaffold/src/theme.css +215 -0
@@ -23,7 +23,13 @@ export type AgentChatToolSlotContext = {
23
23
  resourceTargets: readonly ChatComputerResourceTarget[];
24
24
  openResource: (target: ChatComputerResourceTarget) => void;
25
25
  };
26
+ export type AgentChatActivityIconContext = {
27
+ label: string;
28
+ kind: "tool" | "group" | "planning" | "node";
29
+ part?: ChatToolPart | ChatNodeActivityPart;
30
+ };
26
31
  export type AgentChatToolSlots = {
32
+ renderActivityIcon?: (context: AgentChatActivityIconContext) => React.ReactNode;
27
33
  onExpandedChange?: (open: boolean, part?: ChatToolPart) => void;
28
34
  onToolSelect?: (part: ChatToolPart) => void;
29
35
  onResourceAction?: (target: ChatComputerResourceTarget, part: ChatToolPart) => void;
@@ -37,15 +43,17 @@ export declare function isAgentChatSubagent(part: ChatToolPart): boolean;
37
43
  export declare function isAgentChatToolRunning(part: ChatToolPart): boolean;
38
44
  export declare function latestRunningAgentChatToolId(parts: readonly ChatToolPart[]): string | null;
39
45
  export declare function partitionAgentChatToolTimeline(parts: readonly ChatPart[], toolViewMode?: AgentChatToolViewMode): AgentChatToolTimelineSegment[];
40
- export declare function AgentChatActivityRow({ label, modelTurnId, onSelect, }: {
46
+ export declare function AgentChatActivityRow({ label, modelTurnId, renderActivityIcon, onSelect, }: {
41
47
  label: string;
42
48
  modelTurnId?: string;
49
+ renderActivityIcon?: AgentChatToolSlots["renderActivityIcon"];
43
50
  onSelect?: () => void;
44
51
  }): import("react/jsx-runtime").JSX.Element;
45
52
  export declare function AgentChatModelTurnRow({ part }: {
46
53
  part: ChatModelTurnPart;
47
54
  }): import("react/jsx-runtime").JSX.Element;
48
- export declare function AgentChatNodeActivityRow({ onSelect, part, }: {
55
+ export declare function AgentChatNodeActivityRow({ renderActivityIcon, onSelect, part, }: {
56
+ renderActivityIcon?: AgentChatToolSlots["renderActivityIcon"];
49
57
  onSelect?: (part: ChatNodeActivityPart) => void;
50
58
  part: ChatNodeActivityPart;
51
59
  }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,47 @@
1
+ import * as React from "react";
2
+ import type { AgentChatInputContract } from "../runtime/input-contract";
3
+ import { type AgentChatComposerFile } from "./composer-attachments";
4
+ export type AgentChatComposerOptions = {
5
+ inputContract?: AgentChatInputContract | null;
6
+ recordingEnabled?: boolean;
7
+ textareaRef?: React.RefObject<HTMLTextAreaElement | null>;
8
+ disabled?: boolean;
9
+ isRunning?: boolean;
10
+ isCancelling?: boolean;
11
+ forceExpanded?: boolean;
12
+ focusKey?: string | null;
13
+ restoredDraft?: {
14
+ revision: number;
15
+ text: string;
16
+ files: AgentChatComposerFile[];
17
+ } | null;
18
+ onSubmit(text: string, files: AgentChatComposerFile[]): void | Promise<void>;
19
+ /** Observe the shared recorder. Do not stop its tracks or start another capture. */
20
+ onRecordingStreamChange?: (stream: MediaStream | null) => void;
21
+ };
22
+ export declare function useAgentChatComposer(options: AgentChatComposerOptions): {
23
+ text: string;
24
+ setText: (value: string) => void;
25
+ files: AgentChatComposerFile[];
26
+ addFiles: (selected: readonly File[]) => void;
27
+ removeFile: (id: string) => void;
28
+ textareaRef: React.RefObject<HTMLTextAreaElement | null>;
29
+ submit: () => Promise<void>;
30
+ canSubmit: boolean;
31
+ isSubmitting: boolean;
32
+ attachmentError: string | null;
33
+ attachmentMimeTypes: string[];
34
+ validationError: string | null;
35
+ recorder: {
36
+ error: string | null;
37
+ isRecording: boolean;
38
+ start: () => Promise<void>;
39
+ stop: () => void;
40
+ };
41
+ recordingAllowed: boolean;
42
+ fileAccept: string;
43
+ onKeyDown: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void;
44
+ allowAttachments: boolean;
45
+ isExpanded: boolean;
46
+ action: "stop" | "send";
47
+ };
@@ -1,8 +1,10 @@
1
- export declare function useAudioRecorder(onRecorded: (file: File) => void, options?: {
1
+ export type AudioRecorderOptions = {
2
+ /** Subscribe to the recorder-owned stream; consumers must not stop its tracks. */
2
3
  onStreamChange?: (stream: MediaStream | null) => void;
3
- }): {
4
+ };
5
+ export declare function useAudioRecorder(onRecorded: (file: File) => void, options?: AudioRecorderOptions): {
4
6
  error: string | null;
5
7
  isRecording: boolean;
6
8
  start: () => Promise<void>;
7
- stop: () => void | undefined;
9
+ stop: () => void;
8
10
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agents24/chat-react",
3
- "version": "0.5.29",
3
+ "version": "0.5.31",
4
4
  "description": "Accessible web UI and complete default layout for Agents24 chat applications.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,7 +36,10 @@
36
36
  },
37
37
  "./scaffold/styles.css": "./scaffold/styles.css",
38
38
  "./styles.css": "./dist/styles.css",
39
- "./compatibility.json": "./compatibility.json"
39
+ "./compatibility.json": "./compatibility.json",
40
+ "./scaffold/host-styles.css": "./scaffold/host-styles.css",
41
+ "./scaffold/inherit.css": "./scaffold/inherit.css",
42
+ "./registry/agents24-chat.json": "./dist/registry/agents24-chat.json"
40
43
  },
41
44
  "files": [
42
45
  "dist",
@@ -52,15 +55,17 @@
52
55
  "scripts": {
53
56
  "sync:contract": "node scripts/sync-contract.mjs",
54
57
  "check:contract": "node scripts/sync-contract.mjs --check",
55
- "build": "pnpm run sync:contract && tsup && tsc -p tsconfig.build.json && tsc -p tsconfig.scaffold-build.json",
58
+ "build": "pnpm run sync:contract && tsup && tsc -p tsconfig.build.json && tsc -p tsconfig.scaffold-build.json && pnpm run build:registry",
56
59
  "test": "tsx --test tests/*.test.ts",
57
60
  "check:boundaries": "node tests/check-boundaries.mjs",
58
- "prepack": "pnpm run build && pnpm run check:boundaries"
61
+ "prepack": "pnpm run build && pnpm run check:boundaries",
62
+ "build:registry": "node scripts/generate-registry.mjs",
63
+ "check:registry": "node scripts/generate-registry.mjs --check"
59
64
  },
60
65
  "dependencies": {
61
66
  "@untitledui/file-icons": "0.0.9",
62
- "@agents24/client": "0.2.14",
63
- "@agents24/react": "0.2.18",
67
+ "@agents24/client": "0.2.15",
68
+ "@agents24/react": "0.2.19",
64
69
  "@fontsource-variable/inter": "^5.3.0",
65
70
  "@fontsource-variable/jetbrains-mono": "^5.3.0",
66
71
  "@fontsource-variable/merriweather": "^5.3.0",
@@ -0,0 +1,3 @@
1
+ @import "@agents24/chat-react/styles.css";
2
+ @import "./src/host.css";
3
+ @import "./src/chat.css";
@@ -0,0 +1,4 @@
1
+ @import "@agents24/chat-react/styles.css";
2
+ @import "tw-animate-css";
3
+ @import "./src/chat.css";
4
+ @source "./src/**/*.{ts,tsx}";
@@ -14,8 +14,8 @@
14
14
  "@shadcn/react": "^0.2.1",
15
15
  "class-variance-authority": "^0.7.1",
16
16
  "clsx": "^2.1.1",
17
- "lucide-react": "0.542.0",
18
- "radix-ui": "1.4.3",
17
+ "lucide-react": ">=0.542.0 <2.0.0",
18
+ "radix-ui": ">=1.4.3 <2.0.0",
19
19
  "streamdown": "^2.5.0",
20
20
  "tailwind-merge": "^3.6.0",
21
21
  "tw-animate-css": "^1.4.0"
@@ -15,6 +15,8 @@ import {
15
15
  } from "@agents24/react"
16
16
  import * as React from "react"
17
17
 
18
+ import type { Agents24ChatComposerProps } from "./components/chat/chat-composer"
19
+
18
20
  import { ChatShell } from "./components/chat/chat-shell"
19
21
  import { GUEST_CHAT_IDENTITY, type Agents24ChatIdentity } from "./identity"
20
22
 
@@ -35,7 +37,10 @@ export type Agents24ChatAppProps = {
35
37
  sidebar?: React.ReactNode
36
38
  }
37
39
 
40
+ export type Agents24ChatComposerSlotProps = Agents24ChatComposerProps
41
+
38
42
  export type Agents24ChatScaffoldComponents = {
43
+ Composer?: React.ComponentType<Agents24ChatComposerSlotProps>
39
44
  Timeline?: React.ComponentType<Agents24ChatTimelineSlotProps>
40
45
  }
41
46
 
@@ -50,10 +50,20 @@
50
50
  grid-template-areas: "textarea textarea textarea" "attach spacer submit";
51
51
  }
52
52
 
53
- [data-agents24-theme-root] .agents24-composer-grid:not([data-expanded]) {
53
+ [data-agents24-theme-root] .agents24-composer-grid:not([data-expanded]):not([data-attachments]) {
54
54
  border-radius: var(--a24-composer-single-row-radius);
55
55
  }
56
56
 
57
+ /* Draft attachments stay within the same surface and precede its input rows. */
58
+ .agents24-composer-grid[data-attachments] {
59
+ grid-template-areas: "attachments attachments attachments" "attach textarea submit";
60
+ border-radius: 1.125rem;
61
+ }
62
+ .agents24-composer-grid[data-attachments][data-expanded] {
63
+ grid-template-areas: "attachments attachments attachments" "textarea textarea textarea" "attach spacer submit";
64
+ }
65
+ .agents24-composer-attachments { grid-area: attachments; }
66
+
57
67
  .agents24-composer-attach { grid-area: attach; }
58
68
  .agents24-composer-textarea { grid-area: textarea; }
59
69
  .agents24-composer-actions { grid-area: submit; }
@@ -64,3 +74,27 @@
64
74
  display: -webkit-box;
65
75
  overflow: hidden;
66
76
  }
77
+
78
+ /* Host theme mode changes no document tokens or appearance. */
79
+ [data-agents24-theme-root][data-agents24-theme="inherit"] {
80
+ --a24-composer-single-row-radius: var(--radius-xl, var(--radius, .75rem));
81
+ font-family: inherit;
82
+ }
83
+ [data-agents24-theme-root][data-agents24-theme="inherit"] :focus-visible {
84
+ outline: 2px solid var(--ring, currentColor) !important;
85
+ outline-offset: 2px;
86
+ }
87
+ [data-agents24-theme-root][data-agents24-theme="inherit"] .a24-context-status__popover {
88
+ background: var(--popover, var(--background));
89
+ color: var(--popover-foreground, var(--foreground));
90
+ }
91
+
92
+ [data-agents24-theme-root][data-agents24-theme="inherit"] [data-slot="button"] {
93
+ border-radius: var(--radius);
94
+ }
95
+ [data-agents24-theme-root][data-agents24-theme="inherit"] .a24-context-status__popover {
96
+ border-color: var(--border);
97
+ }
98
+ [data-agents24-theme-root][data-agents24-theme="inherit"] .a24-context-status__popover > span:not(.a24-context-status__sr-only) {
99
+ color: var(--muted-foreground);
100
+ }
@@ -3,14 +3,10 @@ import * as React from "react"
3
3
  import {
4
4
  AgentChatContextStatus,
5
5
  ChatAttachmentRows,
6
- chatComposerAction,
7
- createAgentChatComposerFile,
8
- revokeAgentChatComposerFiles,
9
- useAudioRecorder,
10
- useChatComposerFocus,
6
+ useAgentChatComposer,
11
7
  type AgentChatComposerFile,
12
8
  } from "@agents24/chat-react/ui"
13
- import { attachmentFileAccept, inputMimeTypes, validateAgentChatSubmission, type AgentChatInputContract } from "@agents24/chat-react/runtime"
9
+ import { type AgentChatInputContract } from "@agents24/chat-react/runtime"
14
10
  import type { ContextWindow } from "@agents24/client/protocol"
15
11
 
16
12
  import { Button } from "../ui/button"
@@ -23,7 +19,9 @@ import { Textarea } from "../ui/textarea"
23
19
  import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
24
20
  import { cn } from "../../lib/utils"
25
21
 
26
- type ChatComposerProps = {
22
+ export type Agents24ChatComposerProps = {
23
+ queue?: React.ReactNode
24
+ onRecordingStreamChange?: (stream: MediaStream | null) => void
27
25
  inputContract: AgentChatInputContract
28
26
  disabled?: boolean
29
27
  contextWindow?: ContextWindow | null
@@ -45,6 +43,8 @@ type ChatComposerProps = {
45
43
 
46
44
  export function ChatComposer({
47
45
  inputContract,
46
+ queue,
47
+ onRecordingStreamChange,
48
48
  disabled,
49
49
  contextWindow,
50
50
  focusKey,
@@ -61,45 +61,14 @@ export function ChatComposer({
61
61
  onSubmit,
62
62
  restoredDraft,
63
63
  utilityRow = "inline",
64
- }: ChatComposerProps) {
65
- const [text, setText] = React.useState("")
66
- const [files, setFiles] = React.useState<AgentChatComposerFile[]>([])
67
- const [attachmentError, setAttachmentError] = React.useState<string | null>(null)
64
+ }: Agents24ChatComposerProps) {
68
65
  const inputRef = React.useRef<HTMLInputElement>(null)
69
- const textareaRef = React.useRef<HTMLTextAreaElement>(null)
70
- const filesRef = React.useRef<AgentChatComposerFile[]>([])
71
- const inFlightFilesRef = React.useRef<AgentChatComposerFile[]>([])
72
- const submittingRef = React.useRef(false)
73
- const draftRevisionRef = React.useRef(0)
74
- const attachmentMimeTypes = inputMimeTypes(inputContract)
75
- const allowAttachments = attachmentMimeTypes.length > 0
76
- const recordingAllowed = inputContract.modalities.audio.recording_enabled
77
- React.useEffect(() => {
78
- filesRef.current = files
79
- }, [files])
80
- React.useEffect(() => () => {
81
- revokeAgentChatComposerFiles(filesRef.current)
82
- revokeAgentChatComposerFiles(inFlightFilesRef.current)
83
- }, [])
84
- const focusComposer = useChatComposerFocus({ textareaRef, disabled: Boolean(disabled), focusKey, restoredDraft,
85
- onRestore: (draft) => {
86
- draftRevisionRef.current += 1
87
- revokeAgentChatComposerFiles(filesRef.current)
88
- setText(draft.text)
89
- setFiles(draft.files)
90
- } })
91
- const addRecording = React.useCallback((file: File) => {
92
- draftRevisionRef.current += 1
93
- setFiles((current) => [...current, createAgentChatComposerFile(file)])
94
- }, [])
95
- const recorder = useAudioRecorder(addRecording)
96
- const isExpanded = forceExpanded || text.includes("\n") || text.length > 62
97
- const validationError = validateAgentChatSubmission(
98
- inputContract,
99
- text,
100
- files.map((file) => ({ data: file.source || new Uint8Array(), mediaType: file.mediaType })),
101
- )
102
- const action = chatComposerAction({ hasDraft: Boolean(text.trim() || files.length), isRunning, isCancelling })
66
+ const { text, setText, files, removeFile, addFiles, textareaRef, submit,
67
+ attachmentError, attachmentMimeTypes, allowAttachments, recordingAllowed,
68
+ recorder, isExpanded, validationError, action, canSubmit } = useAgentChatComposer({
69
+ inputContract, disabled, focusKey, forceExpanded, isCancelling, isRunning,
70
+ onSubmit, restoredDraft, onRecordingStreamChange,
71
+ })
103
72
 
104
73
  const selectedAgent = agents.find((item) => item.id === agentId)
105
74
  const selectedModel = models.find((item) => item.id === modelId)
@@ -159,65 +128,33 @@ export function ChatComposer({
159
128
  const hasContextWindow = Number.isFinite(Number(contextWindow?.max_tokens)) && Number(contextWindow?.max_tokens) > 0
160
129
  const showUtilityRow = utilityRow === "below"
161
130
 
162
- const submit = async () => {
163
- if (disabled || isCancelling || submittingRef.current || validationError) return
164
- const value = text
165
- const selectedFiles = files
166
- const clearedRevision = draftRevisionRef.current + 1
167
- draftRevisionRef.current = clearedRevision
168
- submittingRef.current = true
169
- inFlightFilesRef.current = selectedFiles
170
- setText("")
171
- setFiles([])
172
- focusComposer()
173
- try {
174
- await onSubmit(value, selectedFiles)
175
- revokeAgentChatComposerFiles(selectedFiles)
176
- inFlightFilesRef.current = []
177
- } catch {
178
- if (draftRevisionRef.current === clearedRevision) {
179
- draftRevisionRef.current += 1
180
- setText(value)
181
- setFiles(selectedFiles)
182
- inFlightFilesRef.current = []
183
- } else {
184
- revokeAgentChatComposerFiles(selectedFiles)
185
- inFlightFilesRef.current = []
186
- }
187
- } finally {
188
- submittingRef.current = false
189
- }
190
- }
191
-
192
131
  return (
193
132
  <div
194
133
  className="relative z-10 w-full pb-[max(1rem,env(safe-area-inset-bottom))]"
195
134
  data-agents24-chat-composer=""
196
135
  >
197
- {allowAttachments && files.length > 0 && (
198
- <div className="mb-2 min-w-0">
199
- <ChatAttachmentRows
200
- attachments={files}
201
- onRemove={(attachment) => {
202
- if (!attachment.id) return
203
- draftRevisionRef.current += 1
204
- setFiles((current) => {
205
- const removed = current.find((file) => file.id === attachment.id)
206
- if (removed) revokeAgentChatComposerFiles([removed])
207
- return current.filter((file) => file.id !== attachment.id)
208
- })
209
- }}
210
- />
211
- </div>
212
- )}
136
+ {queue}
213
137
  {attachmentError || recorder.error ? <p className="mb-2 text-xs text-destructive" role="alert">{attachmentError || recorder.error}</p> : null}
214
138
  <div
215
139
  data-expanded={isExpanded || undefined}
140
+ data-attachments={files.length > 0 || undefined}
216
141
  className={cn(
217
142
  "agents24-composer-grid grid min-w-0 items-center gap-2 rounded-xl border bg-background shadow-sm",
218
143
  isExpanded ? "p-2" : "p-1.5",
219
144
  )}
220
145
  >
146
+ {allowAttachments && files.length > 0 && (
147
+ <div className="agents24-composer-attachments min-w-0">
148
+ <ChatAttachmentRows
149
+ layout="composer"
150
+ attachments={files}
151
+ onRemove={(attachment) => {
152
+ if (!attachment.id) return
153
+ removeFile(attachment.id)
154
+ }}
155
+ />
156
+ </div>
157
+ )}
221
158
  {allowAttachments ? (
222
159
  <>
223
160
  <input
@@ -225,11 +162,9 @@ export function ChatComposer({
225
162
  className="sr-only"
226
163
  type="file"
227
164
  multiple
228
- accept={attachmentFileAccept(attachmentMimeTypes)}
165
+ accept={attachmentMimeTypes.join(",")}
229
166
  onChange={(event) => {
230
- const selected = Array.from(event.target.files ?? [])
231
- setFiles((current) => [...current, ...selected.map(createAgentChatComposerFile)])
232
- setAttachmentError(null)
167
+ addFiles(Array.from(event.target.files ?? []))
233
168
  event.currentTarget.value = ""
234
169
  }}
235
170
  />
@@ -252,7 +187,6 @@ export function ChatComposer({
252
187
  isExpanded ? "py-2" : "h-9 py-2",
253
188
  )}
254
189
  onChange={(event) => {
255
- draftRevisionRef.current += 1
256
190
  setText(event.target.value)
257
191
  }}
258
192
  onKeyDown={(event) => {
@@ -291,7 +225,7 @@ export function ChatComposer({
291
225
  <TooltipContent>{validationError}</TooltipContent>
292
226
  </Tooltip>
293
227
  ) : (
294
- <Button size="icon" aria-label={isRunning ? "Queue message" : "Send message"} disabled={disabled} onClick={() => void submit()}><ArrowUp /></Button>
228
+ <Button size="icon" aria-label={isRunning ? "Queue message" : "Send message"} disabled={!canSubmit} onClick={() => void submit()}><ArrowUp /></Button>
295
229
  )}
296
230
  </div>
297
231
  </div>
@@ -11,6 +11,7 @@ import type { Agents24ChatIdentity } from "../../identity"
11
11
 
12
12
  import { ChatComposer } from "./chat-composer"
13
13
  import { ChatMessage } from "./chat-message"
14
+ import { SubagentTasks } from "./subagent-tasks"
14
15
  import { ThemeMenu } from "./theme-menu"
15
16
  import { ThreadSidebar } from "./thread-sidebar"
16
17
  import { TurnOutline } from "./turn-outline"
@@ -92,6 +93,7 @@ export function ChatShell({
92
93
  runtime: AgentChatRuntime
93
94
  sidebar?: React.ReactNode
94
95
  }) {
96
+ const Composer = components?.Composer || ChatComposer
95
97
  const { controller } = runtime
96
98
  const isUploading = controller.runState === "uploading"
97
99
  const isRunning =
@@ -167,6 +169,7 @@ export function ChatShell({
167
169
  data-agents24-chat-transcript=""
168
170
  >
169
171
  <OperationErrorNotice runtime={runtime} />
172
+ <SubagentTasks threadId={controller.activeThreadId} />
170
173
  <LatestThreadScroller.Provider autoScroll defaultScrollPosition="last-anchor" scrollPreviousItemPeek={64}>
171
174
  {!isEmptyState && (
172
175
  <LatestThreadScroller.Root className="relative min-h-0 flex-1">
@@ -239,11 +242,10 @@ export function ChatShell({
239
242
  <LatestThreadScroller.Button className="!relative !inset-auto !translate-x-0 rtl:!translate-x-0" />
240
243
  </div>}
241
244
  <div className="pointer-events-auto mx-auto w-full max-w-3xl px-4 sm:px-6">
242
- <AgentChatQueue pending={controller.pendingMessages} queue={controller.messageQueue}
243
- canSteer={controller.runState === "streaming"}
244
- onSteer={controller.steerPendingMessage} onRemove={controller.removePendingMessage}
245
- onEdit={retractForEdit} onResume={controller.resumeQueue} />
246
- <ChatComposer
245
+ <Composer
246
+ queue={<AgentChatQueue pending={controller.pendingMessages} queue={controller.messageQueue}
247
+ onRemove={controller.removePendingMessage}
248
+ onEdit={retractForEdit} onResume={controller.resumeQueue} />}
247
249
  restoredDraft={restoredDraft}
248
250
  inputContract={runtime.inputContract || LOADING_INPUT_CONTRACT}
249
251
  contextWindow={runtime.contextWindow}
@@ -14,9 +14,10 @@ import {
14
14
  export function ExternalLinkDialog({ isOpen, onClose, onConfirm, url }: LinkSafetyModalProps) {
15
15
  const [copied, setCopied] = React.useState(false)
16
16
 
17
- React.useEffect(() => {
18
- if (!isOpen) setCopied(false)
19
- }, [isOpen])
17
+ const close = () => {
18
+ setCopied(false)
19
+ onClose()
20
+ }
20
21
 
21
22
  const copyUrl = async () => {
22
23
  await navigator.clipboard.writeText(url)
@@ -25,11 +26,11 @@ export function ExternalLinkDialog({ isOpen, onClose, onConfirm, url }: LinkSafe
25
26
 
26
27
  const openUrl = () => {
27
28
  onConfirm()
28
- onClose()
29
+ close()
29
30
  }
30
31
 
31
32
  return (
32
- <Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose() }}>
33
+ <Dialog open={isOpen} onOpenChange={(open) => { if (!open) close() }}>
33
34
  <DialogContent className="max-h-[calc(100dvh-2rem)] min-w-0 overflow-hidden sm:max-w-md">
34
35
  <DialogHeader>
35
36
  <DialogTitle>Open external link?</DialogTitle>
@@ -21,7 +21,6 @@ function identityLabel(identity: Agents24ChatIdentity): string {
21
21
  }
22
22
 
23
23
  function IdentityAvatar({ identity }: { identity: Agents24ChatIdentity }) {
24
- const label = identityLabel(identity)
25
24
  if (identity.avatarUrl) {
26
25
  return <img alt="" className="size-6 rounded-full object-cover" src={identity.avatarUrl} />
27
26
  }
@@ -0,0 +1,109 @@
1
+ import * as React from "react"
2
+ import { useAgentClient, partsFromResponseBlocks } from "@agents24/react"
3
+ import { AgentResponseTimeline } from "@agents24/chat-react/ui"
4
+ import { useDelegationTasks, type DelegationHistory, type DelegationTask } from "@agents24/chat-react/runtime"
5
+ import { Button } from "../ui/button"
6
+
7
+ export function SubagentTasks({ threadId }: { threadId: string | null }) {
8
+ const client = useAgentClient()
9
+ const source = React.useMemo(() => ({ events: (input: { threadId: string; after: number; signal: AbortSignal }) =>
10
+ client.threads.delegations.events(input) }), [client])
11
+ const { tasks, error, refresh } = useDelegationTasks({ threadId, source })
12
+ const [selection, setSelection] = React.useState<{ threadId: string | null; handle: string | null }>({ threadId: null, handle: null })
13
+ const selected = selection.threadId === threadId ? selection.handle : null
14
+ if (!threadId || (!tasks.length && !error)) return null
15
+ return <section aria-label="Subagent tasks" className="mx-auto w-full max-w-3xl px-4 py-2 text-sm">
16
+ <details>
17
+ <summary className="cursor-pointer font-medium">Tasks ({tasks.length})</summary>
18
+ {error ? <p role="alert">Task updates are unavailable. Retrying…</p> : null}
19
+ <ul className="max-h-56 overflow-y-auto">
20
+ {tasks.map(task => <li key={task.handle} style={{ paddingInlineStart: Math.max(0, task.depth - 1) * 12 }}>
21
+ <button type="button" className="flex w-full items-center justify-between gap-3 rounded-md px-2 py-2 text-start hover:bg-muted"
22
+ onClick={() => setSelection({ threadId, handle: selected === task.handle ? null : task.handle })} aria-expanded={selected === task.handle}>
23
+ <span className="min-w-0 truncate">{task.name}{task.background ? " · Background" : ""}</span>
24
+ <span className="shrink-0 text-xs text-muted-foreground">{task.status.replaceAll("_", " ")}</span>
25
+ </button>
26
+ </li>)}
27
+ </ul>
28
+ {tasks.filter(task => task.handle === selected).map(task =>
29
+ <TaskConversation key={`${threadId}:${task.handle}`} threadId={threadId} task={task} onChanged={refresh} />)}
30
+ </details>
31
+ </section>
32
+ }
33
+
34
+ function TaskConversation({ threadId, task, onChanged }: {
35
+ threadId: string; task: DelegationTask; onChanged: () => void
36
+ }) {
37
+ const client = useAgentClient()
38
+ const [history, setHistory] = React.useState<DelegationHistory | null>(null)
39
+ const [earlier, setEarlier] = React.useState<DelegationHistory["turns"]>([])
40
+ const historyInitialized = React.useRef(false)
41
+ const [before, setBefore] = React.useState<number | null>(null)
42
+ const [error, setError] = React.useState<string | null>(null)
43
+ const [busy, setBusy] = React.useState(false)
44
+ const operation = React.useRef<{ action: "interrupt" | "cancel"; id: string } | null>(null)
45
+ React.useEffect(() => {
46
+ const controller = new AbortController()
47
+ let timer: ReturnType<typeof setTimeout>
48
+ const refresh = async () => {
49
+ try {
50
+ const page = await client.threads.delegations.history({ threadId, handle: task.handle, signal: controller.signal })
51
+ if (controller.signal.aborted) return
52
+ setHistory(page)
53
+ if (!historyInitialized.current) {
54
+ historyInitialized.current = true
55
+ setBefore(page.paging.next_before_turn_index)
56
+ }
57
+ } catch {
58
+ if (!controller.signal.aborted) setError("Task history could not be loaded.")
59
+ } finally {
60
+ if (!controller.signal.aborted) timer = setTimeout(refresh, 2000)
61
+ }
62
+ }
63
+ void refresh()
64
+ return () => { controller.abort(); clearTimeout(timer) }
65
+ }, [client, threadId, task.handle])
66
+ const loadEarlier = async () => {
67
+ if (before === null) return
68
+ setBusy(true)
69
+ try {
70
+ const page = await client.threads.delegations.history({ threadId, handle: task.handle, before })
71
+ setEarlier(current => [...page.turns, ...current])
72
+ setBefore(page.paging.next_before_turn_index)
73
+ } catch { setError("Earlier task history could not be loaded.") }
74
+ finally { setBusy(false) }
75
+ }
76
+ const act = async (action: "interrupt" | "cancel") => {
77
+ setBusy(true); setError(null)
78
+ if (operation.current?.action !== action) operation.current = { action, id: crypto.randomUUID() }
79
+ try {
80
+ await client.threads.delegations.act({ threadId, handle: task.handle,
81
+ action: { action, operation_id: operation.current.id } })
82
+ operation.current = null
83
+ onChanged()
84
+ } catch { setError("The task action could not be completed. Try again.") }
85
+ finally { setBusy(false) }
86
+ }
87
+ const turns = Array.from(new Map([...earlier, ...(history?.turns || [])].map(turn => [turn.id, turn])).values())
88
+ .sort((a, b) => a.turn_index - b.turn_index)
89
+ return <div className="space-y-3 rounded-md bg-muted/30 p-3">
90
+ <h3 className="font-medium">{task.name}</h3>
91
+ {error ? <p role="alert">{error}</p> : null}
92
+ <div className="flex flex-wrap gap-2">
93
+ {task.capabilities.interrupt ? <Button variant="secondary" size="sm" disabled={busy} onClick={() => void act("interrupt")}>Interrupt</Button> : null}
94
+ {task.capabilities.cancel ? <Button variant="secondary" size="sm" disabled={busy} onClick={() => void act("cancel")}>Cancel task</Button> : null}
95
+ {before !== null ? <Button variant="ghost" size="sm" disabled={busy} onClick={() => void loadEarlier()}>Earlier exchanges</Button> : null}
96
+ </div>
97
+ <div className="max-h-[55dvh] space-y-4 overflow-y-auto break-words">
98
+ {turns.map(turn => <article key={turn.id} className="space-y-2">
99
+ {turn.assignment_instruction ? <p className="whitespace-pre-wrap text-muted-foreground">{turn.assignment_instruction}</p> : null}
100
+ {turn.user_input_text ? <p className="whitespace-pre-wrap">{turn.user_input_text}</p> : null}
101
+ {turn.exchanges?.map(exchange => <div key={exchange.id} className="whitespace-pre-wrap break-words text-sm">
102
+ <p className="text-xs font-medium">{exchange.sender === "parent" ? "Parent Agent" : "Subagent update"}</p>
103
+ {exchange.text}
104
+ </div>)}
105
+ <AgentResponseTimeline message={{ id: turn.id, role: "assistant", createdAt: turn.created_at, content: turn.assistant_output_text || "", parts: partsFromResponseBlocks(turn.response_blocks) }} />
106
+ </article>)}
107
+ </div>
108
+ </div>
109
+ }
@@ -11,7 +11,7 @@ const options = [
11
11
  ]
12
12
 
13
13
  export function ThemeMenu() {
14
- const { appearance, setAppearance } = useTheme()
14
+ const { appearance, setAppearance, inherited } = useTheme()
15
15
  const [open, setOpen] = React.useState(false)
16
16
  const rootRef = React.useRef<HTMLDivElement>(null)
17
17
  const firstItemRef = React.useRef<HTMLButtonElement>(null)
@@ -35,6 +35,8 @@ export function ThemeMenu() {
35
35
  }
36
36
  }, [open])
37
37
 
38
+ if (inherited) return null
39
+
38
40
  const label = appearance === "system" ? "System" : appearance === "light" ? "Light" : "Dark"
39
41
  return (
40
42
  <div ref={rootRef} className="relative">