diff --git a/webapp/src/components/layout/ChatWorkspace.tsx b/webapp/src/components/layout/ChatWorkspace.tsx
index d08f7600..dac1aa87 100644
--- a/webapp/src/components/layout/ChatWorkspace.tsx
+++ b/webapp/src/components/layout/ChatWorkspace.tsx
@@ -9,9 +9,10 @@ import {
Plug, Cpu, Database, GitBranch,
ChevronRight, CheckCircle2, XCircle, Clock, ListTree,
Package, Code2, Globe, Tag, Square,
+ ShieldAlert, Copy, Pencil, Download,
} from 'lucide-react'
import {
- agentsApi, tasksApi, chatApi, chatSessionsApi, debuggerApi, agentDescription,
+ agentsApi, tasksApi, chatApi, chatSessionsApi, debuggerApi, approvalsApi, agentDescription,
type ApiAgent, type ApiTask, type ApiChatMessage, type ApiGraphNode,
type ApiChatMessageRecord, type ApiChatToolAction,
} from '@/api/client'
@@ -47,6 +48,12 @@ interface ChatMessage {
latencyMs?: number
modelUsed?: string
tryItData?: TryItData
+ // Inline approval card fields
+ approvalId?: string
+ approvalAction?: string
+ approvalResource?: string
+ approvalAgentId?: string
+ approvalStatus?: 'pending' | 'approved' | 'denied' | 'sent_back'
}
// ── Slash Commands ─────────────────────────────────────────────────────────
@@ -543,6 +550,130 @@ function ToolStatusCard({ taskId, agentName, navigate }: {
)
}
+// ── Inline Approval Card ───────────────────────────────────────────────────
+
+function ApprovalCard({
+ approvalId,
+ action,
+ resource,
+ agentId,
+ status,
+ onResolved,
+}: {
+ approvalId: string
+ action: string
+ resource: string
+ agentId?: string
+ status: 'pending' | 'approved' | 'denied' | 'sent_back'
+ onResolved: (id: string, newStatus: 'approved' | 'denied' | 'sent_back') => void
+}) {
+ const [feedback, setFeedback] = useState('')
+ const [showFeedback, setShowFeedback] = useState(false)
+ const [resolving, setResolving] = useState(false)
+
+ const resolve = async (act: 'approve' | 'deny' | 'sendBack') => {
+ if (resolving) return
+ setResolving(true)
+ try {
+ if (act === 'approve') {
+ await approvalsApi.approve(approvalId, { resolver_id: 'user' })
+ onResolved(approvalId, 'approved')
+ } else if (act === 'deny') {
+ await approvalsApi.deny(approvalId, { resolver_id: 'user' })
+ onResolved(approvalId, 'denied')
+ } else {
+ await approvalsApi.sendBack(approvalId, { resolver_id: 'user', feedback })
+ onResolved(approvalId, 'sent_back')
+ }
+ } catch {
+ setResolving(false)
+ }
+ }
+
+ if (status !== 'pending') {
+ const statusColor = status === 'approved' ? 'var(--nbl-green)' : status === 'denied' ? 'var(--nbl-red)' : 'var(--nbl-amber)'
+ const statusLabel = status === 'approved' ? 'Approved' : status === 'denied' ? 'Denied' : 'Sent back'
+ return (
+
+
+ {action} on {resource}
+ {statusLabel}
+
+ )
+ }
+
+ return (
+
+
+
+ Approval Required
+ PENDING
+
+
+
+ Action
+ {action}
+
+
+ Resource
+ {resource}
+
+ {agentId && (
+
+ Agent
+ {agentId.slice(-12)}
+
+ )}
+
+ {showFeedback && (
+
+
+ )}
+
+
+
+ {!showFeedback ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
// ── Tool Action Card ────────────────────────────────────────────────────────
function ToolActionCard({ actions, navigate, onSendMessage }: { actions: ToolAction[]; navigate: ReturnType; onSendMessage?: (msg: string) => void }) {
@@ -941,7 +1072,7 @@ function WorkflowTrace({
latencyMs?: number
modelUsed?: string
}) {
- const [open, setOpen] = useState(false)
+ const [open, setOpen] = useState(true)
const nodes: TraceNodeDef[] = []
@@ -1100,11 +1231,15 @@ function MessageBubble({
navigate,
onTryIt,
onSendMessage,
+ onEditAndResend,
+ onApprovalResolved,
}: {
msg: ChatMessage
navigate: ReturnType
onTryIt?: (templateId: string) => void
onSendMessage?: (msg: string) => void
+ onEditAndResend?: (msgId: string, newText: string) => void
+ onApprovalResolved?: (id: string, status: 'approved' | 'denied' | 'sent_back') => void
}) {
const isUser = msg.role === 'user'
const running = msg.taskStatus === 'running' || msg.taskStatus === 'scheduled'
@@ -1113,15 +1248,57 @@ function MessageBubble({
const [stepsOpen, setStepsOpen] = useState(false)
const hasTask = !!msg.taskId && !running
const isTryItPicker = msg.content === '__tryit_picker__'
+ const isApproval = !!msg.approvalId
+
+ const [hovered, setHovered] = useState(false)
+ const [editing, setEditing] = useState(false)
+ const [editText, setEditText] = useState(msg.content)
+
+ const handleCopy = () => { navigator.clipboard.writeText(msg.content).catch(() => { }) }
+
+ const handleEditConfirm = () => {
+ if (editText.trim()) onEditAndResend?.(msg.id, editText.trim())
+ setEditing(false)
+ }
+
+ const handleEditCancel = () => { setEditText(msg.content); setEditing(false) }
+
+ if (isApproval && msg.approvalId) {
+ return (
+
+
+
+ Approval Request
+
+
+
onApprovalResolved?.(id, newStatus)}
+ />
+
+
+ {formatRelativeTime(msg.createdAt)}
+
+
+ )
+ }
return (
-
+
setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ >
{!isUser && (
{isNebula
@@ -1153,7 +1330,25 @@ function MessageBubble({
color: 'var(--nbl-text-primary)',
wordBreak: 'break-word',
}}>
- {running ? (
+ {editing ? (
+
+ ) : running ? (
Running…
@@ -1206,6 +1401,37 @@ function MessageBubble({
)}
+ {hovered && !editing && !running && !isTryItPicker && (
+
+
+ {isUser && onEditAndResend && (
+
+ )}
+
+ )}
+
{formatRelativeTime(msg.createdAt)}
@@ -1585,6 +1811,7 @@ export function ChatWorkspace() {
const abortRef = useRef(null)
const [suggestions, setSuggestions] = useState([])
+ const [exportMenuOpen, setExportMenuOpen] = useState(false)
const { activeSessionId, setActiveSessionId, selectItem, setRightTab } = useShell()
const bridge = useLocalBridge()
@@ -1669,6 +1896,23 @@ export function ChatWorkspace() {
// ── WS — real-time task updates ─────────────────────────────────────────
const handleWsMessage = useCallback((msg: { type: string; entity_id?: string; payload?: Record }) => {
+ // Approval request — inject inline approval card regardless of pending task tracking
+ if (msg.type === 'approval_requested' && msg.entity_id) {
+ const payload = msg.payload as Record | undefined
+ setMessages(prev => [...prev, {
+ id: `apr-${msg.entity_id}`,
+ role: 'agent' as const,
+ content: '__approval_card__',
+ mode: 'nebula',
+ approvalId: msg.entity_id!,
+ approvalAction: (payload?.action as string) ?? 'execute',
+ approvalResource: (payload?.resource as string) ?? '',
+ approvalAgentId: payload?.agent_id as string | undefined,
+ approvalStatus: 'pending' as const,
+ createdAt: new Date().toISOString(),
+ }])
+ return
+ }
const taskId = msg.entity_id
if (!taskId || !pendingTaskIds.has(taskId)) return
if (msg.type === 'task.completed' || msg.type === 'task.failed') {
@@ -1839,6 +2083,17 @@ export function ChatWorkspace() {
const isPending = nebulaMut.isPending || runMut.isPending
+ // Keep the typing indicator visible briefly after response arrives (smooth handoff)
+ const [showTyping, setShowTyping] = useState(false)
+ useEffect(() => {
+ if (isPending) {
+ setShowTyping(true)
+ } else {
+ const t = setTimeout(() => setShowTyping(false), 400)
+ return () => clearTimeout(t)
+ }
+ }, [isPending])
+
const handleStop = useCallback(() => {
abortRef.current?.abort()
nebulaMut.reset()
@@ -1849,6 +2104,50 @@ export function ChatWorkspace() {
}])
}, [nebulaMut])
+ // ── Approval resolution ─────────────────────────────────────────────────
+
+ const handleApprovalResolved = useCallback((approvalId: string, status: 'approved' | 'denied' | 'sent_back') => {
+ setMessages(prev => prev.map(m =>
+ m.approvalId === approvalId ? { ...m, approvalStatus: status } : m
+ ))
+ }, [])
+
+ // ── Edit + regenerate ───────────────────────────────────────────────────
+
+ const handleEditAndResend = useCallback(async (msgId: string, newText: string) => {
+ const idx = messages.findIndex(m => m.id === msgId)
+ if (idx === -1 || !newText.trim()) return
+ setMessages(prev => prev.slice(0, idx))
+ setHistory(prev => prev.slice(0, Math.max(0, idx - 1)))
+ const userMsg: ChatMessage = { id: `u-${Date.now()}`, role: 'user', content: newText, createdAt: new Date().toISOString(), mode: 'nebula' }
+ setMessages(prev => [...prev, userMsg])
+ const sid = await ensureSession(newText).catch(() => undefined)
+ nebulaMut.mutate({ message: newText, sid })
+ }, [messages, ensureSession, nebulaMut])
+
+ // ── Export chat ──────────────────────────────────────────────────────────
+
+ const exportChat = useCallback((format: 'md' | 'json') => {
+ const visibleMsgs = messages.filter(m => m.content && m.content !== '__tryit_picker__' && m.content !== '__approval_card__')
+ if (format === 'json') {
+ const blob = new Blob([JSON.stringify(visibleMsgs, null, 2)], { type: 'application/json' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a'); a.href = url; a.download = `nebula-chat-${Date.now()}.json`; a.click()
+ URL.revokeObjectURL(url)
+ } else {
+ const lines = visibleMsgs.map(m => {
+ const role = m.role === 'user' ? '**You**' : '**Nebula**'
+ const ts = new Date(m.createdAt).toLocaleTimeString()
+ return `${role} _(${ts})_\n\n${m.content}`
+ })
+ const blob = new Blob([lines.join('\n\n---\n\n')], { type: 'text/markdown' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a'); a.href = url; a.download = `nebula-chat-${Date.now()}.md`; a.click()
+ URL.revokeObjectURL(url)
+ }
+ setExportMenuOpen(false)
+ }, [messages])
+
// ── Slash command handler ────────────────────────────────────────────────
const startNewChat = useCallback(() => {
@@ -2006,7 +2305,45 @@ export function ChatWorkspace() {
workspace
-
+
+ {messages.length > 0 && (
+
+
+ {exportMenuOpen && (
+
+
+
+
+ )}
+
+ )}