experimental-a2 0.8.0 → 0.8.1

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 (94) hide show
  1. package/AGENTS.md +11 -0
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +29 -0
  4. package/docs/guides/07-examples.mdx +56 -0
  5. package/docs/index.mdx +13 -0
  6. package/examples/README.md +15 -0
  7. package/examples/playground/AGENTS.md +11 -0
  8. package/examples/playground/DEPLOY.md +106 -0
  9. package/examples/playground/README.md +19 -0
  10. package/examples/playground/activity-feed.test.ts +10 -0
  11. package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
  12. package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
  13. package/examples/playground/app/agent/events/route.ts +4 -0
  14. package/examples/playground/app/agent/model.ts +3 -0
  15. package/examples/playground/app/agent/new-agent-session.tsx +98 -0
  16. package/examples/playground/app/agent/page.tsx +25 -0
  17. package/examples/playground/app/agent/scheduler/route.ts +5 -0
  18. package/examples/playground/app/agent/server.ts +153 -0
  19. package/examples/playground/app/agent/session.ts +13 -0
  20. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
  21. package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
  22. package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
  23. package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
  24. package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
  25. package/examples/playground/app/canvas/events/route.ts +13 -0
  26. package/examples/playground/app/canvas/model.ts +94 -0
  27. package/examples/playground/app/canvas/open-canvas.tsx +40 -0
  28. package/examples/playground/app/canvas/page.tsx +20 -0
  29. package/examples/playground/app/canvas/server.ts +9 -0
  30. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
  31. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
  32. package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
  33. package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
  34. package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
  35. package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
  36. package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
  37. package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
  38. package/examples/playground/app/chat/[chatId]/session.ts +7 -0
  39. package/examples/playground/app/chat/events/route.ts +7 -0
  40. package/examples/playground/app/chat/model.test.ts +155 -0
  41. package/examples/playground/app/chat/model.ts +310 -0
  42. package/examples/playground/app/chat/new-conversation.tsx +16 -0
  43. package/examples/playground/app/chat/page.tsx +25 -0
  44. package/examples/playground/app/chat/scheduler/route.ts +5 -0
  45. package/examples/playground/app/chat/server.ts +184 -0
  46. package/examples/playground/app/components/activity-feed.tsx +54 -0
  47. package/examples/playground/app/components/connection-pill.tsx +29 -0
  48. package/examples/playground/app/counter/counter-client.tsx +72 -0
  49. package/examples/playground/app/counter/events/route.ts +4 -0
  50. package/examples/playground/app/counter/model.test.ts +36 -0
  51. package/examples/playground/app/counter/model.ts +31 -0
  52. package/examples/playground/app/counter/page.tsx +24 -0
  53. package/examples/playground/app/counter/server.ts +9 -0
  54. package/examples/playground/app/counter/session.ts +13 -0
  55. package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
  56. package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
  57. package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
  58. package/examples/playground/app/documents/[documentId]/session.ts +7 -0
  59. package/examples/playground/app/documents/events/route.ts +4 -0
  60. package/examples/playground/app/documents/model.ts +55 -0
  61. package/examples/playground/app/documents/open-document.tsx +40 -0
  62. package/examples/playground/app/documents/page.tsx +22 -0
  63. package/examples/playground/app/documents/server.ts +9 -0
  64. package/examples/playground/app/globals.css +2078 -0
  65. package/examples/playground/app/layout.tsx +44 -0
  66. package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
  67. package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
  68. package/examples/playground/app/orders/[orderId]/session.ts +11 -0
  69. package/examples/playground/app/orders/create/route.ts +30 -0
  70. package/examples/playground/app/orders/events/route.ts +4 -0
  71. package/examples/playground/app/orders/model.ts +79 -0
  72. package/examples/playground/app/orders/new-order-form.tsx +98 -0
  73. package/examples/playground/app/orders/page.tsx +22 -0
  74. package/examples/playground/app/orders/scheduler/route.ts +5 -0
  75. package/examples/playground/app/orders/server.ts +50 -0
  76. package/examples/playground/app/page.tsx +102 -0
  77. package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
  78. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
  79. package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
  80. package/examples/playground/app/recovery/events/route.ts +3 -0
  81. package/examples/playground/app/recovery/model.ts +55 -0
  82. package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
  83. package/examples/playground/app/recovery/page.tsx +22 -0
  84. package/examples/playground/app/recovery/scheduler/route.ts +7 -0
  85. package/examples/playground/app/recovery/server.ts +53 -0
  86. package/examples/playground/app/recovery/start/route.ts +41 -0
  87. package/examples/playground/css.d.ts +4 -0
  88. package/examples/playground/lib/store.ts +15 -0
  89. package/examples/playground/next-env.d.ts +5 -0
  90. package/examples/playground/next.config.ts +10 -0
  91. package/examples/playground/package.json +46 -0
  92. package/examples/playground/tsconfig.json +37 -0
  93. package/examples/playground/vercel.json +40 -0
  94. package/package.json +5 -2
@@ -0,0 +1,922 @@
1
+ 'use client'
2
+ import type {
3
+ FormEvent,
4
+ KeyboardEvent,
5
+ PointerEvent as ReactPointerEvent,
6
+ ReactNode,
7
+ } from 'react'
8
+ import {
9
+ useCallback,
10
+ useEffect,
11
+ useLayoutEffect,
12
+ useMemo,
13
+ useRef,
14
+ useState,
15
+ } from 'react'
16
+ import { inputs } from 'experimental-a2/ai'
17
+ import { ActivityFeed } from '@/app/components/activity-feed'
18
+ import { ConnectionPill } from '@/app/components/connection-pill'
19
+ import {
20
+ AgentStreamDrawer,
21
+ type AgentStreamStatus,
22
+ } from './agent-stream-drawer'
23
+ import {
24
+ buildChatTranscript,
25
+ selectAgentStream,
26
+ type AgentMessageMetadata,
27
+ } from './chat-view'
28
+ import { insertComposerText, isComposerTypingKey } from './composer'
29
+ import { useSession } from './session'
30
+
31
+ /** Max one durable presence ping per author in this window. */
32
+ const TYPING_PUSH_INTERVAL_MS = 4_000
33
+ /** Presence expiry is render-time view logic; the reducer stays pure. */
34
+ const TYPING_TTL_MS = 6_000
35
+ const FOLLOW_DISTANCE_PX = 64
36
+ const MENTION_LIST_ID = 'chat-mention-list'
37
+ const COMPOSER_MODE_DESCRIPTION_ID = 'chat-composer-mode-description'
38
+ const IMPORTANT_FOCUS_SELECTOR = [
39
+ 'a[href]',
40
+ 'button',
41
+ 'input',
42
+ 'label',
43
+ 'select',
44
+ 'summary',
45
+ 'textarea',
46
+ '[contenteditable]:not([contenteditable="false"])',
47
+ '[role="combobox"]',
48
+ '[role="dialog"]',
49
+ '[role="textbox"]',
50
+ ].join(',')
51
+
52
+ const NAME_KEY = 'a2-playground-chat-name'
53
+ const GUESTS = [
54
+ 'lovelace',
55
+ 'hopper',
56
+ 'turing',
57
+ 'curie',
58
+ 'noether',
59
+ 'hamilton',
60
+ 'ritchie',
61
+ ] as const
62
+
63
+ function loadName(): string {
64
+ try {
65
+ const stored = sessionStorage.getItem(NAME_KEY)
66
+ if (stored) return stored
67
+ } catch {
68
+ // Storage may be unavailable; a tab-local identity still works.
69
+ }
70
+ const pick = new Uint32Array(1)
71
+ crypto.getRandomValues(pick)
72
+ const name = `guest-${GUESTS[pick[0]! % GUESTS.length]!}`
73
+ try {
74
+ sessionStorage.setItem(NAME_KEY, name)
75
+ } catch {
76
+ // In-memory identity is enough for this visit.
77
+ }
78
+ return name
79
+ }
80
+
81
+ function withMentions(text: string): ReactNode {
82
+ return text.split(/(@[\w-]+)/g).map((part, i) =>
83
+ part.startsWith('@') ? (
84
+ // oxlint-disable-next-line no-array-index-key -- static split, never reorders
85
+ <mark className="mention" key={i}>
86
+ {part}
87
+ </mark>
88
+ ) : (
89
+ part
90
+ ),
91
+ )
92
+ }
93
+
94
+ function mentionQuery(
95
+ value: string,
96
+ caret: number,
97
+ ): { query: string; start: number } | null {
98
+ const upToCaret = value.slice(0, caret)
99
+ const match = /(?:^|\s)@([\w-]*)$/.exec(upToCaret)
100
+ if (!match) return null
101
+ const query = match[1] ?? ''
102
+ return { query, start: caret - query.length - 1 }
103
+ }
104
+
105
+ const names = (authors: string[]): string =>
106
+ authors.length <= 2
107
+ ? authors.join(' and ')
108
+ : `${authors.slice(0, -1).join(', ')} and ${authors.at(-1)}`
109
+
110
+ function presenceLine(
111
+ authors: string[],
112
+ agentActivity: 'thinking' | 'writing' | null,
113
+ ): string | null {
114
+ const human =
115
+ authors.length === 0
116
+ ? null
117
+ : `${names(authors)} ${authors.length === 1 ? 'is' : 'are'} typing`
118
+ const agent = agentActivity ? `agent is ${agentActivity}` : null
119
+ if (!human && !agent) return null
120
+ return `${[human, agent].filter(Boolean).join('; ')}…`
121
+ }
122
+
123
+ const usageLabel = (metadata: AgentMessageMetadata | null): string | null => {
124
+ if (!metadata) return null
125
+ const trigger = metadata.mode === 'ambient' ? 'ambient' : '@agent'
126
+ if (metadata.steps.length === 0) return `${trigger} · agent stream`
127
+ const tokens = metadata.steps.reduce(
128
+ (total, step) => total + (step.usage.totalTokens ?? 0),
129
+ 0,
130
+ )
131
+ const calls = metadata.steps.length
132
+ return `${trigger} · ${tokens.toLocaleString()} ${tokens === 1 ? 'token' : 'tokens'} · ${calls} model ${calls === 1 ? 'step' : 'steps'}`
133
+ }
134
+
135
+ function AgentStreamTrigger({
136
+ metadata,
137
+ responseMessageId,
138
+ onOpen,
139
+ }: {
140
+ metadata: AgentMessageMetadata | null
141
+ responseMessageId: string
142
+ onOpen: () => void
143
+ }): ReactNode {
144
+ const label = usageLabel(metadata)
145
+ if (!metadata || !label) return null
146
+ return (
147
+ <button
148
+ type="button"
149
+ className="agent-stream-trigger"
150
+ aria-label={`${label}. View full agent stream`}
151
+ aria-haspopup="dialog"
152
+ aria-controls="agent-stream-drawer"
153
+ data-agent-stream-id={responseMessageId}
154
+ onClick={onOpen}
155
+ >
156
+ <span>{label}</span>
157
+ <span aria-hidden="true">View stream →</span>
158
+ </button>
159
+ )
160
+ }
161
+
162
+ const messageTime = (at: string): string =>
163
+ new Date(at).toLocaleTimeString([], {
164
+ hour: 'numeric',
165
+ minute: '2-digit',
166
+ })
167
+
168
+ const compactMessageTime = (at: string): string =>
169
+ new Date(at).toLocaleTimeString([], {
170
+ hour: '2-digit',
171
+ minute: '2-digit',
172
+ hour12: false,
173
+ })
174
+
175
+ const avatarText = (author: string): string =>
176
+ author === 'agent' ? 'A' : author.slice(0, 1).toUpperCase()
177
+
178
+ const hasImportantFocus = (element: EventTarget | null): boolean =>
179
+ element instanceof Element &&
180
+ (element.matches(IMPORTANT_FOCUS_SELECTOR) ||
181
+ element.closest('dialog[open]') !== null)
182
+
183
+ export function ChatClient({ chatId }: { chatId: string }): ReactNode {
184
+ const { state, push, events, index, connection } = useSession()
185
+
186
+ const [name, setName] = useState('')
187
+ const [draft, setDraft] = useState('')
188
+ const [error, setError] = useState<string | null>(null)
189
+ const [retrying, setRetrying] = useState(false)
190
+ const [openResponseId, setOpenResponseId] = useState<string | null>(null)
191
+ const [sidebarOpen, setSidebarOpen] = useState(false)
192
+ const [now, setNow] = useState(() => Date.now())
193
+ const [mention, setMention] = useState<{
194
+ query: string
195
+ start: number
196
+ } | null>(null)
197
+ const [selected, setSelected] = useState(0)
198
+ const lastTypingPush = useRef(0)
199
+ const followTranscript = useRef(true)
200
+ const previousTranscriptSize = useRef(0)
201
+ const focusedComposerOnLoad = useRef(false)
202
+ const inputRef = useRef<HTMLTextAreaElement>(null)
203
+ const transcriptRef = useRef<HTMLDivElement>(null)
204
+ const transcriptContentRef = useRef<HTMLDivElement>(null)
205
+ const composerDockRef = useRef<HTMLDivElement>(null)
206
+
207
+ useEffect(() => setName(loadName()), [])
208
+
209
+ useEffect(() => {
210
+ if (!name || focusedComposerOnLoad.current) return
211
+ focusedComposerOnLoad.current = true
212
+ const activeElement = document.activeElement
213
+ if (
214
+ (activeElement !== document.body &&
215
+ activeElement !== document.documentElement) ||
216
+ document.querySelector('dialog[open]') !== null
217
+ ) {
218
+ return
219
+ }
220
+ requestAnimationFrame(() =>
221
+ inputRef.current?.focus({ preventScroll: true }),
222
+ )
223
+ }, [name])
224
+
225
+ useLayoutEffect(() => {
226
+ const input = inputRef.current
227
+ if (!input) return
228
+ input.style.height = 'auto'
229
+ input.style.height = `${input.scrollHeight}px`
230
+ input.style.overflowY =
231
+ input.scrollHeight > input.clientHeight ? 'auto' : 'hidden'
232
+ }, [draft])
233
+
234
+ const hasTyping = Object.keys(state.lastTypingAt).length > 0
235
+ useEffect(() => {
236
+ if (!hasTyping) return
237
+ const timer = setInterval(() => setNow(Date.now()), 1_000)
238
+ return () => clearInterval(timer)
239
+ }, [hasTyping])
240
+
241
+ const transcriptSize = state.messages.length + state.posts.length
242
+ useEffect(() => {
243
+ const el = transcriptRef.current
244
+ if (
245
+ el &&
246
+ (previousTranscriptSize.current === 0 || followTranscript.current)
247
+ ) {
248
+ el.scrollTop = el.scrollHeight
249
+ }
250
+ previousTranscriptSize.current = transcriptSize
251
+ }, [transcriptSize])
252
+
253
+ useEffect(() => {
254
+ const dock = composerDockRef.current
255
+ const transcriptElement = transcriptRef.current
256
+ const transcriptContent = transcriptContentRef.current
257
+ if (
258
+ !dock ||
259
+ !transcriptElement ||
260
+ !transcriptContent ||
261
+ typeof ResizeObserver === 'undefined'
262
+ ) {
263
+ return
264
+ }
265
+ const observer = new ResizeObserver(() => {
266
+ if (followTranscript.current) {
267
+ transcriptElement.scrollTop = transcriptElement.scrollHeight
268
+ }
269
+ })
270
+ observer.observe(dock)
271
+ observer.observe(transcriptContent)
272
+ return () => observer.disconnect()
273
+ }, [])
274
+
275
+ const typingAuthors = Object.entries(state.lastTypingAt)
276
+ .filter(([author]) => author !== name)
277
+ .filter(([, at]) => now - Date.parse(at) < TYPING_TTL_MS)
278
+ .map(([author]) => author)
279
+
280
+ const runningTool = state.ai.tools.find((tool) => tool.status === 'running')
281
+ const activeResponseId =
282
+ state.ai.activeGeneration?.responseMessageId ??
283
+ state.ai.activeResponseMessageId ??
284
+ runningTool?.messageId ??
285
+ null
286
+ const activeMessage = activeResponseId
287
+ ? state.ai.messages.find((message) => message.id === activeResponseId)
288
+ : undefined
289
+ const writing =
290
+ activeMessage?.parts.some(
291
+ (part) =>
292
+ part.type === 'tool-post' &&
293
+ (part.state === 'input-streaming' || part.state === 'input-available'),
294
+ ) === true ||
295
+ (runningTool?.toolName === 'post' && runningTool.status === 'running')
296
+ const agentActivity =
297
+ state.ai.status === 'generating' ? (writing ? 'writing' : 'thinking') : null
298
+ const presence = presenceLine(typingAuthors, agentActivity)
299
+
300
+ const aiMessages = new Map(
301
+ state.ai.messages.map((message) => [message.id, message]),
302
+ )
303
+ const lastPostByResponse = new Map<string, (typeof state.posts)[number]>()
304
+ for (const post of state.posts) {
305
+ const current = lastPostByResponse.get(post.responseMessageId)
306
+ if (current === undefined || current.index < post.index) {
307
+ lastPostByResponse.set(post.responseMessageId, post)
308
+ }
309
+ }
310
+ const transcript = buildChatTranscript(state.messages, state.posts)
311
+ const selectedAgentStream = openResponseId
312
+ ? selectAgentStream(state.ai.messages, openResponseId)
313
+ : null
314
+ const selectedStreamSummary = usageLabel(
315
+ selectedAgentStream?.metadata ?? null,
316
+ )
317
+ const selectedStreamLive =
318
+ openResponseId !== null &&
319
+ openResponseId === activeResponseId &&
320
+ state.ai.status === 'generating'
321
+ const selectedStreamStatus = (() => {
322
+ if (selectedStreamLive) return 'streaming'
323
+ if (openResponseId === null) return 'completed'
324
+ for (const event of events.toReversed()) {
325
+ if (
326
+ event.type === 'ai.message.interrupted' &&
327
+ event.payload.messageId === openResponseId
328
+ ) {
329
+ return 'interrupted'
330
+ }
331
+ if (
332
+ event.type === 'ai.generation.failed' &&
333
+ event.payload.responseMessageId === openResponseId
334
+ ) {
335
+ return 'failed'
336
+ }
337
+ if (
338
+ event.type === 'ai.message.completed' &&
339
+ event.payload.messageId === openResponseId
340
+ ) {
341
+ return 'completed'
342
+ }
343
+ }
344
+ return 'completed'
345
+ })() satisfies AgentStreamStatus
346
+
347
+ const participants = useMemo(() => {
348
+ const seen = new Set<string>(['agent'])
349
+ for (const message of state.messages) seen.add(message.author)
350
+ for (const author of Object.keys(state.lastTypingAt)) seen.add(author)
351
+ seen.delete(name)
352
+ return [...seen]
353
+ }, [state.messages, state.lastTypingAt, name])
354
+
355
+ const candidates = mention
356
+ ? participants.filter((participant) =>
357
+ participant.toLowerCase().startsWith(mention.query.toLowerCase()),
358
+ )
359
+ : []
360
+
361
+ const totalTokens = state.ai.usage.reduce(
362
+ (total, entry) => total + (entry.usage.totalTokens ?? 0),
363
+ 0,
364
+ )
365
+ const failedGeneration =
366
+ state.ai.status === 'failed'
367
+ ? events.findLast(
368
+ (
369
+ event,
370
+ ): event is Extract<
371
+ (typeof events)[number],
372
+ { type: 'ai.generation.failed' }
373
+ > =>
374
+ event.type === 'ai.generation.failed' &&
375
+ event.payload.superseded !== true,
376
+ )
377
+ : undefined
378
+ const failedRootMessageId = failedGeneration
379
+ ? state.messages.find(
380
+ (message) =>
381
+ `${message.id}:assistant` ===
382
+ failedGeneration.payload.responseMessageId,
383
+ )?.id
384
+ : undefined
385
+
386
+ const sendTypingPing = useCallback((): void => {
387
+ if (!name) return
388
+ const nowMs = Date.now()
389
+ if (nowMs - lastTypingPush.current < TYPING_PUSH_INTERVAL_MS) return
390
+ lastTypingPush.current = nowMs
391
+ push({ type: 'typed', payload: { author: name } }).catch(() => {
392
+ lastTypingPush.current = 0
393
+ })
394
+ }, [name, push])
395
+
396
+ const onDraftChange = useCallback(
397
+ (value: string, caret: number | null): void => {
398
+ setDraft(value)
399
+ if (value.trim()) sendTypingPing()
400
+ const next = mentionQuery(value, caret ?? value.length)
401
+ setMention(next)
402
+ if (next) setSelected(0)
403
+ },
404
+ [sendTypingPing],
405
+ )
406
+
407
+ useEffect(() => {
408
+ const routeTypingToComposer = (event: globalThis.KeyboardEvent): void => {
409
+ const input = inputRef.current
410
+ const selection = window.getSelection()
411
+ if (
412
+ !name ||
413
+ !input ||
414
+ input.disabled ||
415
+ !isComposerTypingKey(event) ||
416
+ hasImportantFocus(event.target) ||
417
+ hasImportantFocus(document.activeElement) ||
418
+ document.querySelector('dialog[open]') !== null ||
419
+ (selection !== null && !selection.isCollapsed)
420
+ ) {
421
+ return
422
+ }
423
+
424
+ const insertion = insertComposerText(
425
+ input.value,
426
+ event.key,
427
+ input.selectionStart,
428
+ input.selectionEnd,
429
+ )
430
+ event.preventDefault()
431
+ input.focus({ preventScroll: true })
432
+ onDraftChange(insertion.value, insertion.caret)
433
+ requestAnimationFrame(() =>
434
+ input.setSelectionRange(insertion.caret, insertion.caret),
435
+ )
436
+ }
437
+ document.addEventListener('keydown', routeTypingToComposer)
438
+ return () => document.removeEventListener('keydown', routeTypingToComposer)
439
+ }, [name, onDraftChange])
440
+
441
+ const acceptMention = (candidate: string): void => {
442
+ if (!mention) return
443
+ const caret = inputRef.current?.selectionStart ?? draft.length
444
+ const next = `${draft.slice(0, mention.start)}@${candidate} ${draft.slice(caret)}`
445
+ setDraft(next)
446
+ setMention(null)
447
+ const pos = mention.start + candidate.length + 2
448
+ requestAnimationFrame(() => {
449
+ inputRef.current?.focus()
450
+ inputRef.current?.setSelectionRange(pos, pos)
451
+ })
452
+ }
453
+
454
+ const onComposerKeys = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
455
+ if (event.nativeEvent.isComposing) return
456
+ if (mention && candidates.length > 0 && event.key === 'ArrowDown') {
457
+ event.preventDefault()
458
+ setSelected((value) => (value + 1) % candidates.length)
459
+ } else if (mention && candidates.length > 0 && event.key === 'ArrowUp') {
460
+ event.preventDefault()
461
+ setSelected(
462
+ (value) => (value - 1 + candidates.length) % candidates.length,
463
+ )
464
+ } else if (
465
+ mention &&
466
+ candidates.length > 0 &&
467
+ !event.shiftKey &&
468
+ (event.key === 'Enter' || event.key === 'Tab')
469
+ ) {
470
+ event.preventDefault()
471
+ acceptMention(candidates[selected]!)
472
+ } else if (event.key === 'Escape') {
473
+ setMention(null)
474
+ } else if (event.key === 'Enter' && !event.shiftKey) {
475
+ event.preventDefault()
476
+ event.currentTarget.form?.requestSubmit()
477
+ }
478
+ }
479
+
480
+ const focusComposerSurface = (
481
+ event: ReactPointerEvent<HTMLFormElement>,
482
+ ): void => {
483
+ if (
484
+ event.button !== 0 ||
485
+ (event.target as Element).closest(IMPORTANT_FOCUS_SELECTOR) !== null
486
+ ) {
487
+ return
488
+ }
489
+ event.preventDefault()
490
+ inputRef.current?.focus({ preventScroll: true })
491
+ }
492
+
493
+ const trackTranscriptPosition = (): void => {
494
+ const el = transcriptRef.current
495
+ if (!el) return
496
+ followTranscript.current =
497
+ el.scrollHeight - el.scrollTop - el.clientHeight < FOLLOW_DISTANCE_PX
498
+ }
499
+
500
+ const send = async (event: FormEvent): Promise<void> => {
501
+ event.preventDefault()
502
+ const text = draft.trim()
503
+ if (!text || !name) return
504
+ const messageId = crypto.randomUUID()
505
+ setError(null)
506
+ setDraft('')
507
+ setMention(null)
508
+ followTranscript.current = true
509
+ lastTypingPush.current = 0
510
+ requestAnimationFrame(() =>
511
+ inputRef.current?.focus({ preventScroll: true }),
512
+ )
513
+ try {
514
+ // The browser authors only the room fact. A serialized server handler
515
+ // derives AI metadata and generation policy from its durable log index.
516
+ await push({
517
+ type: 'message.sent',
518
+ id: messageId,
519
+ payload: { author: name, text },
520
+ })
521
+ } catch (err) {
522
+ setError(err instanceof Error ? err.message : String(err))
523
+ setDraft((current) => (current.length === 0 ? text : current))
524
+ }
525
+ }
526
+
527
+ const changeAmbient = async (enabled: boolean): Promise<void> => {
528
+ if (!name) return
529
+ setError(null)
530
+ try {
531
+ await push({
532
+ type: 'agent.ambient.changed',
533
+ payload: { enabled, author: name },
534
+ })
535
+ setError(null)
536
+ } catch (err) {
537
+ setError(err instanceof Error ? err.message : String(err))
538
+ }
539
+ }
540
+
541
+ const stopAgent = async (): Promise<void> => {
542
+ if (!activeResponseId) return
543
+ const target = state.ai.activeGeneration
544
+ ? { generationId: state.ai.activeGeneration.generationId }
545
+ : runningTool
546
+ ? { generationId: runningTool.generationId }
547
+ : state.ai.activeRequestId
548
+ ? { requestId: state.ai.activeRequestId }
549
+ : null
550
+ if (target === null) return
551
+ setError(null)
552
+ try {
553
+ await push(
554
+ ...inputs.interrupt({
555
+ messageId: activeResponseId,
556
+ ...target,
557
+ reason: 'a chat participant stopped the agent',
558
+ lastSeenIndex: index,
559
+ }),
560
+ )
561
+ } catch (err) {
562
+ setError(err instanceof Error ? err.message : String(err))
563
+ }
564
+ }
565
+
566
+ const retryAgent = async (): Promise<void> => {
567
+ if (!failedGeneration || !failedRootMessageId) return
568
+ setError(null)
569
+ setRetrying(true)
570
+ try {
571
+ await push(
572
+ ...inputs.retry({
573
+ messageId: failedRootMessageId,
574
+ responseMessageId: failedGeneration.payload.responseMessageId,
575
+ retryId: crypto.randomUUID(),
576
+ }),
577
+ )
578
+ } catch (err) {
579
+ setError(err instanceof Error ? err.message : String(err))
580
+ } finally {
581
+ setRetrying(false)
582
+ }
583
+ }
584
+
585
+ const rename = (value: string): void => {
586
+ const next = value.slice(0, 40)
587
+ setName(next)
588
+ try {
589
+ sessionStorage.setItem(NAME_KEY, next)
590
+ } catch {
591
+ // In-memory identity is enough.
592
+ }
593
+ }
594
+
595
+ const closeAgentStream = (): void => {
596
+ const responseMessageId = openResponseId
597
+ setOpenResponseId(null)
598
+ if (responseMessageId === null) return
599
+ requestAnimationFrame(() => {
600
+ document
601
+ .querySelector<HTMLButtonElement>(
602
+ `[data-agent-stream-id="${CSS.escape(responseMessageId)}"]`,
603
+ )
604
+ ?.focus()
605
+ })
606
+ }
607
+
608
+ return (
609
+ <article className="chat-page">
610
+ <header className="session-header chat-page-header">
611
+ <div className="chat-page-title">
612
+ <p className="eyebrow">Multiplayer room</p>
613
+ <h1>
614
+ Chat <code>{chatId}</code>
615
+ </h1>
616
+ </div>
617
+ <div className="chat-header-meta">
618
+ <ConnectionPill connection={connection} />
619
+ <span className="badge">
620
+ {state.messages.length}{' '}
621
+ {state.messages.length === 1 ? 'message' : 'messages'}
622
+ </span>
623
+ {state.ai.usage.length > 0 ? (
624
+ <span className="badge">
625
+ {totalTokens.toLocaleString()} AI tokens
626
+ </span>
627
+ ) : null}
628
+ {state.ai.status === 'failed' ? (
629
+ <span className="badge cut">agent paused</span>
630
+ ) : null}
631
+ </div>
632
+ <label className="identity-field chat-identity">
633
+ <span>You are</span>
634
+ <input
635
+ aria-label="Your chat name"
636
+ value={name}
637
+ onChange={(event) => rename(event.target.value)}
638
+ placeholder="Joining…"
639
+ disabled={!name && draft.length === 0}
640
+ />
641
+ </label>
642
+ </header>
643
+
644
+ <div className="chat-workspace">
645
+ <section className="chat" aria-label="Conversation">
646
+ <div
647
+ className="chat-scroll"
648
+ ref={transcriptRef}
649
+ onScroll={trackTranscriptPosition}
650
+ >
651
+ <div className="chat-scroll-content" ref={transcriptContentRef}>
652
+ {transcript.length === 0 ? (
653
+ <p className="chat-empty hint">
654
+ {state.ambient.enabled
655
+ ? 'No messages yet. Say something; the agent is listening.'
656
+ : 'No messages yet. Say something, or mention @agent to invite it.'}
657
+ </p>
658
+ ) : (
659
+ <ol
660
+ className="chat-transcript"
661
+ role="log"
662
+ aria-live="polite"
663
+ aria-relevant="additions"
664
+ aria-atomic="false"
665
+ >
666
+ {transcript.map((entry) => {
667
+ const author = entry.author
668
+ const at = entry.at
669
+ const text =
670
+ entry.kind === 'participant'
671
+ ? entry.message.text
672
+ : entry.post.text
673
+ const key =
674
+ entry.kind === 'participant'
675
+ ? `message:${entry.message.id}`
676
+ : `post:${entry.post.generationId}:${entry.post.toolCallId}`
677
+ const responseMessageId =
678
+ entry.kind === 'agent'
679
+ ? entry.post.responseMessageId
680
+ : null
681
+ const responseMessage = responseMessageId
682
+ ? aiMessages.get(responseMessageId)
683
+ : undefined
684
+ const metadata =
685
+ responseMessage?.metadata?.kind === 'agent'
686
+ ? responseMessage.metadata
687
+ : null
688
+ const showStreamTrigger =
689
+ entry.kind === 'agent' &&
690
+ lastPostByResponse.get(entry.post.responseMessageId)
691
+ ?.index === entry.post.index
692
+
693
+ return (
694
+ <li
695
+ className={`msg ${entry.kind}${
696
+ author === name ? ' mine' : ''
697
+ }${entry.showHeader ? ' group-start' : ' grouped'}`}
698
+ key={key}
699
+ >
700
+ <div className="msg-gutter">
701
+ {entry.showHeader ? (
702
+ <span className="msg-avatar" aria-hidden="true">
703
+ {avatarText(author)}
704
+ </span>
705
+ ) : (
706
+ <time
707
+ className="msg-compact-time"
708
+ dateTime={at}
709
+ title={new Date(at).toLocaleString()}
710
+ aria-hidden="true"
711
+ >
712
+ {compactMessageTime(at)}
713
+ </time>
714
+ )}
715
+ </div>
716
+ <div className="msg-body">
717
+ {entry.showHeader ? (
718
+ <header>
719
+ <span className="msg-author">{author}</span>
720
+ <time dateTime={at}>{messageTime(at)}</time>
721
+ </header>
722
+ ) : (
723
+ <span className="sr-only">
724
+ {author} at {messageTime(at)}
725
+ </span>
726
+ )}
727
+ <p className="msg-text">{withMentions(text)}</p>
728
+ {showStreamTrigger && responseMessageId ? (
729
+ <AgentStreamTrigger
730
+ metadata={metadata}
731
+ responseMessageId={responseMessageId}
732
+ onOpen={() =>
733
+ setOpenResponseId(responseMessageId)
734
+ }
735
+ />
736
+ ) : null}
737
+ </div>
738
+ </li>
739
+ )
740
+ })}
741
+ </ol>
742
+ )}
743
+ </div>
744
+ </div>
745
+
746
+ <div className="chat-composer-dock" ref={composerDockRef}>
747
+ {state.ai.status === 'failed' ? (
748
+ <div className="chat-agent-error" role="alert">
749
+ <p>
750
+ <strong>Agent paused.</strong>{' '}
751
+ {state.ai.error ?? 'The model step failed.'}
752
+ </p>
753
+ <button
754
+ type="button"
755
+ className="secondary compact"
756
+ disabled={
757
+ !failedGeneration || !failedRootMessageId || retrying
758
+ }
759
+ onClick={() => void retryAgent()}
760
+ >
761
+ {retrying ? 'Retrying…' : 'Retry agent'}
762
+ </button>
763
+ </div>
764
+ ) : null}
765
+
766
+ {error ? (
767
+ <p className="error chat-send-error" role="alert">
768
+ {error}
769
+ </p>
770
+ ) : null}
771
+
772
+ <form
773
+ className="composer"
774
+ onPointerDown={focusComposerSurface}
775
+ onSubmit={(event) => void send(event)}
776
+ >
777
+ {mention && candidates.length > 0 ? (
778
+ <ul
779
+ className="mention-popover"
780
+ id={MENTION_LIST_ID}
781
+ role="listbox"
782
+ >
783
+ {candidates.map((candidate, candidateIndex) => (
784
+ <li key={candidate}>
785
+ <button
786
+ id={`${MENTION_LIST_ID}-${candidateIndex}`}
787
+ type="button"
788
+ role="option"
789
+ aria-selected={candidateIndex === selected}
790
+ className={candidateIndex === selected ? 'active' : ''}
791
+ onMouseDown={(event) => {
792
+ event.preventDefault()
793
+ acceptMention(candidate)
794
+ }}
795
+ >
796
+ @{candidate}
797
+ </button>
798
+ </li>
799
+ ))}
800
+ </ul>
801
+ ) : null}
802
+ <div className="composer-input-surface">
803
+ <textarea
804
+ ref={inputRef}
805
+ value={draft}
806
+ rows={3}
807
+ onChange={(event) =>
808
+ onDraftChange(
809
+ event.target.value,
810
+ event.target.selectionStart,
811
+ )
812
+ }
813
+ onKeyDown={onComposerKeys}
814
+ placeholder={
815
+ state.ambient.enabled
816
+ ? 'Message the room'
817
+ : 'Message the room — @agent to invite the agent'
818
+ }
819
+ role="combobox"
820
+ aria-label="Message"
821
+ aria-multiline="true"
822
+ aria-autocomplete="list"
823
+ aria-expanded={mention !== null && candidates.length > 0}
824
+ aria-controls={
825
+ mention !== null && candidates.length > 0
826
+ ? MENTION_LIST_ID
827
+ : undefined
828
+ }
829
+ aria-activedescendant={
830
+ mention !== null && candidates.length > 0
831
+ ? `${MENTION_LIST_ID}-${selected}`
832
+ : undefined
833
+ }
834
+ disabled={!name}
835
+ />
836
+ </div>
837
+ <div className="composer-toolbar">
838
+ <div className="composer-mode-group">
839
+ <label className="composer-mode-selector">
840
+ <input
841
+ type="checkbox"
842
+ checked={state.ambient.enabled}
843
+ disabled={!name}
844
+ aria-describedby={COMPOSER_MODE_DESCRIPTION_ID}
845
+ onChange={(event) =>
846
+ void changeAmbient(event.target.checked)
847
+ }
848
+ />
849
+ <span>Ambient</span>
850
+ </label>
851
+ <span
852
+ className="composer-mode-description"
853
+ id={COMPOSER_MODE_DESCRIPTION_ID}
854
+ >
855
+ {state.ambient.enabled
856
+ ? 'Agent sees every message'
857
+ : 'Mention @agent to invite it'}
858
+ </span>
859
+ </div>
860
+ <span className="composer-key-hint" aria-hidden="true">
861
+ Shift + Enter for a new line
862
+ </span>
863
+ <button
864
+ type="submit"
865
+ className="composer-send"
866
+ disabled={!draft.trim() || !name}
867
+ >
868
+ <span>Send</span>
869
+ <span aria-hidden="true">↑</span>
870
+ </button>
871
+ </div>
872
+ </form>
873
+
874
+ <div className="chat-presence-row">
875
+ <p
876
+ className="presence"
877
+ role="status"
878
+ aria-live="polite"
879
+ aria-atomic="true"
880
+ >
881
+ {presence}
882
+ </p>
883
+ {activeResponseId ? (
884
+ <button
885
+ type="button"
886
+ className="danger compact"
887
+ onClick={() => void stopAgent()}
888
+ >
889
+ Stop agent
890
+ </button>
891
+ ) : null}
892
+ </div>
893
+ </div>
894
+ </section>
895
+
896
+ <aside className={`chat-sidebar${sidebarOpen ? ' open' : ''}`}>
897
+ <button
898
+ type="button"
899
+ className="chat-sidebar-toggle"
900
+ aria-expanded={sidebarOpen}
901
+ aria-controls="chat-sidebar-body"
902
+ onClick={() => setSidebarOpen((open) => !open)}
903
+ >
904
+ <span>Room details</span>
905
+ <span className="badge">{events.length} events</span>
906
+ </button>
907
+ <div className="chat-sidebar-body" id="chat-sidebar-body">
908
+ <ActivityFeed events={events} index={index} />
909
+ </div>
910
+ </aside>
911
+ </div>
912
+
913
+ <AgentStreamDrawer
914
+ responseMessageId={openResponseId}
915
+ selection={selectedAgentStream}
916
+ summary={selectedStreamSummary}
917
+ status={selectedStreamStatus}
918
+ onClose={closeAgentStream}
919
+ />
920
+ </article>
921
+ )
922
+ }