-
Notifications
You must be signed in to change notification settings - Fork 662
Expand file tree
/
Copy pathAgentConversation.tsx
More file actions
710 lines (669 loc) · 36.8 KB
/
Copy pathAgentConversation.tsx
File metadata and controls
710 lines (669 loc) · 36.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
import {useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject} from "react"
import {
contextWindowForModel,
harnessCapabilitiesAtomFamily,
modalitiesForModel,
workflowMolecule,
} from "@agenta/entities/workflow"
import {simulatedAgentRunAtomFamily} from "@agenta/shared/state"
import {type RichChatInputHandle} from "@agenta/ui/rich-chat-input"
import {UploadSimple} from "@phosphor-icons/react"
import {type FileUIPart, type UIMessage} from "ai"
import {Modal} from "antd"
import {useAtomValue, useSetAtom, useStore} from "jotai"
import {ContextRail} from "@/oss/components/Drives/ContextRail"
import {DriveFileLinkProvider} from "@/oss/components/Drives/DriveFileLinkProvider"
import {DriveSessionProvider} from "@/oss/components/Drives/driveSessionContext"
import {
SessionFilesDrawer,
filesDrawerOpenAtomFamily,
filesDrawerStagedAtomFamily,
} from "@/oss/components/Drives/SessionFilesDrawer"
import {TEMPLATE_STRIP_MODE} from "@/oss/components/pages/agent-home/assets/constants"
import {openTraceDrawerAtom} from "@/oss/components/SharedDrawers/TraceDrawer/store/traceDrawerStore"
import {STRIP_COPY} from "@/oss/components/TemplateStrip/assets/constants"
import CopiedToast from "@/oss/components/TemplateStrip/components/CopiedToast"
import {describeAccepted} from "./assets/attachments"
import {CONTENT_VISIBILITY_ENABLED} from "./assets/conversationLayout"
import {filesToParts} from "./assets/files"
import {isEmptyAssistantTurn, isVisiblePart} from "./assets/messageParts"
import {messageText, sideEffectingToolsInRange} from "./assets/rewind"
import {ignoreStreamRejection} from "./assets/runError"
import {getMessageTraceId} from "./assets/trace"
import AgentComposerDock from "./components/AgentComposerDock"
import AgentTranscript from "./components/AgentTranscript"
import AgentTurn from "./components/AgentTurn"
import {getPendingApprovals} from "./components/ApprovalDock"
import AttachmentViewerDrawer from "./components/AttachmentViewerDrawer"
import {Inspector} from "./components/Inspector/Inspector"
import {getPendingConnectInteraction} from "./components/InteractionDock"
import RightPanelSplit from "./components/RightPanel/RightPanelSplit"
import TranscriptPlaceholder from "./components/TranscriptPlaceholder"
import {useAgentChatQueue, type QueuedMessage} from "./hooks/useAgentChatQueue"
import {useAgentChatSession} from "./hooks/useAgentChatSession"
import {useAgentModelKeyStatus} from "./hooks/useAgentModelKeyStatus"
import {useComposerAttachments} from "./hooks/useComposerAttachments"
import {useComposerDraft} from "./hooks/useComposerDraft"
import {useFirstRunSeed} from "./hooks/useFirstRunSeed"
import {useOnboardingChat} from "./hooks/useOnboardingChat"
import {useScrollIntent} from "./hooks/useScrollIntent"
import {useTranscriptScroll} from "./hooks/useTranscriptScroll"
import {useTurnInspector} from "./hooks/useTurnInspector"
import {useVirtuosoTranscript} from "./hooks/useVirtuosoTranscript"
import {useVoiceComposer} from "./hooks/useVoiceComposer"
import {useChatScopeKey} from "./state/scope"
import {clearSessionFresh} from "./state/sessionEphemera"
import {
type SessionRunStatus,
activeSessionIdAtomFamily,
autoTitleSessionAtomFamily,
bumpSessionActivityAtomFamily,
firstUserText,
sessionMessagesAtom,
setSessionStatusAtom,
} from "./state/sessions"
/**
* One agent conversation for a single session tab. A `useChat` whose transport is fed by the
* PLAYGROUND request builder (`buildAgentRequest`) — the entity supplies the config/auth/
* references, the session id is the tab's id and travels to the backend as `session_id`.
* Messages persist to localStorage (seeded on mount, written when the stream settles) so the
* tab survives a reload / revision swap.
*
* Design decisions baked in (docs/design/agent-workflows/playground-agent-generation.md):
* - D9 teardown: abort the in-flight stream on unmount (tab close / revision swap).
* - DT3 cancelled state: a stopped stream tags its partial bubble "Stopped" + offers Resend.
* - DT4 autoscroll: stick to bottom while streaming; pause when scrolled up; "jump to latest".
* - DT5 a11y: the message log is an aria-live region; controls are keyboard-operable.
*/
const AgentConversation = ({
entityId,
sessionId,
revealPlayedRef,
}: {
entityId: string
sessionId: string
/** Shared across the panel's session panes: the composer entrance plays only once. */
revealPlayedRef: MutableRefObject<boolean>
}) => {
const store = useStore()
// Workflow artifact id for this conversation — the key for the agent's durable `agent-files`
// mount, folded into the session drive by the Drive surfaces below (via the drive context).
const artifactId = useAtomValue(workflowMolecule.selectors.workflowId(entityId))
const setSessionStatus = useSetAtom(setSessionStatusAtom)
// Seed once from the persisted store (read imperatively so our own writes don't feed back).
const [initialMessages] = useState(() => store.get(sessionMessagesAtom)[sessionId] ?? [])
// Themed confirm dialogs. The static `Modal.confirm` renders detached from the app's
// ConfigProvider, so it loses the theme (white box in dark mode). The hook form's
// `contextHolder` is rendered in-tree, so its dialogs inherit the theme — same look as the
// declarative EnhancedModal (centered, 16px radius).
const [modal, modalContextHolder] = Modal.useModal()
const richInputRef = useRef<RichChatInputHandle>(null)
const composer = useComposerDraft({sessionId, richInputRef, revealPlayedRef})
// Pending attachments for this session + the whole-panel drop target.
const attachments = useComposerAttachments({sessionId})
const {
uploadsEnabled,
files,
viewingUid,
setViewingUid,
limits,
atMax,
attachmentsSettled,
isDragging,
addFiles,
} = attachments
// What the transcript should do next (follow / arm a pin / show the pill), shared by the
// producers below (send, queue release, history adoption) and whichever scroll engine is
// active. Declared here so every producer can state intent without touching the DOM.
const scrollIntent = useScrollIntent({initialArmed: initialMessages.length > 0})
const {showJump} = scrollIntent
// The chat stream for this tab: transport, useChat, history hydration, persistence,
// self-commit pickup, stop/kill and teardown.
const {
messages,
status,
busy,
error,
sendMessage,
regenerate,
setMessages,
addToolApprovalResponse,
messagesRef,
busyRef,
isHydrating,
hydratedEmpty,
stopped,
setStopped,
handleStop,
handleClientToolOutput,
markLiveGate,
resumeOrphaned,
isSeen,
runningElsewhere,
} = useAgentChatSession({entityId, sessionId, initialMessages, intent: scrollIntent})
// Turn Inspector: open state, the focused turn, and the assistant → turn-number mapping.
const {
buildMode,
inspectorEnabled,
inspectorOpen,
inspectedTurn,
openInspectorTurn,
turnNumbers,
} = useTurnInspector({sessionId, messages})
// Quick Look + Files-window hosts: cards/tiles/rail request via atoms; these resolve against
// THIS conversation's drive: the link provider makes filename mentions clickable, and the
// Files drawer (below) hosts both the grid and the single-file preview in one surface.
const quickLookHost = <DriveFileLinkProvider sessionId={sessionId} artifactId={artifactId} />
const filesWindowHost = <SessionFilesDrawer sessionId={sessionId} />
const setFilesWindowOpen = useSetAtom(filesDrawerOpenAtomFamily(sessionId))
const setFilesStaged = useSetAtom(filesDrawerStagedAtomFamily(sessionId))
// ── "Run in playground" seam (producer: a trigger drawer's Run-in-playground) ──
// A trigger fires server-side and never reaches the playground; this lets a user
// channel a trigger's resolved inputs into the active session. Only the ACTIVE
// session's conversation consumes the pending run (antd Tabs can keep inactive
// panes mounted), sends it as a user turn, and clears it. A monotonic nonce lets
// the same inputs run again; a ref guards double-firing. The consuming effect lives
// below `useAgentChatQueue` so the run goes through the same `submit` path as a manual
// send — respecting a pending HITL approval and any queued messages instead of jumping
// ahead with a raw `sendMessage`.
const scopeKey = useChatScopeKey()
const activeSessionId = useAtomValue(activeSessionIdAtomFamily(scopeKey))
const pendingRun = useAtomValue(simulatedAgentRunAtomFamily(entityId))
const setPendingRun = useSetAtom(simulatedAgentRunAtomFamily(entityId))
// Auto-name the session from its first user message so the durable list is labeled everywhere
// (cross-tab / cross-device) before it's opened. The atom no-ops once the session has a title,
// so this fires at most once and never overwrites an explicit rename.
const autoTitleSession = useSetAtom(autoTitleSessionAtomFamily(scopeKey))
const firstUserMessage = useMemo(() => firstUserText(messages), [messages])
useEffect(() => {
if (firstUserMessage) autoTitleSession({id: sessionId, text: firstUserMessage})
}, [firstUserMessage, sessionId, autoTitleSession])
// Stamp last-message time when a live turn finishes streaming (issue #5553: order history by
// last message). Gated on the streaming→settled transition so hydration/restore — which sets
// messages while `status` stays "ready" — never back-dates an old session to "now".
const bumpSessionActivity = useSetAtom(bumpSessionActivityAtomFamily(scopeKey))
const prevStatusRef = useRef(status)
useEffect(() => {
if (prevStatusRef.current === "streaming" && status !== "streaming") {
bumpSessionActivity(sessionId)
}
prevStatusRef.current = status
}, [status, sessionId, bumpSessionActivity])
// Model connection: is the project vault empty (no key of any kind), the agent not self-managed,
// and the user never set up a key before? Drives the connect-a-model banner AND disables the
// composer until connected — see `gateActive` on `useAgentModelKeyStatus` for the full chain.
const modelKey = useAgentModelKeyStatus(entityId)
const modelBlocked = modelKey.gateActive
// Context-window denominator for the token-budget indicator: the SDK model catalog's own
// `context_window`, delivered on the (global) harness-capabilities document — never hardcoded.
const harnessCapabilities = useAtomValue(harnessCapabilitiesAtomFamily(""))
const contextMaxTokens = useMemo(
() => contextWindowForModel(harnessCapabilities, modelKey.harness, modelKey.model),
[harnessCapabilities, modelKey.harness, modelKey.model],
)
// Feature flag: the context-budget meter is hidden from the composer for now. The
// component and its logic stay wired up; flip this to `true` to bring the UI back.
const showContextBudget = false
/**
* Whether the selected model can actually take audio in. `null` means the catalog does not
* say — treated as unknown, never as "no", so a missing field can't quietly demote voice.
* Drives which voice mode leads; it never refuses an attachment (design decision D6).
*/
const audioPerceivable = useMemo(() => {
const modalities = modalitiesForModel(harnessCapabilities, modelKey.harness, modelKey.model)
return modalities ? modalities.includes("audio") : null
}, [harnessCapabilities, modelKey.harness, modelKey.model])
// Playground-native onboarding: the hero, Create-agent / Continue-in-IDE, the template strip
// and the optimistic first turn. Every value is inert outside the onboarding playground.
const onboardingChat = useOnboardingChat({
entityId,
richInputRef,
messages,
setMessages,
setStopped,
intent: scrollIntent,
})
const {onboardingActive, ideHandoffActive} = onboardingChat
// The composer's voice surface. A finished take either becomes the message outright (empty
// composer) or joins the tray — `handleSubmit` / `addFiles` are declared around it, so both
// are reached through the callbacks rather than by ordering the hooks around them.
const voice = useVoiceComposer({
richInputRef,
stagedCount: files.length,
onAttach: (file) => addFiles([file]),
onSendVoiceMessage: (file) => handleSubmit("", [file]),
})
const {voiceRecorder} = voice
// While a take is in flight the composer is covered by the recording bar, and a drop landing
// now could take the last tray slot — which would reject (and destroy) the recording on
// attach. Guarded at the entry points, not in `addFiles`, because the recorder's own
// completion goes straight there and must always get through.
/**
* Attachments are refused while a take is in flight (the composer is covered, and a late drop
* could steal the tray slot the recording needs) and while the composer itself is disabled —
* accepting files into an input you cannot send from is a dead end.
*/
const composerDisabled = onboardingActive ? ideHandoffActive : modelBlocked
const attachmentsBlocked = () => voiceRecorder.active || composerDisabled
const dropTarget = attachments.bindDropTarget(attachmentsBlocked)
// First-run seed + its overlay-gated auto-start. `handleSubmit` is declared below, so the
// seed fires through a ref.
const handleSubmitRef = useRef<(text: string) => void | Promise<void>>(() => {})
const {firstRunPrompt} = useFirstRunSeed({
entityId,
sessionId,
activeSessionId,
messagesCount: messages.length,
modelBlocked,
handleSubmitRef,
})
const consumedRunNonceRef = useRef<number | null>(null)
// Send one released queued message. Stable (only depends on `sendMessage`) so the queue's
// release effect doesn't churn on every token.
const sendQueued = useCallback(
(item: QueuedMessage) => {
scrollIntent.follow()
// A real send means this session has run — drop the never-run marker so a later
// cache-cleared reopen hydrates from the server.
clearSessionFresh(sessionId)
// Any actual send supersedes a prior user-stop, so clear the marker here (covers the
// queue-release path; the manual path also clears it in handleSubmit) — otherwise the
// "Stopped" tag would smear onto the freshly-sent turn.
setStopped(false)
sendMessage(
item.fileParts && item.fileParts.length
? item.text
? {text: item.text, files: item.fileParts}
: {files: item.fileParts}
: {text: item.text},
).catch(ignoreStreamRejection)
},
[sendMessage, sessionId],
)
// Queue messages typed while a turn is streaming or paused on a HITL approval; released
// one-by-one once the turn truly settles (never mid-approval). A user stop is the exception —
// it voids the pending gate, so `stopped` lets a fresh send go immediately (not queue). An
// orphaned restored resume shape (reload mid-approval-resume) voids it the same way.
const {queued, submit, removeQueued, clearQueue, hitlPending} = useAgentChatQueue({
status,
messages,
stopped,
resumeOrphaned,
sendQueued,
sessionId,
})
// Approval responses flow through here (not bare `addToolApprovalResponse`) so a decision made
// in THIS mount marks the resume as live — a restored approval-requested tail the user answers
// after a reload genuinely auto-resumes, so the queue's pre-resume hold must apply to it.
const handleApprovalResponse = useCallback(
(args: {id: string; approved: boolean; message?: string}) => {
markLiveGate({kind: "approval", id: args.id})
addToolApprovalResponse({id: args.id, approved: args.approved})
// Steer: a denial that carries a redirect answers the gate AND sends the instruction as a
// follow-up turn. It must be its OWN turn, not bundled into the deny-resume: resuming a
// parked gate calls `respondPermission(reject)`, which makes the harness CONTINUE the
// original prompt (run-turn.ts) — so a note fused into that resume gets subordinated to
// the original intent and ignored. As a separate turn it reliably drives the redirect.
// (The model still reasons about the bare denial first — the "flail" — because the
// harness owns the reject continuation and exposes no reject-with-feedback seam; killing
// that flail needs an upstream ACP change, not an FE one.)
const steer = args.message?.trim()
if (!args.approved && steer) submit({text: steer})
},
[addToolApprovalResponse, markLiveGate, submit],
)
// Pending HITL gates for the paused turn, surfaced in the persistent ApprovalDock above the
// composer (not inline in the transcript, so a paused run can't scroll out of reach). Trace
// opens the paused turn's own trace drawer.
const openTraceDrawer = useSetAtom(openTraceDrawerAtom)
const pendingApprovals = useMemo(() => getPendingApprovals(messages), [messages])
// Parked connect interaction on the paused turn → the InteractionDock owns its actions (the
// inline row is a passive marker). Gated off while busy (`input-streaming` isn't parked yet)
// and after a user stop (the run is dead, nothing to settle — matches the queue's stop void).
const pendingInteraction = useMemo(
() => (busy || stopped ? null : getPendingConnectInteraction(messages)),
[messages, busy, stopped],
)
const openPausedTurnTrace = useMemo(() => {
const last = messages[messages.length - 1]
const traceId = last ? getMessageTraceId(last) : undefined
return traceId ? () => openTraceDrawer({traceId}) : undefined
}, [messages, openTraceDrawer])
// Publish this session's run state (single source of truth: drives the tab bar's status dot
// AND the Session inspector's live-watcher signal, which derives "streaming" from `running`).
// Precedence error > awaiting approval > running > idle. Reset to idle on unmount so a closed
// tab keeps no stale dot and stops claiming it's the live watcher.
useEffect(() => {
const status: SessionRunStatus = error
? "error"
: hitlPending
? "awaiting"
: busy
? "running"
: "idle"
setSessionStatus({id: sessionId, status})
}, [error, hitlPending, busy, sessionId, setSessionStatus])
useEffect(
() => () => setSessionStatus({id: sessionId, status: "idle"}),
[sessionId, setSessionStatus],
)
// Consume a pending "Run in playground" request (declared above) via the queue's `submit`,
// so it interleaves with HITL approval / queued messages exactly like a manual send.
useEffect(() => {
if (!pendingRun || activeSessionId !== sessionId) return
// A new-session run is handled at the panel level first (it creates + activates a fresh
// session and clears the flag); this per-session consumer ignores it until then.
if (pendingRun.newSession) return
if (consumedRunNonceRef.current === pendingRun.nonce) return
consumedRunNonceRef.current = pendingRun.nonce
scrollIntent.follow()
submit({text: pendingRun.text})
setPendingRun(null)
}, [pendingRun, activeSessionId, sessionId, submit, setPendingRun])
// Exactly one scroll engine owns the transcript: Virtuoso when it's enabled in the playground
// settings, the SC-1..4 DOM engine otherwise (each bails on the other's flag). Both act on the
// shared `scrollIntent`, so producers never care which is live.
const virt = useVirtuosoTranscript({intent: scrollIntent, sessionId, messages, status})
const useVirtuoso = virt.enabled
const scroll = useTranscriptScroll({
intent: scrollIntent,
messages,
status,
useVirtuoso,
})
const handleSubmit = async (text: string, extraFiles: File[] = []) => {
const trimmed = text.trim()
const fileObjs = [
...files
.map((f) => f.originFileObj as File | undefined)
.filter((f): f is File => Boolean(f)),
...extraFiles,
]
if (!trimmed && fileObjs.length === 0) return
if (!attachmentsSettled) return
let fileParts: FileUIPart[] | undefined
if (fileObjs.length) {
const {parts, unreadable} = await filesToParts(fileObjs)
// Hold the send rather than quietly dropping bytes the user staged, and say which file
// failed through the same inline channel the other attachment refusals use.
if (unreadable.length) {
attachments.setRejections(
unreadable.map((f) => ({
name: f.name,
reason: "couldn't be read — remove it and attach it again",
})),
)
attachments.setAttachmentsOpen(true)
return
}
fileParts = parts
}
// Glide to the bottom; the min-h-full active turn makes that show the new question at the top
// with the answer streaming below. Park during the glide, follow again on settle. Clear any
// prior "stopped" marker — it's resolved by asking again.
scrollIntent.armGlide()
setStopped(false)
// One path: `submit` sends now or queues behind held messages via the shared release gate.
submit({text: trimmed, fileParts})
// The message left the composer — drop its persisted draft (and any pending capture).
composer.clearDraft()
onboardingChat.consumeTemplateProvenance()
attachments.clearAttachments()
}
handleSubmitRef.current = handleSubmit
const handleRewind = useCallback(
(message: UIMessage) => {
const msgs = messagesRef.current
if (busyRef.current) return
const idx = msgs.findIndex((m) => m.id === message.id)
if (idx < 0) return
const isUser = message.role === "user"
const sideEffects = sideEffectingToolsInRange(msgs.slice(idx))
const run = () => {
if (isUser) {
setMessages(msgs.slice(0, idx))
richInputRef.current?.setMarkdown(messageText(message))
requestAnimationFrame(() => richInputRef.current?.focus())
} else {
regenerate({messageId: message.id}).catch(ignoreStreamRejection)
}
}
if (sideEffects.length > 0) {
modal.confirm({
title: "Rewind past a tool that already ran?",
content: `${sideEffects.join(", ")} already executed. Rewinding re-runs the conversation from here but will NOT undo it.`,
okText: "Rewind anyway",
okButtonProps: {danger: true},
cancelText: "Cancel",
centered: true,
style: {borderRadius: 16},
onOk: run,
})
} else {
run()
}
},
[regenerate, setMessages, modal],
)
// Group the ACTIVE turn (the last user message + its response) into one wrapper that carries the
// fill. Keeping the fill on a STABLE element — not hopping it from the user bubble to the assistant
// bubble when the answer arrives — avoids the mid-stream layout jump.
const lastUserIndex = (() => {
for (let i = messages.length - 1; i >= 0; i--) if (messages[i].role === "user") return i
return -1
})()
const activeStart = lastUserIndex >= 0 ? lastUserIndex : messages.length
// The fill = min-h-full on the active turn whenever there's PRIOR conversation above it (so the
// question can sit at the top). Derived from layout, NOT from `busy` — so it persists when the turn
// settles instead of being yanked away (which clamped the scroll and jumped the view).
const reserveActive = activeStart > 0
// Stable per-session callbacks so <AgentTurn>'s memo holds across streamed commits.
const handleInspectTurn = useCallback(
(turn: number) => openInspectorTurn({sessionId, turn}),
[openInspectorTurn, sessionId],
)
const handleResend = useCallback(
(messageId: string) => {
setStopped(false)
regenerate({messageId}).catch(ignoreStreamRejection)
},
[regenerate, setStopped],
)
const renderMessage = (message: UIMessage, index: number) => {
const isLast = index === messages.length - 1
const isAssistantTurn = message.role === "assistant"
const turn = turnNumbers.get(message.id)
const isInspected = isAssistantTurn && inspectedTurn != null && turn === inspectedTurn
return (
<AgentTurn
key={message.id}
message={message}
// New since mount → fade in once. seenIdsRef is marked in an effect after commit,
// never during render (unsafe under StrictMode's double invoke).
enter={!isSeen(message.id)}
isLast={isLast}
isStreaming={busy && isLast}
precededByEmptyAssistant={index > 0 && isEmptyAssistantTurn(messages[index - 1])}
// A user turn has no trace of its own; borrow the paired (next) assistant turn's
// trace so its timestamp dates from the run, not this browser's first-seen stamp.
turnTraceId={
message.role === "user" && messages[index + 1]
? getMessageTraceId(messages[index + 1])
: undefined
}
inspected={isInspected}
// Whenever the inspector PANEL is open, every assistant turn is click-to-focus —
// gated on the panel being open, not on a turn already being focused.
rowInspectable={inspectorOpen && isAssistantTurn}
showInspect={inspectorEnabled && buildMode && isAssistantTurn}
turn={turn}
onInspectTurn={handleInspectTurn}
showWorking={
isLast && busy && (!isAssistantTurn || message.parts.some(isVisiblePart))
}
// Paused on the user (never concurrently with showWorking — hitlPending implies not
// busy): keeps the turn from reading as finished while the queue holds sends.
showWaiting={isLast && isAssistantTurn && !busy && hitlPending}
showStopped={stopped && isLast && isAssistantTurn}
resendDisabled={busy}
onResend={handleResend}
onRewind={handleRewind}
onClientToolOutput={handleClientToolOutput}
// Content-visibility on settled rows — gated by CONTENT_VISIBILITY_ENABLED
// (currently off) and never under Virtuoso (it windows + corrupts measurement).
offscreenSkip={CONTENT_VISIBILITY_ENABLED && !useVirtuoso && index < activeStart}
/>
)
}
return (
// Ambient drive session: in-thread file cards + rail resolve files against THIS
// conversation without prop-threading through the message tree.
<DriveSessionProvider sessionId={sessionId} artifactId={artifactId}>
<div className="ag-canvas relative flex h-full min-h-0 w-full flex-row" {...dropTarget}>
{/* Themed confirm dialogs (rewind-past-a-tool) mount through this holder. */}
{modalContextHolder}
{quickLookHost}
{filesWindowHost}
{uploadsEnabled ? (
<AttachmentViewerDrawer
uploads={files}
openUid={viewingUid}
onClose={() => setViewingUid(null)}
/>
) : null}
{/* Resizable [chat | right panel] split. The panel (turn inspector OR session content)
pushes the chat aside rather than overlaying it, and collapses to 0 when closed. */}
<RightPanelSplit
open={inspectorOpen}
// Same bar inset as the transcript column: the Inspector is a separate split pane,
// so it needs its own top padding to clear the absolute session bar in build mode
// (the context rail deliberately does NOT get it, so it never rides the transition).
panel={
<div className="box-border h-full pt-[var(--agent-bar-inset,0px)] motion-safe:transition-[padding-top] motion-safe:duration-[240ms] motion-safe:ease-[cubic-bezier(0.4,0,0.2,1)]">
<Inspector sessionId={sessionId} />
</div>
}
>
<div className="flex h-full min-h-0 w-full min-w-0">
{/* Top padding tracks the session bar (--agent-bar-inset, published by
AgentChatPanel and inherited here): the transcript eases down under the
absolute bar in build and reclaims the space in chat. It lives on the
TRANSCRIPT COLUMN alone — not a shared ancestor — so the context rail
beside it keeps a fixed top and doesn't ride the transition upward.
box-border so the padding fits inside h-full (preflight is off). */}
<div className="relative flex h-full min-h-0 w-full min-w-0 flex-col gap-3 box-border pt-[var(--agent-bar-inset,0px)] motion-safe:transition-[padding-top] motion-safe:duration-[240ms] motion-safe:ease-[cubic-bezier(0.4,0,0.2,1)]">
{/* At the limit the overlay says so rather than inviting a drop it is
about to reject wholesale. */}
{isDragging && (
<div
className={`pointer-events-none absolute inset-0 z-30 flex flex-col items-center justify-center gap-1.5 rounded-lg border-2 border-dashed ${
atMax
? "border-colorError bg-[var(--ant-color-error-bg)]"
: "border-colorPrimary bg-[var(--ant-color-primary-bg)]"
}`}
>
<UploadSimple
size={26}
className={atMax ? "text-colorError" : "text-colorPrimary"}
/>
<span
className={`text-sm font-medium ${
atMax ? "text-colorError" : "text-colorPrimary"
}`}
>
{atMax ? "Attachment limit reached" : "Drop files here"}
</span>
<span className="text-xs text-colorTextSecondary">
{atMax
? `Remove one to add another (${limits.maxCount} max)`
: `${describeAccepted(limits)} · up to ${limits.maxCount} files`}
</span>
</div>
)}
{/* Stream errors are surfaced inline on the failing turn (red error bubble with the
real reason), stamped in the effect above — no separate top-level banner. */}
<AgentTranscript
messages={messages}
activeStart={activeStart}
reserveActive={reserveActive}
renderMessage={renderMessage}
virt={virt}
scroll={scroll}
showJump={showJump}
placeholder={
<TranscriptPlaceholder
entityId={entityId}
pendingFirstTurn={onboardingChat.pendingFirstTurn}
pendingFirstMessage={onboardingChat.pendingFirstMessage}
onboardingActive={onboardingActive}
browseAll={onboardingChat.onboarding?.browseAll}
isHydrating={isHydrating}
hydratedEmpty={hydratedEmpty}
firstRunPrompt={firstRunPrompt}
canStart={!modelBlocked}
onStart={handleSubmit}
onPrefill={(text: string) =>
richInputRef.current?.setMarkdown(text)
}
onRewind={handleRewind}
onClientToolOutput={handleClientToolOutput}
/>
}
/>
<AgentComposerDock
entityId={entityId}
messages={messages}
busy={busy}
runningElsewhere={runningElsewhere}
hitlPending={hitlPending}
queue={{queued, removeQueued, clearQueue}}
modelKey={modelKey}
modelBlocked={modelBlocked}
contextMaxTokens={contextMaxTokens}
showContextBudget={showContextBudget}
buildMode={buildMode}
firstRunPrompt={firstRunPrompt}
pendingApprovals={pendingApprovals}
onApprovalResponse={handleApprovalResponse}
onViewTrace={openPausedTurnTrace}
pendingInteraction={pendingInteraction}
onClientToolOutput={handleClientToolOutput}
onSubmit={handleSubmit}
onStop={handleStop}
richInputRef={richInputRef}
composer={composer}
attachments={attachments}
onboardingChat={onboardingChat}
voice={voice}
audioPerceivable={audioPerceivable}
composerDisabled={composerDisabled}
attachmentsBlocked={attachmentsBlocked}
/>
</div>
{/* Chat-mode context rail (spec E1): docked right of the transcript, Files
pinned on top. Always mounted so hide/show SLIDES (width transition) —
hidden in build mode and while the Turn/Session panel owns the right
edge. */}
<ContextRail
sessionId={sessionId}
busy={busy}
hidden={buildMode || inspectorOpen}
onOpenFiles={() => setFilesWindowOpen(true)}
onStageFiles={
uploadsEnabled ? (files) => setFilesStaged(files) : undefined
}
/>
</div>
</RightPanelSplit>
{TEMPLATE_STRIP_MODE ? (
<CopiedToast
open={onboardingChat.copiedToastOpen}
text={STRIP_COPY.copiedToast}
onDone={() => onboardingChat.setCopiedToastOpen(false)}
/>
) : null}
</div>
</DriveSessionProvider>
)
}
export default AgentConversation