mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-09 19:45:17 +02:00
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:
parent
80ed635300
commit
10ce73ae24
3 changed files with 318 additions and 0 deletions
|
|
@ -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} />
|
||||
|
|
|
|||
248
apps/x/apps/renderer/src/components/coding-run.tsx
Normal file
248
apps/x/apps/renderer/src/components/coding-run.tsx
Normal 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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue