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.
- package/AGENTS.md +11 -0
- package/CHANGELOG.md +8 -0
- package/README.md +29 -0
- package/docs/guides/07-examples.mdx +56 -0
- package/docs/index.mdx +13 -0
- package/examples/README.md +15 -0
- package/examples/playground/AGENTS.md +11 -0
- package/examples/playground/DEPLOY.md +106 -0
- package/examples/playground/README.md +19 -0
- package/examples/playground/activity-feed.test.ts +10 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
- package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
- package/examples/playground/app/agent/events/route.ts +4 -0
- package/examples/playground/app/agent/model.ts +3 -0
- package/examples/playground/app/agent/new-agent-session.tsx +98 -0
- package/examples/playground/app/agent/page.tsx +25 -0
- package/examples/playground/app/agent/scheduler/route.ts +5 -0
- package/examples/playground/app/agent/server.ts +153 -0
- package/examples/playground/app/agent/session.ts +13 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
- package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
- package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
- package/examples/playground/app/canvas/events/route.ts +13 -0
- package/examples/playground/app/canvas/model.ts +94 -0
- package/examples/playground/app/canvas/open-canvas.tsx +40 -0
- package/examples/playground/app/canvas/page.tsx +20 -0
- package/examples/playground/app/canvas/server.ts +9 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
- package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
- package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
- package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
- package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
- package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
- package/examples/playground/app/chat/[chatId]/session.ts +7 -0
- package/examples/playground/app/chat/events/route.ts +7 -0
- package/examples/playground/app/chat/model.test.ts +155 -0
- package/examples/playground/app/chat/model.ts +310 -0
- package/examples/playground/app/chat/new-conversation.tsx +16 -0
- package/examples/playground/app/chat/page.tsx +25 -0
- package/examples/playground/app/chat/scheduler/route.ts +5 -0
- package/examples/playground/app/chat/server.ts +184 -0
- package/examples/playground/app/components/activity-feed.tsx +54 -0
- package/examples/playground/app/components/connection-pill.tsx +29 -0
- package/examples/playground/app/counter/counter-client.tsx +72 -0
- package/examples/playground/app/counter/events/route.ts +4 -0
- package/examples/playground/app/counter/model.test.ts +36 -0
- package/examples/playground/app/counter/model.ts +31 -0
- package/examples/playground/app/counter/page.tsx +24 -0
- package/examples/playground/app/counter/server.ts +9 -0
- package/examples/playground/app/counter/session.ts +13 -0
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
- package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
- package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
- package/examples/playground/app/documents/[documentId]/session.ts +7 -0
- package/examples/playground/app/documents/events/route.ts +4 -0
- package/examples/playground/app/documents/model.ts +55 -0
- package/examples/playground/app/documents/open-document.tsx +40 -0
- package/examples/playground/app/documents/page.tsx +22 -0
- package/examples/playground/app/documents/server.ts +9 -0
- package/examples/playground/app/globals.css +2078 -0
- package/examples/playground/app/layout.tsx +44 -0
- package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
- package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
- package/examples/playground/app/orders/[orderId]/session.ts +11 -0
- package/examples/playground/app/orders/create/route.ts +30 -0
- package/examples/playground/app/orders/events/route.ts +4 -0
- package/examples/playground/app/orders/model.ts +79 -0
- package/examples/playground/app/orders/new-order-form.tsx +98 -0
- package/examples/playground/app/orders/page.tsx +22 -0
- package/examples/playground/app/orders/scheduler/route.ts +5 -0
- package/examples/playground/app/orders/server.ts +50 -0
- package/examples/playground/app/page.tsx +102 -0
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
- package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
- package/examples/playground/app/recovery/events/route.ts +3 -0
- package/examples/playground/app/recovery/model.ts +55 -0
- package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
- package/examples/playground/app/recovery/page.tsx +22 -0
- package/examples/playground/app/recovery/scheduler/route.ts +7 -0
- package/examples/playground/app/recovery/server.ts +53 -0
- package/examples/playground/app/recovery/start/route.ts +41 -0
- package/examples/playground/css.d.ts +4 -0
- package/examples/playground/lib/store.ts +15 -0
- package/examples/playground/next-env.d.ts +5 -0
- package/examples/playground/next.config.ts +10 -0
- package/examples/playground/package.json +46 -0
- package/examples/playground/tsconfig.json +37 -0
- package/examples/playground/vercel.json +40 -0
- 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
|
+
}
|