feat(code-mode): render coding runs inline (live timeline + permission card)

Render a code_agent_run tool call as a live CodingRun block instead of generic
tool output: the agent's text, tool-call rows (kind icon + status + changed-file
names from diffs), a plan checklist, and resolved-permission lines — plus an
inline Allow / Always-allow / Deny card wired to codeRun:resolvePermission.

- chat-conversation.ts: ToolCall carries codeRunEvents + pendingCodePermission;
  code_agent_run is excluded from tool-grouping so it renders standalone.
- App.tsx: handle code-run-event / code-run-permission-request, clear the pending
  card on tool-result, handleCodePermissionResponse, render via CodingRunBlock.
This commit is contained in:
Gagancreates 2026-06-02 00:57:42 +05:30
parent 80ed635300
commit 10ce73ae24
3 changed files with 318 additions and 0 deletions

View file

@ -29,6 +29,7 @@ import { LiveNotesView } from '@/components/live-notes-view';
import { BgTasksView } from '@/components/bg-tasks-view';
import { EmailView } from '@/components/email-view';
import { WorkspaceView } from '@/components/workspace-view';
import { CodingRunBlock } from '@/components/coding-run';
import { KnowledgeView } from '@/components/knowledge-view';
import { ChatHistoryView } from '@/components/chat-history-view';
import { HomeView } from '@/components/home-view';
@ -2295,6 +2296,8 @@ function App() {
...item,
result: event.result as ToolUIPart['output'],
status: 'completed' as const,
// a code_agent_run finished — drop any lingering permission card
pendingCodePermission: null,
}
}
return item
@ -2375,6 +2378,33 @@ function App() {
break
}
case 'code-run-event': {
if (!isActiveRun) return
setConversation(prev => prev.map(item => {
if (isToolCall(item) && item.id === event.toolCallId) {
const existing = item.codeRunEvents ?? []
if (existing.length === 0) {
setToolOpenForTab(activeChatTabIdRef.current, item.id, true)
}
return { ...item, codeRunEvents: [...existing, event.event] }
}
return item
}))
break
}
case 'code-run-permission-request': {
if (!isActiveRun) return
setConversation(prev => prev.map(item => {
if (isToolCall(item) && item.id === event.toolCallId) {
setToolOpenForTab(activeChatTabIdRef.current, item.id, true)
return { ...item, pendingCodePermission: { requestId: event.requestId, ask: event.ask } }
}
return item
}))
break
}
case 'ask-human-request': {
if (!isActiveRun) return
const key = event.toolCallId
@ -2705,6 +2735,26 @@ function App() {
}
}, [runId])
// Answer a mid-run permission request from a code_agent_run coding turn. The
// pending ask lives on the tool call itself, so we optimistically clear it and
// tell main which decision the user picked (keyed by the request id).
const handleCodePermissionResponse = useCallback(async (
toolCallId: string,
requestId: string,
decision: 'allow_once' | 'allow_always' | 'reject',
) => {
setConversation(prev => prev.map(item =>
isToolCall(item) && item.id === toolCallId
? { ...item, pendingCodePermission: null }
: item
))
try {
await window.ipc.invoke('codeRun:resolvePermission', { requestId, decision })
} catch (error) {
console.error('Failed to resolve code permission:', error)
}
}, [])
const handleAskHumanResponse = useCallback(async (toolCallId: string, subflow: string[], response: string) => {
if (!runId) return
try {
@ -5115,6 +5165,21 @@ function App() {
}
if (isToolCall(item)) {
if (item.name === 'code_agent_run') {
return (
<CodingRunBlock
key={item.id}
item={item}
open={isToolOpenForTab(tabId, item.id)}
onOpenChange={(open) => setToolOpenForTab(tabId, item.id, open)}
onPermissionDecision={(decision) => {
if (item.pendingCodePermission) {
handleCodePermissionResponse(item.id, item.pendingCodePermission.requestId, decision)
}
}}
/>
)
}
const appActionData = getAppActionCardData(item)
if (appActionData) {
return <AppActionCard key={item.id} data={appActionData} status={item.status} />

View file

@ -0,0 +1,248 @@
import { useMemo, useState } from 'react'
import {
CheckCircle2,
Circle,
CircleDot,
Eye,
FileText,
Loader,
Pencil,
Search,
ShieldQuestion,
Terminal,
Trash2,
Wrench,
} from 'lucide-react'
import type { CodeRunEvent, PermissionAsk, PermissionDecision } from '@x/shared/src/code-mode.js'
import { cn } from '@/lib/utils'
import { Tool, ToolContent, ToolHeader } from '@/components/ai-elements/tool'
import { toToolState, type ToolCall } from '@/lib/chat-conversation'
// ── Timeline reduction ──────────────────────────────────────────────
// The raw ACP stream is a flat list of events; collapse it into ordered rows,
// folding tool_call + tool_call_update (by id) and the latest plan in place.
type TextRow = { kind: 'text'; id: string; text: string }
type ToolRow = { kind: 'tool'; id: string; title?: string; toolKind?: string; status?: string; diffs: string[] }
type PlanRow = { kind: 'plan'; id: string; entries: { content: string; status?: string }[] }
type PermRow = { kind: 'perm'; id: string; title: string; decision: string }
type Row = TextRow | ToolRow | PlanRow | PermRow
function reduceEvents(events: CodeRunEvent[]): Row[] {
const rows: Row[] = []
const toolIdx = new Map<string, number>()
let planIdx = -1
events.forEach((e, i) => {
switch (e.type) {
case 'message': {
if (e.role !== 'agent' || !e.text) return
const last = rows[rows.length - 1]
if (last && last.kind === 'text') last.text += e.text
else rows.push({ kind: 'text', id: `t${i}`, text: e.text })
break
}
case 'tool_call': {
const id = e.id ?? `tc${i}`
const at = toolIdx.get(id)
if (at != null) {
const r = rows[at] as ToolRow
r.title = e.title ?? r.title
r.toolKind = e.kind ?? r.toolKind
r.status = e.status ?? r.status
} else {
toolIdx.set(id, rows.length)
rows.push({ kind: 'tool', id, title: e.title, toolKind: e.kind, status: e.status, diffs: [] })
}
break
}
case 'tool_call_update': {
const id = e.id ?? `tu${i}`
let at = toolIdx.get(id)
if (at == null) {
at = rows.length
toolIdx.set(id, at)
rows.push({ kind: 'tool', id, diffs: [] })
}
const r = rows[at] as ToolRow
if (e.status) r.status = e.status
for (const d of e.diffs) if (!r.diffs.includes(d)) r.diffs.push(d)
break
}
case 'plan': {
if (planIdx >= 0) (rows[planIdx] as PlanRow).entries = e.entries
else {
planIdx = rows.length
rows.push({ kind: 'plan', id: 'plan', entries: e.entries })
}
break
}
case 'permission':
rows.push({ kind: 'perm', id: `p${i}`, title: e.ask.title, decision: e.decision })
break
default:
break
}
})
return rows
}
function toolKindIcon(kind?: string) {
switch (kind) {
case 'read': return <Eye className="size-3.5 shrink-0 text-muted-foreground" />
case 'edit': return <Pencil className="size-3.5 shrink-0 text-muted-foreground" />
case 'delete': return <Trash2 className="size-3.5 shrink-0 text-muted-foreground" />
case 'search': return <Search className="size-3.5 shrink-0 text-muted-foreground" />
case 'execute': return <Terminal className="size-3.5 shrink-0 text-muted-foreground" />
case 'fetch': return <FileText className="size-3.5 shrink-0 text-muted-foreground" />
default: return <Wrench className="size-3.5 shrink-0 text-muted-foreground" />
}
}
function planMarker(status?: string) {
if (status === 'completed') return <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />
if (status === 'in_progress') return <CircleDot className="size-3.5 shrink-0 text-blue-500" />
return <Circle className="size-3.5 shrink-0 text-muted-foreground" />
}
const basename = (p: string) => p.split(/[\\/]/).pop() || p
function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) {
const rows = useMemo(() => reduceEvents(events), [events])
if (rows.length === 0) {
return <div className="px-4 py-3 text-xs text-muted-foreground">Starting the agent</div>
}
return (
<div className="flex flex-col gap-2 px-4 py-3">
{rows.map((row) => {
if (row.kind === 'text') {
return (
<p key={row.id} className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">
{row.text}
</p>
)
}
if (row.kind === 'tool') {
const running = row.status !== 'completed' && row.status !== 'failed'
return (
<div key={row.id} className="flex flex-col gap-1">
<div className="flex items-center gap-2 text-sm">
{running
? <Loader className="size-3.5 shrink-0 animate-spin text-muted-foreground" />
: <CheckCircle2 className="size-3.5 shrink-0 text-green-600" />}
{toolKindIcon(row.toolKind)}
<span className="truncate text-foreground/90">{row.title ?? row.toolKind ?? 'Tool call'}</span>
</div>
{row.diffs.length > 0 && (
<div className="ml-7 flex flex-col gap-0.5">
{row.diffs.map((d) => (
<span key={d} className="truncate font-mono text-xs text-muted-foreground" title={d}>
{basename(d)}
</span>
))}
</div>
)}
</div>
)
}
if (row.kind === 'plan') {
return (
<div key={row.id} className="flex flex-col gap-1 rounded-lg border bg-muted/30 p-2">
{row.entries.map((entry, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm text-foreground/90">
{planMarker(entry.status)}
<span className={cn('truncate', entry.status === 'completed' && 'text-muted-foreground line-through')}>
{entry.content}
</span>
</div>
))}
</div>
)
}
// resolved permission
const denied = row.decision === 'reject' || row.decision === 'cancelled'
return (
<div key={row.id} className={cn('flex items-center gap-2 text-xs', denied ? 'text-red-600' : 'text-green-600')}>
{denied ? '✕' : '✓'}
<span className="truncate">{denied ? 'Denied' : 'Allowed'}: {row.title}</span>
</div>
)
})}
</div>
)
}
// ── In-run permission card ──────────────────────────────────────────
export function CodeRunPermissionRequest({
ask,
onDecide,
}: {
ask: PermissionAsk
onDecide: (decision: PermissionDecision) => void
}) {
const [busy, setBusy] = useState(false)
const decide = (d: PermissionDecision) => {
if (busy) return
setBusy(true)
onDecide(d)
}
const btn = 'rounded-full px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50'
return (
<div className="mb-4 rounded-[20px] border border-amber-500/40 bg-amber-500/5 p-4">
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<ShieldQuestion className="size-4 shrink-0 text-amber-600" />
Permission needed
</div>
<p className="mt-1 text-sm text-muted-foreground">
The agent wants to: <span className="font-medium text-foreground">{ask.title}</span>
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" disabled={busy} onClick={() => decide('allow_once')}
className={cn(btn, 'bg-foreground text-background hover:bg-foreground/90')}>
Allow
</button>
<button type="button" disabled={busy} onClick={() => decide('allow_always')}
className={cn(btn, 'border hover:bg-muted')}>
Always allow{ask.kind ? ` (${ask.kind})` : ''}
</button>
<button type="button" disabled={busy} onClick={() => decide('reject')}
className={cn(btn, 'border border-red-500/40 text-red-600 hover:bg-red-500/10')}>
Deny
</button>
</div>
</div>
)
}
// ── Block wrapper (rendered in the chat for a code_agent_run tool call) ──
const AGENT_LABEL: Record<string, string> = { claude: 'Claude Code', codex: 'Codex' }
export function CodingRunBlock({
item,
open,
onOpenChange,
onPermissionDecision,
}: {
item: ToolCall
open: boolean
onOpenChange: (open: boolean) => void
onPermissionDecision: (decision: PermissionDecision) => void
}) {
const agent = (item.input as { agent?: string } | undefined)?.agent
const title = AGENT_LABEL[agent ?? ''] ?? 'Coding agent'
return (
<>
<Tool open={open} onOpenChange={onOpenChange}>
<ToolHeader title={title} type="tool-code_agent_run" state={toToolState(item.status)} />
<ToolContent>
<CodingRunTimeline events={item.codeRunEvents ?? []} />
</ToolContent>
</Tool>
{item.pendingCodePermission && (
<CodeRunPermissionRequest ask={item.pendingCodePermission.ask} onDecide={onPermissionDecision} />
)}
</>
)
}

View file

@ -2,6 +2,7 @@ import type { ToolUIPart } from 'ai'
import z from 'zod'
import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js'
import { COMPOSIO_DISPLAY_NAMES } from '@x/shared/src/composio.js'
import type { CodeRunEvent, PermissionAsk } from '@x/shared/src/code-mode.js'
export interface MessageAttachment {
path: string
@ -27,6 +28,9 @@ export interface ToolCall {
streamingOutput?: string
status: 'pending' | 'running' | 'completed' | 'error'
timestamp: number
// code_agent_run only: structured ACP stream items + the in-flight permission ask.
codeRunEvents?: CodeRunEvent[]
pendingCodePermission?: { requestId: string; ask: PermissionAsk } | null
}
export interface ErrorMessage {
@ -632,6 +636,7 @@ export const isToolGroup = (item: GroupedConversationItem): item is ToolGroup =>
const isPlainToolCall = (item: ConversationItem): item is ToolCall => {
if (!isToolCall(item)) return false
if (item.name === 'code_agent_run') return false // rich standalone block, never grouped
if (getWebSearchCardData(item)) return false
if (getComposioConnectCardData(item)) return false
if (getAppActionCardData(item)) return false