chat-header.tsx
1 'use client' 2 3 import { useEffect, useState, useMemo, useRef, type ReactNode } from 'react' 4 import { Plus } from 'lucide-react' 5 import type { Session } from '@/types' 6 import { useAppStore } from '@/stores/use-app-store' 7 import { useChatStore } from '@/stores/use-chat-store' 8 import { useNow } from '@/hooks/use-now' 9 import { IconButton } from '@/components/shared/icon-button' 10 import { api } from '@/lib/app/api-client' 11 import { 12 ConnectorPlatformIcon, 13 getSessionConnector, 14 resolveConnectorPlatformMeta, 15 } from '@/components/shared/connector-platform-icon' 16 import { AgentAvatar } from '@/components/agents/agent-avatar' 17 import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip' 18 import { copyTextToClipboard } from '@/lib/clipboard' 19 import { useNavigate } from '@/lib/app/navigation' 20 import { getEnabledToolIds } from '@/lib/capability-selection' 21 import { getNewSessionButtonTitle, hasResettableSessionRuntime } from '@/lib/chat/new-session' 22 import { ContextMeterBadge } from './context-meter-badge' 23 24 function Tip({ label, children, side = 'bottom' }: { label: string; children: ReactNode; side?: 'top' | 'bottom' | 'left' | 'right' }) { 25 return ( 26 <Tooltip> 27 <TooltipTrigger asChild>{children}</TooltipTrigger> 28 <TooltipContent side={side} sideOffset={6} 29 className="bg-raised border border-white/[0.08] text-text shadow-[0_8px_32px_rgba(0,0,0,0.5)] rounded-[8px] px-2.5 py-1.5 text-[11px] z-[100]"> 30 {label} 31 </TooltipContent> 32 </Tooltip> 33 ) 34 } 35 36 function HeaderChip({ 37 children, 38 title, 39 onClick, 40 className = '', 41 active = false, 42 }: { 43 children: ReactNode 44 title?: string 45 onClick?: () => void 46 className?: string 47 active?: boolean 48 }) { 49 const baseClass = `inline-flex max-w-full items-center gap-1.5 rounded-[9px] border px-2.5 py-1 text-[10px] font-600 backdrop-blur-sm transition-colors ${ 50 active 51 ? 'border-accent-bright/20 bg-accent-soft/50 text-accent-bright' 52 : 'border-white/[0.06] bg-white/[0.03] text-text-3/68' 53 } ${onClick ? 'cursor-pointer hover:border-white/[0.1] hover:bg-white/[0.06] hover:text-text-2' : ''} ${className}` 54 55 if (onClick) { 56 return ( 57 <button type="button" onClick={onClick} title={title} className={baseClass}> 58 {children} 59 </button> 60 ) 61 } 62 63 return ( 64 <span title={title} className={baseClass}> 65 {children} 66 </span> 67 ) 68 } 69 70 interface Props { 71 session: Session 72 streaming: boolean 73 onStop: () => void 74 onMenuToggle: () => void 75 onBack?: () => void 76 mobile?: boolean 77 browserActive?: boolean 78 onStopBrowser?: () => void 79 onVoiceToggle?: () => void 80 voiceActive?: boolean 81 voiceSupported?: boolean 82 connectorSources?: Map<string, { platform: string; connectorName: string }> 83 connectorFilter?: string | null 84 onConnectorFilterChange?: (filter: string | null) => void 85 hasMultipleSources?: boolean 86 messageCount?: number 87 onCompactComplete?: () => void 88 onClearRequest?: () => void 89 onStartNewSession?: () => void 90 } 91 92 export function ChatHeader({ session, streaming, onStop, onMenuToggle, onBack, mobile, browserActive, onStopBrowser, onVoiceToggle, voiceActive, voiceSupported, connectorSources, connectorFilter, onConnectorFilterChange, hasMultipleSources, messageCount = 0, onCompactComplete, onClearRequest, onStartNewSession }: Props) { 93 const now = useNow() 94 const agentStatus = useChatStore((s) => s.agentStatus) 95 const agents = useAppStore((s) => s.agents) 96 const tasks = useAppStore((s) => s.tasks) 97 const navigateTo = useNavigate() 98 const setMemoryAgentFilter = useAppStore((s) => s.setMemoryAgentFilter) 99 const setSidebarOpen = useAppStore((s) => s.setSidebarOpen) 100 const refreshSession = useAppStore((s) => s.refreshSession) 101 const loadAgents = useAppStore((s) => s.loadAgents) 102 const inspectorOpen = useAppStore((s) => s.inspectorOpen) 103 const setInspectorOpen = useAppStore((s) => s.setInspectorOpen) 104 const connectors = useAppStore((s) => s.connectors) 105 const loadConnectors = useAppStore((s) => s.loadConnectors) 106 const agent = session.agentId ? agents[session.agentId] : null 107 const connector = getSessionConnector(session, connectors) 108 const connectorMeta = connector ? resolveConnectorPlatformMeta(connector.platform) : null 109 const connectorPresence = connector?.presence 110 const [copied, setCopied] = useState(false) 111 const [sourceDropdownOpen, setSourceDropdownOpen] = useState(false) 112 const sourceDropdownRef = useRef<HTMLDivElement>(null) 113 const [renaming, setRenaming] = useState(false) 114 const [renameDraft, setRenameDraft] = useState('') 115 const [renameSaving, setRenameSaving] = useState(false) 116 const [renameError, setRenameError] = useState('') 117 const renameInputRef = useRef<HTMLInputElement>(null) 118 const renameContainerRef = useRef<HTMLSpanElement>(null) 119 const liveStatus = agentStatus || null 120 const canStartNewSession = !streaming && !!onStartNewSession && (messageCount > 0 || hasResettableSessionRuntime(session)) 121 const newSessionTitle = getNewSessionButtonTitle(session) 122 const connectorPresenceMeta = useMemo(() => { 123 if (!connector) return null 124 const lastAt = connectorPresence?.lastMessageAt 125 if (!lastAt) { 126 return { 127 label: 'Idle', 128 dotClass: 'bg-text-3/30', 129 textClass: 'text-text-3/45', 130 } 131 } 132 if (!now) { 133 return { 134 label: 'Idle', 135 dotClass: 'bg-text-3/30', 136 textClass: 'text-text-3/45', 137 } 138 } 139 const ago = now - lastAt 140 if (ago < 5 * 60_000) { 141 return { 142 label: 'Active', 143 dotClass: 'bg-emerald-400', 144 textClass: 'text-emerald-400', 145 } 146 } 147 if (ago < 30 * 60_000) { 148 return { 149 label: `${Math.floor(ago / 60_000)}m ago`, 150 dotClass: 'bg-amber-400', 151 textClass: 'text-amber-300', 152 } 153 } 154 return { 155 label: 'Idle', 156 dotClass: 'bg-text-3/30', 157 textClass: 'text-text-3/45', 158 } 159 }, [connector, connectorPresence?.lastMessageAt, now]) 160 161 // Find linked task for this session 162 const linkedTask = useMemo(() => { 163 return Object.values(tasks).find((t) => t.sessionId === session.id) 164 }, [tasks, session.id]) 165 166 const resumeHandle = useMemo(() => { 167 const fromSessionClaude = session.claudeSessionId 168 ? { label: 'Claude', id: session.claudeSessionId, command: `claude --resume ${session.claudeSessionId}` } 169 : null 170 const fromSessionCodex = session.codexThreadId 171 ? { label: 'Codex', id: session.codexThreadId, command: `codex exec resume ${session.codexThreadId}` } 172 : null 173 const fromSessionOpenCode = session.opencodeSessionId 174 ? { label: 'OpenCode', id: session.opencodeSessionId, command: `opencode run \"<task>\" --session ${session.opencodeSessionId}` } 175 : null 176 const fromSessionCursor = session.cursorSessionId 177 ? { label: 'Cursor', id: session.cursorSessionId, command: `cursor-agent --resume ${session.cursorSessionId} --print \"<task>\"` } 178 : null 179 const fromSessionQwen = session.qwenSessionId 180 ? { label: 'Qwen Code', id: session.qwenSessionId, command: `qwen --resume ${session.qwenSessionId} -p \"<task>\"` } 181 : null 182 const fromDelegateClaude = session.delegateResumeIds?.claudeCode 183 ? { label: 'Claude', id: session.delegateResumeIds.claudeCode, command: `claude --resume ${session.delegateResumeIds.claudeCode}` } 184 : null 185 const fromDelegateCodex = session.delegateResumeIds?.codex 186 ? { label: 'Codex', id: session.delegateResumeIds.codex, command: `codex exec resume ${session.delegateResumeIds.codex}` } 187 : null 188 const fromDelegateOpenCode = session.delegateResumeIds?.opencode 189 ? { label: 'OpenCode', id: session.delegateResumeIds.opencode, command: `opencode run \"<task>\" --session ${session.delegateResumeIds.opencode}` } 190 : null 191 const fromDelegateGemini = session.delegateResumeIds?.gemini 192 ? { label: 'Gemini', id: session.delegateResumeIds.gemini, command: `gemini --resume ${session.delegateResumeIds.gemini} --prompt \"<task>\"` } 193 : null 194 const fromDelegateCursor = session.delegateResumeIds?.cursor 195 ? { label: 'Cursor', id: session.delegateResumeIds.cursor, command: `cursor-agent --resume ${session.delegateResumeIds.cursor} --print \"<task>\"` } 196 : null 197 const fromDelegateQwen = session.delegateResumeIds?.qwen 198 ? { label: 'Qwen Code', id: session.delegateResumeIds.qwen, command: `qwen --resume ${session.delegateResumeIds.qwen} -p \"<task>\"` } 199 : null 200 return fromSessionClaude 201 || fromSessionCodex 202 || fromSessionOpenCode 203 || fromSessionCursor 204 || fromSessionQwen 205 || fromDelegateClaude 206 || fromDelegateCodex 207 || fromDelegateOpenCode 208 || fromDelegateGemini 209 || fromDelegateCursor 210 || fromDelegateQwen 211 || null 212 }, [session.claudeSessionId, session.codexThreadId, session.opencodeSessionId, session.cursorSessionId, session.qwenSessionId, session.delegateResumeIds]) 213 214 const handleCopySessionId = () => { 215 if (!resumeHandle) return 216 void copyTextToClipboard(resumeHandle.command).then((copiedCommand) => { 217 if (!copiedCommand) return 218 setCopied(true) 219 setTimeout(() => setCopied(false), 2000) 220 }) 221 } 222 223 const handleDismissResumeHandle = async (e: React.MouseEvent) => { 224 e.stopPropagation() 225 try { 226 await api('PUT', `/chats/${session.id}`, { 227 claudeSessionId: null, 228 codexThreadId: null, 229 opencodeSessionId: null, 230 geminiSessionId: null, 231 copilotSessionId: null, 232 droidSessionId: null, 233 cursorSessionId: null, 234 qwenSessionId: null, 235 acpSessionId: null, 236 delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null, copilot: null, droid: null, cursor: null, qwen: null }, 237 }) 238 await refreshSession(session.id) 239 } catch { /* best-effort */ } 240 } 241 242 const startRename = () => { 243 if (!agent) return 244 setRenameDraft(agent.name) 245 setRenameError('') 246 setRenaming(true) 247 requestAnimationFrame(() => { 248 renameInputRef.current?.focus() 249 renameInputRef.current?.select() 250 }) 251 } 252 253 const cancelRename = () => { 254 setRenaming(false) 255 setRenameDraft('') 256 setRenameError('') 257 } 258 259 const commitRename = async () => { 260 if (!agent || renameSaving) return 261 const trimmed = renameDraft.trim() 262 if (!trimmed || trimmed === agent.name) { 263 cancelRename() 264 return 265 } 266 setRenameSaving(true) 267 setRenameError('') 268 try { 269 await api('PUT', `/agents/${agent.id}`, { name: trimmed }) 270 await loadAgents() 271 setRenaming(false) 272 } catch (err: unknown) { 273 setRenameError(err instanceof Error ? err.message : 'Rename failed') 274 } finally { 275 setRenameSaving(false) 276 } 277 } 278 279 useEffect(() => { 280 if (!renaming) return 281 const handler = (e: PointerEvent) => { 282 if (renameContainerRef.current && !renameContainerRef.current.contains(e.target as Node)) { 283 cancelRename() 284 } 285 } 286 document.addEventListener('pointerdown', handler, true) 287 return () => document.removeEventListener('pointerdown', handler, true) 288 }, [renaming]) 289 290 useEffect(() => { 291 if (!sourceDropdownOpen) return 292 const handler = (e: MouseEvent) => { 293 if (sourceDropdownRef.current && !sourceDropdownRef.current.contains(e.target as Node)) setSourceDropdownOpen(false) 294 } 295 document.addEventListener('mousedown', handler) 296 return () => document.removeEventListener('mousedown', handler) 297 }, [sourceDropdownOpen]) 298 299 useEffect(() => { 300 if (session.name.startsWith('connector:')) { 301 void loadConnectors() 302 } 303 }, [session.name, loadConnectors]) 304 305 // Context bar shows for memories, source filter, task links, resume handles, browser 306 const hasMemoryLink = !!(agent && getEnabledToolIds(session).includes('memory')) 307 const hasSourceFilter = !!hasMultipleSources 308 const hasContextBar = !!(hasMemoryLink || hasSourceFilter || linkedTask || resumeHandle || browserActive) 309 310 return ( 311 <> 312 <header 313 className="relative z-20 border-b border-white/[0.06] shrink-0" 314 style={{ 315 background: 'radial-gradient(circle at top left, rgba(66, 211, 255, 0.08), transparent 32%), radial-gradient(circle at top right, rgba(255, 190, 92, 0.05), transparent 28%), linear-gradient(180deg, rgba(var(--rgb-bg, 15,15,26), 0.96) 0%, rgba(var(--rgb-bg, 15,15,26), 0.9) 100%)', 316 backdropFilter: 'blur(20px) saturate(1.4)', 317 WebkitBackdropFilter: 'blur(20px) saturate(1.4)', 318 ...(mobile ? { paddingTop: 'max(12px, env(safe-area-inset-top))' } : {}), 319 }} 320 > 321 {/* Main row */} 322 <div className="flex flex-wrap items-start gap-3 px-4 py-2.5 min-h-[64px]"> 323 {/* Back button */} 324 {onBack && ( 325 <IconButton onClick={onBack} aria-label="Go back" size="sm"> 326 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 327 <polyline points="15 18 9 12 15 6" /> 328 </svg> 329 </IconButton> 330 )} 331 332 {/* Avatar */} 333 {agent && ( 334 <div className="relative shrink-0"> 335 {streaming && ( 336 <div 337 className="absolute -inset-[4px] rounded-full" 338 style={{ 339 background: 'radial-gradient(circle, var(--color-accent-bright), transparent 70%)', 340 animation: 'pulse-glow 2s ease-in-out infinite', 341 filter: 'blur(5px)', 342 }} 343 /> 344 )} 345 <div 346 className="relative rounded-full transition-transform duration-500" 347 style={{ 348 padding: 2, 349 background: streaming 350 ? 'linear-gradient(135deg, var(--color-accent-bright), var(--color-accent))' 351 : 'linear-gradient(135deg, rgba(255,255,255,0.10), rgba(255,255,255,0.03))', 352 animation: streaming ? 'avatar-pulse 2s ease-in-out infinite' : undefined, 353 }} 354 > 355 <div className="rounded-full bg-bg"> 356 <AgentAvatar seed={agent.avatarSeed} avatarUrl={agent.avatarUrl} name={agent.name} size={hasContextBar ? 44 : 34} /> 357 </div> 358 </div> 359 </div> 360 )} 361 362 {/* Identity + metadata — fills center */} 363 <div className="min-w-0 flex-1"> 364 <div className="flex min-w-0 flex-wrap items-center gap-2"> 365 {renaming && agent ? ( 366 <span ref={renameContainerRef} className="inline-flex items-center gap-2"> 367 <input 368 ref={renameInputRef} 369 value={renameDraft} 370 onChange={(e) => setRenameDraft(e.target.value)} 371 onKeyDown={(e) => { 372 if (e.key === 'Enter') void commitRename() 373 if (e.key === 'Escape') cancelRename() 374 }} 375 disabled={renameSaving} 376 className="font-display text-[15px] font-700 tracking-[-0.02em] bg-transparent border-b border-accent-bright/40 outline-none text-text px-0 py-0 w-[180px]" 377 style={{ fontFamily: 'inherit' }} 378 /> 379 {renameSaving && <span className="w-3 h-3 rounded-full border-2 border-text-3/30 border-t-accent-bright animate-spin shrink-0" />} 380 {renameError && <span className="text-[10px] text-red-400 shrink-0">{renameError}</span>} 381 </span> 382 ) : agent ? ( 383 <button 384 type="button" 385 onClick={startRename} 386 title="Rename agent" 387 className="group/title inline-flex min-w-0 items-center gap-1.5 rounded-[9px] px-1 py-0.5 text-left transition-colors hover:bg-white/[0.03] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent-bright/40" 388 > 389 <span className="font-display text-[16px] font-700 truncate tracking-[-0.02em] text-text transition-colors group-hover/title:text-accent-bright"> 390 {(session.shortcutForAgentId && agent.id === session.shortcutForAgentId) || agent.threadSessionId === session.id 391 ? agent.name 392 : session.name} 393 </span> 394 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="shrink-0 text-text-3/40 opacity-0 transition-opacity group-hover/title:opacity-100 group-focus-visible/title:opacity-100"> 395 <path d="M12 20h9" /> 396 <path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /> 397 </svg> 398 </button> 399 ) : ( 400 <span className="font-display text-[16px] font-700 truncate tracking-[-0.02em] text-text">{session.name}</span> 401 )} 402 {connector && connectorMeta && ( 403 <span 404 className="inline-flex min-w-0 items-center gap-1 px-2 py-1 rounded-[8px] border text-[10px] font-700 uppercase tracking-wider shrink-0" 405 style={{ 406 color: connectorMeta.color, 407 backgroundColor: `${connectorMeta.color}12`, 408 borderColor: `${connectorMeta.color}22`, 409 }} 410 title={`${connector.name} connector`} 411 > 412 <ConnectorPlatformIcon platform={connector.platform} size={10} /> 413 <span className="truncate max-w-[140px]">{connectorMeta.label}</span> 414 </span> 415 )} 416 {connectorPresenceMeta && ( 417 <HeaderChip className={`${connectorPresenceMeta.textClass} shrink-0`}> 418 <span className={`w-1.5 h-1.5 rounded-full ${connectorPresenceMeta.dotClass}`} /> 419 {connectorPresenceMeta.label} 420 </HeaderChip> 421 )} 422 {agent?.delegationEnabled === true && ( 423 <HeaderChip className="bg-amber-500/10 border-amber-500/15 text-amber-400 shrink-0">Delegates</HeaderChip> 424 )} 425 {streaming && ( 426 <HeaderChip className="bg-accent-soft/60 border-accent-bright/20 text-accent-bright shrink-0"> 427 <span className="w-1.5 h-1.5 rounded-full bg-accent-bright" style={{ animation: 'pulse 1.5s ease infinite' }} /> 428 Responding 429 </HeaderChip> 430 )} 431 {messageCount > 0 && onCompactComplete && onClearRequest && ( 432 <ContextMeterBadge 433 sessionId={session.id} 434 messageCount={messageCount} 435 onCompactComplete={onCompactComplete} 436 onClearRequest={onClearRequest} 437 /> 438 )} 439 {canStartNewSession && ( 440 <Tip label={newSessionTitle}> 441 <button 442 type="button" 443 onClick={onStartNewSession} 444 className="inline-flex items-center gap-1.5 rounded-[9px] border border-white/[0.06] bg-white/[0.03] px-2.5 py-1 text-[10px] font-600 text-text-3/70 transition-colors shrink-0 cursor-pointer hover:border-white/[0.15] hover:bg-white/[0.05] hover:text-text-2" 445 aria-label="Start a new chat session" 446 title={newSessionTitle} 447 > 448 <Plus className="h-3 w-3" aria-hidden="true" strokeWidth={2.2} /> 449 <span>New chat</span> 450 </button> 451 </Tip> 452 )} 453 </div> 454 {liveStatus?.status && ( 455 <div className="mt-1.5 flex min-w-0 flex-wrap items-center gap-1.5"> 456 <HeaderChip 457 className={`${ 458 liveStatus.status === 'blocked' ? 'bg-amber-400/12 border-amber-400/15 text-amber-300' 459 : liveStatus.status === 'ok' ? 'bg-emerald-400/12 border-emerald-400/15 text-emerald-400' 460 : liveStatus.status === 'progress' ? 'bg-blue-500/12 border-blue-500/15 text-blue-400' 461 : 'text-text-3/60' 462 }`} 463 title={liveStatus.goal || liveStatus.summary || liveStatus.nextAction || liveStatus.status} 464 > 465 <span className={`w-1.5 h-1.5 rounded-full ${ 466 liveStatus.status === 'blocked' ? 'bg-amber-300' 467 : liveStatus.status === 'ok' ? 'bg-emerald-400' 468 : liveStatus.status === 'progress' ? 'bg-blue-400' 469 : 'bg-text-3/30' 470 }`} /> 471 {liveStatus.status} 472 </HeaderChip> 473 {!mobile && liveStatus?.nextAction && ( 474 <span className="text-[10px] text-text-3/45 font-mono truncate max-w-[min(34vw,220px)]" title={liveStatus.nextAction}> 475 Next: {liveStatus.nextAction} 476 </span> 477 )} 478 </div> 479 )} 480 </div> 481 482 <div className={`flex items-center gap-2 shrink-0 ${mobile ? 'w-full justify-between pt-1' : 'ml-auto'}`}> 483 {/* Action buttons */} 484 <div className="flex items-center shrink-0 rounded-[12px] border border-white/[0.06] bg-white/[0.03] shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] p-1"> 485 {streaming && ( 486 <> 487 <IconButton onClick={onStop} variant="danger" tooltip="Stop" aria-label="Stop generation" size="sm"> 488 <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> 489 <rect x="6" y="6" width="12" height="12" rx="2" /> 490 </svg> 491 </IconButton> 492 <div className="w-px h-3.5 bg-white/[0.06] mx-0.5" /> 493 </> 494 )} 495 {voiceSupported && onVoiceToggle && ( 496 <IconButton onClick={onVoiceToggle} active={voiceActive} tooltip="Voice mode" aria-label="Toggle voice" size="sm"> 497 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 498 <path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z" /> 499 <path d="M19 10v2a7 7 0 0 1-14 0v-2" /> 500 <line x1="12" x2="12" y1="19" y2="22" /> 501 </svg> 502 </IconButton> 503 )} 504 <div className="w-px h-3.5 bg-white/[0.06] mx-0.5" /> 505 <IconButton onClick={(e) => { e.stopPropagation(); onMenuToggle() }} tooltip="More" aria-label="Chat menu" size="sm"> 506 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 507 <circle cx="12" cy="6" r="1" /><circle cx="12" cy="12" r="1" /><circle cx="12" cy="18" r="1" /> 508 </svg> 509 </IconButton> 510 {agent && ( 511 <IconButton onClick={() => setInspectorOpen(!inspectorOpen)} active={inspectorOpen} tooltip="Settings" aria-label="Toggle inspector" size="sm"> 512 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"> 513 <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" /> 514 <circle cx="12" cy="12" r="3" /> 515 </svg> 516 </IconButton> 517 )} 518 </div> 519 </div> 520 </div> 521 522 {/* Context bar: tools and links */} 523 {hasContextBar && ( 524 <div className="border-t border-white/[0.05] bg-black/[0.08] px-4 py-2"> 525 <div className="flex items-center gap-1.5 flex-wrap"> 526 {hasMemoryLink && ( 527 <Tip label="View agent memories"> 528 <button 529 onClick={() => { setMemoryAgentFilter(session.agentId!); navigateTo('memory'); setSidebarOpen(true) }} 530 className="flex items-center gap-1 px-2.5 py-1 rounded-[8px] bg-accent-soft/40 hover:bg-accent-soft/70 transition-colors cursor-pointer text-[10px] font-600 text-accent-bright/55 hover:text-accent-bright/80 shrink-0" 531 > 532 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 533 <ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" /><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" /> 534 </svg> 535 Memories 536 </button> 537 </Tip> 538 )} 539 {hasSourceFilter && onConnectorFilterChange && connectorSources && ( 540 <div className="relative shrink-0" ref={sourceDropdownRef}> 541 <Tip label="Filter messages by source connector"> 542 <button 543 onClick={() => setSourceDropdownOpen((o) => !o)} 544 className={`flex items-center gap-1 px-2 py-1 rounded-[7px] transition-colors cursor-pointer border-none text-[10px] font-600 shrink-0 ${ 545 connectorFilter 546 ? 'bg-accent-soft/60 text-accent-bright/80 hover:bg-accent-soft' 547 : 'bg-white/[0.03] text-text-3/50 hover:bg-white/[0.06] hover:text-text-3/70' 548 }`} 549 > 550 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 551 <polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" /> 552 </svg> 553 {connectorFilter 554 ? (connectorSources.get(connectorFilter)?.connectorName || 'Source') 555 : 'Source'} 556 <svg width="7" height="7" viewBox="0 0 16 16" fill="none" className="opacity-40"> 557 <path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> 558 </svg> 559 </button> 560 </Tip> 561 {sourceDropdownOpen && ( 562 <div className="absolute top-full right-0 sm:left-0 sm:right-auto mt-1 py-1 rounded-[10px] border border-white/[0.06] bg-bg/95 backdrop-blur-md shadow-lg z-50 min-w-[160px] max-w-[calc(100vw-2rem)]"> 563 <button 564 onClick={() => { onConnectorFilterChange(null); setSourceDropdownOpen(false) }} 565 className={`w-full text-left px-3 py-1.5 text-[11px] font-600 transition-colors cursor-pointer border-none flex items-center gap-2 ${ 566 !connectorFilter ? 'bg-accent-soft text-accent-bright' : 'text-text-3 hover:bg-white/[0.06]' 567 }`} 568 > 569 All Sources 570 </button> 571 {Array.from(connectorSources.entries()).map(([cid, info]) => { 572 const active = connectorFilter === cid 573 const meta = resolveConnectorPlatformMeta(info.platform) 574 return ( 575 <button 576 key={cid} 577 onClick={() => { onConnectorFilterChange(active ? null : cid); setSourceDropdownOpen(false) }} 578 className={`w-full text-left px-3 py-1.5 text-[11px] font-600 transition-colors cursor-pointer border-none flex items-center gap-2 ${ 579 active ? 'bg-accent-soft text-accent-bright' : 'text-text-3 hover:bg-white/[0.06]' 580 }`} 581 > 582 <ConnectorPlatformIcon platform={info.platform} size={12} /> 583 {info.connectorName || meta?.label || info.platform} 584 </button> 585 ) 586 })} 587 </div> 588 )} 589 </div> 590 )} 591 {linkedTask && ( 592 <Tip label="View linked task"> 593 <button 594 onClick={() => navigateTo('tasks')} 595 className="flex items-center gap-1 px-2 py-1 rounded-[7px] bg-amber-500/8 hover:bg-amber-500/12 transition-colors cursor-pointer text-[10px] font-600 text-amber-500 shrink-0" 596 > 597 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"> 598 <path d="M9 11l3 3L22 4" /><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" /> 599 </svg> 600 <span className="truncate max-w-[160px]">{linkedTask.title}</span> 601 </button> 602 </Tip> 603 )} 604 {resumeHandle && ( 605 <div className="flex items-center rounded-[7px] bg-white/[0.03] group/resume shrink-0"> 606 <Tip label="Copy CLI resume command"> 607 <button 608 onClick={handleCopySessionId} 609 className="flex min-w-0 items-center gap-1 px-2 py-1 rounded-l-[7px] hover:bg-white/[0.06] transition-colors cursor-pointer" 610 > 611 <svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" className="text-text-3/40 shrink-0"> 612 <path d="M4 17l6 0l0 -6" /><path d="M20 7l-6 0l0 6" /><path d="M4 17l10 -10" /> 613 </svg> 614 <span className="text-[10px] font-mono text-text-3/40 group-hover/resume:text-text-3/60 truncate max-w-[min(46vw,220px)]"> 615 {copied ? 'Copied!' : `${resumeHandle.label}: ${resumeHandle.id}`} 616 </span> 617 </button> 618 </Tip> 619 <Tip label="Dismiss resume handle"> 620 <button 621 onClick={handleDismissResumeHandle} 622 className="px-1 py-1 rounded-r-[7px] hover:bg-white/[0.06] transition-colors cursor-pointer opacity-60 md:opacity-0 md:group-hover/resume:opacity-100 group-focus-within/resume:opacity-100" 623 > 624 <svg width="8" height="8" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" className="text-text-3/40 hover:text-text-3"> 625 <path d="M4 4l8 8M12 4l-8 8" /> 626 </svg> 627 </button> 628 </Tip> 629 </div> 630 )} 631 {browserActive && ( 632 <Tip label="Close the browser session"> 633 <button 634 onClick={onStopBrowser} 635 className="flex items-center gap-1 px-2 py-1 rounded-[7px] bg-accent-bright/8 hover:bg-red-500/12 transition-colors cursor-pointer group text-[10px] font-600 shrink-0" 636 > 637 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-accent-bright group-hover:text-red-400"> 638 <rect x="3" y="3" width="18" height="14" rx="2" /><path d="M3 9h18" /> 639 </svg> 640 <span className="text-accent-bright group-hover:text-red-400">Browser</span> 641 <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-text-3/40 group-hover:text-red-400"> 642 <line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /> 643 </svg> 644 </button> 645 </Tip> 646 )} 647 </div> 648 </div> 649 )} 650 651 </header> 652 </> 653 ) 654 }