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:
@@ -1,7 +1,18 @@
|
||||
import { ThreadPrimitive, useAuiEvent, useAuiState } from '@assistant-ui/react'
|
||||
import { useVirtualizer, type Virtualizer } from '@tanstack/react-virtual'
|
||||
import { type ComponentProps, type FC, memo, type ReactNode, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
memo,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef
|
||||
} from 'react'
|
||||
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { setThreadScrolledUp } from '@/store/thread-scroll'
|
||||
|
||||
@@ -66,6 +77,7 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
|
||||
const messageSignature = useAuiState(s =>
|
||||
s.thread.messages.map((message, index) => `${index}:${message.id}:${message.role}`).join('\n')
|
||||
)
|
||||
|
||||
const isRunning = useAuiState(s => s.thread.isRunning)
|
||||
|
||||
const groups = useMemo(() => buildGroups(messageSignature), [messageSignature])
|
||||
@@ -93,6 +105,7 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
|
||||
// then jumps back up).
|
||||
scrollToFn: (offset, _options, instance) => {
|
||||
const el = instance.scrollElement
|
||||
|
||||
if (!el) {
|
||||
return
|
||||
}
|
||||
@@ -202,6 +215,10 @@ const VirtualizedThreadInner: FC<VirtualizedThreadProps> = ({
|
||||
|
||||
export const VirtualizedThread = memo(VirtualizedThreadInner)
|
||||
|
||||
function scrollElementToBottom(el: HTMLDivElement) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
|
||||
interface ScrollAnchorOptions {
|
||||
enabled: boolean
|
||||
groupCount: number
|
||||
@@ -250,14 +267,14 @@ function useThreadScrollAnchor({
|
||||
|
||||
// Hold the disarm gate across the scroll event the next line will fire.
|
||||
programmaticScrollPendingRef.current += 1
|
||||
el.scrollTop = el.scrollHeight
|
||||
scrollElementToBottom(el)
|
||||
lastTopRef.current = el.scrollTop
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
}, [scrollerRef])
|
||||
|
||||
const jumpToBottom = useCallback(() => {
|
||||
stickyBottomRef.current = true
|
||||
setMutableRef(stickyBottomRef, true)
|
||||
|
||||
if (groupCount > 0) {
|
||||
virtualizer.scrollToIndex(groupCount - 1, { align: 'end', behavior: 'auto' })
|
||||
@@ -282,7 +299,7 @@ function useThreadScrollAnchor({
|
||||
}
|
||||
|
||||
const disarm = () => {
|
||||
stickyBottomRef.current = false
|
||||
setMutableRef(stickyBottomRef, false)
|
||||
programmaticScrollPendingRef.current = 0
|
||||
}
|
||||
|
||||
@@ -302,7 +319,7 @@ function useThreadScrollAnchor({
|
||||
lastHeightRef.current = el.scrollHeight
|
||||
lastClientHeightRef.current = el.clientHeight
|
||||
// Always re-arm — sticky-bottom should hold through clamp races.
|
||||
stickyBottomRef.current = true
|
||||
setMutableRef(stickyBottomRef, true)
|
||||
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
|
||||
setThreadScrolledUp(!atBottom)
|
||||
|
||||
@@ -317,8 +334,9 @@ function useThreadScrollAnchor({
|
||||
// their own listeners below, so real user intent remains covered.
|
||||
const heightGrew = el.scrollHeight > lastHeightRef.current
|
||||
const clientHeightChanged = Math.abs(el.clientHeight - lastClientHeightRef.current) > 1
|
||||
|
||||
if (!heightGrew && !clientHeightChanged && top + 1 < lastTopRef.current) {
|
||||
stickyBottomRef.current = false
|
||||
setMutableRef(stickyBottomRef, false)
|
||||
}
|
||||
|
||||
lastTopRef.current = top
|
||||
@@ -328,7 +346,7 @@ function useThreadScrollAnchor({
|
||||
const atBottom = el.scrollHeight - (top + el.clientHeight) <= AT_BOTTOM_THRESHOLD
|
||||
|
||||
if (atBottom) {
|
||||
stickyBottomRef.current = true
|
||||
setMutableRef(stickyBottomRef, true)
|
||||
}
|
||||
|
||||
setThreadScrolledUp(!atBottom)
|
||||
@@ -369,13 +387,16 @@ function useThreadScrollAnchor({
|
||||
}
|
||||
|
||||
let pinRafScheduled = false
|
||||
|
||||
const schedulePin = () => {
|
||||
if (pinRafScheduled || !stickyBottomRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
pinRafScheduled = true
|
||||
requestAnimationFrame(() => {
|
||||
pinRafScheduled = false
|
||||
|
||||
if (stickyBottomRef.current) {
|
||||
pinToBottom()
|
||||
}
|
||||
@@ -429,6 +450,7 @@ function useThreadScrollAnchor({
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (groupCount > prevGroupCountForLayoutRef.current && stickyBottomRef.current) {
|
||||
// Defer to rAF so that browser scroll/wheel events from the current
|
||||
// frame are processed first. Without this deferral, a trackpad
|
||||
@@ -442,6 +464,7 @@ function useThreadScrollAnchor({
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
prevGroupCountForLayoutRef.current = groupCount
|
||||
}, [enabled, groupCount, pinToBottom, stickyBottomRef])
|
||||
|
||||
|
||||
@@ -12,12 +12,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ChevronDown, Loader2 } from '@/lib/icons'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
|
||||
@@ -33,9 +33,7 @@ export function DisclosureRow({
|
||||
// max-w-fit so the click target hugs the title text width — no
|
||||
// background fill, just the cursor + the affordance caret.
|
||||
'flex min-w-0 max-w-fit items-start gap-1.5 text-left transition-colors',
|
||||
onToggle
|
||||
? 'hover:text-foreground focus-visible:text-foreground focus-visible:outline-none'
|
||||
: 'cursor-default'
|
||||
onToggle ? 'hover:text-foreground focus-visible:text-foreground focus-visible:outline-none' : 'cursor-default'
|
||||
)}
|
||||
disabled={!onToggle}
|
||||
onClick={onToggle}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
DesktopBootstrapEvent,
|
||||
DesktopBootstrapStageDescriptor,
|
||||
@@ -10,6 +8,8 @@ import type {
|
||||
DesktopBootstrapStageState,
|
||||
DesktopBootstrapState
|
||||
} from '@/global'
|
||||
import { AlertTriangle, Check, ChevronDown, ChevronRight, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* DesktopInstallOverlay
|
||||
@@ -59,7 +59,10 @@ const STATE_LABEL: Record<DesktopBootstrapStageState, string> = {
|
||||
|
||||
function formatStageName(name: string): string {
|
||||
// 'system-packages' -> 'System packages'; 'uv' stays 'uv'
|
||||
if (name.length <= 3) return name
|
||||
if (name.length <= 3) {
|
||||
return name
|
||||
}
|
||||
|
||||
return name
|
||||
.split('-')
|
||||
.map((word, i) => (i === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word))
|
||||
@@ -67,38 +70,61 @@ function formatStageName(name: string): string {
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null | undefined): string {
|
||||
if (typeof ms !== 'number' || !Number.isFinite(ms)) return ''
|
||||
if (ms < 1000) return `${ms} ms`
|
||||
if (typeof ms !== 'number' || !Number.isFinite(ms)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (ms < 1000) {
|
||||
return `${ms} ms`
|
||||
}
|
||||
|
||||
const s = ms / 1000
|
||||
if (s < 60) return `${s.toFixed(1)}s`
|
||||
|
||||
if (s < 60) {
|
||||
return `${s.toFixed(1)}s`
|
||||
}
|
||||
|
||||
const m = Math.floor(s / 60)
|
||||
const rs = Math.round(s - m * 60)
|
||||
|
||||
return `${m}m ${rs}s`
|
||||
}
|
||||
|
||||
// Live elapsed for a running stage, as m:ss (or s for sub-minute).
|
||||
function formatElapsed(ms: number): string {
|
||||
const s = Math.max(0, Math.floor(ms / 1000))
|
||||
if (s < 60) return `${s}s`
|
||||
|
||||
if (s < 60) {
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
const m = Math.floor(s / 60)
|
||||
|
||||
return `${m}:${String(s - m * 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function StageRow({ descriptor, result, isCurrent, now }: StageRowProps) {
|
||||
const state: DesktopBootstrapStageState = result?.state || 'pending'
|
||||
|
||||
const elapsed =
|
||||
state === 'running' && typeof result?.startedAt === 'number' ? formatElapsed(now - result.startedAt) : ''
|
||||
|
||||
const icon = useMemo(() => {
|
||||
switch (state) {
|
||||
case 'running':
|
||||
return <Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
|
||||
case 'succeeded':
|
||||
return <Check className="h-4 w-4 text-emerald-600" />
|
||||
|
||||
case 'skipped':
|
||||
return <Check className="h-4 w-4 text-muted-foreground" />
|
||||
|
||||
case 'failed':
|
||||
return <AlertTriangle className="h-4 w-4 text-destructive" />
|
||||
|
||||
case 'pending':
|
||||
|
||||
default:
|
||||
return <div className="h-2 w-2 rounded-full border border-muted-foreground/40" />
|
||||
}
|
||||
@@ -146,9 +172,11 @@ const EMPTY_STATE: DesktopBootstrapState = {
|
||||
function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): DesktopBootstrapState {
|
||||
if (ev.type === 'manifest') {
|
||||
const stages: Record<string, DesktopBootstrapStageResult> = {}
|
||||
|
||||
for (const stage of ev.stages) {
|
||||
stages[stage.name] = { state: 'pending', durationMs: null, startedAt: null, json: null, error: null }
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
active: true,
|
||||
@@ -158,8 +186,10 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
|
||||
startedAt: state.startedAt || Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.type === 'stage') {
|
||||
const prev = state.stages[ev.name]
|
||||
|
||||
return {
|
||||
...state,
|
||||
stages: {
|
||||
@@ -176,17 +206,25 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ev.type === 'log') {
|
||||
const next = state.log.concat({ ts: Date.now(), stage: ev.stage ?? null, line: ev.line, stream: ev.stream })
|
||||
while (next.length > 500) next.shift()
|
||||
|
||||
while (next.length > 500) {
|
||||
next.shift()
|
||||
}
|
||||
|
||||
return { ...state, log: next }
|
||||
}
|
||||
|
||||
if (ev.type === 'complete') {
|
||||
return { ...state, active: false, completedAt: Date.now(), error: null }
|
||||
}
|
||||
|
||||
if (ev.type === 'failed') {
|
||||
return { ...state, active: false, error: ev.error || 'unknown error' }
|
||||
}
|
||||
|
||||
if (ev.type === 'unsupported-platform') {
|
||||
return {
|
||||
...state,
|
||||
@@ -199,6 +237,7 @@ function applyEvent(state: DesktopBootstrapState, ev: DesktopBootstrapEvent): De
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -213,23 +252,35 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
// Tick once a second while a bootstrap is in flight so running steps show a
|
||||
// live elapsed timer. Stops when nothing is active to avoid idle renders.
|
||||
useEffect(() => {
|
||||
if (!state.active) return
|
||||
if (!state.active) {
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setInterval(() => setNow(Date.now()), 1000)
|
||||
|
||||
return () => window.clearInterval(id)
|
||||
}, [state.active])
|
||||
|
||||
// Subscribe to bootstrap events + load initial snapshot
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
if (!desktop || typeof desktop.onBootstrapEvent !== 'function') return
|
||||
|
||||
if (!desktop || typeof desktop.onBootstrapEvent !== 'function') {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
desktop
|
||||
.getBootstrapState()
|
||||
.then(snapshot => {
|
||||
if (!cancelled && snapshot) setState(snapshot)
|
||||
if (!cancelled && snapshot) {
|
||||
setState(snapshot)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Older Electron build without the IPC handler -- bootstrap UI just
|
||||
@@ -237,6 +288,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
})
|
||||
|
||||
const off = desktop.onBootstrapEvent(ev => setState(prev => applyEvent(prev, ev)))
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
off?.()
|
||||
@@ -255,21 +307,37 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
// the top-level error message and the user has to click "Show installer
|
||||
// output" to see WHY the stage failed.
|
||||
useEffect(() => {
|
||||
if (state.error) setLogOpen(true)
|
||||
if (state.error) {
|
||||
setLogOpen(true)
|
||||
}
|
||||
}, [state.error])
|
||||
|
||||
// Mount logic: show whenever a bootstrap is in flight, completed-with-error,
|
||||
// or actively running with a manifest. Hide entirely after a successful
|
||||
// completion so the rest of the UI can take over.
|
||||
const shouldShow = useMemo(() => {
|
||||
if (!enabled) return false
|
||||
if (state.active) return true
|
||||
if (state.error) return true
|
||||
if (state.unsupportedPlatform) return true
|
||||
if (!enabled) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (state.active) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (state.error) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (state.unsupportedPlatform) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}, [enabled, state.active, state.error, state.unsupportedPlatform])
|
||||
|
||||
if (!shouldShow) return null
|
||||
if (!shouldShow) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Unsupported-platform branch: macOS/Linux packaged builds hit this when
|
||||
// there's no Hermes Agent installed yet and we can't drive install.sh
|
||||
@@ -278,6 +346,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
if (state.unsupportedPlatform) {
|
||||
const ups = state.unsupportedPlatform
|
||||
const platformLabel = ups.platform === 'darwin' ? 'macOS' : ups.platform === 'linux' ? 'Linux' : ups.platform
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[1400] flex items-center justify-center bg-background/90 backdrop-blur-md">
|
||||
<div className="w-full max-w-xl rounded-xl border bg-card p-8 shadow-xl">
|
||||
@@ -294,20 +363,20 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</pre>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(ups.installCommand).catch(() => {})
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
Copy command
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
window.hermesDesktop?.openExternal?.(ups.docsUrl)
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
View install docs
|
||||
</Button>
|
||||
@@ -318,7 +387,7 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Will install to <code className="rounded bg-muted/50 px-1 py-0.5 font-mono">{ups.activeRoot}</code>
|
||||
</span>
|
||||
<Button variant="default" size="sm" onClick={() => window.location.reload()}>
|
||||
<Button onClick={() => window.location.reload()} size="sm" variant="default">
|
||||
I{'\u2019'}ve run it -- retry
|
||||
</Button>
|
||||
</div>
|
||||
@@ -329,9 +398,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
|
||||
const stages = state.manifest?.stages || []
|
||||
const currentStage = stages.find(s => state.stages[s.name]?.state === 'running')?.name
|
||||
|
||||
const completedCount = stages.filter(
|
||||
s => state.stages[s.name]?.state === 'succeeded' || state.stages[s.name]?.state === 'skipped'
|
||||
).length
|
||||
|
||||
const totalCount = stages.length
|
||||
const failed = Boolean(state.error)
|
||||
const progressPct = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0
|
||||
@@ -396,11 +467,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
<ol className="mb-4 space-y-1">
|
||||
{stages.map(stage => (
|
||||
<StageRow
|
||||
key={stage.name}
|
||||
descriptor={stage}
|
||||
result={state.stages[stage.name]}
|
||||
isCurrent={stage.name === currentStage}
|
||||
key={stage.name}
|
||||
now={now}
|
||||
result={state.stages[stage.name]}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
@@ -408,9 +479,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogOpen(v => !v)}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
onClick={() => setLogOpen(v => !v)}
|
||||
type="button"
|
||||
>
|
||||
{logOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<span>{logOpen ? 'Hide installer output' : 'Show installer output'}</span>
|
||||
@@ -432,8 +503,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
<>
|
||||
{state.log.map((entry, i) => (
|
||||
<div
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-words',
|
||||
entry.stream === 'stderr' && 'text-muted-foreground'
|
||||
)}
|
||||
key={i}
|
||||
className={cn('whitespace-pre-wrap break-words', entry.stream === 'stderr' && 'text-muted-foreground')}
|
||||
>
|
||||
{entry.stage ? <span className="text-muted-foreground/70">[{entry.stage}] </span> : null}
|
||||
<span>{entry.line}</span>
|
||||
@@ -482,13 +556,13 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const text = state.log
|
||||
.map(entry => (entry.stage ? `[${entry.stage}] ${entry.line}` : entry.line))
|
||||
.join('\n')
|
||||
|
||||
const fullText = state.error ? `Error: ${state.error}\n\n${text}` : text
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullText)
|
||||
setCopied(true)
|
||||
@@ -497,12 +571,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
// ignore -- some environments forbid clipboard writes
|
||||
}
|
||||
}}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
>
|
||||
{copied ? 'Copied!' : 'Copy output'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
// Tell main.cjs to clear its latched failure BEFORE we
|
||||
// reload. Otherwise the renderer reload calls getConnection
|
||||
@@ -513,8 +587,11 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
|
||||
} catch {
|
||||
// best-effort -- continue with reload regardless
|
||||
}
|
||||
|
||||
window.location.reload()
|
||||
}}
|
||||
size="sm"
|
||||
variant="default"
|
||||
>
|
||||
Reload and retry
|
||||
</Button>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { OAuthProvider } from '@/types/hermes'
|
||||
|
||||
import { $desktopOnboarding, type DesktopOnboardingState, type OnboardingContext } from '@/store/onboarding'
|
||||
import type { OAuthProvider } from '@/types/hermes'
|
||||
|
||||
import { Picker } from './desktop-onboarding-overlay'
|
||||
|
||||
|
||||
@@ -584,7 +584,9 @@ export function ApiKeyForm({
|
||||
className="font-mono"
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && void submit()}
|
||||
placeholder={currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')}
|
||||
placeholder={
|
||||
currentRedacted ?? (alreadySet ? 'Replace current value' : option.placeholder || 'Paste API key')
|
||||
}
|
||||
type={isLocal ? 'text' : 'password'}
|
||||
value={value}
|
||||
/>
|
||||
@@ -676,8 +678,8 @@ function FlowPanel({ ctx, flow }: { ctx: OnboardingContext; flow: OnboardingFlow
|
||||
return (
|
||||
<Step title={`Sign in with ${title}`}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
We opened {title} in your browser. Authorize Hermes there and you'll be connected
|
||||
automatically — nothing to copy or paste.
|
||||
We opened {title} in your browser. Authorize Hermes there and you'll be connected automatically — nothing to
|
||||
copy or paste.
|
||||
</p>
|
||||
<FlowFooter left={<DocsLink href={flow.start.auth_url}>Re-open sign-in page</DocsLink>}>
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
|
||||
@@ -65,10 +65,7 @@ function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
|
||||
<Button onClick={() => window.location.reload()} variant="text">
|
||||
Reload window
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)}
|
||||
variant="text"
|
||||
>
|
||||
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="text">
|
||||
Open logs
|
||||
</Button>
|
||||
</ErrorState>
|
||||
|
||||
@@ -185,6 +185,7 @@ function ModelResults({
|
||||
}
|
||||
|
||||
const q = search.trim().toLowerCase()
|
||||
|
||||
const matches = (provider: ModelOptionProvider, model: string) =>
|
||||
!q ||
|
||||
model.toLowerCase().includes(q) ||
|
||||
|
||||
@@ -25,7 +25,13 @@ interface ModelVisibilityDialogProps {
|
||||
sessionId?: string | null
|
||||
}
|
||||
|
||||
export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open, sessionId }: ModelVisibilityDialogProps) {
|
||||
export function ModelVisibilityDialog({
|
||||
gw,
|
||||
onOpenChange,
|
||||
onOpenProviders,
|
||||
open,
|
||||
sessionId
|
||||
}: ModelVisibilityDialogProps) {
|
||||
const [search, setSearch] = useState('')
|
||||
const stored = useStore($visibleModels)
|
||||
|
||||
@@ -87,17 +93,11 @@ export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open,
|
||||
<div className="max-h-[55vh] overflow-y-auto pb-1">
|
||||
{providers.length === 0 ? (
|
||||
<div className="px-3 py-5 text-center text-xs text-muted-foreground">
|
||||
{modelOptions.isPending ? (
|
||||
<BrailleSpinner className="mx-auto text-sm" />
|
||||
) : (
|
||||
'No authenticated providers.'
|
||||
)}
|
||||
{modelOptions.isPending ? <BrailleSpinner className="mx-auto text-sm" /> : 'No authenticated providers.'}
|
||||
</div>
|
||||
) : (
|
||||
providers.map(provider => {
|
||||
const models = collapseModelFamilies(provider.models ?? []).filter(family =>
|
||||
matches(provider, family.id)
|
||||
)
|
||||
const models = collapseModelFamilies(provider.models ?? []).filter(family => matches(provider, family.id))
|
||||
|
||||
if (models.length === 0) {
|
||||
return null
|
||||
@@ -121,10 +121,7 @@ export function ModelVisibilityDialog({ gw, onOpenChange, onOpenProviders, open,
|
||||
{name}
|
||||
{tag ? <span className="text-(--ui-text-tertiary)"> {tag}</span> : null}
|
||||
</span>
|
||||
<Switch
|
||||
checked={visible.has(key)}
|
||||
onCheckedChange={() => toggle(provider, family.id)}
|
||||
/>
|
||||
<Switch checked={visible.has(key)} onCheckedChange={() => toggle(provider, family.id)} />
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -132,9 +132,7 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
|
||||
function NotificationDetail({ detail }: { detail: string }) {
|
||||
return (
|
||||
<details className="mt-2 text-xs text-muted-foreground">
|
||||
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">
|
||||
Details
|
||||
</summary>
|
||||
<summary className="select-none font-medium text-muted-foreground hover:text-foreground">Details</summary>
|
||||
<div className="mt-1 rounded-md border border-border/70 bg-background/65 p-2">
|
||||
<pre className="max-h-32 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed">
|
||||
{detail}
|
||||
|
||||
@@ -4,7 +4,14 @@ import { useStore } from '@nanostores/react'
|
||||
import { type FormEvent, useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { KeyRound, Loader2, Lock } from '@/lib/icons'
|
||||
|
||||
@@ -36,7 +36,8 @@ const buttonVariants = cva(
|
||||
'icon-xs': "size-6 rounded-[4px] [&_svg:not([class*='size-'])]:size-3",
|
||||
'icon-sm': 'size-8 rounded-[4px]',
|
||||
'icon-lg': 'size-10 rounded-[4px]',
|
||||
'icon-titlebar': 'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]'
|
||||
'icon-titlebar':
|
||||
'h-(--titlebar-control-height) w-(--titlebar-control-size) rounded-[4px] [&_.codicon]:text-[0.875rem]'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -4,12 +4,7 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
import { type ControlVariantProps, controlVariants } from './control'
|
||||
|
||||
function Input({
|
||||
className,
|
||||
type,
|
||||
size,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
|
||||
function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
|
||||
return (
|
||||
<input
|
||||
className={cn(
|
||||
|
||||
@@ -73,13 +73,14 @@ function SidebarProvider({
|
||||
} else {
|
||||
_setOpen(openState)
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
},
|
||||
[setOpenProp, open]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
|
||||
}, [open])
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open)
|
||||
|
||||
@@ -5,13 +5,7 @@ import { cn } from '@/lib/utils'
|
||||
import { type ControlVariantProps, controlVariants } from './control'
|
||||
|
||||
function Textarea({ className, size, ...props }: React.ComponentProps<'textarea'> & ControlVariantProps) {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(controlVariants({ size }), 'min-h-16', className)}
|
||||
data-slot="textarea"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <textarea className={cn(controlVariants({ size }), 'min-h-16', className)} data-slot="textarea" {...props} />
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
|
||||
Reference in New Issue
Block a user