From 10ce73ae24c1812923ac23563b3adf1e84e5f383 Mon Sep 17 00:00:00 2001 From: Gagancreates Date: Tue, 2 Jun 2026 00:57:42 +0530 Subject: [PATCH] feat(code-mode): render coding runs inline (live timeline + permission card) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/x/apps/renderer/src/App.tsx | 65 +++++ .../renderer/src/components/coding-run.tsx | 248 ++++++++++++++++++ .../renderer/src/lib/chat-conversation.ts | 5 + 3 files changed, 318 insertions(+) create mode 100644 apps/x/apps/renderer/src/components/coding-run.tsx diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 57a03727..6e9b8701 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -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 ( + setToolOpenForTab(tabId, item.id, open)} + onPermissionDecision={(decision) => { + if (item.pendingCodePermission) { + handleCodePermissionResponse(item.id, item.pendingCodePermission.requestId, decision) + } + }} + /> + ) + } const appActionData = getAppActionCardData(item) if (appActionData) { return diff --git a/apps/x/apps/renderer/src/components/coding-run.tsx b/apps/x/apps/renderer/src/components/coding-run.tsx new file mode 100644 index 00000000..ae6fe055 --- /dev/null +++ b/apps/x/apps/renderer/src/components/coding-run.tsx @@ -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() + 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 + case 'edit': return + case 'delete': return + case 'search': return + case 'execute': return + case 'fetch': return + default: return + } +} + +function planMarker(status?: string) { + if (status === 'completed') return + if (status === 'in_progress') return + return +} + +const basename = (p: string) => p.split(/[\\/]/).pop() || p + +function CodingRunTimeline({ events }: { events: CodeRunEvent[] }) { + const rows = useMemo(() => reduceEvents(events), [events]) + if (rows.length === 0) { + return
Starting the agent…
+ } + return ( +
+ {rows.map((row) => { + if (row.kind === 'text') { + return ( +

+ {row.text} +

+ ) + } + if (row.kind === 'tool') { + const running = row.status !== 'completed' && row.status !== 'failed' + return ( +
+
+ {running + ? + : } + {toolKindIcon(row.toolKind)} + {row.title ?? row.toolKind ?? 'Tool call'} +
+ {row.diffs.length > 0 && ( +
+ {row.diffs.map((d) => ( + + {basename(d)} + + ))} +
+ )} +
+ ) + } + if (row.kind === 'plan') { + return ( +
+ {row.entries.map((entry, idx) => ( +
+ {planMarker(entry.status)} + + {entry.content} + +
+ ))} +
+ ) + } + // resolved permission + const denied = row.decision === 'reject' || row.decision === 'cancelled' + return ( +
+ {denied ? '✕' : '✓'} + {denied ? 'Denied' : 'Allowed'}: {row.title} +
+ ) + })} +
+ ) +} + +// ── 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 ( +
+
+ + Permission needed +
+

+ The agent wants to: {ask.title} +

+
+ + + +
+
+ ) +} + +// ── Block wrapper (rendered in the chat for a code_agent_run tool call) ── + +const AGENT_LABEL: Record = { 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 ( + <> + + + + + + + {item.pendingCodePermission && ( + + )} + + ) +} diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index bbf1cde2..189bfdf7 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -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