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
1,864 changes: 1,864 additions & 0 deletions console/web/DESIGN.md

Large diffs are not rendered by default.

89 changes: 89 additions & 0 deletions console/web/src/components/chat/AutoAcceptToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useId } from 'react'
import { cn } from '@/lib/utils'

interface AutoAcceptToggleProps {
value: boolean
onChange: (next: boolean) => void
disabled?: boolean
className?: string
}

/**
* Per-conversation toggle that flips the auto-accept-all-approvals
* mode. Lives in the composer's bottom bar next to the mode picker.
*
* The label is rendered verbatim ("auto-accept: on" / "auto-accept:
* off") rather than as an icon because this is a foot-gun: when ON,
* every safe `agent_trigger` from the model is auto-resolved without a
* human click (state-mutating, destructive, and egress calls remain
* gated client-side by `auto-accept-policy.ts`). The user needs to
* read the state at a glance.
*
* Accessibility:
* - `role="switch"` + `aria-checked` carry the binary state to SRs;
* the visible "on"/"off" text matches the announced state so the
* "label in name" requirement is satisfied.
* - `focus-visible` rings the control for keyboard navigation.
* - The explanatory text lives in a visually-hidden node and is
* wired in via `aria-describedby` so keyboard/SR users get the
* same context that mouse users get from the `title` tooltip.
* - `aria-disabled` rather than `disabled` so the control stays in
* the tab order and an SR can still read the current policy
* while streaming.
*
* Contrast: the ON state uses `bg-ink text-bg` (light theme:
* near-black on near-white, ~16:1; dark theme: near-white on
* near-black, similar) rather than `bg-accent text-bg`, which
* measured ~3.2:1 on light bg and failed WCAG 1.4.3.
*/
export function AutoAcceptToggle({
value,
onChange,
disabled,
className,
}: AutoAcceptToggleProps) {
const descId = useId()
const interactive = !disabled

const handleClick = () => {
if (!interactive) return
onChange(!value)
}

return (
<>
<button
type="button"
role="switch"
aria-checked={value}
aria-disabled={disabled || undefined}
aria-describedby={descId}
title={
value
? 'auto-accept: ON. every safe approval prompt is resolved automatically (state-mutating calls still require a click). click to turn off.'
: 'auto-accept: OFF. each approval prompt waits for your click. click to turn on.'
}
onClick={handleClick}
className={cn(
'font-mono text-[13px] px-3 py-1 border lowercase transition-colors',
'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
value
? 'border-ink bg-ink text-bg'
: 'border-rule text-ink-faint hover:text-ink',
disabled && 'opacity-50 cursor-not-allowed',
className,
)}
>
auto-accept: <span className="font-mono">{value ? 'on' : 'off'}</span>
</button>
<span
id={descId}
className="sr-only"
>
{value
? 'Auto-accept is on. Approval prompts for safe calls (reads, lookups, listings) are resolved automatically. Destructive or state-mutating calls still require a click.'
: 'Auto-accept is off. Every approval prompt waits for an explicit click.'}
</span>
</>
)
}
2 changes: 2 additions & 0 deletions console/web/src/components/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function ChatPanel({ density = 'route', onClose }: ChatPanelProps) {
remove,
setModel,
setMode,
setAutoAccept,
appendMessage,
updateMessage,
compactConversation,
Expand Down Expand Up @@ -87,6 +88,7 @@ export function ChatPanel({ density = 'route', onClose }: ChatPanelProps) {
onClose={onClose}
onUpdateModel={setModel}
onUpdateMode={setMode}
onUpdateAutoAccept={setAutoAccept}
onAppendMessage={appendMessage}
onPatchMessage={updateMessage}
onCompactConversation={compactConversation}
Expand Down
122 changes: 109 additions & 13 deletions console/web/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { Copy, X } from 'lucide-react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { LiveRegion } from '@/components/ui/LiveRegion'
import { StatusDot } from '@/components/ui/StatusDot'
import { useAutoAcceptApprovals } from '@/hooks/use-auto-accept-approvals'
import { uid } from '@/hooks/use-conversations'
import { useFunctionsCatalog } from '@/hooks/use-functions-catalog'
import { useLiveAnnouncer } from '@/hooks/use-live-announcer'
import type { ChatBackend } from '@/lib/backend'
import { translateUiHistoryForBackend } from '@/lib/backend/history'
import type { CompactResult } from '@/lib/backend/types'
import { formatStopReason } from '@/lib/format-stop-reason'
import { makeSessionId } from '@/lib/session-id'
import { cn } from '@/lib/utils'
import type {
Expand Down Expand Up @@ -72,6 +76,7 @@ interface ChatViewProps {
onClose?: () => void
onUpdateModel: (id: string, model: ModelId) => void
onUpdateMode: (id: string, mode: Mode) => void
onUpdateAutoAccept: (id: string, autoAccept: boolean) => void
onAppendMessage: (id: string, message: Message) => void
onPatchMessage: (id: string, messageId: string, patch: MessagePatch) => void
onCompactConversation: (id: string, marker: Message) => void
Expand All @@ -86,6 +91,7 @@ export function ChatView({
onClose,
onUpdateModel,
onUpdateMode,
onUpdateAutoAccept,
onAppendMessage,
onPatchMessage,
onCompactConversation,
Expand All @@ -106,6 +112,44 @@ export function ChatView({
return match?.contextWindow
}, [modelOptions, conversation.model])

/* Shared live region: SR announcements for auto-accept, stop-reason
* notices, and compaction markers route through this hook. Sighted
* users see the same messages in the transcript; visually-impaired
* users hear them via the polite/assertive ARIA live regions
* rendered at the bottom of the component. */
const announcer = useLiveAnnouncer()

/* Wrap the backend's resolver in a stable callback so MessageList
* row-level memoization isn't broken by a fresh lambda identity on
* every render, and so the auto-accept hook's deps don't shift
* every render. */
const resolveApproval = useMemo(() => {
const fn = backend.resolveApproval
if (!fn) return undefined
return (
sessionId: string,
functionCallId: string,
decision: 'allow' | 'deny',
) => fn(sessionId, functionCallId, decision)
}, [backend])

useAutoAcceptApprovals({
conversationId: conversation.id,
enabled: !!conversation.autoAccept,
messages: conversation.messages,
resolveApproval,
onAccepted: (functionId) => {
announcer.announce(`auto-accepted: ${functionId}`)
},
onDenied: (functionId) => {
/* The policy refused — leave the card for manual click. Tell
* SR users so they know to navigate to it. */
announcer.announce(
`auto-accept refused for ${functionId}: high-risk call requires manual approval`,
)
},
})

const handleCopySessionId = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.clipboard) return
void navigator.clipboard.writeText(sessionId).then(() => {
Expand All @@ -114,13 +158,22 @@ export function ChatView({
})
}, [sessionId])

/* Read the messages snapshot through a ref inside handleSubmit so
* the callback identity is stable across token-by-token re-renders.
* Pre-fix, listing `conversation.messages` in the deps array
* rebuilt handleSubmit on every assistant/thought delta, which
* cascaded into Composer rebuilding and `LexicalShell` re-binding
* its `KEY_ENTER_COMMAND` listener once per token. */
const messagesRef = useRef(conversation.messages)
messagesRef.current = conversation.messages

const handleSubmit = useCallback(
async (payload: ComposerSubmitPayload) => {
const conversationId = conversation.id

// Snapshot prior history BEFORE appending the new user msg —
// run::start overwrites flat state with whatever we send.
const priorHistory = translateUiHistoryForBackend(conversation.messages)
const priorHistory = translateUiHistoryForBackend(messagesRef.current)

const userMsg: UserMessage = {
id: uid(),
Expand Down Expand Up @@ -323,6 +376,53 @@ export function ChatView({
assistantBuffer = ''
break
}
case 'compaction': {
// Server-side context-compaction finished rewriting the
// session's flat-state. Append a marker so:
// 1. The user sees that compaction happened.
// 2. estimateConversationTokens stops counting pre-marker
// messages → the CTX bar drops to the real value.
// We append (not replace) so the transcript stays scrollable.
const compactionContent =
event.mode === 'sync'
? `compacted ${event.tokensBefore.toLocaleString()} tokens before continuing`
: `compacted ${event.tokensBefore.toLocaleString()} tokens (background)`
const marker: SystemMessage = {
id: uid(),
role: 'system',
kind: 'compaction',
content: compactionContent,
tone: 'info',
summaryText: event.summaryText,
tokensBefore: event.tokensBefore,
createdAt: Date.now(),
}
onAppendMessage(conversationId, marker)
announcer.announce(compactionContent)
break
}
case 'stop-reason': {
// The assistant turn ended abnormally — show the user why
// instead of leaving the response looking like it just
// ran out of words. Pre-fix this event didn't exist and
// the same condition produced a silently truncated reply.
const noticeContent = formatStopReason(event.reason, event.message)
const notice: SystemMessage = {
id: uid(),
role: 'system',
kind: 'notice',
content: noticeContent,
tone: event.reason === 'error' ? 'error' : 'warn',
createdAt: Date.now(),
}
onAppendMessage(conversationId, notice)
if (event.reason === 'error') {
announcer.announceAssertive(noticeContent)
} else {
announcer.announce(noticeContent)
}
break
}
}
if (
event.kind === 'fcall-start' ||
Expand Down Expand Up @@ -355,9 +455,10 @@ export function ChatView({
conversation.id,
conversation.mode,
conversation.model,
conversation.messages,
sessionId,
contextWindow,
backend,
announcer,
onAppendMessage,
onPatchMessage,
onCompactConversation,
Expand Down Expand Up @@ -468,18 +569,9 @@ export function ChatView({
messages={conversation.messages}
isThinking={isThinking}
density={density}
onResolveApproval={
backend.resolveApproval
? async (sessionId, functionCallId, decision) => {
await backend.resolveApproval?.(
sessionId,
functionCallId,
decision,
)
}
: undefined
}
onResolveApproval={resolveApproval}
/>
<LiveRegion announcement={announcer.announcement} />

<footer className={footerPad}>
<div className="mx-auto max-w-[760px]">
Expand All @@ -489,8 +581,12 @@ export function ChatView({
modelOptions={modelOptions}
catalogLoading={catalogLoading}
functionEntries={functionEntries}
autoAccept={conversation.autoAccept}
onModeChange={(next) => onUpdateMode(conversation.id, next)}
onModelChange={(next) => onUpdateModel(conversation.id, next)}
onAutoAcceptChange={(next) =>
onUpdateAutoAccept(conversation.id, next)
}
onSubmit={handleSubmit}
onStop={handleStop}
isStreaming={isStreaming}
Expand Down
16 changes: 16 additions & 0 deletions console/web/src/components/chat/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Attachment, Mode, ModelId, ModelOption } from '@/types/chat'
import type { FunctionEntry } from '@/lib/functions'
import { AttachmentButton } from './AttachmentButton'
import { AttachmentChip } from './AttachmentChip'
import { AutoAcceptToggle } from './AutoAcceptToggle'
import { LexicalShell } from './LexicalShell'
import { ModelPicker } from './ModelPicker'
import { ModePicker } from './ModePicker'
Expand All @@ -19,8 +20,16 @@ interface ComposerProps {
model: ModelId
modelOptions: ModelOption[]
catalogLoading?: boolean
/**
* Per-conversation auto-accept-all-approvals flag. When true, the
* chat client auto-resolves every pending approval that surfaces
* for this conversation. Rendered as a sibling pill of the mode
* picker.
*/
autoAccept: boolean
onModeChange: (next: Mode) => void
onModelChange: (next: ModelId) => void
onAutoAcceptChange: (next: boolean) => void
onSubmit: (payload: ComposerSubmitPayload) => void
onStop?: () => void
isStreaming?: boolean
Expand All @@ -36,8 +45,10 @@ export function Composer({
model,
modelOptions,
catalogLoading,
autoAccept,
onModeChange,
onModelChange,
onAutoAcceptChange,
onSubmit,
onStop,
isStreaming,
Expand Down Expand Up @@ -100,6 +111,11 @@ export function Composer({
<div className="flex items-center gap-2 flex-wrap px-3 py-2 border-t border-rule-2">
<AttachmentButton onAttach={handleAttach} disabled={isStreaming} />
<ModePicker value={mode} onChange={onModeChange} />
<AutoAcceptToggle
value={autoAccept}
onChange={onAutoAcceptChange}
disabled={isStreaming}
/>
<div className="flex-1 min-w-0" />
<ModelPicker
value={model}
Expand Down
14 changes: 11 additions & 3 deletions console/web/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,17 @@ export function MessageList({
(We dedupe via lastPendingIdRef so React's re-renders don't keep
forcing the scroll while the user is reading the request body.) */
useEffect(() => {
const newestPending = [...messages]
.reverse()
.find((m) => m.role === 'function-call' && m.pendingApproval === true)
// Walk backwards instead of spreading + reversing — the spread
// copies the whole array every render, which is a real cost on
// token-by-token re-renders during streaming.
let newestPending: (typeof messages)[number] | null = null
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i]
if (m.role === 'function-call' && m.pendingApproval === true) {
newestPending = m
break
}
}
if (!newestPending) {
lastPendingIdRef.current = null
return
Expand Down
Loading
Loading