fix: retain workflow trace on response — linger + default open
All checks were successful
Stuffle/nebula-os/pipeline/head This commit was not built
All checks were successful
Stuffle/nebula-os/pipeline/head This commit was not built
- showTyping state lingers 400ms after isPending drops so TypingBubble stays visible during the handoff to the completed message - WorkflowTrace defaults to open=true so the execution diagram is immediately visible below the response (no need to click to expand)
This commit is contained in:
@@ -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 (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '7px 10px', borderRadius: 8, background: `color-mix(in srgb, ${statusColor} 6%, var(--nbl-bg-surface))`, border: `1px solid color-mix(in srgb, ${statusColor} 20%, var(--nbl-border))`, fontSize: 11 }}>
|
||||
<ShieldAlert size={11} style={{ color: statusColor }} />
|
||||
<span style={{ color: 'var(--nbl-text-secondary)' }}>{action} on {resource}</span>
|
||||
<span style={{ marginLeft: 'auto', color: statusColor, fontWeight: 600 }}>{statusLabel}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ borderRadius: 10, overflow: 'hidden', border: '1px solid color-mix(in srgb, var(--nbl-amber) 35%, var(--nbl-border))', background: 'color-mix(in srgb, var(--nbl-amber) 5%, var(--nbl-bg-surface))', marginTop: 4 }}>
|
||||
<div style={{ padding: '9px 12px', borderBottom: '1px solid var(--nbl-border)', display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<ShieldAlert size={12} style={{ color: 'var(--nbl-amber)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--nbl-text-primary)', flex: 1 }}>Approval Required</span>
|
||||
<span style={{ fontSize: 9, padding: '1px 6px', borderRadius: 4, background: 'color-mix(in srgb, var(--nbl-amber) 12%, transparent)', border: '1px solid color-mix(in srgb, var(--nbl-amber) 25%, transparent)', color: 'var(--nbl-amber)', fontWeight: 600 }}>PENDING</span>
|
||||
</div>
|
||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 4, borderBottom: '1px solid var(--nbl-border)' }}>
|
||||
<div style={{ display: 'flex', gap: 8, fontSize: 11 }}>
|
||||
<span style={{ color: 'var(--nbl-text-ghost)', minWidth: 60 }}>Action</span>
|
||||
<span style={{ color: 'var(--nbl-text-primary)', fontFamily: 'monospace', fontWeight: 500 }}>{action}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, fontSize: 11 }}>
|
||||
<span style={{ color: 'var(--nbl-text-ghost)', minWidth: 60 }}>Resource</span>
|
||||
<span style={{ color: 'var(--nbl-text-secondary)', fontFamily: 'monospace', wordBreak: 'break-all' }}>{resource}</span>
|
||||
</div>
|
||||
{agentId && (
|
||||
<div style={{ display: 'flex', gap: 8, fontSize: 11 }}>
|
||||
<span style={{ color: 'var(--nbl-text-ghost)', minWidth: 60 }}>Agent</span>
|
||||
<span style={{ color: 'var(--nbl-text-secondary)', fontFamily: 'monospace' }}>{agentId.slice(-12)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showFeedback && (
|
||||
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--nbl-border)' }}>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={feedback}
|
||||
onChange={e => setFeedback(e.target.value)}
|
||||
placeholder="Feedback for the agent…"
|
||||
rows={2}
|
||||
style={{ width: '100%', background: 'var(--nbl-bg-input)', border: '1px solid var(--nbl-border)', borderRadius: 6, padding: '6px 8px', fontSize: 11, color: 'var(--nbl-text-primary)', resize: 'none', outline: 'none', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ padding: '8px 12px', display: 'flex', gap: 6 }}>
|
||||
<button
|
||||
onClick={() => resolve('approve')}
|
||||
disabled={resolving}
|
||||
style={{ flex: 1, padding: '6px', borderRadius: 7, fontSize: 11, fontWeight: 600, cursor: resolving ? 'default' : 'pointer', background: 'color-mix(in srgb, var(--nbl-green) 15%, transparent)', border: '1px solid color-mix(in srgb, var(--nbl-green) 30%, var(--nbl-border))', color: 'var(--nbl-green)' }}
|
||||
>
|
||||
{resolving ? '…' : '✓ Approve'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => resolve('deny')}
|
||||
disabled={resolving}
|
||||
style={{ padding: '6px 12px', borderRadius: 7, fontSize: 11, cursor: resolving ? 'default' : 'pointer', background: 'color-mix(in srgb, var(--nbl-red) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--nbl-red) 20%, var(--nbl-border))', color: 'var(--nbl-red)' }}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
{!showFeedback ? (
|
||||
<button
|
||||
onClick={() => setShowFeedback(true)}
|
||||
disabled={resolving}
|
||||
style={{ padding: '6px 12px', borderRadius: 7, fontSize: 11, cursor: resolving ? 'default' : 'pointer', background: 'transparent', border: '1px solid var(--nbl-border)', color: 'var(--nbl-text-ghost)' }}
|
||||
>
|
||||
↩ Send back
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => resolve('sendBack')}
|
||||
disabled={resolving || !feedback.trim()}
|
||||
style={{ padding: '6px 12px', borderRadius: 7, fontSize: 11, cursor: (resolving || !feedback.trim()) ? 'default' : 'pointer', background: 'color-mix(in srgb, var(--nbl-amber) 12%, transparent)', border: '1px solid color-mix(in srgb, var(--nbl-amber) 25%, var(--nbl-border))', color: 'var(--nbl-amber)' }}
|
||||
>
|
||||
↩ Send
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Tool Action Card ────────────────────────────────────────────────────────
|
||||
|
||||
function ToolActionCard({ actions, navigate, onSendMessage }: { actions: ToolAction[]; navigate: ReturnType<typeof useNavigate>; 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<typeof useNavigate>
|
||||
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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 4, marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, paddingLeft: 2, marginBottom: 2 }}>
|
||||
<ShieldAlert size={11} style={{ color: 'var(--nbl-amber)' }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--nbl-text-ghost)', fontWeight: 500 }}>Approval Request</span>
|
||||
</div>
|
||||
<div style={{ maxWidth: '88%', width: '100%' }}>
|
||||
<ApprovalCard
|
||||
approvalId={msg.approvalId}
|
||||
action={msg.approvalAction ?? 'unknown'}
|
||||
resource={msg.approvalResource ?? ''}
|
||||
agentId={msg.approvalAgentId}
|
||||
status={msg.approvalStatus ?? 'pending'}
|
||||
onResolved={(id, newStatus) => onApprovalResolved?.(id, newStatus)}
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: 10, color: 'var(--nbl-text-ghost)', paddingLeft: 2 }}>
|
||||
{formatRelativeTime(msg.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: isUser ? 'flex-end' : 'flex-start',
|
||||
gap: 4,
|
||||
marginBottom: 14,
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: isUser ? 'flex-end' : 'flex-start',
|
||||
gap: 4,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
{!isUser && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, paddingLeft: 2, marginBottom: 2 }}>
|
||||
{isNebula
|
||||
@@ -1153,7 +1330,25 @@ function MessageBubble({
|
||||
color: 'var(--nbl-text-primary)',
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{running ? (
|
||||
{editing ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={editText}
|
||||
onChange={e => setEditText(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleEditConfirm() }
|
||||
if (e.key === 'Escape') handleEditCancel()
|
||||
}}
|
||||
rows={Math.min(Math.max(editText.split('\n').length, 2), 8)}
|
||||
style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', resize: 'none', fontSize: 13, lineHeight: 1.6, color: 'var(--nbl-text-primary)', fontFamily: 'inherit', boxSizing: 'border-box' }}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 5, justifyContent: 'flex-end' }}>
|
||||
<button onClick={handleEditCancel} style={{ padding: '3px 9px', borderRadius: 5, fontSize: 11, cursor: 'pointer', background: 'transparent', border: '1px solid var(--nbl-border)', color: 'var(--nbl-text-ghost)' }}>Cancel</button>
|
||||
<button onClick={handleEditConfirm} disabled={!editText.trim()} style={{ padding: '3px 9px', borderRadius: 5, fontSize: 11, cursor: 'pointer', background: 'var(--nbl-nebula)', border: 'none', color: '#fff', fontWeight: 600 }}>Send ↵</button>
|
||||
</div>
|
||||
</div>
|
||||
) : running ? (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 7, color: 'var(--nbl-text-secondary)' }}>
|
||||
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite', flexShrink: 0, color: 'var(--nbl-cyan)' }} />
|
||||
Running…
|
||||
@@ -1206,6 +1401,37 @@ function MessageBubble({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hovered && !editing && !running && !isTryItPicker && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 2,
|
||||
padding: '2px 4px', borderRadius: 8,
|
||||
background: 'var(--nbl-bg-surface)',
|
||||
border: '1px solid var(--nbl-border)',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
|
||||
}}>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
title="Copy message"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 3, padding: '3px 7px', borderRadius: 5, background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--nbl-text-ghost)', fontSize: 10 }}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--nbl-text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--nbl-text-ghost)')}
|
||||
>
|
||||
<Copy size={10} /> Copy
|
||||
</button>
|
||||
{isUser && onEditAndResend && (
|
||||
<button
|
||||
onClick={() => { setEditText(msg.content); setEditing(true) }}
|
||||
title="Edit & resend"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 3, padding: '3px 7px', borderRadius: 5, background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--nbl-text-ghost)', fontSize: 10 }}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--nbl-nebula)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--nbl-text-ghost)')}
|
||||
>
|
||||
<Pencil size={10} /> Edit
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, paddingInline: 2 }}>
|
||||
<span style={{ fontSize: 10, color: 'var(--nbl-text-ghost)' }}>
|
||||
{formatRelativeTime(msg.createdAt)}
|
||||
@@ -1585,6 +1811,7 @@ export function ChatWorkspace() {
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const [suggestions, setSuggestions] = useState<string[]>([])
|
||||
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<string, unknown> }) => {
|
||||
// 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<string, unknown> | 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() {
|
||||
<Hash size={10} style={{ color: 'var(--nbl-text-ghost)', margin: '0 2px' }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--nbl-text-ghost)' }}>workspace</span>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
{messages.length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setExportMenuOpen(o => !o)}
|
||||
title="Export chat"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--nbl-text-ghost)', padding: '3px 6px', borderRadius: 5, fontSize: 11 }}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--nbl-text-primary)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--nbl-text-ghost)')}
|
||||
>
|
||||
<Download size={11} />
|
||||
</button>
|
||||
{exportMenuOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 50,
|
||||
background: 'var(--nbl-surface)', border: '1px solid var(--nbl-border)',
|
||||
borderRadius: 8, padding: 4, minWidth: 150,
|
||||
boxShadow: '0 8px 20px rgba(0,0,0,0.4)',
|
||||
}}>
|
||||
<button
|
||||
onClick={() => exportChat('md')}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '6px 10px', borderRadius: 5, background: 'transparent', border: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--nbl-text-primary)', textAlign: 'left' }}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--nbl-bg-surface)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
Export as Markdown
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportChat('json')}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '6px 10px', borderRadius: 5, background: 'transparent', border: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--nbl-text-primary)', textAlign: 'left' }}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--nbl-bg-surface)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
Export as JSON
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setMessages([]); setHistory([]) }}
|
||||
title="Clear view"
|
||||
@@ -2055,10 +2392,12 @@ export function ChatWorkspace() {
|
||||
navigate={navigate}
|
||||
onTryIt={(templateId) => handleSend(`/tryit ${templateId}`)}
|
||||
onSendMessage={(m) => handleSend(m)}
|
||||
onEditAndResend={handleEditAndResend}
|
||||
onApprovalResolved={handleApprovalResolved}
|
||||
/>
|
||||
))}
|
||||
|
||||
{!loadingSession && isPending && (
|
||||
{!loadingSession && showTyping && (
|
||||
<TypingBubble
|
||||
lastUserMsg={[...messages].reverse().find(m => m.role === 'user')?.content ?? ''}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user