experimental-a2 0.18.1 → 0.19.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.
Files changed (78) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/{ai-BMLlI2Ff.d.ts → ai-Cz3hqVhk.d.ts} +33 -16
  3. package/dist/ai-Cz3hqVhk.d.ts.map +1 -0
  4. package/dist/ai-client.d.ts +1 -1
  5. package/dist/ai-client.js +1 -1
  6. package/dist/ai-control-CvLbW10B.js +46 -0
  7. package/dist/ai-control-CvLbW10B.js.map +1 -0
  8. package/dist/ai-fx.d.ts +1 -1
  9. package/dist/ai-fx.d.ts.map +1 -1
  10. package/dist/ai-fx.js +30 -10
  11. package/dist/ai-fx.js.map +1 -1
  12. package/dist/{ai-harness-BuLh_EYi.js → ai-harness-BuuC-gJ3.js} +79 -15
  13. package/dist/ai-harness-BuuC-gJ3.js.map +1 -0
  14. package/dist/{ai-harness-B2suP2Qh.d.ts → ai-harness-CV6rXbB3.d.ts} +2 -2
  15. package/dist/ai-harness-CV6rXbB3.d.ts.map +1 -0
  16. package/dist/{ai-D6mGlO8j.js → ai-mMiWul1T.js} +116 -77
  17. package/dist/ai-mMiWul1T.js.map +1 -0
  18. package/dist/ai-pi.d.ts +1 -1
  19. package/dist/ai-pi.d.ts.map +1 -1
  20. package/dist/ai-pi.js +55 -10
  21. package/dist/ai-pi.js.map +1 -1
  22. package/dist/ai-react.d.ts +1 -1
  23. package/dist/ai-react.js +1 -1
  24. package/dist/ai-server.d.ts +2 -2
  25. package/dist/ai-server.d.ts.map +1 -1
  26. package/dist/ai-server.js +118 -50
  27. package/dist/ai-server.js.map +1 -1
  28. package/dist/ai.d.ts +2 -2
  29. package/dist/ai.js +1 -1
  30. package/dist/scheduler-vercel.d.ts.map +1 -1
  31. package/dist/scheduler-vercel.js +1 -4
  32. package/dist/scheduler-vercel.js.map +1 -1
  33. package/docs/agents/03-conversations.mdx +25 -13
  34. package/docs/agents/04-harnesses.mdx +51 -5
  35. package/docs/contract/03-sessions.mdx +1 -1
  36. package/docs/reference/04-ai.mdx +27 -18
  37. package/docs/reference/05-adapters.mdx +2 -2
  38. package/docs/server/02-timers.mdx +1 -1
  39. package/docs/server/05-production.mdx +2 -2
  40. package/examples/playground/DEPLOY.md +9 -0
  41. package/examples/playground/app/a0/a0.css +244 -5
  42. package/examples/playground/app/a0/app-view.tsx +2 -1
  43. package/examples/playground/app/a0/attachments.ts +133 -0
  44. package/examples/playground/app/a0/chat.test.tsx +504 -0
  45. package/examples/playground/app/a0/chat.tsx +623 -63
  46. package/examples/playground/app/a0/context-menu.tsx +38 -11
  47. package/examples/playground/app/a0/model.test.ts +28 -0
  48. package/examples/playground/app/a0/model.ts +30 -0
  49. package/examples/playground/app/a0/server.ts +5 -0
  50. package/examples/playground/app/a0/uploads/route.ts +29 -0
  51. package/examples/playground/app/agent/[agentId]/agent-client.tsx +2 -1
  52. package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +1 -0
  53. package/examples/playground/app/agent/model.test.ts +12 -3
  54. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +1 -5
  55. package/examples/playground/next.config.ts +7 -4
  56. package/examples/playground/package.json +3 -1
  57. package/package.json +1 -1
  58. package/src/ai-agent.ts +4 -0
  59. package/src/ai-client-state.ts +21 -9
  60. package/src/ai-context-schema.ts +28 -4
  61. package/src/ai-context.ts +83 -3
  62. package/src/ai-control-server.ts +70 -43
  63. package/src/ai-control-state.ts +3 -4
  64. package/src/ai-control.ts +59 -10
  65. package/src/ai-coordinator.ts +7 -6
  66. package/src/ai-fx.ts +58 -20
  67. package/src/ai-harness.ts +153 -29
  68. package/src/ai-message-shape.ts +41 -0
  69. package/src/ai-pi.ts +88 -32
  70. package/src/ai-stored-state.ts +1 -3
  71. package/src/ai.ts +116 -77
  72. package/src/libfx.d.ts +8 -1
  73. package/src/scheduler-vercel.ts +1 -12
  74. package/dist/ai-BMLlI2Ff.d.ts.map +0 -1
  75. package/dist/ai-D6mGlO8j.js.map +0 -1
  76. package/dist/ai-harness-B2suP2Qh.d.ts.map +0 -1
  77. package/dist/ai-harness-BuLh_EYi.js.map +0 -1
  78. package/examples/playground/app/a0/no-photon.cjs +0 -4
@@ -1,9 +1,22 @@
1
1
  'use client'
2
- import type { ReactNode } from 'react'
3
- import { useEffect, useRef, useState } from 'react'
2
+ import type { DragEvent, ReactNode, SetStateAction } from 'react'
3
+ import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
4
4
  import { useRouter, useSearchParams } from 'next/navigation'
5
- import { Asterisk, LoaderCircle, Zap } from 'lucide-react'
5
+ import {
6
+ Asterisk,
7
+ ChevronDown,
8
+ LoaderCircle,
9
+ Paperclip,
10
+ Pause,
11
+ Play,
12
+ Square,
13
+ X,
14
+ Zap,
15
+ } from 'lucide-react'
16
+ import type { FileUIPart } from 'ai'
6
17
  import { inputs } from 'experimental-a2/ai'
18
+ import { type Attachments, attachedLink, useAttachments } from './attachments'
19
+ import { ContextMenu } from './context-menu'
7
20
  import { queryString } from './flags'
8
21
  import {
9
22
  type AppMessage,
@@ -25,12 +38,17 @@ const suggestions = [
25
38
  'A tip calculator that splits the bill',
26
39
  ]
27
40
 
28
- /** The coding agent's conversation: its words, and one line per tool step. */
41
+ /**
42
+ * The coding agent's conversation: its words, and one line per tool step.
43
+ * Files dropped anywhere on it attach to the next message.
44
+ */
29
45
  export function Chat({
46
+ appId,
30
47
  harness,
31
48
  onPrompt,
32
49
  autoFocus,
33
50
  }: {
51
+ appId: string
34
52
  harness: Harness
35
53
  onPrompt: (prompt: string) => void
36
54
  autoFocus: boolean
@@ -53,18 +71,69 @@ export function Chat({
53
71
  const [chosen, setChosen] = useState<ModelChoice | null>(null)
54
72
  const settings = chosen ?? state.modelChoice
55
73
 
74
+ const fail = (cause: unknown): void =>
75
+ setError(cause instanceof Error ? cause.message : String(cause))
76
+
77
+ const [draft, setDraft] = useState('')
78
+ const attachments = useAttachments({
79
+ appId,
80
+ onLink: (line) =>
81
+ setDraft((current) =>
82
+ !current || current.endsWith('\n')
83
+ ? `${current}${line}`
84
+ : `${current}\n${line}`,
85
+ ),
86
+ onError: fail,
87
+ })
88
+ const [dragging, setDragging] = useState(false)
89
+
56
90
  const last = state.messages.at(-1)
91
+ const turn = state.active
92
+ const paused = state.status === 'paused'
57
93
  // A send is optimistic, and so is the turn it starts, so this holds from
58
94
  // the moment it's pushed rather than once the server accepts it.
59
95
  const working =
60
- state.starting || state.status === 'generating' || state.active !== null
96
+ state.starting || state.status === 'generating' || turn !== null
61
97
  const waiting =
62
98
  working &&
99
+ !paused &&
63
100
  (last?.role !== 'assistant' || !last.parts.some((part) => isVisible(part)))
64
101
 
65
- const send = async (text: string): Promise<void> => {
102
+ // The view follows the conversation's end until the reader scrolls up from
103
+ // it; a send follows it again.
104
+ const view = useRef<HTMLDivElement>(null)
105
+ const list = useRef<HTMLOListElement>(null)
106
+ const following = useRef(true)
107
+ useEffect(() => {
108
+ const scroller = view.current
109
+ const transcript = list.current
110
+ if (!scroller || !transcript) return
111
+ const observer = new ResizeObserver(() => {
112
+ if (following.current) toEnd(scroller, transcript)
113
+ })
114
+ observer.observe(scroller)
115
+ observer.observe(transcript)
116
+ return () => observer.disconnect()
117
+ }, [])
118
+
119
+ const send = async ({
120
+ text,
121
+ files = [],
122
+ mode,
123
+ }: {
124
+ text: string
125
+ files?: FileUIPart[]
126
+ mode: SendMode
127
+ }): Promise<void> => {
66
128
  setError(null)
67
- onPrompt(text)
129
+ onPrompt(text || files.map((file) => file.filename ?? 'image').join(', '))
130
+ following.current = true
131
+ if (view.current && list.current) toEnd(view.current, list.current)
132
+ const message: AppMessage = {
133
+ id: crypto.randomUUID(),
134
+ role: 'user',
135
+ parts: [...files, ...(text ? [{ type: 'text' as const, text }] : [])],
136
+ }
68
137
  try {
69
138
  await push(
70
139
  ...(chosen === null
@@ -75,30 +144,61 @@ export function Chat({
75
144
  payload: { ...chosen, model: chosen.model.trim() },
76
145
  },
77
146
  ]),
78
- ...inputs.message({
79
- id: crypto.randomUUID(),
80
- role: 'user',
81
- parts: [{ type: 'text', text }],
82
- }),
147
+ ...(mode === 'steer' && turn
148
+ ? inputs.steer({ turnId: turn.turnId, message })
149
+ : inputs.message(message)),
83
150
  )
84
151
  setChosen(null)
85
152
  } catch (cause) {
86
- setError(cause instanceof Error ? cause.message : String(cause))
153
+ fail(cause)
87
154
  throw cause
88
155
  }
89
156
  }
90
- const stop = (): void => {
91
- if (!state.active) return
92
- push(...inputs.stop({ turnId: state.active.turnId })).catch((cause) =>
93
- setError(cause instanceof Error ? cause.message : String(cause)),
94
- )
95
- }
96
157
 
97
158
  return (
98
- <>
99
- {/* Reversed, so it opens at the newest message and stays there. */}
100
- <div className="a0-transcript-scroll">
101
- <ol className="a0-transcript" aria-label="Conversation">
159
+ // Lays nothing out: the chat's column holds its parts, and the overlay
160
+ // covers that column.
161
+ <div
162
+ className="a0-chat-drop"
163
+ onDragEnter={(event) => {
164
+ if (!carriesFiles(event)) return
165
+ event.preventDefault()
166
+ setDragging(true)
167
+ }}
168
+ onDragOver={(event) => {
169
+ if (!carriesFiles(event)) return
170
+ event.preventDefault()
171
+ event.dataTransfer.dropEffect = 'copy'
172
+ }}
173
+ onDragLeave={(event) => {
174
+ // Where the files went: null once they leave the window.
175
+ const to = event.relatedTarget
176
+ if (!(to instanceof Node && event.currentTarget.contains(to)))
177
+ setDragging(false)
178
+ }}
179
+ onDrop={(event) => {
180
+ if (!carriesFiles(event)) return
181
+ event.preventDefault()
182
+ setDragging(false)
183
+ attachments.add(Array.from(event.dataTransfer.files))
184
+ }}
185
+ >
186
+ {dragging && (
187
+ <div className="a0-drop" aria-hidden>
188
+ <Paperclip size={14} aria-hidden />
189
+ Drop to attach
190
+ </div>
191
+ )}
192
+ {/* Reversed, so it opens at the newest message before any script runs. */}
193
+ <div
194
+ ref={view}
195
+ className="a0-transcript-scroll"
196
+ onScroll={() => {
197
+ if (view.current && list.current)
198
+ following.current = below(view.current, list.current) < FOLLOW_GAP
199
+ }}
200
+ >
201
+ <ol ref={list} className="a0-transcript" aria-label="Conversation">
102
202
  {!state.messages.length && (
103
203
  <li className="a0-suggestions">
104
204
  {suggestions.map((suggestion) => (
@@ -107,7 +207,11 @@ export function Chat({
107
207
  type="button"
108
208
  className="a0-suggestion"
109
209
  disabled={!settings.model.trim()}
110
- onClick={() => void send(suggestion).catch(() => {})}
210
+ onClick={() =>
211
+ void send({ text: suggestion, mode: 'queue' }).catch(
212
+ () => {},
213
+ )
214
+ }
111
215
  >
112
216
  <Asterisk size={12} aria-hidden />
113
217
  {suggestion}
@@ -115,21 +219,29 @@ export function Chat({
115
219
  ))}
116
220
  </li>
117
221
  )}
118
- {state.messages.flatMap((message) =>
119
- message.parts.map((part, index) => (
222
+ {state.messages.flatMap((message) => [
223
+ <MessageFiles key={`${message.id}:files`} message={message} />,
224
+ ...message.parts.map((part, index) => (
120
225
  <MessagePart
121
226
  key={`${message.id}:${index}`}
122
227
  message={message}
123
228
  part={part}
229
+ paused={paused}
124
230
  />
125
231
  )),
126
- )}
232
+ ])}
127
233
  {waiting && (
128
234
  <li className="a0-step">
129
235
  <LoaderCircle size={12} className="a0-spin" aria-hidden />
130
236
  Thinking
131
237
  </li>
132
238
  )}
239
+ {paused && (
240
+ <li className="a0-step">
241
+ <Pause size={12} aria-hidden />
242
+ Paused
243
+ </li>
244
+ )}
133
245
  {state.error && (
134
246
  <li role="alert" className="a0-error">
135
247
  {state.error}
@@ -142,37 +254,263 @@ export function Chat({
142
254
  {error}
143
255
  </p>
144
256
  )}
257
+ {!!state.inbox.items.length && (
258
+ <ol className="a0-queue" aria-label="Queued messages">
259
+ {state.inbox.items.map((item) => (
260
+ <li key={item.id}>
261
+ <span title={summaryOf(item.message)}>
262
+ {summaryOf(item.message)}
263
+ </span>
264
+ {turn && (
265
+ <button
266
+ type="button"
267
+ className="a0-ghost"
268
+ aria-label="Steer with it now"
269
+ title="Steer with it now, at the agent's next step"
270
+ onClick={() =>
271
+ void push(
272
+ ...inputs.queue.sendNow({
273
+ inputId: item.id,
274
+ turnId: turn.turnId,
275
+ }),
276
+ ).catch(fail)
277
+ }
278
+ >
279
+ Steer
280
+ </button>
281
+ )}
282
+ <button
283
+ type="button"
284
+ className="a0-icon-button"
285
+ aria-label="Remove"
286
+ title="Remove"
287
+ onClick={() =>
288
+ void push(...inputs.queue.remove({ inputId: item.id })).catch(
289
+ fail,
290
+ )
291
+ }
292
+ >
293
+ <X size={12} aria-hidden />
294
+ </button>
295
+ </li>
296
+ ))}
297
+ </ol>
298
+ )}
145
299
  <Composer
300
+ text={draft}
301
+ onText={setDraft}
302
+ attachments={attachments}
146
303
  onSend={send}
147
- working={working}
148
- // A turn can be stopped once it's accepted and has an id.
149
- onStop={state.active ? stop : null}
150
- queued={state.inbox.items.length}
304
+ // A turn can be steered or queued behind, and stopped, once it's
305
+ // accepted and has an id.
306
+ steerable={turn !== null}
307
+ controls={
308
+ (working || state.inbox.paused) && (
309
+ <Player
310
+ paused={state.inbox.paused}
311
+ onPause={() =>
312
+ void push(...inputs.pause({ when: 'now' })).catch(fail)
313
+ }
314
+ onResume={() => void push(...inputs.resume()).catch(fail)}
315
+ // A player's stop also ends its pause, so the next message
316
+ // goes out instead of waiting for Play.
317
+ onStop={
318
+ turn
319
+ ? () =>
320
+ void push(
321
+ ...inputs.stop({ turnId: turn.turnId }),
322
+ ...(state.inbox.paused ? inputs.resume() : []),
323
+ ).catch(fail)
324
+ : null
325
+ }
326
+ />
327
+ )
328
+ }
151
329
  settings={settings}
152
330
  onSettings={setChosen}
153
331
  harness={harness}
154
332
  onHarness={state.messages.length ? null : pickHarness}
155
333
  autoFocus={autoFocus}
156
334
  />
157
- </>
335
+ </div>
336
+ )
337
+ }
338
+
339
+ const carriesFiles = (event: DragEvent): boolean =>
340
+ event.dataTransfer.types.includes('Files')
341
+
342
+ /** How a message goes out during a turn: into it at its next step, or after it. */
343
+ type SendMode = 'steer' | 'queue'
344
+
345
+ // Closer than this to the end, the view keeps following it.
346
+ const FOLLOW_GAP = 24
347
+
348
+ /** How far the transcript's end is below the bottom of its view. */
349
+ const below = (view: HTMLElement, list: HTMLElement): number =>
350
+ list.getBoundingClientRect().bottom - view.getBoundingClientRect().bottom
351
+
352
+ const toEnd = (view: HTMLElement, list: HTMLElement): void => {
353
+ view.scrollTop += below(view, list)
354
+ }
355
+
356
+ const textOf = (message: AppMessage): string =>
357
+ message.parts
358
+ .flatMap((part) => (part.type === 'text' ? [part.text] : []))
359
+ .join('\n')
360
+
361
+ const filesOf = (message: AppMessage): FileUIPart[] =>
362
+ message.parts.filter((part): part is FileUIPart => part.type === 'file')
363
+
364
+ /** A message in one line: its text, or the files it sends. */
365
+ const summaryOf = (message: AppMessage): string =>
366
+ textOf(message) ||
367
+ filesOf(message)
368
+ .map((file) => file.filename ?? 'image')
369
+ .join(', ')
370
+
371
+ /** A message's files: images as thumbnails, any other file by its name. */
372
+ function MessageFiles({ message }: { message: AppMessage }): ReactNode {
373
+ const files = filesOf(message)
374
+ if (!files.length) return null
375
+ return (
376
+ <li className="a0-files" data-role={message.role}>
377
+ {files.map((file, index) => (
378
+ <a
379
+ key={index}
380
+ href={file.url}
381
+ target="_blank"
382
+ rel="noreferrer"
383
+ title={file.filename}
384
+ >
385
+ {file.mediaType.startsWith('image/') ? (
386
+ <img src={file.url} alt={file.filename ?? 'Image'} />
387
+ ) : (
388
+ <span className="a0-file">
389
+ <Paperclip size={12} aria-hidden />
390
+ {file.filename ?? 'File'}
391
+ </span>
392
+ )}
393
+ </a>
394
+ ))}
395
+ </li>
158
396
  )
159
397
  }
160
398
 
399
+ /** A user's text, where each link the composer added shows as its file. */
400
+ function Linked({ text }: { text: string }): ReactNode {
401
+ return text.split('\n').map((line, index) => {
402
+ const link = attachedLink(line)
403
+ return (
404
+ <Fragment key={index}>
405
+ {index > 0 && '\n'}
406
+ {link ? (
407
+ <a
408
+ className="a0-file"
409
+ href={link.url}
410
+ target="_blank"
411
+ rel="noreferrer"
412
+ >
413
+ <Paperclip size={12} aria-hidden />
414
+ {link.name}
415
+ </a>
416
+ ) : (
417
+ line
418
+ )}
419
+ </Fragment>
420
+ )
421
+ })
422
+ }
423
+
424
+ /** An image's thumbnail from the file itself, while and after it uploads. */
425
+ function Thumbnail({ file }: { file: File }): ReactNode {
426
+ const [src, setSrc] = useState<string | null>(null)
427
+ useEffect(() => {
428
+ const url = URL.createObjectURL(file)
429
+ setSrc(url)
430
+ return () => URL.revokeObjectURL(url)
431
+ }, [file])
432
+ return src && <img src={src} alt="" />
433
+ }
434
+
161
435
  const isVisible = (part: Part): boolean =>
162
436
  ((part.type === 'text' || part.type === 'reasoning') && !!part.text.trim()) ||
163
437
  part.type.startsWith('tool-')
164
438
 
439
+ /**
440
+ * What a call writes that its one line can't show: a file's text, an edit, or
441
+ * a command over several lines. It streams in as the model writes the call.
442
+ */
443
+ function written(name: string, input: unknown): string | undefined {
444
+ if (typeof input !== 'object' || input === null) return
445
+ const { content, edits, command } = input as {
446
+ content?: unknown
447
+ edits?: unknown
448
+ command?: unknown
449
+ }
450
+ const text =
451
+ name === 'write' && typeof content === 'string'
452
+ ? content
453
+ : name === 'edit'
454
+ ? editsOf(input, edits).map(edited).join('\n…\n')
455
+ : name === 'bash' &&
456
+ typeof command === 'string' &&
457
+ command.includes('\n')
458
+ ? command
459
+ : undefined
460
+ return text || undefined
461
+ }
462
+
463
+ /** Pi's edits: a list, one edit, or the edit's own fields on the call. */
464
+ const editsOf = (input: object, edits: unknown): unknown[] =>
465
+ Array.isArray(edits)
466
+ ? edits
467
+ : typeof edits === 'object' && edits !== null
468
+ ? [edits]
469
+ : 'oldText' in input || 'newText' in input
470
+ ? [input]
471
+ : []
472
+
473
+ const signed = (text: unknown, sign: string): string[] =>
474
+ typeof text === 'string'
475
+ ? text.split('\n').map((line) => `${sign} ${line}`)
476
+ : []
477
+
478
+ /** One of Pi's edits as a diff: the text it replaces, then its replacement. */
479
+ function edited(edit: unknown): string {
480
+ const { oldText, newText } = (edit ?? {}) as {
481
+ oldText?: unknown
482
+ newText?: unknown
483
+ }
484
+ return [...signed(oldText, '-'), ...signed(newText, '+')].join('\n')
485
+ }
486
+
487
+ /** A call's written text, kept scrolled to its newest line while it streams. */
488
+ function Written({ text, live }: { text: string; live: boolean }): ReactNode {
489
+ const pre = useRef<HTMLPreElement>(null)
490
+ useLayoutEffect(() => {
491
+ if (live && pre.current) pre.current.scrollTop = pre.current.scrollHeight
492
+ }, [text, live])
493
+ return (
494
+ <pre ref={pre} className="a0-tool-text">
495
+ {text}
496
+ </pre>
497
+ )
498
+ }
499
+
165
500
  function MessagePart({
166
501
  message,
167
502
  part,
503
+ paused,
168
504
  }: {
169
505
  message: AppMessage
170
506
  part: Part
507
+ /** The turn is paused: a call still open waits for Resume. */
508
+ paused: boolean
171
509
  }): ReactNode {
172
510
  if (part.type === 'text' && part.text.trim())
173
511
  return (
174
512
  <li className="a0-text" data-role={message.role}>
175
- {part.text}
513
+ {message.role === 'user' ? <Linked text={part.text} /> : part.text}
176
514
  </li>
177
515
  )
178
516
  if (part.type === 'reasoning' && part.text.trim()) {
@@ -208,18 +546,41 @@ function MessagePart({
208
546
  (part.state === 'input-streaming' && writingLabels[name]) ||
209
547
  toolLabels[name] ||
210
548
  name
211
- return (
212
- <li className="a0-step" data-tool={name} data-failed={failed || undefined}>
549
+ const running = !done && !failed && !paused
550
+ const line = (
551
+ <>
213
552
  {done || failed ? (
214
553
  <Asterisk size={12} className="a0-step-done" aria-hidden />
215
- ) : (
554
+ ) : running ? (
216
555
  <LoaderCircle size={12} className="a0-spin" aria-hidden />
556
+ ) : (
557
+ <Pause size={12} aria-hidden />
217
558
  )}
218
559
  <span className="a0-step-label">{label}</span>
219
560
  {typeof detail === 'string' && <code>{detail}</code>}
220
561
  {done && typeof took === 'number' && (
221
562
  <span className="a0-step-took">{duration(took)}</span>
222
563
  )}
564
+ </>
565
+ )
566
+ const text = written(name, input)
567
+ if (text === undefined)
568
+ return (
569
+ <li
570
+ className="a0-step"
571
+ data-tool={name}
572
+ data-failed={failed || undefined}
573
+ >
574
+ {line}
575
+ </li>
576
+ )
577
+ return (
578
+ <li className="a0-tool" data-tool={name} data-failed={failed || undefined}>
579
+ {/* Open while the call is written and runs, folded once it's done. */}
580
+ <details open={running}>
581
+ <summary className="a0-step">{line}</summary>
582
+ <Written text={text} live={running} />
583
+ </details>
223
584
  </li>
224
585
  )
225
586
  }
@@ -234,22 +595,146 @@ const reasoningLabel = (level: ModelChoice['reasoning']): string =>
234
595
  ? 'extra high'
235
596
  : level
236
597
 
237
- /** The message box: a dotted frame, then the model settings it sends with. */
598
+ /** A turn's controls, as a player's: pause or resume, and stop. */
599
+ function Player({
600
+ paused,
601
+ onPause,
602
+ onResume,
603
+ onStop,
604
+ }: {
605
+ paused: boolean
606
+ onPause: () => void
607
+ onResume: () => void
608
+ /** Once the turn is accepted and has an id. */
609
+ onStop: (() => void) | null
610
+ }): ReactNode {
611
+ return (
612
+ <div className="a0-player" role="group" aria-label="Turn">
613
+ {paused ? (
614
+ <button
615
+ type="button"
616
+ className="a0-icon-button"
617
+ aria-label="Resume"
618
+ title="Resume"
619
+ onClick={onResume}
620
+ >
621
+ <Play size={13} fill="currentColor" aria-hidden />
622
+ </button>
623
+ ) : (
624
+ <button
625
+ type="button"
626
+ className="a0-icon-button"
627
+ aria-label="Pause"
628
+ title="Pause: stop the model and tools now, and keep the turn"
629
+ onClick={onPause}
630
+ >
631
+ <Pause size={13} fill="currentColor" aria-hidden />
632
+ </button>
633
+ )}
634
+ <button
635
+ type="button"
636
+ className="a0-icon-button"
637
+ aria-label="Stop"
638
+ title="Stop: end the turn"
639
+ disabled={!onStop}
640
+ onClick={onStop ?? undefined}
641
+ >
642
+ <Square size={11} fill="currentColor" aria-hidden />
643
+ </button>
644
+ </div>
645
+ )
646
+ }
647
+
648
+ const sendTitles: Record<SendMode, string> = {
649
+ steer: 'Steer: the agent reads it at its next step, in this turn',
650
+ queue: 'Queue: it goes out when this turn ends',
651
+ }
652
+
653
+ /**
654
+ * The send button during a turn: Steer or Queue, with the chevron choosing
655
+ * which. Its menu goes with it when the turn ends.
656
+ */
657
+ function SteerOrQueue({
658
+ ready,
659
+ mode,
660
+ onMode,
661
+ onClose,
662
+ }: {
663
+ ready: boolean
664
+ mode: SendMode
665
+ onMode: (mode: SendMode) => void
666
+ /** After a pick or Escape, for focus to go back to the message. */
667
+ onClose: () => void
668
+ }): ReactNode {
669
+ const [menu, setMenu] = useState<{ x: number; y: number } | null>(null)
670
+ const chevron = useRef<HTMLButtonElement>(null)
671
+ return (
672
+ <div className="a0-send">
673
+ <button type="submit" disabled={!ready} title={sendTitles[mode]}>
674
+ {mode === 'steer' ? 'Steer' : 'Queue'}
675
+ </button>
676
+ <button
677
+ ref={chevron}
678
+ type="button"
679
+ className="a0-send-mode"
680
+ aria-label="Send mode"
681
+ aria-haspopup="menu"
682
+ aria-expanded={menu !== null}
683
+ onClick={(event) => {
684
+ const box = event.currentTarget.getBoundingClientRect()
685
+ setMenu((open) => (open ? null : { x: box.right, y: box.top - 4 }))
686
+ }}
687
+ >
688
+ <ChevronDown size={14} aria-hidden />
689
+ </button>
690
+ {menu && (
691
+ <ContextMenu
692
+ at={menu}
693
+ corner="bottom-right"
694
+ trigger={chevron}
695
+ items={(['steer', 'queue'] as const).map((option) => ({
696
+ label: option === 'steer' ? 'Steer' : 'Queue',
697
+ checked: mode === option,
698
+ onSelect: () => onMode(option),
699
+ }))}
700
+ onClose={({ returnFocus }) => {
701
+ setMenu(null)
702
+ if (returnFocus) onClose()
703
+ }}
704
+ />
705
+ )}
706
+ </div>
707
+ )
708
+ }
709
+
710
+ /**
711
+ * The message box: a dotted frame with the files attached so far, then the
712
+ * model settings it sends with.
713
+ */
238
714
  function Composer({
715
+ text,
716
+ onText,
717
+ attachments,
239
718
  onSend,
240
- working,
241
- onStop,
242
- queued,
719
+ steerable,
720
+ controls,
243
721
  settings,
244
722
  onSettings,
245
723
  harness,
246
724
  onHarness,
247
725
  autoFocus,
248
726
  }: {
249
- onSend: (text: string) => Promise<void>
250
- working: boolean
251
- onStop: (() => void) | null
252
- queued: number
727
+ text: string
728
+ onText: (text: SetStateAction<string>) => void
729
+ attachments: Attachments
730
+ onSend: (message: {
731
+ text: string
732
+ files: FileUIPart[]
733
+ mode: SendMode
734
+ }) => Promise<void>
735
+ /** A turn is active: a message steers it or queues behind it. */
736
+ steerable: boolean
737
+ controls: ReactNode
253
738
  settings: ModelChoice
254
739
  onSettings: (settings: ModelChoice) => void
255
740
  harness: Harness
@@ -257,8 +742,9 @@ function Composer({
257
742
  onHarness: ((harness: Harness) => void) | null
258
743
  autoFocus: boolean
259
744
  }): ReactNode {
260
- const [text, setText] = useState('')
745
+ const [mode, setMode] = useState<SendMode>('steer')
261
746
  const input = useRef<HTMLTextAreaElement>(null)
747
+ const picker = useRef<HTMLInputElement>(null)
262
748
  // Typing anywhere else starts a message. Focus moves on keydown, so the
263
749
  // key itself lands in the box.
264
750
  useEffect(() => {
@@ -289,7 +775,13 @@ function Composer({
289
775
  textarea.style.height = 'auto'
290
776
  textarea.style.height = `${Math.min(textarea.scrollHeight, 208)}px`
291
777
  }, [text])
292
- const ready = !!text.trim() && !!settings.model.trim()
778
+ // A file that isn't an image joins the text once it's uploaded, so nothing
779
+ // goes out while one is still uploading.
780
+ const uploading = attachments.list.some((item) => !item.part)
781
+ const ready =
782
+ (!!text.trim() || attachments.list.length > 0) &&
783
+ !uploading &&
784
+ !!settings.model.trim()
293
785
  return (
294
786
  <form
295
787
  className="a0-composer"
@@ -297,19 +789,69 @@ function Composer({
297
789
  event.preventDefault()
298
790
  const message = text.trim()
299
791
  if (!ready) return
300
- setText('')
301
- await onSend(message).catch(() =>
302
- setText((current) => current || message),
303
- )
792
+ const sent = attachments.list
793
+ onText('')
794
+ attachments.clear()
795
+ await onSend({
796
+ text: message,
797
+ files: sent.flatMap((item) => (item.part ? [item.part] : [])),
798
+ mode,
799
+ }).catch(() => {
800
+ onText((current) => current || message)
801
+ attachments.restore(sent)
802
+ })
304
803
  }}
305
804
  >
805
+ {!!attachments.list.length && (
806
+ <ul className="a0-attachments" aria-label="Attachments">
807
+ {attachments.list.map((item) => (
808
+ <li
809
+ key={item.id}
810
+ data-kind={item.kind}
811
+ data-uploading={!item.part || undefined}
812
+ title={item.file.name}
813
+ >
814
+ {item.kind === 'image' ? (
815
+ <Thumbnail file={item.file} />
816
+ ) : (
817
+ <>
818
+ <Paperclip size={12} aria-hidden />
819
+ <span>{item.file.name}</span>
820
+ </>
821
+ )}
822
+ {!item.part && (
823
+ <LoaderCircle
824
+ size={12}
825
+ className="a0-spin"
826
+ aria-label="Uploading"
827
+ />
828
+ )}
829
+ <button
830
+ type="button"
831
+ className="a0-icon-button"
832
+ aria-label={`Remove ${item.file.name}`}
833
+ title="Remove"
834
+ onClick={() => attachments.remove(item.id)}
835
+ >
836
+ <X size={12} aria-hidden />
837
+ </button>
838
+ </li>
839
+ ))}
840
+ </ul>
841
+ )}
306
842
  <textarea
307
843
  ref={input}
308
844
  aria-label="Message"
309
845
  value={text}
310
846
  rows={2}
311
847
  autoFocus={autoFocus}
312
- onChange={(event) => setText(event.target.value)}
848
+ onChange={(event) => onText(event.target.value)}
849
+ onPaste={(event) => {
850
+ const files = Array.from(event.clipboardData.files)
851
+ if (!files.length) return
852
+ event.preventDefault()
853
+ attachments.add(files)
854
+ }}
313
855
  onKeyDown={(event) => {
314
856
  if (
315
857
  event.key === 'Enter' &&
@@ -389,20 +931,38 @@ function Composer({
389
931
  </button>
390
932
  </div>
391
933
  <div className="a0-composer-actions">
392
- {!!queued && <span className="a0-queued">{queued} queued</span>}
393
- {working && (
394
- <button
395
- type="button"
396
- className="a0-ghost"
397
- disabled={!onStop}
398
- onClick={onStop ?? undefined}
399
- >
400
- Stop
934
+ <input
935
+ ref={picker}
936
+ type="file"
937
+ multiple
938
+ hidden
939
+ onChange={(event) => {
940
+ attachments.add(Array.from(event.target.files ?? []))
941
+ event.target.value = ''
942
+ }}
943
+ />
944
+ <button
945
+ type="button"
946
+ className="a0-icon-button"
947
+ aria-label="Attach files"
948
+ title="Attach files: images go with the message, other files as links"
949
+ onClick={() => picker.current?.click()}
950
+ >
951
+ <Paperclip size={14} aria-hidden />
952
+ </button>
953
+ {controls}
954
+ {steerable ? (
955
+ <SteerOrQueue
956
+ ready={ready}
957
+ mode={mode}
958
+ onMode={setMode}
959
+ onClose={() => input.current?.focus()}
960
+ />
961
+ ) : (
962
+ <button type="submit" disabled={!ready}>
963
+ Send
401
964
  </button>
402
965
  )}
403
- <button type="submit" disabled={!ready}>
404
- Send
405
- </button>
406
966
  </div>
407
967
  </div>
408
968
  </form>