Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@
"cmdk": "^1.1.1",
"hast-util-from-html-isomorphic": "^2.0.0",
"hast-util-to-text": "^4.0.2",
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"ignore": "^7.0.5",
"katex": "^0.16.45",
"leva": "^0.10.1",
Expand All @@ -81,6 +83,7 @@
"react": "^19.2.5",
"react-arborist": "^3.5.0",
"react-dom": "^19.2.5",
"react-i18next": "^17.0.8",
"react-router-dom": "^7.14.2",
"react-shiki": "^0.9.3",
"remark-math": "^6.0.0",
Expand Down
82 changes: 45 additions & 37 deletions apps/desktop/src/app/updates-overlay.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

import { Button } from '@/components/ui/button'
import { writeClipboardText } from '@/components/ui/copy-button'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog'
import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
import i18n from '@/i18n'
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
import { AlertCircle, Check, CheckCircle2, Copy, Loader2, Sparkles, Terminal } from '@/lib/icons'
import { cn } from '@/lib/utils'
Expand All @@ -21,21 +23,22 @@ import {
} from '@/store/updates'

const STAGE_LABELS: Record<DesktopUpdateStage, string> = {
idle: 'Getting ready…',
prepare: 'Getting ready…',
fetch: 'Downloading…',
pull: 'Almost there…',
pydeps: 'Finishing up…',
restart: 'Restarting Hermes…',
manual: 'Update from your terminal',
error: 'Update paused'
idle: i18n.t('updates.stage.idle'),
prepare: i18n.t('updates.stage.prepare'),
fetch: i18n.t('updates.stage.fetch'),
pull: i18n.t('updates.stage.pull'),
pydeps: i18n.t('updates.stage.pydeps'),
restart: i18n.t('updates.stage.restart'),
manual: i18n.t('updates.stage.manual'),
error: i18n.t('updates.stage.error')
}

function totalItems(groups: readonly CommitGroup[]) {
return groups.reduce((sum, g) => sum + g.items.length, 0)
}

export function UpdatesOverlay() {
const { t } = useTranslation()
const open = useStore($updateOverlayOpen)
const status = useStore($updateStatus)
const checking = useStore($updateChecking)
Expand Down Expand Up @@ -123,9 +126,11 @@ function IdleView({
onRetryCheck: () => void
status: DesktopUpdateStatus | null
}) {
const { t } = useTranslation()

if (!status && checking) {
return (
<CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title="Looking for updates…" />
<CenteredStatus icon={<Loader2 className="size-6 animate-spin text-primary" />} title={t('updates.checking.title')} />
)
}

Expand All @@ -134,11 +139,11 @@ function IdleView({
<CenteredStatus
action={
<Button onClick={onRetryCheck} size="sm">
Try again
{t('updates.checking.retry')}
</Button>
}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title="Couldn’t check for updates"
title={t('updates.checking.error_title')}
/>
)
}
Expand All @@ -148,12 +153,12 @@ function IdleView({
<CenteredStatus
action={
<Button onClick={onLater} size="sm" variant="outline">
Close
{t('updates.status.close')}
</Button>
}
body={status.message ?? 'This version of Hermes can’t update itself from inside the app.'}
body={status.message ?? t('updates.status.not_supported')}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title="Update not available"
title={t('updates.status.not_available')}
/>
)
}
Expand All @@ -163,12 +168,12 @@ function IdleView({
<CenteredStatus
action={
<Button disabled={checking} onClick={onRetryCheck} size="sm">
Try again
{t('updates.checking.retry')}
</Button>
}
body="Check your connection and try again."
body={t('updates.status.check_connection')}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title="Couldn’t check for updates"
title={t('updates.checking.error_title')}
/>
)
}
Expand All @@ -178,12 +183,12 @@ function IdleView({
<CenteredStatus
action={
<Button onClick={onLater} size="sm" variant="outline">
Close
{t('updates.status.close')}
</Button>
}
body="You’re running the latest version."
body={t('updates.status.up_to_date')}
icon={<CheckCircle2 className="size-7 text-emerald-600 dark:text-emerald-400" />}
title="You’re all set"
title={t('updates.status.all_set')}
/>
)
}
Expand All @@ -199,9 +204,9 @@ function IdleView({
<Sparkles className="size-7" />
</span>

<DialogTitle className="text-center text-xl">New update available</DialogTitle>
<DialogTitle className="text-center text-xl">{t('updates.available.title')}</DialogTitle>
<DialogDescription className="text-center text-sm">
A new version of Hermes is ready to install.
{t('updates.available.desc')}
</DialogDescription>
</div>

Expand All @@ -223,27 +228,28 @@ function IdleView({

<div className="grid gap-2">
<Button className="h-10 text-sm font-semibold" onClick={onInstall} size="default">
Update now
{t('updates.available.update_now')}
</Button>
<button
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={onLater}
type="button"
>
Maybe later
{t('updates.available.maybe_later')}
</button>
</div>

{remaining > 0 && (
<p className="text-center text-xs text-muted-foreground">
+ {remaining} more change{remaining === 1 ? '' : 's'} included.
{t('updates.available.more_changes', { count: remaining })}
</p>
)}
</div>
)
}

function ManualView({ command, onDone }: { command: string; onDone: () => void }) {
const { t } = useTranslation()
const [copied, setCopied] = useState(false)

const handleCopy = () => {
Expand All @@ -260,16 +266,16 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
<Terminal className="size-7" />
</span>

<DialogTitle className="text-center text-xl">Update from your terminal</DialogTitle>
<DialogTitle className="text-center text-xl">{t('updates.manual.title')}</DialogTitle>
<DialogDescription className="text-center text-sm">
You installed Hermes from the command line, so updates run there too. Paste this into your terminal:
{t('updates.manual.desc')}
</DialogDescription>
</div>

<button
type="button"
onClick={handleCopy}
className="group flex w-full items-center justify-between gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-3 text-left transition-colors hover:border-border hover:bg-muted/50"
onClick={handleCopy}
type="button"
>
<code className="select-all font-mono text-sm text-foreground">
<span className="text-muted-foreground">$ </span>
Expand All @@ -279,23 +285,23 @@ function ManualView({ command, onDone }: { command: string; onDone: () => void }
{copied ? (
<>
<Check className="size-3.5 text-emerald-600 dark:text-emerald-400" />
Copied
{t('updates.manual.copied')}
</>
) : (
<>
<Copy className="size-3.5" />
Copy
{t('updates.manual.copy')}
</>
)}
</span>
</button>

<p className="text-center text-xs text-muted-foreground">
Hermes will pick up the new version next time you launch it.
{t('updates.manual.footnote')}
</p>

<Button className="h-10 text-sm font-semibold" onClick={onDone} variant="outline">
Done
{t('updates.manual.done')}
</Button>
</div>
)
Expand Down Expand Up @@ -338,29 +344,31 @@ function ApplyingView({ apply }: { apply: UpdateApplyState }) {
}

function ErrorView({ message, onDismiss, onRetry }: { message: string; onDismiss: () => void; onRetry: () => void }) {
const { t } = useTranslation()

return (
<div className="grid gap-5 px-6 pb-6 pt-7 pr-8">
<div className="flex flex-col items-center gap-3 text-center">
<span className="flex size-14 items-center justify-center rounded-2xl bg-destructive/10 text-destructive">
<AlertCircle className="size-7" />
</span>

<DialogTitle className="text-center text-xl">Update didn’t finish</DialogTitle>
<DialogTitle className="text-center text-xl">{t('updates.error.title')}</DialogTitle>
<DialogDescription className="text-center text-sm">
{message || 'No worries — nothing was lost. You can try again now.'}
{message || t('updates.error.default_msg')}
</DialogDescription>
</div>

<div className="grid gap-2">
<Button className="h-10 text-sm font-semibold" onClick={onRetry}>
Try again
{t('updates.error.retry')}
</Button>
<button
className="text-center text-sm font-medium text-muted-foreground transition-colors hover:text-foreground"
onClick={onDismiss}
type="button"
>
Not now
{t('updates.error.not_now')}
</button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,13 +308,16 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
}

let pinRafScheduled = false

const schedulePin = () => {
if (pinRafScheduled || !armedRef.current) {
return
}

pinRafScheduled = true
requestAnimationFrame(() => {
pinRafScheduled = false

if (armedRef.current) {
pinToBottom()
}
Expand Down Expand Up @@ -367,6 +370,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
if (!enabled) {
return
}

if (groupCount > prevGroupCountForLayoutRef.current && armedRef.current) {
pinToBottom()
requestAnimationFrame(() => {
Expand All @@ -375,6 +379,7 @@ function useThreadScrollAnchor({ enabled, groupCount, scrollerRef, sessionKey, v
}
})
}

prevGroupCountForLayoutRef.current = groupCount
}, [enabled, groupCount, pinToBottom])

Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/components/assistant-ui/thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ import { ComposerTriggerPopover } from '@/app/chat/composer/trigger-popover'
import { extractDroppedFiles, HERMES_PATHS_MIME } from '@/app/chat/hooks/use-composer-actions'
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
import { DirectiveContent } from '@/components/assistant-ui/directive-text'
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
import { hermesDirectiveFormatter } from '@/components/assistant-ui/directive-text'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { VirtualizedThread } from '@/components/assistant-ui/thread-virtualizer'
import { HoistedTodoPanel, todosFromMessageContent } from '@/components/assistant-ui/todo-tool'
import { ToolFallback, ToolGroupSlot } from '@/components/assistant-ui/tool-fallback'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { UserMessageText } from '@/components/assistant-ui/user-message-text'
import { useElapsedSeconds } from '@/components/chat/activity-timer'
import { ActivityTimerText } from '@/components/chat/activity-timer-text'
import { DisclosureRow } from '@/components/chat/disclosure-row'
Expand Down
19 changes: 10 additions & 9 deletions apps/desktop/src/components/boot-failure-overlay.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useStore } from '@nanostores/react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'

import { Button } from '@/components/ui/button'
import { AlertTriangle, FileText, Loader2, RefreshCw, Wrench } from '@/lib/icons'
Expand All @@ -13,6 +14,7 @@ type BusyAction = 'local' | 'repair' | 'retry' | null
// renders dead — "gateway offline", no composer, only a toast — with no way
// to retry, repair the install, switch the gateway, or find the logs.
export function BootFailureOverlay() {
const { t } = useTranslation()
const boot = useStore($desktopBoot)
const onboarding = useStore($desktopOnboarding)
const [busy, setBusy] = useState<BusyAction>(null)
Expand Down Expand Up @@ -69,10 +71,9 @@ export function BootFailureOverlay() {
<AlertTriangle className="size-5" />
</div>
<div>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">Hermes couldn't start</h2>
<h2 className="text-[0.9375rem] font-semibold tracking-tight">{t('boot_failure.title')}</h2>
<p className="mt-1 text-[0.8125rem] leading-5 text-(--ui-text-tertiary)">
The background gateway didn't come up. Try one of the recovery steps below — nothing here deletes your
chats or settings.
{t('boot_failure.desc')}
</p>
</div>
</div>
Expand All @@ -86,23 +87,23 @@ export function BootFailureOverlay() {
<div className="flex flex-wrap gap-2">
<Button disabled={Boolean(busy)} onClick={() => void retry()}>
{busy === 'retry' ? <Loader2 className="size-4 animate-spin" /> : <RefreshCw className="size-4" />}
Retry
{t('boot_failure.retry')}
</Button>
<Button disabled={Boolean(busy)} onClick={() => void repair()} variant="outline">
{busy === 'repair' ? <Loader2 className="size-4 animate-spin" /> : <Wrench className="size-4" />}
Repair install
{t('boot_failure.repair')}
</Button>
<Button disabled={Boolean(busy)} onClick={() => void switchToLocalGateway()} variant="outline">
{busy === 'local' ? <Loader2 className="size-4 animate-spin" /> : null}
Use local gateway
{t('boot_failure.use_local')}
</Button>
<Button onClick={openLogs} variant="ghost">
<FileText className="size-4" />
Open logs
{t('boot_failure.open_logs')}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Repair re-runs the installer and can take a few minutes on a fresh machine.
{t('boot_failure.repair_hint')}
</p>
</div>

Expand All @@ -113,7 +114,7 @@ export function BootFailureOverlay() {
onClick={() => setShowLogs(v => !v)}
type="button"
>
{showLogs ? 'Hide' : 'Show'} recent logs
{showLogs ? t('boot_failure.hide_logs') : t('boot_failure.show_logs')}
</button>
{showLogs ? (
<pre className="max-h-48 overflow-auto rounded-2xl border border-border bg-secondary/30 p-3 font-mono text-[0.7rem] leading-4 text-muted-foreground">
Expand Down
Loading