diff --git a/packages/ui/README.md b/packages/ui/README.md
index 996f3b30..db47abd6 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -834,6 +834,18 @@ The chat's links (each conversation, a new chat, attachments, and the
these links, so `navigation` handles only the moves that aren't clicks, such as
keyboard shortcuts.
+`ChatThread` is `SessionView` bound to the chat provider. A host with its own
+transcript, such as an agent session fed by an event stream, renders
+`SessionView` with its messages, streaming and queued messages, and callbacks:
+`onSend`, and `turn` (`running`, `onStop`) for a turn the provider doesn't run.
+What it leaves out it doesn't offer: `onEdit`, `onRegenerate`, `onRetry` and
+`onNavigateBranch` turn on editing, regenerating, retrying and branches.
+Escape stops the running turn either way. `queueDelivery` says when a queued
+message reaches the model, per message when it takes a function. `composer`
+passes `ChatInput`'s props; `ComposerControls` takes `model` and `allocation`
+as a `BoundSetting` for a session that owns its own, and `tools={false}` keeps
+the chat's tools out of a composer they don't apply to.
+
`ChatLayout sidebarMode="auto"` shows the conversation list as a drawer over
the thread when the chat is narrower than `drawerBelowPx` (`DRAWER_BELOW_PX`, 896, by default),
measured on the chat's own box; `"drawer"` always does, and `"inline"` (the
diff --git a/packages/ui/src/ai/components/AllocationSelector.tsx b/packages/ui/src/ai/components/AllocationSelector.tsx
index 44d5b86c..f707fffb 100644
--- a/packages/ui/src/ai/components/AllocationSelector.tsx
+++ b/packages/ui/src/ai/components/AllocationSelector.tsx
@@ -4,6 +4,7 @@ import { Select } from '../../components/Select'
import { ChevronDownIcon } from '../../icons'
import { useChat } from '../core/ChatProvider'
import { useChatConfig } from '../core/config'
+import type { BoundSetting } from '../types'
import { chipClassName } from '../ui/chip'
import { isOrgModel } from '../utils'
@@ -13,9 +14,21 @@ const money = (amount: number) => dollars.format(amount)
/** Which allocation an organization model bills, with its spend so far. Shows
* only for an organization model, and picks the first allocation for it. */
-export default function AllocationSelector() {
- const { adapter, selectedProvider, selectedAllocation, setSelectedAllocation } = useChat()
+export default function AllocationSelector({
+ model,
+ allocation,
+}: {
+ /** The model a session owns, which decides whether it bills an allocation. */
+ model?: string | null | undefined
+ /** The allocation a session owns, in place of the chat's own selection. */
+ allocation?: BoundSetting | undefined
+} = {}) {
+ const chat = useChat()
const t = useChatConfig().strings.allocationPicker
+ const { adapter } = chat
+ const bound = allocation !== undefined
+ const selectedProvider = bound ? (model ?? null) : chat.selectedProvider
+ const selectedAllocation = bound ? (allocation.value ?? null) : chat.selectedAllocation
const isOrgProvider = isOrgModel(selectedProvider)
const allocationsAdapter = adapter.allocations
@@ -27,15 +40,19 @@ export default function AllocationSelector() {
)
const first = allocations?.[0]
+ // A session keeps the allocation it has; only the chat's own selection follows the model.
useEffect(() => {
+ if (bound) {
+ return
+ }
if (!isOrgProvider) {
if (selectedAllocation) {
- setSelectedAllocation(null)
+ chat.setSelectedAllocation(null)
}
} else if (first && !selectedAllocation) {
- setSelectedAllocation(first.name)
+ chat.setSelectedAllocation(first.name)
}
- }, [isOrgProvider, first, selectedAllocation, setSelectedAllocation])
+ }, [bound, isOrgProvider, first, selectedAllocation, chat.setSelectedAllocation])
if (!isOrgProvider || !allocations?.length) {
return null
@@ -48,7 +65,10 @@ export default function AllocationSelector() {
aria-label={t.choose}
items={allocations.map((a) => ({ value: a.name, label: a.name }))}
value={selectedAllocation}
- onValueChange={setSelectedAllocation}
+ disabled={!!allocation?.disabled}
+ onValueChange={(name) =>
+ bound ? name && allocation.onChange(name) : chat.setSelectedAllocation(name)
+ }
trigger={
}
+ />,
+ )
+ expect(screen.getByText('Could not load the transcript')).toBeInTheDocument()
+ expect(screen.queryByTestId('list')).not.toBeInTheDocument()
+ expect(screen.getByTestId('composer')).toBeInTheDocument()
+ })
+
+ it('disables the composer while the first messages load', () => {
+ render()
+ expect(screen.queryByTestId('list')).not.toBeInTheDocument()
+ expect(screen.getByTestId('composer')).toHaveAttribute('data-disabled', 'true')
+ })
+})
diff --git a/packages/ui/src/ai/components/SessionView.tsx b/packages/ui/src/ai/components/SessionView.tsx
new file mode 100644
index 00000000..9fedbb20
--- /dev/null
+++ b/packages/ui/src/ai/components/SessionView.tsx
@@ -0,0 +1,202 @@
+import {
+ type ComponentProps,
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+} from 'react'
+import { useChat } from '../core/ChatProvider'
+import useDragDrop from '../core/useDragDrop'
+import { escapeClosesLayer } from '../core/useKeyboardShortcuts'
+import type { ApprovalAnswerValue, AttachmentMeta, ChatMessage as Message } from '../types'
+import ChatInput, { type ChatInputHandle } from './ChatInput'
+import ChatMessageList from './ChatMessageList'
+import DragOverlay from './DragOverlay'
+
+type ComposerProps = Omit<
+ ComponentProps,
+ 'ref' | 'onSend' | 'turn' | 'conversationId' | 'onEditLastMessage'
+>
+
+type ListProps = ComponentProps
+
+export interface SessionViewProps {
+ /** Keys the composer's draft, so each conversation or session keeps its own. */
+ sessionKey: string
+ messages: Message[]
+ /** Every message branch navigation and turn attribution look through;
+ * defaults to `messages`. */
+ allMessages?: Message[]
+ streamingMessage?: Message | null
+ queuedMessages?: Message[]
+ onRemoveQueued?: (id: string) => void
+ canRemoveQueued?: (id: string) => boolean
+ queueDelivery?: ListProps['queueDelivery']
+ /** A turn the chat provider does not run, such as an agent session's: it
+ * drives the composer's Stop and Escape in place of the provider's stream. */
+ turn?: { running: boolean; onStop: () => void; stopping?: boolean }
+ onSend: (content: string, attachmentIds?: string[], attachmentMeta?: AttachmentMeta) => void
+ /** Resends an edited user message as a new branch. Without it, messages
+ * cannot be edited. */
+ onEdit?: (message: Message, content: string) => void
+ onRegenerate?: (messageId: string) => void
+ onRetry?: (messageId: string) => void
+ onNavigateBranch?: (messageId: string) => void
+ onApprovalAnswer?: (id: string, answer: ApprovalAnswerValue) => void
+ showAuthor?: boolean
+ currentUsername?: string
+ /** Shown above the transcript, such as a blocked allocation or an
+ * unreachable machine. */
+ banners?: ReactNode
+ /** In place of the transcript, such as a failed load with a way to retry. */
+ error?: ReactNode
+ /** Still loading the first messages: the transcript stays empty and the
+ * composer disabled. */
+ loading?: boolean
+ /** Files dropped anywhere on the view go to the composer's attachments. */
+ dropFiles?: boolean
+ footer?: ReactNode
+ label?: string
+ flush?: boolean
+ workingIndicator?: boolean
+ composer?: ComposerProps
+}
+
+/** One conversation or agent session: its transcript, with the composer at
+ * the foot of the scrolling column. The host binds its own data and actions;
+ * what it leaves out (editing, regenerating, branches) the view leaves out. */
+export default function SessionView({
+ sessionKey,
+ messages,
+ allMessages,
+ streamingMessage,
+ queuedMessages,
+ onRemoveQueued,
+ canRemoveQueued,
+ queueDelivery,
+ turn,
+ onSend,
+ onEdit,
+ onRegenerate,
+ onRetry,
+ onNavigateBranch,
+ onApprovalAnswer,
+ showAuthor,
+ currentUsername,
+ banners,
+ error,
+ loading = false,
+ dropFiles = false,
+ footer,
+ label,
+ flush,
+ workingIndicator,
+ composer,
+}: SessionViewProps) {
+ const inputRef = useRef(null)
+ const [editingId, setEditingId] = useState(null)
+ const { isStreaming: providerStreaming, stopStreaming } = useChat()
+ const running = turn ? turn.running : providerStreaming
+ const stop = turn ? turn.onStop : stopStreaming
+
+ // Escape stops the turn, wherever the turn runs.
+ useEffect(() => {
+ if (!running) {
+ return
+ }
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape' && !e.defaultPrevented && !escapeClosesLayer(e.target)) {
+ stop()
+ }
+ }
+ window.addEventListener('keydown', onKeyDown)
+ return () => window.removeEventListener('keydown', onKeyDown)
+ }, [running, stop])
+
+ const editable = (id: string) => messages.find((m) => m.id === id && m.role === 'user')
+ const startEdit = useCallback(
+ (id: string) => {
+ if (messages.some((m) => m.id === id && m.role === 'user')) {
+ setEditingId(id)
+ }
+ },
+ [messages],
+ )
+ const editLast = useCallback(() => {
+ const last = messages.findLast((m) => m.role === 'user')
+ if (last) {
+ setEditingId(last.id)
+ }
+ }, [messages])
+
+ const onFilesDropped = useCallback((files: FileList) => {
+ inputRef.current?.addFiles(files)
+ }, [])
+ const { isDragging, dragHandlers } = useDragDrop({ onFilesDropped })
+
+ const input = (
+
+ )
+
+ if (loading && !error) {
+ return (
+