chore(desktop): zero eslint/typecheck debt + prettier pass (#39100)
- eslint --fix across src/ and electron/ (unused imports, import/prop sort, padding) - flatten empty catch blocks in electron CJS; drop unused applyUpdatesPosixInApp arg - add setMutableRef helper for imperative ref writes (react-compiler clean) - move sidebar cookie persistence into an effect; extract scrollElementToBottom helper
This commit is contained in:
@@ -2,13 +2,7 @@ import { useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -104,8 +98,8 @@ export function ContextMenu({
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<div className="px-2 py-1 text-[0.7rem] text-muted-foreground/80">
|
||||
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference files
|
||||
inline.
|
||||
Tip: type <kbd className="rounded bg-muted/70 px-1 py-px font-mono text-[0.65rem]">@</kbd> to reference
|
||||
files inline.
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -120,12 +114,7 @@ export function ContextMenu({
|
||||
)
|
||||
}
|
||||
|
||||
function PromptSnippetsDialog({
|
||||
onInsertText,
|
||||
onOpenChange,
|
||||
open,
|
||||
snippets
|
||||
}: PromptSnippetsDialogProps) {
|
||||
function PromptSnippetsDialog({ onInsertText, onOpenChange, open, snippets }: PromptSnippetsDialogProps) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md gap-3">
|
||||
@@ -160,12 +149,7 @@ function PromptSnippetsDialog({
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextMenuItem({
|
||||
children,
|
||||
disabled,
|
||||
icon: Icon,
|
||||
onSelect
|
||||
}: ContextMenuItemProps) {
|
||||
export function ContextMenuItem({ children, disabled, icon: Icon, onSelect }: ContextMenuItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
|
||||
<Icon />
|
||||
|
||||
@@ -21,11 +21,7 @@ import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$composerAttachments,
|
||||
clearComposerAttachments,
|
||||
type ComposerAttachment
|
||||
} from '@/store/composer'
|
||||
import { $composerAttachments, clearComposerAttachments, type ComposerAttachment } from '@/store/composer'
|
||||
import {
|
||||
$queuedPromptsBySession,
|
||||
enqueueQueuedPrompt,
|
||||
@@ -172,7 +168,7 @@ export function ChatBar({
|
||||
const [queueEdit, setQueueEdit] = useState<QueueEditState | null>(null)
|
||||
const [focusRequestId, setFocusRequestId] = useState(0)
|
||||
const dragDepthRef = useRef(0)
|
||||
const composingRef = useRef(false) // true during IME composition (CJK input)
|
||||
const composingRef = useRef(false) // true during IME composition (CJK input)
|
||||
const lastSpokenIdRef = useRef<string | null>(null)
|
||||
|
||||
const narrow = useMediaQuery('(max-width: 30rem)')
|
||||
@@ -1253,9 +1249,11 @@ export function ChatBar({
|
||||
onDrop={handleDrop}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
|
||||
if (composingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
submitDraft()
|
||||
}}
|
||||
ref={composerRef}
|
||||
|
||||
@@ -37,7 +37,10 @@ function Harness({
|
||||
const refreshTrigger = useCallback(() => {
|
||||
const editor = editorRef.current
|
||||
|
||||
if (!editor) {return}
|
||||
if (!editor) {
|
||||
return
|
||||
}
|
||||
|
||||
const raw = editor.textContent ?? ''
|
||||
|
||||
if (!raw.includes('@') && !raw.includes('/')) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Profiler, type ProfilerOnRenderCallback, type ReactNode } from 'react'
|
||||
|
||||
import { $messages, setMessages, setBusy } from '@/store/session'
|
||||
import { $messages, setBusy, setMessages } from '@/store/session'
|
||||
|
||||
type Sample = {
|
||||
id: string
|
||||
@@ -40,13 +40,16 @@ if (typeof window !== 'undefined' && !window.__PERF_PROBE__) {
|
||||
},
|
||||
summary: () => {
|
||||
const byId = new Map<string, number[]>()
|
||||
|
||||
for (const s of samples) {
|
||||
const k = `${s.id}:${s.phase}`
|
||||
const arr = byId.get(k) ?? []
|
||||
arr.push(s.actualDuration)
|
||||
byId.set(k, arr)
|
||||
}
|
||||
|
||||
const out: Record<string, { count: number; total: number; max: number; p50: number; p95: number }> = {}
|
||||
|
||||
for (const [k, arr] of byId) {
|
||||
arr.sort((a, b) => a - b)
|
||||
const total = arr.reduce((a, b) => a + b, 0)
|
||||
@@ -55,19 +58,27 @@ if (typeof window !== 'undefined' && !window.__PERF_PROBE__) {
|
||||
total: Math.round(total * 100) / 100,
|
||||
max: Math.round(arr[arr.length - 1] * 100) / 100,
|
||||
p50: Math.round(arr[Math.floor(arr.length * 0.5)] * 100) / 100,
|
||||
p95: Math.round(arr[Math.floor(arr.length * 0.95)] * 100) / 100,
|
||||
p95: Math.round(arr[Math.floor(arr.length * 0.95)] * 100) / 100
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const onRender: ProfilerOnRenderCallback = (id, phase, actualDuration, baseDuration, startTime, commitTime) => {
|
||||
const probe = typeof window !== 'undefined' ? window.__PERF_PROBE__ : undefined
|
||||
if (!probe || !probe.enabled) return
|
||||
|
||||
if (!probe || !probe.enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
probe.samples.push({ id, phase, actualDuration, baseDuration, startTime, commitTime })
|
||||
if (probe.samples.length > 5000) probe.samples.splice(0, probe.samples.length - 5000)
|
||||
|
||||
if (probe.samples.length > 5000) {
|
||||
probe.samples.splice(0, probe.samples.length - 5000)
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
@@ -86,7 +97,11 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
snapshotMsgs: () => $messages.get().length,
|
||||
reset: () => {
|
||||
activeHandle?.stop()
|
||||
if (baseline) setMessages(baseline)
|
||||
|
||||
if (baseline) {
|
||||
setMessages(baseline)
|
||||
}
|
||||
|
||||
baseline = null
|
||||
setBusy(false)
|
||||
},
|
||||
@@ -104,7 +119,11 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
}: { chunk?: string; intervalMs?: number; totalTokens?: number; flushMinMs?: number } = {}) => {
|
||||
activeHandle?.stop()
|
||||
const current = $messages.get()
|
||||
if (!baseline) baseline = current
|
||||
|
||||
if (!baseline) {
|
||||
baseline = current
|
||||
}
|
||||
|
||||
const msgId = `synthetic-${Date.now()}`
|
||||
// Seed an empty assistant message — assistant-ui will see it grow.
|
||||
setMessages([
|
||||
@@ -126,13 +145,20 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
let flushHandle: number | null = null
|
||||
|
||||
const applyDelta = (delta: string) => {
|
||||
if (!delta) return
|
||||
if (!delta) {
|
||||
return
|
||||
}
|
||||
|
||||
setMessages(prev =>
|
||||
prev.map(m => {
|
||||
if (m.id !== msgId) return m
|
||||
if (m.id !== msgId) {
|
||||
return m
|
||||
}
|
||||
|
||||
const head = m.parts.slice(0, -1)
|
||||
const last = m.parts.at(-1)
|
||||
const lastText = last && last.type === 'text' ? last.text : ''
|
||||
|
||||
return {
|
||||
...m,
|
||||
parts: [...head, { type: 'text', text: lastText + delta }]
|
||||
@@ -150,8 +176,16 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
}
|
||||
|
||||
const scheduleFlush = () => {
|
||||
if (flushHandle !== null) return
|
||||
if (flushMinMs <= 0) { flushNow(); return }
|
||||
if (flushHandle !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (flushMinMs <= 0) {
|
||||
flushNow()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const since = performance.now() - lastFlushAt
|
||||
const wait = Math.max(0, flushMinMs - since)
|
||||
flushHandle =
|
||||
@@ -162,48 +196,62 @@ if (typeof window !== 'undefined' && !window.__PERF_DRIVE__) {
|
||||
|
||||
const handle: SyntheticDriverHandle = {
|
||||
stop: () => {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
timer = null
|
||||
|
||||
if (flushHandle !== null) {
|
||||
clearTimeout(flushHandle)
|
||||
cancelAnimationFrame?.(flushHandle)
|
||||
}
|
||||
|
||||
flushHandle = null
|
||||
|
||||
if (pendingDelta) {
|
||||
applyDelta(pendingDelta)
|
||||
pendingDelta = ''
|
||||
}
|
||||
|
||||
activeHandle = null
|
||||
// Mark message finalized.
|
||||
setMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === msgId
|
||||
? { ...m, pending: false }
|
||||
: m
|
||||
)
|
||||
)
|
||||
setMessages(prev => prev.map(m => (m.id === msgId ? { ...m, pending: false } : m)))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
activeHandle = handle
|
||||
|
||||
const tick = () => {
|
||||
if (activeHandle !== handle) return
|
||||
if (pushed >= totalTokens) {
|
||||
if (pendingDelta) flushNow()
|
||||
handle.stop()
|
||||
if (activeHandle !== handle) {
|
||||
return
|
||||
}
|
||||
|
||||
if (pushed >= totalTokens) {
|
||||
if (pendingDelta) {
|
||||
flushNow()
|
||||
}
|
||||
|
||||
handle.stop()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
pushed += 1
|
||||
|
||||
if (flushMinMs > 0) {
|
||||
pendingDelta += chunk
|
||||
scheduleFlush()
|
||||
} else {
|
||||
applyDelta(chunk)
|
||||
}
|
||||
|
||||
timer = setTimeout(tick, intervalMs)
|
||||
}
|
||||
|
||||
timer = setTimeout(tick, intervalMs)
|
||||
|
||||
return handle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,12 +101,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) return
|
||||
if (event.button !== 1) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
|
||||
@@ -146,7 +146,12 @@ export function SidebarSessionRow({
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn('grid w-3.5 shrink-0 place-items-center', needsInput ? 'overflow-visible' : 'overflow-hidden')}>
|
||||
<span
|
||||
className={cn(
|
||||
'grid w-3.5 shrink-0 place-items-center',
|
||||
needsInput ? 'overflow-visible' : 'overflow-hidden'
|
||||
)}
|
||||
>
|
||||
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -6,14 +6,7 @@ import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SearchField } from '@/components/ui/search-field'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import {
|
||||
getActionStatus,
|
||||
getLogs,
|
||||
getStatus,
|
||||
getUsageAnalytics,
|
||||
restartGateway,
|
||||
updateHermes
|
||||
} from '@/hermes'
|
||||
import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway, updateHermes } from '@/hermes'
|
||||
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { Activity, AlertCircle, BarChart3, type IconComponent, Pin } from '@/lib/icons'
|
||||
@@ -128,12 +121,7 @@ function EmptyPanel({ action, description, title }: { action?: ReactNode; descri
|
||||
)
|
||||
}
|
||||
|
||||
export function CommandCenterView({
|
||||
initialSection,
|
||||
onClose,
|
||||
onDeleteSession,
|
||||
onOpenSession
|
||||
}: CommandCenterViewProps) {
|
||||
export function CommandCenterView({ initialSection, onClose, onDeleteSession, onOpenSession }: CommandCenterViewProps) {
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
|
||||
|
||||
@@ -4,14 +4,7 @@ import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList
|
||||
} from '@/components/ui/command'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { getHermesConfigRecord, listSessions } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
@@ -137,7 +130,11 @@ export function CommandPalette() {
|
||||
|
||||
// Server-backed sources for the type-to-search groups, fetched lazily while
|
||||
// the palette is open. react-query handles caching/dedup/staleness.
|
||||
const configQuery = useQuery({ queryKey: ['command-palette', 'config'], queryFn: getHermesConfigRecord, enabled: open })
|
||||
const configQuery = useQuery({
|
||||
queryKey: ['command-palette', 'config'],
|
||||
queryFn: getHermesConfigRecord,
|
||||
enabled: open
|
||||
})
|
||||
|
||||
const sessionsQuery = useQuery({
|
||||
queryKey: ['command-palette', 'sessions'],
|
||||
@@ -154,7 +151,9 @@ export function CommandPalette() {
|
||||
const mcpServers = useMemo(() => {
|
||||
const raw = configQuery.data?.mcp_servers
|
||||
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw) ? Object.keys(raw as Record<string, unknown>).sort() : []
|
||||
return raw && typeof raw === 'object' && !Array.isArray(raw)
|
||||
? Object.keys(raw as Record<string, unknown>).sort()
|
||||
: []
|
||||
}, [configQuery.data])
|
||||
|
||||
const sessions = useMemo(() => (sessionsQuery.data?.sessions ?? []).map(toSessionEntry), [sessionsQuery.data])
|
||||
|
||||
@@ -432,20 +432,20 @@ export function CronView({ onClose }: CronViewProps) {
|
||||
{!jobs ? (
|
||||
<PageLoader label="Loading cron jobs..." />
|
||||
) : visibleJobs.length === 0 ? (
|
||||
// Empty state owns the primary "create" CTA — we used to also have
|
||||
// one in the filters bar but it was redundant. Only show the button
|
||||
// when there are zero jobs total; the search-empty case ("No
|
||||
// matches") just asks the user to broaden their query.
|
||||
<EmptyState
|
||||
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
|
||||
description={
|
||||
totalCount === 0
|
||||
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
|
||||
: 'Try a broader search query.'
|
||||
}
|
||||
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
|
||||
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
|
||||
/>
|
||||
// Empty state owns the primary "create" CTA — we used to also have
|
||||
// one in the filters bar but it was redundant. Only show the button
|
||||
// when there are zero jobs total; the search-empty case ("No
|
||||
// matches") just asks the user to broaden their query.
|
||||
<EmptyState
|
||||
actionLabel={totalCount === 0 ? 'Create first cron' : undefined}
|
||||
description={
|
||||
totalCount === 0
|
||||
? 'Schedule a prompt to run on a cron expression. Hermes will run it and deliver results to the destination you pick.'
|
||||
: 'Try a broader search query.'
|
||||
}
|
||||
onAction={totalCount === 0 ? () => setEditor({ mode: 'create' }) : undefined}
|
||||
title={totalCount === 0 ? 'No scheduled jobs yet' : 'No matches'}
|
||||
/>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-4xl min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||
{/* Inline header replaces the old top-bar "New cron" button. We
|
||||
|
||||
@@ -194,6 +194,7 @@ export function useGatewayBoot({
|
||||
scheduleReconnect()
|
||||
}
|
||||
})
|
||||
|
||||
const offEvent = gateway.onEvent(event => callbacksRef.current.handleGatewayEvent(event))
|
||||
|
||||
// Wake signals: power resume (macOS/Windows), network coming back, and the
|
||||
@@ -201,6 +202,7 @@ export function useGatewayBoot({
|
||||
const offPowerResume = desktop.onPowerResume?.(() => reconnectNow())
|
||||
|
||||
const onOnline = () => reconnectNow()
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
reconnectNow()
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
import {
|
||||
SiApple,
|
||||
SiBilibili,
|
||||
@@ -14,6 +12,7 @@ import {
|
||||
SiWechat,
|
||||
SiWhatsapp
|
||||
} from '@icons-pack/react-simple-icons'
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -69,10 +68,7 @@ export function PlatformAvatar({ className, platformId, platformName }: Platform
|
||||
|
||||
if (!spec) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
|
||||
>
|
||||
<span aria-hidden="true" className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -51,9 +51,7 @@ export function PageSearchShell({
|
||||
<div className="shrink-0">
|
||||
{(tabs || !searchHidden) && (
|
||||
<div className="flex items-center gap-3 px-3 pb-2 pt-[calc(var(--titlebar-height)+0.5rem)]">
|
||||
{tabs ? (
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div>
|
||||
) : null}
|
||||
{tabs ? <div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">{tabs}</div> : null}
|
||||
{!searchHidden && (
|
||||
<div className={cn('flex shrink-0 items-center', !tabs && 'flex-1')}>
|
||||
<SearchField
|
||||
@@ -66,9 +64,7 @@ export function PageSearchShell({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{filters ? (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div>
|
||||
) : null}
|
||||
{filters ? <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 pb-2">{filters}</div> : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden bg-(--ui-chat-surface-background)">{children}</div>
|
||||
</section>
|
||||
|
||||
@@ -166,9 +166,7 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
profile={profile}
|
||||
/>
|
||||
))}
|
||||
{profiles.length === 0 && (
|
||||
<p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</p>
|
||||
)}
|
||||
{profiles.length === 0 && <p className="px-1.5 py-3 text-xs text-muted-foreground">No profiles yet.</p>}
|
||||
</OverlaySidebar>
|
||||
|
||||
<OverlayMain className="px-0">
|
||||
|
||||
@@ -31,6 +31,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
|
||||
if (takeover) {
|
||||
setRightSidebarTab('terminal')
|
||||
}
|
||||
|
||||
setTerminalTakeover(!takeover)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import { type CSSProperties, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
|
||||
import { TERMINAL_BG } from './selection'
|
||||
|
||||
import { TerminalTab } from './index'
|
||||
import { TERMINAL_BG } from './selection'
|
||||
|
||||
/**
|
||||
* One xterm Terminal mounted at the layout root and CSS-overlayed onto
|
||||
@@ -21,11 +22,17 @@ export function TerminalSlot({ className = SLOT_CLASS }: { className?: string })
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
|
||||
$slot.set(el)
|
||||
|
||||
return () => {
|
||||
if ($slot.get() === el) $slot.set(null)
|
||||
if ($slot.get() === el) {
|
||||
$slot.set(null)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -55,6 +62,7 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
|
||||
useLayoutEffect(() => {
|
||||
if (!slot) {
|
||||
setRect(null)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,13 +80,17 @@ export function PersistentTerminal({ cwd, onAddSelectionToChat }: PersistentTerm
|
||||
if (!sameRect(prev, next)) {
|
||||
prev = next
|
||||
setRect(next)
|
||||
if (next.width > 0 && next.height > 0) setReady(true)
|
||||
|
||||
if (next.width > 0 && next.height > 0) {
|
||||
setReady(true)
|
||||
}
|
||||
}
|
||||
|
||||
frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
tick()
|
||||
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [slot])
|
||||
|
||||
|
||||
@@ -96,11 +96,18 @@ interface UseTerminalSessionOptions {
|
||||
}
|
||||
|
||||
function transferHasDropCandidates(t: DataTransfer): boolean {
|
||||
if (t.types?.includes(HERMES_PATHS_MIME)) return true
|
||||
if ((t.files?.length ?? 0) > 0) return true
|
||||
if (t.types?.includes(HERMES_PATHS_MIME)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if ((t.files?.length ?? 0) > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
|
||||
if (t.items[i]?.kind === 'file') return true
|
||||
if (t.items[i]?.kind === 'file') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
@@ -108,22 +115,38 @@ function transferHasDropCandidates(t: DataTransfer): boolean {
|
||||
|
||||
function collectDroppedPaths(t: DataTransfer): string[] {
|
||||
const seen = new Set<string>()
|
||||
|
||||
const push = (value: unknown) => {
|
||||
if (typeof value !== 'string') return
|
||||
if (typeof value !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
const path = value.trim()
|
||||
if (path) seen.add(path)
|
||||
|
||||
if (path) {
|
||||
seen.add(path)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = t.getData(HERMES_PATHS_MIME)
|
||||
if (raw) for (const entry of JSON.parse(raw) as { path?: unknown }[]) push(entry?.path)
|
||||
|
||||
if (raw) {
|
||||
for (const entry of JSON.parse(raw) as { path?: unknown }[]) {
|
||||
push(entry?.path)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Malformed in-app drag payload — fall through to OS files.
|
||||
}
|
||||
|
||||
const getPath = window.hermesDesktop?.getPathForFile
|
||||
|
||||
const addFile = (file: File | null) => {
|
||||
if (!file || !getPath) return
|
||||
if (!file || !getPath) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
push(getPath(file))
|
||||
} catch {
|
||||
@@ -131,10 +154,16 @@ function collectDroppedPaths(t: DataTransfer): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < (t.files?.length ?? 0); i += 1) addFile(t.files.item(i))
|
||||
for (let i = 0; i < (t.files?.length ?? 0); i += 1) {
|
||||
addFile(t.files.item(i))
|
||||
}
|
||||
|
||||
for (let i = 0; i < (t.items?.length ?? 0); i += 1) {
|
||||
const item = t.items[i]
|
||||
if (item?.kind === 'file') addFile(item.getAsFile())
|
||||
|
||||
if (item?.kind === 'file') {
|
||||
addFile(item.getAsFile())
|
||||
}
|
||||
}
|
||||
|
||||
return [...seen]
|
||||
@@ -142,8 +171,15 @@ function collectDroppedPaths(t: DataTransfer): string[] {
|
||||
|
||||
function quotePathForShell(path: string, shellName: string): string {
|
||||
const shell = shellName.toLowerCase()
|
||||
if (shell.includes('powershell') || shell.includes('pwsh')) return `'${path.replace(/'/g, "''")}'`
|
||||
if (shell.includes('cmd')) return `"${path.replace(/"/g, '""')}"`
|
||||
|
||||
if (shell.includes('powershell') || shell.includes('pwsh')) {
|
||||
return `'${path.replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
if (shell.includes('cmd')) {
|
||||
return `"${path.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
return `'${path.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
|
||||
@@ -250,12 +286,14 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
webgl.onContextLoss(() => webgl.dispose())
|
||||
term.loadAddon(webgl)
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[hermes-terminal] WebGL unavailable; falling back to DOM', err)
|
||||
}
|
||||
|
||||
const onDragOver = (e: DragEvent) => {
|
||||
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
|
||||
if (!e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
@@ -263,11 +301,19 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
const id = sessionIdRef.current
|
||||
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) return
|
||||
|
||||
if (!id || !e.dataTransfer || !transferHasDropCandidates(e.dataTransfer)) {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const paths = collectDroppedPaths(e.dataTransfer)
|
||||
if (!paths.length) return
|
||||
|
||||
if (!paths.length) {
|
||||
return
|
||||
}
|
||||
|
||||
void terminalApi.write(id, `${paths.map(p => quotePathForShell(p, shellNameRef.current)).join(' ')} `)
|
||||
term.focus()
|
||||
triggerHaptic('selection')
|
||||
@@ -305,11 +351,18 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
// synchronously while sibling panes are mid-transition (e.g. file browser
|
||||
// collapsing to 0px) crashes the WebGL renderer mid texture-atlas rebuild.
|
||||
let pendingFrame = 0
|
||||
|
||||
const scheduleResize = () => {
|
||||
if (pendingFrame) return
|
||||
if (pendingFrame) {
|
||||
return
|
||||
}
|
||||
|
||||
pendingFrame = window.requestAnimationFrame(() => {
|
||||
pendingFrame = 0
|
||||
if (!disposed) fitAndResize()
|
||||
|
||||
if (!disposed) {
|
||||
fitAndResize()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -317,7 +370,10 @@ export function useTerminalSession({ cwd, onAddSelectionToChat }: UseTerminalSes
|
||||
resizeObserver.observe(host)
|
||||
cleanup.push(() => {
|
||||
resizeObserver.disconnect()
|
||||
if (pendingFrame) window.cancelAnimationFrame(pendingFrame)
|
||||
|
||||
if (pendingFrame) {
|
||||
window.cancelAnimationFrame(pendingFrame)
|
||||
}
|
||||
})
|
||||
|
||||
const dataDisposable = term.onData(data => {
|
||||
|
||||
@@ -55,13 +55,7 @@ const RESERVED_PATHS: ReadonlySet<string> = new Set(APP_ROUTES.map(route => rout
|
||||
// Views that render as a full-screen modal card (OverlayView) over the shell.
|
||||
// While one is open the app's titlebar control clusters must hide so they don't
|
||||
// bleed over the overlay (they sit at a higher z-index than the overlay card).
|
||||
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set([
|
||||
'agents',
|
||||
'command-center',
|
||||
'cron',
|
||||
'profiles',
|
||||
'settings'
|
||||
])
|
||||
export const OVERLAY_VIEWS: ReadonlySet<AppView> = new Set(['agents', 'command-center', 'cron', 'profiles', 'settings'])
|
||||
|
||||
export function isOverlayView(view: AppView): boolean {
|
||||
return OVERLAY_VIEWS.has(view)
|
||||
|
||||
@@ -326,10 +326,7 @@ export function useMessageStream({
|
||||
return
|
||||
}
|
||||
|
||||
flushHandleRef.current = window.setTimeout(
|
||||
runFlush,
|
||||
Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast)
|
||||
)
|
||||
flushHandleRef.current = window.setTimeout(runFlush, Math.max(0, STREAM_DELTA_FLUSH_MS - sinceLast))
|
||||
}, [flushQueuedDeltas])
|
||||
|
||||
const queueDelta = useCallback(
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
isDesktopSlashCommand
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import {
|
||||
@@ -246,7 +247,7 @@ export function usePromptActions({
|
||||
}
|
||||
|
||||
const releaseBusy = () => {
|
||||
busyRef.current = false
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
}
|
||||
@@ -290,7 +291,7 @@ export function usePromptActions({
|
||||
)
|
||||
}
|
||||
|
||||
busyRef.current = true
|
||||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
clearNotifications()
|
||||
@@ -594,7 +595,7 @@ export function usePromptActions({
|
||||
const cancelRun = useCallback(async () => {
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
busyRef.current = false
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
|
||||
@@ -753,7 +754,7 @@ export function usePromptActions({
|
||||
const editedMessage: ChatMessage = { ...source, parts: [textPart(text)] }
|
||||
|
||||
clearNotifications()
|
||||
busyRef.current = true
|
||||
setMutableRef(busyRef, true)
|
||||
setBusy(true)
|
||||
setAwaitingResponse(true)
|
||||
updateSessionState(sessionId, state => ({
|
||||
@@ -791,7 +792,7 @@ export function usePromptActions({
|
||||
}
|
||||
}
|
||||
|
||||
busyRef.current = false
|
||||
setMutableRef(busyRef, false)
|
||||
setBusy(false)
|
||||
setAwaitingResponse(false)
|
||||
updateSessionState(sessionId, state => ({ ...state, busy: false, awaitingResponse: false }))
|
||||
|
||||
@@ -36,8 +36,8 @@ import {
|
||||
setMessages,
|
||||
setSelectedStoredSessionId,
|
||||
setSessions,
|
||||
setSessionsTotal,
|
||||
setSessionStartedAt,
|
||||
setSessionsTotal,
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
@@ -311,74 +311,77 @@ export function useSessionActions({
|
||||
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
|
||||
)
|
||||
|
||||
const createBackendSessionForSend = useCallback(async (preview: string | null = null): Promise<string | null> => {
|
||||
const startingActiveSessionId = activeSessionIdRef.current
|
||||
const startingStoredSessionId = selectedStoredSessionIdRef.current
|
||||
const startingRouteToken = getRouteToken()
|
||||
const createBackendSessionForSend = useCallback(
|
||||
async (preview: string | null = null): Promise<string | null> => {
|
||||
const startingActiveSessionId = activeSessionIdRef.current
|
||||
const startingStoredSessionId = selectedStoredSessionIdRef.current
|
||||
const startingRouteToken = getRouteToken()
|
||||
|
||||
creatingSessionRef.current = true
|
||||
creatingSessionRef.current = true
|
||||
|
||||
try {
|
||||
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
|
||||
const stored = created.stored_session_id ?? null
|
||||
try {
|
||||
const cwd = $currentCwd.get().trim() || getRememberedWorkspaceCwd()
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', { cols: 96, ...(cwd && { cwd }) })
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
if (
|
||||
activeSessionIdRef.current !== startingActiveSessionId ||
|
||||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
|
||||
getRouteToken() !== startingRouteToken
|
||||
) {
|
||||
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
|
||||
if (
|
||||
activeSessionIdRef.current !== startingActiveSessionId ||
|
||||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
|
||||
getRouteToken() !== startingRouteToken
|
||||
) {
|
||||
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
|
||||
|
||||
return null
|
||||
return null
|
||||
}
|
||||
|
||||
activeSessionIdRef.current = created.session_id
|
||||
selectedStoredSessionIdRef.current = stored
|
||||
ensureSessionState(created.session_id, stored)
|
||||
|
||||
if (stored) {
|
||||
// Seed the sidebar preview with the user's first message so the row
|
||||
// reads meaningfully while the turn is in flight, instead of flashing
|
||||
// "Untitled session" until the turn persists and auto-title runs. The
|
||||
// server later returns its own preview/title and supersedes this.
|
||||
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
|
||||
navigate(sessionRoute(stored), { replace: true })
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
setActiveSessionId(created.session_id)
|
||||
setSelectedStoredSessionId(stored)
|
||||
setSessionStartedAt(Date.now())
|
||||
const yoloArmed = $yoloActive.get()
|
||||
const runtimeInfo = applyRuntimeInfo(created.info)
|
||||
|
||||
if (runtimeInfo) {
|
||||
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
|
||||
}
|
||||
|
||||
// User may have armed YOLO on the new-chat draft before the runtime
|
||||
// session existed — apply it to the freshly created session.
|
||||
if (yoloArmed) {
|
||||
await setSessionYolo(requestGateway, created.session_id, true).catch(() => undefined)
|
||||
}
|
||||
|
||||
return created.session_id
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
creatingSessionRef.current = false
|
||||
}, 0)
|
||||
}
|
||||
|
||||
activeSessionIdRef.current = created.session_id
|
||||
selectedStoredSessionIdRef.current = stored
|
||||
ensureSessionState(created.session_id, stored)
|
||||
|
||||
if (stored) {
|
||||
// Seed the sidebar preview with the user's first message so the row
|
||||
// reads meaningfully while the turn is in flight, instead of flashing
|
||||
// "Untitled session" until the turn persists and auto-title runs. The
|
||||
// server later returns its own preview/title and supersedes this.
|
||||
upsertOptimisticSession(created, stored, null, preview?.trim() || null)
|
||||
navigate(sessionRoute(stored), { replace: true })
|
||||
}
|
||||
|
||||
setFreshDraftReady(false)
|
||||
setActiveSessionId(created.session_id)
|
||||
setSelectedStoredSessionId(stored)
|
||||
setSessionStartedAt(Date.now())
|
||||
const yoloArmed = $yoloActive.get()
|
||||
const runtimeInfo = applyRuntimeInfo(created.info)
|
||||
|
||||
if (runtimeInfo) {
|
||||
updateSessionState(created.session_id, state => ({ ...state, ...runtimeInfo }), stored)
|
||||
}
|
||||
|
||||
// User may have armed YOLO on the new-chat draft before the runtime
|
||||
// session existed — apply it to the freshly created session.
|
||||
if (yoloArmed) {
|
||||
await setSessionYolo(requestGateway, created.session_id, true).catch(() => undefined)
|
||||
}
|
||||
|
||||
return created.session_id
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
creatingSessionRef.current = false
|
||||
}, 0)
|
||||
}
|
||||
}, [
|
||||
activeSessionIdRef,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
getRouteToken,
|
||||
navigate,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
])
|
||||
},
|
||||
[
|
||||
activeSessionIdRef,
|
||||
creatingSessionRef,
|
||||
ensureSessionState,
|
||||
getRouteToken,
|
||||
navigate,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
]
|
||||
)
|
||||
|
||||
const selectSidebarItem = useCallback(
|
||||
(item: SidebarNavItem) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionAttention, setSessionWorking } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
@@ -38,7 +39,7 @@ export function useSessionStateCache({
|
||||
}, [activeSessionId])
|
||||
|
||||
useEffect(() => {
|
||||
busyRef.current = busy
|
||||
setMutableRef(busyRef, busy)
|
||||
}, [busy, busyRef])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,7 +90,7 @@ export function useSessionStateCache({
|
||||
|
||||
setMessages(preserveLocalAssistantErrors(pending.state.messages, $messages.get()))
|
||||
setBusy(pending.state.busy)
|
||||
busyRef.current = pending.state.busy
|
||||
setMutableRef(busyRef, pending.state.busy)
|
||||
setAwaitingResponse(pending.state.awaitingResponse)
|
||||
}, [busyRef, setAwaitingResponse, setBusy, setMessages])
|
||||
|
||||
|
||||
@@ -187,6 +187,7 @@ export function GatewaySettings() {
|
||||
// While probing (or after a probe error), the scheme is unknown and we show
|
||||
// the probe status row instead of a control.
|
||||
const hasSavedRemote = state.remoteTokenSet || state.remoteOauthConnected
|
||||
|
||||
const authResolved = useMemo(() => {
|
||||
if (probeStatus === 'done') {
|
||||
return true
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { getAuxiliaryModels, getGlobalModelInfo, getGlobalModelOptions, setModelAssignment } from '@/hermes'
|
||||
import type { AuxiliaryModelsResponse, ModelOptionProvider } from '@/hermes'
|
||||
import { Cpu, Loader2 } from '@/lib/icons'
|
||||
@@ -231,7 +225,11 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button disabled={!selectedProvider || !selectedModel || applying} onClick={() => void applyMainModel()} size="sm">
|
||||
<Button
|
||||
disabled={!selectedProvider || !selectedModel || applying}
|
||||
onClick={() => void applyMainModel()}
|
||||
size="sm"
|
||||
>
|
||||
{applying && <Loader2 className="size-3.5 animate-spin" />}
|
||||
{applying ? 'Applying...' : 'Apply'}
|
||||
</Button>
|
||||
@@ -332,7 +330,9 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
|
||||
}
|
||||
description={
|
||||
<span className="font-mono text-[0.68rem]">
|
||||
{isAuto ? 'auto · use main model' : `${current.provider} · ${current.model || '(provider default)'}`}
|
||||
{isAuto
|
||||
? 'auto · use main model'
|
||||
: `${current.provider} · ${current.model || '(provider default)'}`}
|
||||
</span>
|
||||
}
|
||||
key={meta.key}
|
||||
|
||||
@@ -29,7 +29,11 @@ export type ProviderView = (typeof PROVIDER_VIEWS)[number]
|
||||
const isKeyVar = (key: string, info: EnvVarInfo) => info.is_password || /(?:_API_KEY|_TOKEN|_KEY)$/.test(key)
|
||||
|
||||
const friendlyFieldLabel = (key: string, info: EnvVarInfo) =>
|
||||
info.description?.trim() || key.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, c => c.toUpperCase())
|
||||
info.description?.trim() ||
|
||||
key
|
||||
.replace(/_/g, ' ')
|
||||
.toLowerCase()
|
||||
.replace(/\b\w/g, c => c.toUpperCase())
|
||||
|
||||
// Advanced (non-primary) fields are mostly base-URL / endpoint overrides, not
|
||||
// keys — so don't reuse the "Paste key" placeholder that makes them read as a
|
||||
@@ -196,9 +200,7 @@ function KeyField({
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
{label && (
|
||||
<label className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{label}
|
||||
</label>
|
||||
<label className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{label}</label>
|
||||
)}
|
||||
{dim ? (
|
||||
<div className="opacity-55 transition-opacity focus-within:opacity-100 hover:opacity-100">{control}</div>
|
||||
@@ -251,7 +253,10 @@ function ProviderKeyCard({
|
||||
<div className="flex flex-wrap items-start gap-x-4 gap-y-2">
|
||||
<div className="flex min-w-44 flex-1 items-center gap-2 py-1">
|
||||
<span
|
||||
className={cn('size-2 shrink-0 rounded-full', group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)')}
|
||||
className={cn(
|
||||
'size-2 shrink-0 rounded-full',
|
||||
group.hasAnySet ? 'bg-primary' : 'bg-(--ui-stroke-secondary)'
|
||||
)}
|
||||
/>
|
||||
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium">{group.name}</span>
|
||||
{expandable && (
|
||||
|
||||
@@ -251,13 +251,7 @@ function DefaultProjectDirSetting() {
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void choose()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="textStrong"
|
||||
>
|
||||
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="textStrong">
|
||||
<FolderOpen className="size-3.5" />
|
||||
<span>{dir ? 'Change' : 'Choose'}</span>
|
||||
</Button>
|
||||
|
||||
@@ -126,7 +126,13 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
||||
{isSet ? 'Replace' : 'Set'}
|
||||
</Button>
|
||||
{isSet && (
|
||||
<Button disabled={busy} onClick={() => void handleClear()} size="icon-xs" title="Clear value" variant="ghost">
|
||||
<Button
|
||||
disabled={busy}
|
||||
onClick={() => void handleClear()}
|
||||
size="icon-xs"
|
||||
title="Clear value"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
)}
|
||||
@@ -134,7 +140,9 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
|
||||
</div>
|
||||
|
||||
{isSet && revealed !== null && (
|
||||
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">{revealed || '---'}</div>
|
||||
<div className="rounded-md bg-background px-2.5 py-1.5 font-mono text-xs text-foreground">
|
||||
{revealed || '---'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
|
||||
@@ -4,7 +4,18 @@ import { useCallback, useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
|
||||
import { Activity, AlertCircle, ChevronDown, Clock, Command, Hash, Loader2, Sparkles, Zap, ZapFilled } from '@/lib/icons'
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
Command,
|
||||
Hash,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Zap,
|
||||
ZapFilled
|
||||
} from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
|
||||
import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar'
|
||||
@@ -311,7 +322,11 @@ export function useStatusbarItems({
|
||||
{
|
||||
className: cn('px-1', yoloActive && 'bg-(--chrome-action-hover)'),
|
||||
hidden: !showYoloToggle,
|
||||
icon: yoloActive ? <ZapFilled className="size-3.5 shrink-0" /> : <Zap className="size-3.5 shrink-0 opacity-70" />,
|
||||
icon: yoloActive ? (
|
||||
<ZapFilled className="size-3.5 shrink-0" />
|
||||
) : (
|
||||
<Zap className="size-3.5 shrink-0 opacity-70" />
|
||||
),
|
||||
id: 'yolo',
|
||||
onSelect: () => void toggleYolo(),
|
||||
title: yoloActive
|
||||
|
||||
@@ -55,9 +55,7 @@ export function resolveFastControl(
|
||||
|
||||
// Only a toggle if there's a base to switch back to; otherwise it's a
|
||||
// standalone fast model with no "off" state.
|
||||
return providerModels.includes(baseId)
|
||||
? { kind: 'variant', baseId, fastId: model, on: true }
|
||||
: { kind: 'none' }
|
||||
return providerModels.includes(baseId) ? { kind: 'variant', baseId, fastId: model, on: true } : { kind: 'none' }
|
||||
}
|
||||
|
||||
const fastId = `${model}-fast`
|
||||
@@ -182,24 +180,20 @@ export function ModelEditSubmenu({
|
||||
<>
|
||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>Options</DropdownMenuLabel>
|
||||
{reasoning ? (
|
||||
<DropdownMenuItem
|
||||
className={dropdownMenuRow}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
||||
Thinking
|
||||
<Switch
|
||||
checked={thinkingOn}
|
||||
className="ml-auto"
|
||||
onCheckedChange={checked => void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)}
|
||||
onCheckedChange={checked =>
|
||||
void patchReasoning(checked ? effort || 'medium' : 'none', currentReasoningEffort)
|
||||
}
|
||||
size="xs"
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{hasFast ? (
|
||||
<DropdownMenuItem
|
||||
className={dropdownMenuRow}
|
||||
onSelect={event => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
||||
Fast
|
||||
<Switch checked={fastOn} className="ml-auto" onCheckedChange={toggleFast} size="xs" />
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -157,7 +157,10 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
|
||||
// Grayed text: active row shows live state (Fast + effort);
|
||||
// others show a fast-capability hint.
|
||||
const meta = isCurrent
|
||||
? [fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null, reasoningEffortLabel(currentReasoningEffort) || 'Med']
|
||||
? [
|
||||
fastControl.kind !== 'none' && fastControl.on ? 'Fast' : null,
|
||||
reasoningEffortLabel(currentReasoningEffort) || 'Med'
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
: caps?.fast || family.fastId
|
||||
|
||||
@@ -15,7 +15,8 @@ export const TITLEBAR_EDGE_INSET = 14
|
||||
// Titlebar palette only. All sizing/radius/cursor/centering come from the
|
||||
// shared <Button size="icon-titlebar"> (used polymorphically via asChild) —
|
||||
// Button is the single source of button styling.
|
||||
export const titlebarButtonClass = 'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
export const titlebarButtonClass =
|
||||
'text-muted-foreground/85 hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
|
||||
export const titlebarHeaderBaseClass =
|
||||
'pointer-events-none relative z-3 flex h-(--titlebar-height) shrink-0 items-center justify-start gap-3 border-b border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-[max(0.75rem,var(--titlebar-content-inset,0rem))]'
|
||||
|
||||
@@ -160,8 +160,9 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
|
||||
try {
|
||||
await toggleToolset(toolset.name, enabled)
|
||||
setToolsets(current =>
|
||||
current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current
|
||||
setToolsets(
|
||||
current =>
|
||||
current?.map(row => (row.name === toolset.name ? { ...row, enabled, available: enabled } : row)) ?? current
|
||||
)
|
||||
notify({
|
||||
kind: 'success',
|
||||
@@ -275,7 +276,9 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
aria-expanded={expanded}
|
||||
aria-label={`Configure ${label}`}
|
||||
className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
|
||||
onClick={() => setExpandedToolset(current => (current === toolset.name ? null : toolset.name))}
|
||||
onClick={() =>
|
||||
setExpandedToolset(current => (current === toolset.name ? null : toolset.name))
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<StatusPill active={toolset.configured}>
|
||||
@@ -321,7 +324,9 @@ export function SkillsView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...p
|
||||
function StatusPill({ active, children }: { active: boolean; children: string }) {
|
||||
return (
|
||||
<Badge
|
||||
className={active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'}
|
||||
className={
|
||||
active ? 'bg-(--ui-bg-tertiary) text-(--ui-text-secondary)' : 'bg-(--ui-bg-quinary) text-(--ui-text-tertiary)'
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
|
||||
@@ -199,9 +199,7 @@ function IdleView({
|
||||
<div className="grid gap-3 rounded-xl border border-border/70 bg-muted/20 px-4 py-3">
|
||||
{groups.map(group => (
|
||||
<div key={group.id}>
|
||||
<p className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{group.label}
|
||||
</p>
|
||||
<p className="text-[0.625rem] font-semibold uppercase tracking-wide text-muted-foreground">{group.label}</p>
|
||||
<ul className="mt-1.5 grid gap-1.5 text-xs text-foreground">
|
||||
{group.items.map(item => (
|
||||
<li className="flex items-start gap-2" key={item}>
|
||||
@@ -339,7 +337,9 @@ function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss
|
||||
{message || 'No worries — nothing was lost. You can try again now.'}
|
||||
</DialogDescription>
|
||||
}
|
||||
title={<DialogTitle className="text-center text-xl font-semibold tracking-tight">Update didn’t finish</DialogTitle>}
|
||||
title={
|
||||
<DialogTitle className="text-center text-xl font-semibold tracking-tight">Update didn’t finish</DialogTitle>
|
||||
}
|
||||
>
|
||||
<Button className="font-semibold" onClick={onRetry} size="lg">
|
||||
Try again
|
||||
|
||||
Reference in New Issue
Block a user