fix(chat): restore clean layout with docked agent view

This commit is contained in:
Aurora release bot
2026-05-03 14:27:44 -04:00
parent a4d83886a0
commit 5ad773753a
5 changed files with 343 additions and 467 deletions

View File

@@ -832,17 +832,17 @@ export function AgentViewPanel() {
<>
{isDesktop ? (
<motion.aside
initial={{ x: panelWidth, opacity: 0 }}
initial={false}
animate={{
x: panelVisible ? 0 : panelWidth,
width: panelVisible ? panelWidth : 0,
opacity: panelVisible ? 1 : 0,
}}
transition={{
x: { duration: 0.32, ease: [0.32, 0.72, 0.24, 1] },
width: { duration: 0.32, ease: [0.32, 0.72, 0.24, 1] },
opacity: { duration: 0.22, ease: 'easeOut' },
}}
className={cn(
'fixed right-0 bottom-0 top-[var(--titlebar-h,0px)] z-40 w-72 bg-[color:var(--theme-sidebar,#060914)]/95 backdrop-blur-xl',
'relative h-full shrink-0 overflow-hidden border-l border-primary-300/50 bg-[color:var(--theme-sidebar,#060914)]/92 backdrop-blur-xl',
panelVisible ? 'pointer-events-auto' : 'pointer-events-none',
)}
>
@@ -928,7 +928,7 @@ export function AgentViewPanel() {
</div>
<ScrollAreaRoot className="h-[calc(100vh-3.25rem)]">
<ScrollAreaRoot className="h-[calc(100%-3.25rem)]">
<ScrollAreaViewport>
<div className="space-y-3 p-3">
{/* Main Agent Card (includes usage section) */}

View File

@@ -525,13 +525,13 @@ export function InspectorPanel() {
return (
<div
className={cn(
'fixed right-0 top-0 h-full z-40 flex flex-col overflow-hidden transition-[width] duration-200',
'relative h-full shrink-0 overflow-hidden transition-[width] duration-200',
isOpen ? 'w-[350px]' : 'w-0',
)}
style={{
background: 'var(--theme-panel)',
borderLeft: '2px solid var(--theme-border)',
boxShadow: '-4px 0 16px rgba(0, 0, 0, 0.2)',
borderLeft: '1px solid var(--theme-border)',
boxShadow: '-4px 0 16px rgba(0, 0, 0, 0.12)',
}}
>
{isOpen && (

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type {
@@ -58,7 +58,6 @@ type AgentViewState = {
const PANEL_WIDTH_PX = 320
const MIN_DESKTOP_WIDTH = 1024
const AUTO_OPEN_WIDTH = 1440
const REFRESH_INTERVAL_MS = 5000
function createDemoActiveAgents(): Array<ActiveAgent> {
@@ -146,8 +145,7 @@ function createDemoHistory(): Array<AgentHistoryItem> {
}
function inferInitialOpenState(): boolean {
if (typeof window === 'undefined') return true
return window.innerWidth >= AUTO_OPEN_WIDTH
return false
}
function readString(value: unknown): string {
@@ -515,7 +513,7 @@ export function useAgentView(): AgentViewResult {
const missionSessionMap = useMissionStore((state) => state.agentSessionMap)
const [viewportWidth, setViewportWidth] = useState(() => {
if (typeof window === 'undefined') return AUTO_OPEN_WIDTH
if (typeof window === 'undefined') return MIN_DESKTOP_WIDTH
return window.innerWidth
})
const [nowMs, setNowMs] = useState(() => Date.now())
@@ -530,8 +528,6 @@ export function useAgentView(): AgentViewResult {
const [isLiveConnected, setIsLiveConnected] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const previousAutoOpenRef = useRef(false)
useEffect(() => {
function handleResize() {
setViewportWidth(window.innerWidth)
@@ -640,15 +636,7 @@ export function useAgentView(): AgentViewResult {
}
}, [])
const shouldAutoOpen = viewportWidth >= AUTO_OPEN_WIDTH
useEffect(() => {
const isCrossingToLargeDesktop =
shouldAutoOpen && previousAutoOpenRef.current !== shouldAutoOpen
previousAutoOpenRef.current = shouldAutoOpen
if (isCrossingToLargeDesktop) {
setOpen(true)
}
}, [setOpen, shouldAutoOpen])
const shouldAutoOpen = false
const isDesktop = viewportWidth >= MIN_DESKTOP_WIDTH
const panelVisible = isDesktop && isOpen

View File

@@ -2565,7 +2565,7 @@ export function ChatScreen({
? 'flex min-h-0 w-full flex-col'
: isMobile
? 'flex flex-col'
: 'grid grid-cols-[auto_1fr] grid-rows-[minmax(0,1fr)]',
: 'grid grid-cols-[auto_minmax(0,1fr)_auto_auto] grid-rows-[minmax(0,1fr)]',
)}
>
{hideUi || compact || isFocusMode ? null : isMobile ? null : (
@@ -2578,8 +2578,7 @@ export function ChatScreen({
<main
className={cn(
'flex h-full flex-1 min-h-0 min-w-0 flex-col overflow-hidden transition-[margin-right,margin-bottom] duration-200',
'mr-0',
'flex h-full flex-1 min-h-0 min-w-0 flex-col overflow-hidden transition-[margin-bottom] duration-200',
(activeIsRealtimeStreaming || hasPendingGeneration()) &&
'chat-streaming-glow',
)}
@@ -2750,10 +2749,10 @@ export function ChatScreen({
/>
) : null}
</main>
{!compact && !hideUi && !isMobile && !isFocusMode && <InspectorPanel />}
{!compact && !isFocusMode && <AgentViewPanel />}
</div>
{!compact && !hideUi && !isMobile && !isFocusMode && <TerminalPanel />}
<InspectorPanel />
{suggestion && (
<ModelSuggestionToast

View File

@@ -836,6 +836,7 @@ function ChatComposerComponent({
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false)
const [isWorkspaceMenuOpen, setIsWorkspaceMenuOpen] = useState(false)
const [isThinkingMenuOpen, setIsThinkingMenuOpen] = useState(false)
const [isControlsMenuOpen, setIsControlsMenuOpen] = useState(false)
const [isProviderSwitcherExpanded, setIsProviderSwitcherExpanded] =
useState(false)
const [isMobileActionsMenuOpen, setIsMobileActionsMenuOpen] = useState(false)
@@ -869,6 +870,7 @@ function ChatComposerComponent({
const modelSelectorRef = useRef<HTMLDivElement | null>(null)
const workspaceMenuRef = useRef<HTMLDivElement | null>(null)
const thinkingMenuRef = useRef<HTMLDivElement | null>(null)
const controlsMenuRef = useRef<HTMLDivElement | null>(null)
const composerWrapperRef = useRef<HTMLDivElement | null>(null)
const focusFrameRef = useRef<number | null>(null)
@@ -1239,15 +1241,18 @@ function ChatComposerComponent({
!isModelMenuOpen &&
!isProfileMenuOpen &&
!isWorkspaceMenuOpen &&
!isThinkingMenuOpen
!isThinkingMenuOpen &&
!isControlsMenuOpen
)
return
function handleOutsideClick(event: MouseEvent) {
const target = event.target as Node
if (controlsMenuRef.current?.contains(target)) return
if (modelSelectorRef.current?.contains(target)) return
if (profileMenuRef.current?.contains(target)) return
if (workspaceMenuRef.current?.contains(target)) return
if (thinkingMenuRef.current?.contains(target)) return
setIsControlsMenuOpen(false)
setIsModelMenuOpen(false)
setIsProviderSwitcherExpanded(false)
setIsProfileMenuOpen(false)
@@ -1264,6 +1269,7 @@ function ChatComposerComponent({
isProfileMenuOpen,
isWorkspaceMenuOpen,
isThinkingMenuOpen,
isControlsMenuOpen,
])
const persistDraft = useCallback(
@@ -2650,465 +2656,348 @@ function ChatComposerComponent({
</span>
)}
{!hideModelSelector ? (
<>
<div
className="relative ml-0.5 flex min-w-0 items-center"
ref={profileMenuRef}
>
<button
type="button"
onClick={() => {
setIsProfileMenuOpen((open) => !open)
setIsWorkspaceMenuOpen(false)
setIsThinkingMenuOpen(false)
setIsModelMenuOpen(false)
}}
disabled={disabled || profileActivateMutation.isPending}
className="inline-flex h-8 max-w-[8rem] items-center gap-1.5 rounded-full bg-primary-100/70 px-2.5 text-xs font-medium text-primary-600 transition-colors hover:bg-primary-200/80 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-primary-800/60"
title={
activeProfile
? `${activeProfile.name}${profileMeta(activeProfile) ? ` · ${profileMeta(activeProfile)}` : ''}`
: activeProfileName
}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span className="truncate">{activeProfileName}</span>
<HugeiconsIcon icon={ArrowDown01Icon} size={11} />
</button>
{isProfileMenuOpen && (
<div className="absolute bottom-full left-0 z-[200] mb-2 min-w-[14rem] overflow-hidden rounded-xl border border-neutral-200 bg-white p-1 shadow-xl animate-in fade-in slide-in-from-bottom-2 duration-150 dark:border-neutral-700 dark:bg-neutral-900">
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-neutral-400">
Agent profile
</div>
{(profilesQuery.data?.profiles ?? []).map(
(profile) => {
const selected =
profile.name === activeProfileName
return (
<button
key={profile.name}
type="button"
onClick={() => {
if (selected) {
setIsProfileMenuOpen(false)
return
}
profileActivateMutation.mutate(profile.name)
}}
className={cn(
'flex w-full flex-col rounded-lg px-3 py-2 text-left text-sm transition-colors',
selected
? 'bg-neutral-100 text-neutral-950 dark:bg-neutral-800 dark:text-neutral-50'
: 'text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800/60',
)}
>
<span className="flex items-center gap-2">
<span className="truncate font-medium">
{profile.name}
</span>
{selected ? (
<span className="text-[10px] text-accent-500">
active
</span>
) : null}
</span>
{profileMeta(profile) ? (
<span className="mt-0.5 max-w-[12rem] truncate text-[11px] text-neutral-500">
{profileMeta(profile)}
</span>
) : null}
</button>
)
},
)}
{profilesQuery.isError ? (
<div className="px-3 py-2 text-xs text-red-500">
Failed to load profiles
</div>
) : null}
</div>
)}
</div>
<div
className="relative ml-0.5 hidden min-w-0 items-center sm:flex"
ref={workspaceMenuRef}
>
<button
type="button"
onClick={() => {
setIsWorkspaceMenuOpen((open) => !open)
setIsProfileMenuOpen(false)
setIsThinkingMenuOpen(false)
setIsModelMenuOpen(false)
}}
className="inline-flex h-8 max-w-[9rem] items-center gap-1.5 rounded-full bg-primary-100/70 px-2.5 text-xs font-medium text-primary-600 transition-colors hover:bg-primary-200/80 dark:hover:bg-primary-800/60"
title={detectedWorkspacePath || 'Workspace context'}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<span className="truncate">{workspaceButtonLabel}</span>
<HugeiconsIcon icon={ArrowDown01Icon} size={11} />
</button>
{isWorkspaceMenuOpen && (
<div className="absolute bottom-full left-0 z-[200] mb-2 min-w-[19rem] overflow-hidden rounded-xl border border-neutral-200 bg-white p-1 shadow-xl animate-in fade-in slide-in-from-bottom-2 duration-150 dark:border-neutral-700 dark:bg-neutral-900">
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-neutral-400">
Workspace context
</div>
<div className="max-h-56 overflow-y-auto">
{workspaceEntries.length > 0 ? (
workspaceEntries.map((workspace) => {
const selected =
workspace.path === detectedWorkspacePath
return (
<button
key={workspace.path}
type="button"
onClick={() => {
if (selected) {
setIsWorkspaceMenuOpen(false)
return
}
workspaceSelectMutation.mutate(workspace)
}}
className={cn(
'flex w-full flex-col rounded-lg px-3 py-2 text-left text-sm transition-colors',
selected
? 'bg-neutral-100 text-neutral-950 dark:bg-neutral-800 dark:text-neutral-50'
: 'text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800/60',
)}
>
<span className="flex items-center gap-2">
<span className="truncate font-medium">
{workspace.name ||
shortPathLabel(workspace.path)}
</span>
{selected ? (
<span className="text-[10px] text-accent-500">
active
</span>
) : null}
</span>
<span className="mt-0.5 max-w-[16rem] truncate font-mono text-[11px] text-neutral-500">
{workspace.path}
</span>
</button>
)
})
) : (
<div className="px-3 py-2 text-xs text-neutral-500">
No valid workspaces detected
</div>
)}
</div>
{workspaceContextQuery.isError ? (
<div className="px-3 py-2 text-xs text-red-500">
Failed to load workspaces
</div>
) : null}
<div className="my-1 h-px bg-neutral-200 dark:bg-neutral-800" />
<button
type="button"
onClick={handleOpenWorkspaceManager}
className="w-full rounded-lg px-3 py-2 text-left text-sm text-neutral-700 transition-colors hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800/60"
>
Show files sidebar
</button>
</div>
)}
</div>
<div
className="relative ml-0.5 hidden min-w-0 items-center sm:flex"
ref={thinkingMenuRef}
>
<button
type="button"
onClick={() => {
setIsThinkingMenuOpen((open) => !open)
setIsProfileMenuOpen(false)
setIsWorkspaceMenuOpen(false)
setIsModelMenuOpen(false)
}}
className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-full bg-primary-100/70 px-2.5 text-xs font-medium text-primary-600 transition-colors hover:bg-primary-200/80 dark:hover:bg-primary-800/60',
thinkingLevel === 'off' && 'opacity-70',
)}
title={`Reasoning effort: ${thinkingLabel(thinkingLevel)}`}
>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.46 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.46 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
</svg>
<span>{thinkingLabel(thinkingLevel)}</span>
<HugeiconsIcon icon={ArrowDown01Icon} size={11} />
</button>
{isThinkingMenuOpen && (
<div className="absolute bottom-full left-0 z-[200] mb-2 min-w-[10rem] overflow-hidden rounded-xl border border-neutral-200 bg-white p-1 shadow-xl animate-in fade-in slide-in-from-bottom-2 duration-150 dark:border-neutral-700 dark:bg-neutral-900">
{(
[
['off', 'None'],
['low', 'Low'],
['medium', 'Medium'],
['high', 'High'],
] as Array<[ThinkingLevel, string]>
).map(([level, label]) => (
<button
key={level}
type="button"
onClick={() => handleThinkingSelect(level)}
className={cn(
'flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors',
thinkingLevel === level
? 'bg-neutral-100 text-neutral-950 dark:bg-neutral-800 dark:text-neutral-50'
: 'text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800/60',
)}
>
<span>{label}</span>
{thinkingLevel === level ? (
<span className="h-1.5 w-1.5 rounded-full bg-accent-500" />
) : null}
</button>
))}
</div>
)}
</div>
</>
) : null}
{!hideModelSelector ? (
<div
className="relative ml-0.5 md:ml-1 flex min-w-0 items-center"
ref={modelSelectorRef}
className="relative ml-0.5 flex min-w-0 items-center"
ref={controlsMenuRef}
>
<button
type="button"
onClick={() => {
setIsModelMenuOpen((prev) => !prev)
setIsControlsMenuOpen((open) => !open)
setIsProfileMenuOpen(false)
setIsWorkspaceMenuOpen(false)
setIsThinkingMenuOpen(false)
setIsModelMenuOpen(false)
}}
disabled={isModelSwitcherDisabled}
className="inline-flex h-8 max-w-[9rem] items-center rounded-full bg-primary-100/70 px-2 md:max-w-none md:px-3 text-xs font-medium text-primary-600 hover:bg-primary-200/80 dark:hover:bg-primary-800/60 transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
title={modelButtonLabel}
className="inline-flex h-8 items-center gap-1.5 rounded-full bg-primary-100/70 px-2.5 text-xs font-medium text-primary-600 transition-colors hover:bg-primary-200/80 dark:hover:bg-primary-800/60"
title="Chat controls"
>
<span className="max-w-[5.5rem] truncate sm:max-w-[8.5rem] md:max-w-[12rem]">
{modelButtonLabel}
</span>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<line x1="4" y1="6" x2="20" y2="6" />
<line x1="4" y1="12" x2="20" y2="12" />
<line x1="4" y1="18" x2="20" y2="18" />
<circle cx="9" cy="6" r="2" fill="currentColor" stroke="none" />
<circle cx="15" cy="12" r="2" fill="currentColor" stroke="none" />
<circle cx="11" cy="18" r="2" fill="currentColor" stroke="none" />
</svg>
<span>Controls</span>
<HugeiconsIcon icon={ArrowDown01Icon} size={11} />
</button>
{isModelMenuOpen && (
<>
<div
className="fixed inset-0 z-[199]"
onClick={() => setIsModelMenuOpen(false)}
/>
<div className="absolute bottom-full left-0 mb-2 z-[200] w-[min(28rem,calc(100vw-2rem))] min-w-[18rem] origin-bottom-left overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900 animate-in fade-in slide-in-from-bottom-2 duration-150">
<div className="max-h-[20rem] overflow-y-auto overflow-x-hidden p-1">
{(() => {
const allModels = modelsQuery.data?.models ?? []
const defaultProvider =
modelsQuery.data?.currentProvider ?? ''
if (allModels.length === 0) {
return (
<div className="p-4 text-center text-sm text-neutral-500">
No models available
</div>
)
{isControlsMenuOpen ? (
<div className="absolute bottom-full left-0 z-[190] mb-2 w-[min(32rem,calc(100vw-2rem))] min-w-[18rem] overflow-visible rounded-2xl border border-neutral-200 bg-white p-2 shadow-xl animate-in fade-in slide-in-from-bottom-2 duration-150 dark:border-neutral-700 dark:bg-neutral-900">
<div className="mb-2 px-2 pt-1 text-[10px] font-semibold uppercase tracking-wider text-neutral-400">
Chat controls
</div>
<div className="flex flex-wrap items-start gap-2">
<div
className="relative flex min-w-0 items-center"
ref={profileMenuRef}
>
<button
type="button"
onClick={() => {
setIsProfileMenuOpen((open) => !open)
setIsWorkspaceMenuOpen(false)
setIsThinkingMenuOpen(false)
setIsModelMenuOpen(false)
}}
disabled={disabled || profileActivateMutation.isPending}
className="inline-flex h-8 max-w-[8rem] items-center gap-1.5 rounded-full bg-primary-100/70 px-2.5 text-xs font-medium text-primary-600 transition-colors hover:bg-primary-200/80 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-primary-800/60"
title={
activeProfile
? `${activeProfile.name}${profileMeta(activeProfile) ? ` · ${profileMeta(activeProfile)}` : ''}`
: activeProfileName
}
const parsed = allModels.map((m) => {
const mId = String(
typeof m === 'string'
? m
: m.id || m.model || m.name || 'unknown',
)
const mName = String(
typeof m === 'string'
? m
: m.name ||
m.displayName ||
m.label ||
m.id ||
m.model ||
m,
)
const mProvider =
typeof m === 'string'
? defaultProvider
: ((m as Record<string, unknown>)
.provider as string) || defaultProvider
const isLocal =
typeof m !== 'string' &&
(m as Record<string, unknown>).description ===
'local'
return {
id: mId,
name: mName,
provider: mProvider,
isLocal,
}
})
const pinnedEntries = parsed.filter((e) =>
isPinned(e.id),
)
const unpinnedGroups = new Map<
string,
typeof parsed
>()
for (const entry of parsed) {
if (isPinned(entry.id)) continue
const group =
unpinnedGroups.get(entry.provider) ?? []
group.push(entry)
unpinnedGroups.set(entry.provider, group)
}
const renderEntry = (
entry: (typeof parsed)[0],
) => {
const isActive =
entry.id === currentModel ||
`${defaultProvider}/${entry.id}` ===
currentModel
return (
<div
key={entry.id}
className="group relative flex items-center"
>
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<span className="truncate">{activeProfileName}</span>
<HugeiconsIcon icon={ArrowDown01Icon} size={11} />
</button>
{isProfileMenuOpen && (
<div className="absolute bottom-full left-0 z-[200] mb-2 min-w-[14rem] overflow-hidden rounded-xl border border-neutral-200 bg-white p-1 shadow-xl animate-in fade-in slide-in-from-bottom-2 duration-150 dark:border-neutral-700 dark:bg-neutral-900">
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-neutral-400">
Agent profile
</div>
{(profilesQuery.data?.profiles ?? []).map((profile) => {
const selected = profile.name === activeProfileName
return (
<button
key={profile.name}
type="button"
onClick={() => {
handleModelSelect(
entry.id,
entry.provider || undefined,
)
setIsModelMenuOpen(false)
}}
className={`flex flex-1 items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors ${
isActive
? 'border-l-2 border-accent-500 bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100'
: 'text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800/50'
}`}
>
<span className="flex-1 truncate">
{entry.name}
</span>
{entry.isLocal && (
<span className="text-[10px] text-neutral-400 px-1.5 py-0.5 rounded-full bg-neutral-100 dark:bg-neutral-700">
local
</span>
)}
{isActive && (
<span className="h-1.5 w-1.5 rounded-full bg-accent-500" />
)}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
togglePin(entry.id)
}}
className={`absolute right-2 rounded p-1 transition-opacity ${
isPinned(entry.id)
? 'text-accent-500 opacity-80 hover:opacity-100'
: 'text-neutral-400 opacity-0 group-hover:opacity-60 hover:!opacity-100 hover:text-accent-500'
}`}
aria-label={
isPinned(entry.id)
? `Unpin ${entry.name}`
: `Pin ${entry.name}`
}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill={
isPinned(entry.id)
? 'currentColor'
: 'none'
if (selected) {
setIsProfileMenuOpen(false)
return
}
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 2l3 7h7l-5.5 4 2 7L12 16l-6.5 4 2-7L2 9h7z" />
</svg>
profileActivateMutation.mutate(profile.name)
}}
className={cn(
'flex w-full flex-col rounded-lg px-3 py-2 text-left text-sm transition-colors',
selected
? 'bg-neutral-100 text-neutral-950 dark:bg-neutral-800 dark:text-neutral-50'
: 'text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800/60',
)}
>
<span className="flex items-center gap-2">
<span className="truncate font-medium">{profile.name}</span>
{selected ? <span className="text-[10px] text-accent-500">active</span> : null}
</span>
{profileMeta(profile) ? <span className="mt-0.5 max-w-[12rem] truncate text-[11px] text-neutral-500">{profileMeta(profile)}</span> : null}
</button>
)
})}
{profilesQuery.isError ? <div className="px-3 py-2 text-xs text-red-500">Failed to load profiles</div> : null}
</div>
)}
</div>
<div
className="relative flex min-w-0 items-center"
ref={workspaceMenuRef}
>
<button
type="button"
onClick={() => {
setIsWorkspaceMenuOpen((open) => !open)
setIsProfileMenuOpen(false)
setIsThinkingMenuOpen(false)
setIsModelMenuOpen(false)
}}
className="inline-flex h-8 max-w-[9rem] items-center gap-1.5 rounded-full bg-primary-100/70 px-2.5 text-xs font-medium text-primary-600 transition-colors hover:bg-primary-200/80 dark:hover:bg-primary-800/60"
title={detectedWorkspacePath || 'Workspace context'}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
<span className="truncate">{workspaceButtonLabel}</span>
<HugeiconsIcon icon={ArrowDown01Icon} size={11} />
</button>
{isWorkspaceMenuOpen && (
<div className="absolute bottom-full left-0 z-[200] mb-2 min-w-[19rem] overflow-hidden rounded-xl border border-neutral-200 bg-white p-1 shadow-xl animate-in fade-in slide-in-from-bottom-2 duration-150 dark:border-neutral-700 dark:bg-neutral-900">
<div className="px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-neutral-400">Workspace context</div>
<div className="max-h-56 overflow-y-auto">
{workspaceEntries.length > 0 ? workspaceEntries.map((workspace) => {
const selected = workspace.path === detectedWorkspacePath
return (
<button
key={workspace.path}
type="button"
onClick={() => {
if (selected) {
setIsWorkspaceMenuOpen(false)
return
}
workspaceSelectMutation.mutate(workspace)
}}
className={cn(
'flex w-full flex-col rounded-lg px-3 py-2 text-left text-sm transition-colors',
selected
? 'bg-neutral-100 text-neutral-950 dark:bg-neutral-800 dark:text-neutral-50'
: 'text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800/60',
)}
>
<span className="flex items-center gap-2">
<span className="truncate font-medium">{workspace.name || shortPathLabel(workspace.path)}</span>
{selected ? <span className="text-[10px] text-accent-500">active</span> : null}
</span>
<span className="mt-0.5 max-w-[16rem] truncate font-mono text-[11px] text-neutral-500">{workspace.path}</span>
</button>
)
}) : <div className="px-3 py-2 text-xs text-neutral-500">No valid workspaces detected</div>}
</div>
{workspaceContextQuery.isError ? <div className="px-3 py-2 text-xs text-red-500">Failed to load workspaces</div> : null}
<div className="my-1 h-px bg-neutral-200 dark:bg-neutral-800" />
<button
type="button"
onClick={handleOpenWorkspaceManager}
className="w-full rounded-lg px-3 py-2 text-left text-sm text-neutral-700 transition-colors hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800/60"
>
Show files sidebar
</button>
</div>
)}
</div>
<div
className="relative flex min-w-0 items-center"
ref={thinkingMenuRef}
>
<button
type="button"
onClick={() => {
setIsThinkingMenuOpen((open) => !open)
setIsProfileMenuOpen(false)
setIsWorkspaceMenuOpen(false)
setIsModelMenuOpen(false)
}}
className={cn(
'inline-flex h-8 items-center gap-1.5 rounded-full bg-primary-100/70 px-2.5 text-xs font-medium text-primary-600 transition-colors hover:bg-primary-200/80 dark:hover:bg-primary-800/60',
thinkingLevel === 'off' && 'opacity-70',
)}
title={`Reasoning effort: ${thinkingLabel(thinkingLevel)}`}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.46 2.5 2.5 0 0 1-2.96-3.08 3 3 0 0 1-.34-5.58 2.5 2.5 0 0 1 1.32-4.24 2.5 2.5 0 0 1 1.98-3A2.5 2.5 0 0 1 9.5 2Z" />
<path d="M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.46 2.5 2.5 0 0 0 2.96-3.08 3 3 0 0 0 .34-5.58 2.5 2.5 0 0 0-1.32-4.24 2.5 2.5 0 0 0-1.98-3A2.5 2.5 0 0 0 14.5 2Z" />
</svg>
<span>{thinkingLabel(thinkingLevel)}</span>
<HugeiconsIcon icon={ArrowDown01Icon} size={11} />
</button>
{isThinkingMenuOpen && (
<div className="absolute bottom-full left-0 z-[200] mb-2 min-w-[10rem] overflow-hidden rounded-xl border border-neutral-200 bg-white p-1 shadow-xl animate-in fade-in slide-in-from-bottom-2 duration-150 dark:border-neutral-700 dark:bg-neutral-900">
{([
['off', 'None'],
['low', 'Low'],
['medium', 'Medium'],
['high', 'High'],
] as Array<[ThinkingLevel, string]>).map(([level, label]) => (
<button
key={level}
type="button"
onClick={() => handleThinkingSelect(level)}
className={cn(
'flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm transition-colors',
thinkingLevel === level
? 'bg-neutral-100 text-neutral-950 dark:bg-neutral-800 dark:text-neutral-50'
: 'text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800/60',
)}
>
<span>{label}</span>
{thinkingLevel === level ? <span className="h-1.5 w-1.5 rounded-full bg-accent-500" /> : null}
</button>
))}
</div>
)}
</div>
<div
className="relative flex min-w-0 items-center"
ref={modelSelectorRef}
>
<button
type="button"
onClick={() => {
setIsModelMenuOpen((prev) => !prev)
setIsProfileMenuOpen(false)
setIsWorkspaceMenuOpen(false)
setIsThinkingMenuOpen(false)
}}
disabled={isModelSwitcherDisabled}
className="inline-flex h-8 max-w-[9rem] items-center rounded-full bg-primary-100/70 px-2 md:max-w-none md:px-3 text-xs font-medium text-primary-600 hover:bg-primary-200/80 dark:hover:bg-primary-800/60 transition-colors cursor-pointer disabled:cursor-not-allowed disabled:opacity-50"
title={modelButtonLabel}
>
<span className="max-w-[5.5rem] truncate sm:max-w-[8.5rem] md:max-w-[12rem]">{modelButtonLabel}</span>
</button>
{isModelMenuOpen && (
<>
<div className="fixed inset-0 z-[199]" onClick={() => setIsModelMenuOpen(false)} />
<div className="absolute bottom-full left-0 mb-2 z-[200] w-[min(28rem,calc(100vw-2rem))] min-w-[18rem] origin-bottom-left overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-xl dark:border-neutral-700 dark:bg-neutral-900 animate-in fade-in slide-in-from-bottom-2 duration-150">
<div className="max-h-[20rem] overflow-y-auto overflow-x-hidden p-1">
{(() => {
const allModels = modelsQuery.data?.models ?? []
const defaultProvider = modelsQuery.data?.currentProvider ?? ''
if (allModels.length === 0) {
return <div className="p-4 text-center text-sm text-neutral-500">No models available</div>
}
const parsed = allModels.map((m) => {
const mId = String(typeof m === 'string' ? m : m.id || m.model || m.name || 'unknown')
const mName = String(typeof m === 'string' ? m : m.name || m.displayName || m.label || m.id || m.model || m)
const mProvider = typeof m === 'string' ? defaultProvider : ((m as Record<string, unknown>).provider as string) || defaultProvider
const isLocal = typeof m !== 'string' && (m as Record<string, unknown>).description === 'local'
return { id: mId, name: mName, provider: mProvider, isLocal }
})
const pinnedEntries = parsed.filter((e) => isPinned(e.id))
const unpinnedGroups = new Map<string, typeof parsed>()
for (const entry of parsed) {
if (isPinned(entry.id)) continue
const group = unpinnedGroups.get(entry.provider) ?? []
group.push(entry)
unpinnedGroups.set(entry.provider, group)
}
const renderEntry = (entry: (typeof parsed)[0]) => {
const isActive = entry.id === currentModel || `${defaultProvider}/${entry.id}` === currentModel
return (
<div key={entry.id} className="group relative flex items-center">
<button
type="button"
onClick={() => {
handleModelSelect(entry.id, entry.provider || undefined)
setIsModelMenuOpen(false)
}}
className={`flex flex-1 items-center gap-2 px-3 py-2.5 text-left text-sm transition-colors ${
isActive
? 'border-l-2 border-accent-500 bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100'
: 'text-neutral-700 hover:bg-neutral-50 dark:text-neutral-300 dark:hover:bg-neutral-800/50'
}`}
>
<span className="flex-1 truncate">{entry.name}</span>
{entry.isLocal ? <span className="text-[10px] text-neutral-400 px-1.5 py-0.5 rounded-full bg-neutral-100 dark:bg-neutral-700">local</span> : null}
{isActive ? <span className="h-1.5 w-1.5 rounded-full bg-accent-500" /> : null}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
togglePin(entry.id)
}}
className={`absolute right-2 rounded p-1 transition-opacity ${
isPinned(entry.id)
? 'text-accent-500 opacity-80 hover:opacity-100'
: 'text-neutral-400 opacity-0 group-hover:opacity-60 hover:!opacity-100 hover:text-accent-500'
}`}
aria-label={isPinned(entry.id) ? `Unpin ${entry.name}` : `Pin ${entry.name}`}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill={isPinned(entry.id) ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
<path d="M12 2l3 7h7l-5.5 4 2 7L12 16l-6.5 4 2-7L2 9h7z" />
</svg>
</button>
</div>
)
}
return (
<>
{pinnedEntries.length > 0 ? (
<div className="mb-1 border-b border-neutral-200 pb-1 dark:border-neutral-700">
<div className="mb-1 flex items-center gap-1 px-3 text-[11px] font-medium uppercase tracking-wider text-neutral-500">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="2" className="text-accent-500">
<path d="M12 2l3 7h7l-5.5 4 2 7L12 16l-6.5 4 2-7L2 9h7z" />
</svg>
<span>Pinned</span>
</div>
{pinnedEntries.map(renderEntry)}
</div>
) : null}
{Array.from(unpinnedGroups.entries()).sort((a, b) => a[0].localeCompare(b[0])).map(([provider, models]) => (
<div key={provider}>
<div className="px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-neutral-400">{provider}</div>
{models.map(renderEntry)}
</div>
))}
</>
)
})()}
</div>
)
}
return (
<>
{pinnedEntries.length > 0 && (
<div className="mb-1 border-b border-neutral-200 dark:border-neutral-700 pb-1">
<div className="mb-1 flex items-center gap-1 px-3 text-[11px] font-medium uppercase tracking-wider text-neutral-500">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
stroke="currentColor"
strokeWidth="2"
className="text-accent-500"
>
<path d="M12 2l3 7h7l-5.5 4 2 7L12 16l-6.5 4 2-7L2 9h7z" />
</svg>
<span>Pinned</span>
</div>
{pinnedEntries.map(renderEntry)}
</div>
)}
{Array.from(unpinnedGroups.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([provider, models]) => (
<div key={provider}>
<div className="px-3 pb-1 pt-2 text-[10px] font-medium uppercase tracking-wider text-neutral-400">
{provider}
</div>
{models.map(renderEntry)}
</div>
))}
</>
)
})()}
</div>
</>
)}
</div>
</div>
</>
)}
</div>
) : null}
</div>
) : null}
</div>