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={ + + + , + ) + pressEscape(screen.getByRole('button', { name: 'close' })) + expect(onStop).not.toHaveBeenCalled() + }) + + it('resends an edited user message through onEdit with the message it edits', () => { + const onEdit = vi.fn() + render() + act(() => listProps?.onEditMessage?.('u1')) + expect(listProps?.editingMessageId).toBe('u1') + + act(() => listProps?.onSaveEdit?.('u1', 'stop it now')) + + expect(onEdit).toHaveBeenCalledWith(ask, 'stop it now') + expect(listProps?.editingMessageId).toBeNull() + }) + + it('offers no editing without onEdit', () => { + render() + expect(listProps?.onEditMessage).toBeUndefined() + expect(screen.getByTestId('composer')).toHaveAttribute('data-edits-last', 'false') + }) + + it('shows an error in place of the transcript and keeps the composer', () => { + render( + Could not load the transcript

} + />, + ) + 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 ( +
+ {banners} +
+ {input} +
+ ) + } + + return ( +
+
+ {isDragging && dropFiles && } + {banners} + {error ?? ( + { + const message = editable(id) + if (message && onEdit) { + onEdit(message, content) + } + setEditingId(null) + }} + onCancelEdit={() => setEditingId(null)} + onRegenerateMessage={onRegenerate} + onRetryMessage={onRetry} + {...(onApprovalAnswer ? { onApprovalAnswer } : {})} + {...(footer ? { footer } : {})} + {...(label ? { label } : {})} + {...(flush ? { flush } : {})} + {...(workingIndicator !== undefined ? { workingIndicator } : {})} + inputElement={input} + /> + )} + {error && input} +
+
+ ) +} diff --git a/packages/ui/src/ai/core/useKeyboardShortcuts.ts b/packages/ui/src/ai/core/useKeyboardShortcuts.ts index a52942f1..0fc77275 100644 --- a/packages/ui/src/ai/core/useKeyboardShortcuts.ts +++ b/packages/ui/src/ai/core/useKeyboardShortcuts.ts @@ -71,14 +71,6 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) const handleKeyDown = (e: KeyboardEvent) => { const cmdKey = isMac() ? e.metaKey : e.ctrlKey - // Escape - Stop generation - if (e.key === 'Escape') { - if (!escapeClosesLayer(e.target)) { - handleStopGeneration() - } - return - } - // Cmd/Ctrl + Shift + O - New conversation if (cmdKey && e.shiftKey && e.key === 'O') { e.preventDefault() @@ -103,7 +95,7 @@ export function useKeyboardShortcuts(options: UseKeyboardShortcutsOptions = {}) document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) - }, [enabled, handleNewConversation, handleCopyLastResponse, handleStopGeneration]) + }, [enabled, handleNewConversation, handleCopyLastResponse]) // Return helper functions for components that need them return { diff --git a/packages/ui/src/ai/index.ts b/packages/ui/src/ai/index.ts index fce5bce5..5c7cc24a 100644 --- a/packages/ui/src/ai/index.ts +++ b/packages/ui/src/ai/index.ts @@ -53,6 +53,7 @@ export { getProviderKeyFromModelId, } from './components/ModelSelector' export { default as ProviderIssueBanner } from './components/ProviderIssueBanner' +export { default as SessionView, type SessionViewProps } from './components/SessionView' export { default as ShareDialog, ShareButton, diff --git a/packages/ui/src/ai/types.ts b/packages/ui/src/ai/types.ts index 2b36d90a..2d4a4180 100644 --- a/packages/ui/src/ai/types.ts +++ b/packages/ui/src/ai/types.ts @@ -1,6 +1,14 @@ // Package-owned chat domain types. Adapters map backend responses into these // shapes; no generated API client types cross the package boundary. +/** A turn setting a session owns, shown in place of the chat's own selection. */ +export interface BoundSetting { + value: string | null | undefined + onChange: (value: string) => void + /** The session cannot take a change right now, such as while it is unreachable. */ + disabled?: boolean | undefined +} + export interface ChatUser { id: string username: string