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
86 changes: 0 additions & 86 deletions console/web/src/components/chat/AutoAcceptToggle.tsx

This file was deleted.

2 changes: 0 additions & 2 deletions console/web/src/components/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export function ChatPanel({ density = 'route' }: ChatPanelProps) {
remove,
setModel,
setMode,
setAutoAccept,
appendMessage,
updateMessage,
compactConversation,
Expand Down Expand Up @@ -85,7 +84,6 @@ export function ChatPanel({ density = 'route' }: ChatPanelProps) {
density={density}
onUpdateModel={setModel}
onUpdateMode={setMode}
onUpdateAutoAccept={setAutoAccept}
onAppendMessage={appendMessage}
onPatchMessage={updateMessage}
onCompactConversation={compactConversation}
Expand Down
50 changes: 28 additions & 22 deletions console/web/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Copy } from 'lucide-react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { FullPermissionsBanner } from '@/components/permissions/FullPermissionsBanner'
import { LiveRegion } from '@/components/ui/LiveRegion'
import { StatusDot } from '@/components/ui/StatusDot'
import { useAutoAcceptApprovals } from '@/hooks/use-auto-accept-approvals'
import { useApprovalSettings } from '@/hooks/use-approval-settings'
import { uid } from '@/hooks/use-conversations'
import { useFunctionsCatalog } from '@/hooks/use-functions-catalog'
import { useLiveAnnouncer } from '@/hooks/use-live-announcer'
Expand Down Expand Up @@ -74,7 +75,6 @@ interface ChatViewProps {
density?: 'route' | 'dock'
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 @@ -88,7 +88,6 @@ export function ChatView({
density = 'route',
onUpdateModel,
onUpdateMode,
onUpdateAutoAccept,
onAppendMessage,
onPatchMessage,
onCompactConversation,
Expand Down Expand Up @@ -130,22 +129,21 @@ export function ChatView({
) => 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 approvalSettings = useApprovalSettings(sessionId)

const handleAlwaysAllow = useMemo(() => {
const resolveFn = backend.resolveApproval
if (!resolveFn) return undefined
// "Approve always" is a per-session grant honored in every mode, so the
// button shows on every prompt (full mode never produces prompts, so
// it's moot there). Approves this call and stops asking for the same
// function for the rest of the conversation.
return async (sId: string, functionCallId: string, functionId: string) => {
await approvalSettings.approveAlways(functionId)
await resolveFn(sId, functionCallId, 'allow')
announcer.announce(`approved always this session: ${functionId}`)
}
}, [backend, approvalSettings, announcer])

const handleCopySessionId = useCallback(() => {
if (typeof navigator === 'undefined' || !navigator.clipboard) return
Expand Down Expand Up @@ -555,11 +553,18 @@ export function ChatView({
</div>
</header>

{approvalSettings.settings.mode === 'full' ? (
<FullPermissionsBanner
onDisable={() => void approvalSettings.setMode('manual')}
/>
) : null}

<MessageList
messages={conversation.messages}
isThinking={isThinking}
density={density}
onResolveApproval={resolveApproval}
onAlwaysAllow={handleAlwaysAllow}
/>
<LiveRegion announcement={announcer.announcement} />

Expand All @@ -571,11 +576,12 @@ export function ChatView({
modelOptions={modelOptions}
catalogLoading={catalogLoading}
functionEntries={functionEntries}
autoAccept={conversation.autoAccept}
permissionMode={approvalSettings.settings.mode}
permissionModeLoading={!approvalSettings.loaded}
onModeChange={(next) => onUpdateMode(conversation.id, next)}
onModelChange={(next) => onUpdateModel(conversation.id, next)}
onAutoAcceptChange={(next) =>
onUpdateAutoAccept(conversation.id, next)
onPermissionModeChange={(next) =>
void approvalSettings.setMode(next)
}
onSubmit={handleSubmit}
onStop={handleStop}
Expand Down
28 changes: 15 additions & 13 deletions console/web/src/components/chat/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { LexicalEditor } from 'lexical'
import { useCallback, useRef, useState } from 'react'
import { PermissionModePicker } from '@/components/permissions/PermissionModePicker'
import { Button } from '@/components/ui/Button'
import type { PermissionMode } from '@/lib/backend/approval-settings'
import type { FunctionEntry } from '@/lib/functions'
import type { Attachment, Mode, ModelId, ModelOption } from '@/types/chat'
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 @@ -21,15 +22,15 @@ interface ComposerProps {
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.
* Per-conversation permission mode (manual / auto / full). Owned by
* the backend `approval_settings` scope; ChatView passes the loaded
* value here. While loading, the picker disables.
*/
autoAccept: boolean
permissionMode: PermissionMode
permissionModeLoading?: boolean
onModeChange: (next: Mode) => void
onModelChange: (next: ModelId) => void
onAutoAcceptChange: (next: boolean) => void
onPermissionModeChange: (next: PermissionMode) => void
onSubmit: (payload: ComposerSubmitPayload) => void
onStop?: () => void
isStreaming?: boolean
Expand All @@ -45,10 +46,11 @@ export function Composer({
model,
modelOptions,
catalogLoading,
autoAccept,
permissionMode,
permissionModeLoading,
onModeChange,
onModelChange,
onAutoAcceptChange,
onPermissionModeChange,
onSubmit,
onStop,
isStreaming,
Expand Down Expand Up @@ -111,10 +113,10 @@ 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}
<PermissionModePicker
value={permissionMode}
onChange={onPermissionModeChange}
disabled={isStreaming || !!permissionModeLoading}
/>
<div className="flex-1 min-w-0" />
<ModelPicker
Expand Down
12 changes: 12 additions & 0 deletions console/web/src/components/chat/FunctionCallGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ interface FunctionCallGroupProps {
functionCallId: string,
decision: 'allow' | 'deny',
) => Promise<void>
onAlwaysAllow?: (
sessionId: string,
functionCallId: string,
functionId: string,
) => Promise<void>
}

/**
Expand Down Expand Up @@ -132,6 +137,7 @@ export function FunctionCallGroup({
messages,
defaultOpen,
onResolveApproval,
onAlwaysAllow,
}: FunctionCallGroupProps) {
const status = deriveStatus(messages)
const concerning = hasConcerningChild(messages)
Expand Down Expand Up @@ -190,18 +196,24 @@ export function FunctionCallGroup({
const functionCallId = m.functionCallId
let onApprove: (() => Promise<void>) | undefined
let onDeny: (() => Promise<void>) | undefined
let onAlwaysAllowHandler: (() => Promise<void>) | undefined
if (onResolveApproval && sessionId && functionCallId) {
onApprove = () =>
onResolveApproval(sessionId, functionCallId, 'allow')
onDeny = () =>
onResolveApproval(sessionId, functionCallId, 'deny')
}
if (onAlwaysAllow && sessionId && functionCallId) {
onAlwaysAllowHandler = () =>
onAlwaysAllow(sessionId, functionCallId, m.functionId)
}
return (
<FunctionCallMessage
key={m.id}
message={m}
onApprove={onApprove}
onDeny={onDeny}
onAlwaysAllow={onAlwaysAllowHandler}
embedded
/>
)
Expand Down
25 changes: 22 additions & 3 deletions console/web/src/components/chat/FunctionCallMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
SandboxFunctionIdLabel,
SandboxToolView,
} from '@/components/chat/sandbox'
import { AlwaysAllowButton } from '@/components/permissions/AlwaysAllowButton'
import { Button } from '@/components/ui/Button'
import { StatusDot } from '@/components/ui/StatusDot'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs'
Expand All @@ -20,6 +21,12 @@ interface FunctionCallMessageProps {
*/
onApprove?: () => void | Promise<void>
onDeny?: () => void | Promise<void>
/**
* Approve + add to per-conversation always-allow list. When provided,
* an "always allow" button renders next to approve/deny. Destructive
* function ids gate on a confirmation modal inside the button.
*/
onAlwaysAllow?: () => void | Promise<void>
/**
* When true, render without the outer `border border-rule bg-bg` chrome
* so the parent (typically a `FunctionCallGroup`) can frame the stack.
Expand Down Expand Up @@ -84,13 +91,16 @@ export function FunctionCallMessage({
defaultOpen,
onApprove,
onDeny,
onAlwaysAllow,
embedded,
}: FunctionCallMessageProps) {
const pending = !!message.pendingApproval
const running = !!message.running
const [open, setOpen] = useState(!!defaultOpen || pending)
const [tab, setTab] = useState<'terminal' | 'json'>('terminal')
const [submitting, setSubmitting] = useState<'approve' | 'deny' | null>(null)
const [submitting, setSubmitting] = useState<
'approve' | 'deny' | 'always_allow' | null
>(null)
const [submitError, setSubmitError] = useState<string | null>(null)

const sandboxPreview = SandboxToolView.tryRenderPreview(message)
Expand All @@ -101,8 +111,9 @@ export function FunctionCallMessage({
!(running && hasSandboxTerminal) &&
!(!pending && !running && hasSandboxTerminal)

const runResolve = async (kind: 'approve' | 'deny') => {
const handler = kind === 'approve' ? onApprove : onDeny
const runResolve = async (kind: 'approve' | 'deny' | 'always_allow') => {
const handler =
kind === 'approve' ? onApprove : kind === 'deny' ? onDeny : onAlwaysAllow
if (!handler || submitting) return
setSubmitError(null)
setSubmitting(kind)
Expand Down Expand Up @@ -235,6 +246,14 @@ export function FunctionCallMessage({
>
{submitting === 'deny' ? 'denying…' : 'deny'}
</Button>
{onAlwaysAllow ? (
<AlwaysAllowButton
functionId={message.functionId}
onConfirm={() => void runResolve('always_allow')}
disabled={!!submitting}
submitting={submitting === 'always_allow'}
/>
) : null}
{submitting ? (
<span className="font-mono text-[12px] text-ink-faint">
waiting for the agent to resume…
Expand Down
Loading
Loading