/ src / components / chat / chat-header.tsx
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  }