Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 26 additions & 6 deletions packages/ui/src/ai/components/AllocationSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand All @@ -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
Expand All @@ -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={
<button type="button" className={chipClassName}>
<span className="truncate">{selected?.name ?? t.placeholder}</span>
Expand Down
16 changes: 16 additions & 0 deletions packages/ui/src/ai/components/ChatInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -441,3 +441,19 @@ describe('ChatInput height', () => {
}
})
})

describe('ChatInput tools', () => {
afterEach(() => {
chatState.tools = []
})

it("leaves the chat's tools out where they do not apply", () => {
chatState.adapter.attachments = undefined
chatState.tools = [{ id: 'list_clusters', label: 'Clusters' }]
const { rerender } = render(<ChatInput onSend={vi.fn()} />)
expect(screen.getByRole('button', { name: /tools/i })).toBeInTheDocument()

rerender(<ChatInput onSend={vi.fn()} tools={false} />)
expect(screen.queryByRole('button', { name: /tools/i })).not.toBeInTheDocument()
})
})
8 changes: 6 additions & 2 deletions packages/ui/src/ai/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ interface ChatInputProps {
/** A turn the chat provider does not run, such as an agent session's: it
* drives the Stop button in place of the provider's stream and queue. */
turn?: { running: boolean; onStop: () => void; stopping?: boolean }
/** False where the chat's tools do not apply, such as starting an agent
* session, which brings its own. */
tools?: boolean
pastes?: ComposerPastes | undefined
ref?: Ref<ChatInputHandle> | undefined
}
Expand Down Expand Up @@ -187,6 +190,7 @@ export default function ChatInput({
settingsRight = <ComposerControls />,
hint,
turn,
tools: toolsAllowed = true,
pastes,
ref,
}: ChatInputProps) {
Expand Down Expand Up @@ -371,7 +375,7 @@ export default function ChatInput({
})

// A turn the provider doesn't run, such as an agent's, can't use its tools.
const toolsAvailable = !turn && tools.length > 0
const toolsAvailable = toolsAllowed && !turn && tools.length > 0
const toolbar = attachmentsAvailable || toolsAvailable || !!settingsRight
const action =
isStreaming && !input.trim() ? (
Expand Down Expand Up @@ -490,7 +494,7 @@ export default function ChatInput({
attachmentsAvailable ? () => setShowAttachments(!showAttachments) : undefined
}
disabled={disabled}
withTools={!turn}
withTools={toolsAllowed && !turn}
/>
{/* Send stays in the box however wide the host's controls
are; they give up their room first. */}
Expand Down
31 changes: 31 additions & 0 deletions packages/ui/src/ai/components/ChatMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -648,3 +648,34 @@ describe('turn duration row', () => {
expect(screen.queryByText('because of reasons')).not.toBeInTheDocument()
})
})

describe("an agent's reply", () => {
const reply = createMockMessage({
id: 'turn-1',
role: 'assistant',
content: null,
model: 'opus',
timestamp: '2026-10-18T10:00:00Z',
parts: [
{ kind: 'text', text: 'Stopping gpu-a.' },
{ kind: 'tool_call', id: 'c1', name: 'Bash', args: '{}', status: 'ok' },
{ kind: 'text', text: 'It is stopped.' },
],
})

it('offers copy of what it said, its model, and regenerate where the host supports it', () => {
const onRegenerate = vi.fn()
render(<ChatMessage {...defaultProps} message={reply} onRegenerate={onRegenerate} />)

expect(screen.getByTestId('agent-parts-mock')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /copy/i })).toBeInTheDocument()
expect(screen.getByText('opus')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /regenerate/i }))
expect(onRegenerate).toHaveBeenCalledWith('turn-1')
})

it('shows no actions while it streams', () => {
render(<ChatMessage {...defaultProps} message={reply} isStreaming />)
expect(screen.queryByRole('button', { name: /copy/i })).not.toBeInTheDocument()
})
})
93 changes: 64 additions & 29 deletions packages/ui/src/ai/components/ChatMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,15 +177,78 @@ function ChatMessage({
// flat-path hiding below never applies to them. A chat message with both
// parts and content (tool calls made on the way to a normal answer) falls
// through and renders the parts above its content with the usual chrome.
const timestamp = parseTimestamp(message.timestamp)
const totalTokens = message.tokensUsed?.total_tokens
const meta = (timestamp || (!isUser && (message.model || totalTokens))) && (
<span
className={cx(
'chat-meta flex items-center gap-2 ml-2 text-xs tabular-nums theme-muted-text transition-opacity duration-150 select-none',
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100',
)}
>
<RelativeTime value={timestamp} />
{!isUser && message.model && <span>{message.model}</span>}
{!isUser && totalTokens ? (
<span
title={
message.tokensUsed?.prompt_tokens !== undefined &&
message.tokensUsed?.completion_tokens !== undefined
? t.tokensBreakdown(
message.tokensUsed.prompt_tokens,
message.tokensUsed.completion_tokens,
)
: undefined
}
>
{t.tokens(totalTokens)}
</span>
) : null}
</span>
)

const hasParts = !!message.parts?.length
if (hasParts && !message.content) {
// An agent's reply is its timeline; what it said is the text parts.
const said = message.parts
?.flatMap((p) => (p.kind === 'text' && p.text.trim() ? [p.text.trim()] : []))
.join('\n\n')
return (
<div className={cx('group py-3 w-full', !flush && 'px-4')}>
<AgentMessageParts
message={message}
isStreaming={isStreaming}
onApprovalAnswer={onApprovalAnswer}
/>
{!isUser && !isStreaming && (said || onRegenerate || meta) && (
<div className="chat-actions -ml-1.5 mt-2 flex items-center gap-0.5">
{said && (
<CopyButton
text={said}
label={t.copyLabel}
quiet
size="md"
className={actionClasses}
/>
)}
{onRegenerate && (
<IconButton
label={t.regenerate}
onClick={() => onRegenerate(message.id)}
size="md"
className={actionClasses}
icon={<RetryIcon />}
/>
)}
{onNavigateBranch && (
<BranchNavigator
messages={allMessages}
currentMessageId={message.id}
onNavigate={onNavigateBranch}
/>
)}
{meta}
</div>
)}
</div>
)
}
Expand All @@ -201,8 +264,6 @@ function ChatMessage({
return null
}

const timestamp = parseTimestamp(message.timestamp)
const totalTokens = message.tokensUsed?.total_tokens
const collapsed = !isUser && collapsible && !expanded && !isStreaming && !latest

return (
Expand Down Expand Up @@ -439,33 +500,7 @@ function ChatMessage({
/>
)}

{/* Message metadata, hover-revealed alongside the actions */}
{(timestamp || (!isUser && (message.model || totalTokens))) && (
<span
className={cx(
'chat-meta flex items-center gap-2 ml-2 text-xs tabular-nums theme-muted-text transition-opacity duration-150 select-none',
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100',
)}
>
<RelativeTime value={timestamp} />
{!isUser && message.model && <span>{message.model}</span>}
{!isUser && totalTokens ? (
<span
title={
message.tokensUsed?.prompt_tokens !== undefined &&
message.tokensUsed?.completion_tokens !== undefined
? t.tokensBreakdown(
message.tokensUsed.prompt_tokens,
message.tokensUsed.completion_tokens,
)
: undefined
}
>
{t.tokens(totalTokens)}
</span>
) : null}
</span>
)}
{meta}
</div>
)}
</div>
Expand Down
13 changes: 13 additions & 0 deletions packages/ui/src/ai/components/ChatMessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,19 @@ describe('ChatMessageList queued messages', () => {
'Queued · sends when this reply finishes',
'Queued · sends when this reply finishes',
])

rerender(
<ChatMessageList
messages={[]}
allMessages={[]}
queuedMessages={queued}
queueDelivery={(id) => (id === 'steered' ? 'nextStep' : 'afterReply')}
/>,
)
expect(getAllByTestId('queued-status').map((el) => el.textContent)).toEqual([
'Queued · reaches the agent after its current step',
'Queued · sends when this reply finishes',
])
})
})

Expand Down
10 changes: 7 additions & 3 deletions packages/ui/src/ai/components/ChatMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import AgentMessageParts from './agent/AgentMessageParts'
import ChatMessage from './ChatMessage'
import { Reasoning, ReasoningBody } from './Reasoning'

export type QueueDelivery = 'afterReply' | 'nextStep'

interface ChatMessageListProps {
messages: Message[]
allMessages: Message[]
Expand All @@ -35,8 +37,9 @@ interface ChatMessageListProps {
canRemoveQueued?: (id: string) => boolean
/** When a queued message reaches the model: once the running reply
* finishes (the chat provider's queue), or between the agent's steps for a
* host that steers the turn in progress. */
queueDelivery?: 'afterReply' | 'nextStep'
* host that steers the turn in progress. A host with both kinds says which
* each message is. */
queueDelivery?: QueueDelivery | ((messageId: string) => QueueDelivery)
editingMessageId?: string | null | undefined
showAuthor?: boolean
currentUsername?: string
Expand Down Expand Up @@ -410,7 +413,8 @@ export default function ChatMessageList({
const removable = canRemoveQueued?.(msg.id) ?? true
const status = !removable
? strings.queue.handedOver
: queueDelivery === 'nextStep'
: (typeof queueDelivery === 'function' ? queueDelivery(msg.id) : queueDelivery) ===
'nextStep'
? strings.queue.waitingNextStep
: strings.queue.waiting
return (
Expand Down
Loading
Loading