mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
fix(desktop): triage batch of GUI quality-of-life fixes (#37536)
* fix(desktop): triage 24 GUI quality-of-life fixes across sidebar, composer, tool cards, messaging, and platform plumbing
A grab-bag of high-leverage UX fixes plus a few backend touches that the
GUI needs to behave correctly on Windows.
Sidebar / sessions
- Decrement $sessionsTotal on delete + archive so "Load N more" stops
claiming removed rows are still on the server.
- Hide the "Group by workspace" toggle when no unpinned sessions exist.
- Accept Cmd/Ctrl+N as a "new session" accelerator (in addition to bare
Shift+N), and render the kbd hint per-platform.
- Switch the statusbar to overflow-x-clip so untitled sessions don't
paint a horizontal scrollbar at the bottom of the window.
Messaging + Cron
- Add [-webkit-app-region: no-drag] to the page-search input so clicks
reach the field instead of routing to the OS window-drag handler.
- Replace single-letter PlatformAvatar with brand glyphs from
@icons-pack/react-simple-icons (telegram, discord, matrix, signal,
whatsapp, mattermost, wechat, qq, ...). Letter monogram fallback for
Slack / Dingtalk / Feishu / WeCom (removed from Simple Icons at brand
owner request).
- Drop the duplicate "Create first cron" button in the empty state.
Composer
- Dedupe pasted images by (name, size, lastModified, type) instead of
Blob identity; Chromium hands us the same screenshot via both
clipboard.items and clipboard.files with fresh File instances.
- Enable spellcheck on the contentEditable, configure Chromium's
spellchecker with the system locale on whenReady, and add
replaceMisspelling + "Add to dictionary" entries to the context menu.
- Render user messages through a minimal markdown pipeline (inline
backtick code + fenced ``` blocks) while keeping @file:/@image:
directive chips intact.
- max-h-[60vh] overflow-y-auto + collisionPadding on the prompt-snippet
submenu.
- Bake cursor-pointer into the <Button> primitive (with
disabled:cursor-default) and into titlebarButtonClass.
Dialogs + tabs + version
- Default DialogContent now has max-h-[85vh] overflow-y-auto so long
bodies scroll instead of falling off-screen.
- Right-rail preview tabs close on middle-click (button === 1), with an
onMouseDown swallow to suppress Chromium autoscroll.
- New refreshDesktopVersion() helper called from About mount, after
every update check, and on throttled window focus so About reflects
the just-installed binary.
Keys + Artifacts + Terminal
- Drop the global "Show advanced" toggle in KeysSettings. Provider
groups now default-expand when they have any key set.
- Extend openExternalUrl to handle file:// via shell.openPath, with
showItemInFolder fallback when the OS can't open the file.
- New lib/ansi.ts SGR parser + <AnsiText> component, applied to
terminal/execute_code tool output.
- ToolView gained stdout / stderr / rendersAnsi; tool-fallback renders
the two streams as separate labeled blocks with stderr in a neutral
tone (not destructive — many CLIs log info on stderr).
- Drop 'stderr' from ERROR_MSG_KEYS in tool-result-summary.
Paths + platform
- resolveHermesCwd skips process.cwd() when packaged and prefers a
user-configurable default project directory.
- New hermes:setting:defaultProjectDir:{get,set,pick} IPC handlers +
preload bridge + global.d.ts typing + a "Default project directory"
row in Sessions settings.
- FileOperations.delete_path(path, recursive=True) on the abstract
base; ShellFileOperations.delete_file rewritten to run a cross-
platform python3 -c snippet so deletes work on Windows shells (which
have no rm/rm -rf). Fallback to `python` when `python3` isn't on PATH.
- README troubleshooting block split into macOS/Linux + Windows
PowerShell recipes.
- Tightened renderer favicon links in index.html + added color-scheme
and theme-color meta.
Backend lifecycle (renderer-side mitigation)
- New noteSessionActivity() heartbeat + session.ts watchdog: an
8-minute silence on the stream auto-clears stuck $workingSessionIds
entries so "Session Busy" never gets permanently wedged. Wired into
useSessionStateCache so every state update refreshes the timer.
i18n spike
- docs/desktop-i18n-rfc.md scoping a future language-switcher PR
(recommends react-intl, audits IME/RTL/CJK in the composer +
chat bubbles, 4-PR rollout plan, ~3-4 eng-weeks for the first
non-English locale).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): replace native OS scrollbar in portaled dropdown menus
Radix's DropdownMenuPrimitive.Portal renders content under document.body,
outside the `.scrollbar-dt` scope on #root. Whenever a menu's max-height
clipped its content (even by a pixel — common for the composer "+" menu
that opens upward near the bottom of the window), the user saw the OS's
chunky native scrollbar painted across the whole menu.
Bake a thin, slot-styled scrollbar onto DropdownMenuContent and
DropdownMenuSubContent via [scrollbar-width:thin] + WebKit pseudo-element
arbitrary variants. The submenu also gets a max-h tied to
--radix-dropdown-menu-content-available-height so long snippet lists scroll
cleanly instead of running off the bottom of the viewport. Drop the now-
redundant max-h-[60vh] override on the prompt-snippet submenu.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): unbork dropdown menu — submenu opens, parent isn't a circle
Two regressions from the previous dropdown-scrollbar fix:
- The parent menu rendered as a rounded oval. Long Tailwind v4 arbitrary-
variant strings like [&::-webkit-scrollbar-thumb]:rounded-full inside a
cn() call were being mis-resolved so the `rounded-full` leaked onto the
menu container itself. Replaced the whole tower of arbitrary variants
with a real `.dt-portal-scrollbar` class in styles.css that mirrors what
`.scrollbar-dt` already does for #root descendants. Plain CSS, no Tailwind
parser ambiguity.
- The Prompt snippets submenu didn't open. Radix publishes
--radix-dropdown-menu-content-available-height on Content but NOT on
SubContent, so the `max-h` bound to that variable computed to 0 and the
submenu collapsed to zero height. Switched SubContent to a fixed
max-h-80 (≈20rem) which is plenty for a snippet list and never collapses.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): promote prompt snippets from Radix submenu to a real Dialog
The submenu refused to open when the parent dropdown was anchored at the
bottom of the window (composer "+" button) — Radix's collision detection +
SubContent positioning was fighting us. Rather than keep tuning side /
sideOffset / collisionPadding / max-h until something stuck, replace the
DropdownMenuSub with a clicked DropdownMenuItem that opens a proper
Dialog.
Side benefits over the submenu:
- Each snippet gets a description line, so a glance is enough to pick one.
- Focus management is handled by Dialog automatically.
- Easy to grow (search, custom user snippets, categories) without
another round of Radix positioning bugs.
Also extract types/interfaces to the bottom of the file per workspace
convention.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): move cron 'New cron' button off the top bar into the body
Reverses the previous direction on cron empty-state dedup. The body
button is more discoverable for first-time users (it's anchored next to
the "No scheduled jobs yet" copy that explains the feature) and frees
the top bar from a global CTA that wasn't pulling its weight.
- Empty (zero jobs): EmptyState renders the "Create first cron" button
again, like the original design.
- Empty (search filtered out all jobs): no button, just "Try a broader
search query" copy.
- Has jobs: small inline header above the list shows `N/M active` plus
a single "New cron" button (right-aligned). The rows themselves
already cover edit/pause/trigger/delete, so this is the only "create"
affordance.
Also drop the dead `<div className="hidden">…</div>` enabledCount line
the previous patch left behind; the count is now visible in the new
header instead of hidden.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): address Copilot review on PR 37536
- sessions-settings: guard the WHOLE bridge call rather than chaining
`?.settings.foo().then(...)` — the latter throws when
`window.hermesDesktop` is undefined (non-Electron / Vitest contexts)
because the chain short-circuits to `undefined.then(...)`.
- file_operations: drop `Path.unlink(missing_ok=True)` (Py>=3.8) so the
generated delete snippet still works on remote backends running
Python 3.7. The existing FileNotFoundError handler covers the same
case and works back to 3.4.
- ansi.test.ts: add focused Vitest coverage for the SGR parser
(basic/bright colors, bold toggles, default-fg reset, coalescing,
256-color / truecolor arg consumption, non-SGR CSI drop, empty SGR
full-reset) so future refactors can't silently regress terminal
rendering.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop/updates): swallow refreshDesktopVersion bridge errors
`refreshDesktopVersion()` is called best-effort with `void` from
`checkUpdates()`, `startUpdatePoller()`, and the window focus handler.
If the IPC bridge rejects (main process shutting down during reload,
bridge not yet ready on first paint), the rejection surfaces as an
unhandled promise rejection in the renderer. Wrap the call in try/catch
and return null on failure so callers can keep the existing
fire-and-forget pattern safely.
Co-authored-by: Cursor <cursoragent@cursor.com>
* chore(desktop): drop work duplicated by other in-flight PRs
- composer/text-utils.ts: revert paste-image dedupe — PR #37596
ships the same fix with a cleaner content-key approach and a
Vitest file (text-utils.test.ts). Letting that PR own the change.
- docs/desktop-i18n-rfc.md: delete the i18n scoping RFC — PR #37568
has already shipped a working i18n surface (homegrown nanostores
`t()` helper over en/zh dictionaries), so the RFC's framework
recommendation (`react-intl`) is now obsolete and would just
contradict the implementation that's actually landing.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
31c40c72c0
commit
ac76bbe21f
38 changed files with 1594 additions and 2183 deletions
|
|
@ -1,14 +1,20 @@
|
|||
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 {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Clipboard, FileText, FolderOpen, type IconComponent, ImageIcon, Link, MessageSquareText } from '@/lib/icons'
|
||||
|
|
@ -17,6 +23,24 @@ import { cn } from '@/lib/utils'
|
|||
import { GHOST_ICON_BTN } from './controls'
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
const PROMPT_SNIPPETS: readonly PromptSnippet[] = [
|
||||
{
|
||||
description: 'Audit the current change for regressions, dropped edge cases, and missing tests.',
|
||||
label: 'Code review',
|
||||
text: 'Please review this for bugs, regressions, and missing tests.'
|
||||
},
|
||||
{
|
||||
description: 'Outline an approach before touching code so the diff stays focused.',
|
||||
label: 'Implementation plan',
|
||||
text: 'Please make a concise implementation plan before changing code.'
|
||||
},
|
||||
{
|
||||
description: 'Walk through how the selected code works and link to the key files.',
|
||||
label: 'Explain this',
|
||||
text: 'Please explain how this works and point me to the key files.'
|
||||
}
|
||||
]
|
||||
|
||||
export function ContextMenu({
|
||||
state,
|
||||
onInsertText,
|
||||
|
|
@ -25,81 +49,114 @@ export function ContextMenu({
|
|||
onPickFiles,
|
||||
onPickFolders,
|
||||
onPickImages
|
||||
}: {
|
||||
state: ChatBarState
|
||||
onInsertText: (text: string) => void
|
||||
onOpenUrlDialog: () => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
}) {
|
||||
}: ContextMenuProps) {
|
||||
// Prompt snippets used to be a Radix submenu. That submenu didn't open
|
||||
// reliably when the parent menu was positioned at the bottom of the
|
||||
// window (composer "+" anchor), so we promoted it to a real Dialog —
|
||||
// easier to grow with search / descriptions, and no positioning math.
|
||||
const [snippetsOpen, setSnippetsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="1rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Attach
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folder…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Paste image
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL…
|
||||
</ContextMenuItem>
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={state.tools.label}
|
||||
className={cn(
|
||||
GHOST_ICON_BTN,
|
||||
'data-[state=open]:bg-(--chrome-action-hover) data-[state=open]:text-foreground'
|
||||
)}
|
||||
disabled={!state.tools.enabled}
|
||||
size="icon"
|
||||
title={state.tools.label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name="add" size="1rem" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-60" side="top" sideOffset={10}>
|
||||
<DropdownMenuLabel className="text-[0.7rem] font-medium uppercase tracking-wide text-muted-foreground/85">
|
||||
Attach
|
||||
</DropdownMenuLabel>
|
||||
<ContextMenuItem disabled={!onPickFiles} icon={FileText} onSelect={onPickFiles}>
|
||||
Files…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickFolders} icon={FolderOpen} onSelect={onPickFolders}>
|
||||
Folder…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPickImages} icon={ImageIcon} onSelect={onPickImages}>
|
||||
Images…
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem disabled={!onPasteClipboardImage} icon={Clipboard} onSelect={onPasteClipboardImage}>
|
||||
Paste image
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem icon={Link} onSelect={onOpenUrlDialog}>
|
||||
URL…
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<MessageSquareText />
|
||||
<span>Prompt snippets</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-72">
|
||||
{[
|
||||
{ label: 'Code review', text: 'Please review this for bugs, regressions, and missing tests.' },
|
||||
{ label: 'Implementation plan', text: 'Please make a concise implementation plan before changing code.' },
|
||||
{ label: 'Explain this', text: 'Please explain how this works and point me to the key files.' }
|
||||
].map(snippet => (
|
||||
<ContextMenuItem icon={MessageSquareText} key={snippet.label} onSelect={() => onInsertText(snippet.text)}>
|
||||
{snippet.label}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<ContextMenuItem icon={MessageSquareText} onSelect={() => setSnippetsOpen(true)}>
|
||||
Prompt snippets…
|
||||
</ContextMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<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.
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<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.
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<PromptSnippetsDialog
|
||||
onInsertText={onInsertText}
|
||||
onOpenChange={setSnippetsOpen}
|
||||
open={snippetsOpen}
|
||||
snippets={PROMPT_SNIPPETS}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PromptSnippetsDialog({
|
||||
onInsertText,
|
||||
onOpenChange,
|
||||
open,
|
||||
snippets
|
||||
}: PromptSnippetsDialogProps) {
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="max-w-md gap-3">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Prompt snippets</DialogTitle>
|
||||
<DialogDescription>Pick a starter prompt to drop into the composer.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="grid gap-1">
|
||||
{snippets.map(snippet => (
|
||||
<li key={snippet.label}>
|
||||
<button
|
||||
className="group/snippet flex w-full cursor-pointer items-start gap-2.5 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors hover:border-(--ui-stroke-tertiary) hover:bg-(--ui-control-hover-background) focus-visible:border-(--ui-stroke-tertiary) focus-visible:bg-(--ui-control-hover-background) focus-visible:outline-none"
|
||||
onClick={() => {
|
||||
onInsertText(snippet.text)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<MessageSquareText className="mt-0.5 size-3.5 shrink-0 text-(--ui-text-tertiary) group-hover/snippet:text-foreground" />
|
||||
<span className="grid min-w-0 gap-0.5">
|
||||
<span className="text-sm font-medium text-foreground">{snippet.label}</span>
|
||||
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{snippet.description}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -108,12 +165,7 @@ export function ContextMenuItem({
|
|||
disabled,
|
||||
icon: Icon,
|
||||
onSelect
|
||||
}: {
|
||||
children: string
|
||||
disabled?: boolean
|
||||
icon: IconComponent
|
||||
onSelect?: () => void
|
||||
}) {
|
||||
}: ContextMenuItemProps) {
|
||||
return (
|
||||
<DropdownMenuItem disabled={disabled} onSelect={onSelect}>
|
||||
<Icon />
|
||||
|
|
@ -121,3 +173,33 @@ export function ContextMenuItem({
|
|||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
interface ContextMenuItemProps {
|
||||
children: string
|
||||
disabled?: boolean
|
||||
icon: IconComponent
|
||||
onSelect?: () => void
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
onInsertText: (text: string) => void
|
||||
onOpenUrlDialog: () => void
|
||||
onPasteClipboardImage?: () => void
|
||||
onPickFiles?: () => void
|
||||
onPickFolders?: () => void
|
||||
onPickImages?: () => void
|
||||
state: ChatBarState
|
||||
}
|
||||
|
||||
interface PromptSnippet {
|
||||
description: string
|
||||
label: string
|
||||
text: string
|
||||
}
|
||||
|
||||
interface PromptSnippetsDialogProps {
|
||||
onInsertText: (text: string) => void
|
||||
onOpenChange: (open: boolean) => void
|
||||
open: boolean
|
||||
snippets: readonly PromptSnippet[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1024,6 +1024,8 @@ export function ChatBar({
|
|||
<div className={cn('relative', stacked ? 'w-full' : 'min-w-(--composer-input-inline-min-width) flex-1')}>
|
||||
<div
|
||||
aria-label="Message"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
className={cn(
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) overflow-y-auto bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
|
|
@ -1045,6 +1047,7 @@ export function ChatBar({
|
|||
onPaste={handlePaste}
|
||||
ref={editorRef}
|
||||
role="textbox"
|
||||
spellCheck="true"
|
||||
suppressContentEditableWarning
|
||||
/>
|
||||
{/* assistant-ui requires ComposerPrimitive.Input somewhere in the tree
|
||||
|
|
|
|||
|
|
@ -97,6 +97,17 @@ export function ChatPreviewRail({ onRestartServer, setTitlebarToolGroup }: ChatP
|
|||
: 'border-r border-(--ui-stroke-quaternary) text-(--ui-text-tertiary) [--tab-bg:var(--ui-sidebar-surface-background)] hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
key={tab.id}
|
||||
// Middle-click closes the tab, matching browser/IDE muscle
|
||||
// memory. `onMouseDown` swallows the middle-button press so
|
||||
// Chromium doesn't switch into autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button !== 1) return
|
||||
event.preventDefault()
|
||||
closeRightRailTab(tab.id)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) event.preventDefault()
|
||||
}}
|
||||
>
|
||||
{active && (
|
||||
<span aria-hidden="true" className="absolute inset-x-0 top-0 h-px bg-(--ui-stroke-primary)" />
|
||||
|
|
|
|||
|
|
@ -67,6 +67,12 @@ import { VirtualSessionList } from './virtual-session-list'
|
|||
|
||||
const VIRTUALIZE_THRESHOLD = 25
|
||||
|
||||
// Render the modifier key the user actually presses on this platform. The
|
||||
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
|
||||
// else) in desktop-controller.tsx, but the hint should match muscle memory.
|
||||
const NEW_SESSION_KBD: readonly string[] =
|
||||
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
id: 'new-session',
|
||||
|
|
@ -438,7 +444,7 @@ export function ChatSidebar({
|
|||
<>
|
||||
<span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">{item.label}</span>
|
||||
{item.id === 'new-session' && (
|
||||
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={['⇧', 'N']} />
|
||||
<KbdGroup className="ml-auto max-[46.25rem]:hidden" keys={[...NEW_SESSION_KBD]} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
@ -540,23 +546,28 @@ export function ChatSidebar({
|
|||
forceEmptyState={showSessionSkeletons}
|
||||
groups={agentsGrouped ? agentGroups : undefined}
|
||||
headerAction={
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
// Grouping operates on unpinned recents; if everything is
|
||||
// pinned the toggle does nothing visible, so hide it to avoid
|
||||
// a phantom click target.
|
||||
agentSessions.length > 0 ? (
|
||||
<Button
|
||||
aria-label={agentsGrouped ? 'Show sessions as a single list' : 'Group sessions by workspace'}
|
||||
className={cn(
|
||||
'cursor-pointer text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
|
||||
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
|
||||
)}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
setSidebarRecentsOpen(true)
|
||||
setSidebarAgentsGrouped(!agentsGrouped)
|
||||
}}
|
||||
size="icon-xs"
|
||||
title={agentsGrouped ? 'Ungroup sessions' : 'Group by workspace'}
|
||||
variant="ghost"
|
||||
>
|
||||
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
label="Sessions"
|
||||
labelMeta={countLabel(agentSessions.length, knownSessionTotal)}
|
||||
|
|
@ -633,7 +644,7 @@ function SidebarPinnedEmptyState() {
|
|||
<span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
|
||||
<Codicon name="pin" size="0.75rem" />
|
||||
</span>
|
||||
<span>Shift click to pin a chat</span>
|
||||
<span>Shift-click a chat to pin · drag to reorder</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -428,14 +428,6 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
|||
return (
|
||||
<PageSearchShell
|
||||
{...props}
|
||||
filters={
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
onSearchChange={setQuery}
|
||||
searchPlaceholder="Search cron jobs..."
|
||||
searchTrailingAction={
|
||||
|
|
@ -457,6 +449,10 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
|||
{!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={
|
||||
|
|
@ -469,6 +465,19 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
|||
/>
|
||||
) : (
|
||||
<div className="h-full overflow-y-auto px-4 py-3">
|
||||
{/* Inline header replaces the old top-bar "New cron" button. We
|
||||
still need a single, always-visible affordance to add a job
|
||||
when the list is non-empty (rows themselves only expose
|
||||
edit/pause/trigger/delete). */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[0.7rem] uppercase tracking-wide text-muted-foreground">
|
||||
{enabledCount}/{totalCount} active
|
||||
</span>
|
||||
<Button onClick={() => setEditor({ mode: 'create' })} size="sm">
|
||||
<Codicon name="add" />
|
||||
New cron
|
||||
</Button>
|
||||
</div>
|
||||
<div className="divide-y divide-border/40 rounded-lg border border-border/40 bg-background/70">
|
||||
{visibleJobs.map(job => (
|
||||
<CronJobRow
|
||||
|
|
@ -484,8 +493,6 @@ export function CronView({ setStatusbarItemGroup: _setStatusbarItemGroup, ...pro
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden">{totalCount === 0 ? 'No scheduled jobs' : `${enabledCount}/${totalCount} active`}</div>
|
||||
|
||||
<CronEditorDialog editor={editor} onClose={() => setEditor({ mode: 'closed' })} onSave={handleEditorSave} />
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
|
|
|
|||
|
|
@ -372,14 +372,22 @@ export function DesktopController() {
|
|||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLSelectElement
|
||||
|
||||
if (editing || event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey) {
|
||||
if (event.defaultPrevented || event.repeat || event.altKey || event.code !== 'KeyN') {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.shiftKey && event.code === 'KeyN') {
|
||||
event.preventDefault()
|
||||
startFreshSessionDraft()
|
||||
// Two accelerators for "new session":
|
||||
// - Cmd/Ctrl+N (browser-like, works while typing in any input)
|
||||
// - Shift+N (single-key, only when no input is focused)
|
||||
const accelerator = event.metaKey || event.ctrlKey
|
||||
const singleKey = !accelerator && !editing && event.shiftKey
|
||||
|
||||
if (!accelerator && !singleKey) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
startFreshSessionDraft()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import { useRouteEnumParam } from '../hooks/use-route-enum-param'
|
|||
import { PageSearchShell } from '../page-search-shell'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { PlatformAvatar } from './platform-icon'
|
||||
|
||||
interface MessagingViewProps extends React.ComponentProps<'section'> {
|
||||
setStatusbarItemGroup?: SetStatusbarItemGroup
|
||||
}
|
||||
|
|
@ -39,29 +41,6 @@ const STATE_LABELS: Record<string, string> = {
|
|||
startup_failed: 'Startup failed'
|
||||
}
|
||||
|
||||
const PLATFORM_TINTS: Record<string, string> = {
|
||||
telegram: 'bg-sky-500/15 text-sky-600 dark:text-sky-300',
|
||||
discord: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-300',
|
||||
slack: 'bg-violet-500/15 text-violet-600 dark:text-violet-300',
|
||||
mattermost: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
matrix: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
signal: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
|
||||
whatsapp: 'bg-green-500/15 text-green-600 dark:text-green-300',
|
||||
bluebubbles: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
homeassistant: 'bg-teal-500/15 text-teal-600 dark:text-teal-300',
|
||||
email: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
|
||||
sms: 'bg-rose-500/15 text-rose-600 dark:text-rose-300',
|
||||
dingtalk: 'bg-blue-500/15 text-blue-600 dark:text-blue-300',
|
||||
feishu: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-300',
|
||||
wecom: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
wecom_callback: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-300',
|
||||
weixin: 'bg-green-500/15 text-green-600 dark:text-green-300',
|
||||
qqbot: 'bg-amber-500/15 text-amber-600 dark:text-amber-300',
|
||||
yuanbao: 'bg-orange-500/15 text-orange-600 dark:text-orange-300',
|
||||
api_server: 'bg-slate-500/15 text-slate-600 dark:text-slate-300',
|
||||
webhook: 'bg-zinc-500/15 text-zinc-600 dark:text-zinc-300'
|
||||
}
|
||||
|
||||
const PILL_TONE: Record<StatusTone, string> = {
|
||||
good: 'bg-primary/10 text-primary',
|
||||
muted: 'bg-muted text-muted-foreground',
|
||||
|
|
@ -442,19 +421,6 @@ function PlatformRow({
|
|||
)
|
||||
}
|
||||
|
||||
function PlatformAvatar({ platformId, platformName }: { platformId: string; platformName: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex size-6 shrink-0 items-center justify-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
|
||||
PLATFORM_TINTS[platformId] || 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)'
|
||||
)}
|
||||
>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function PlatformDetail({
|
||||
edits,
|
||||
onClear,
|
||||
|
|
|
|||
97
apps/desktop/src/app/messaging/platform-icon.tsx
Normal file
97
apps/desktop/src/app/messaging/platform-icon.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import type { ComponentType, SVGProps } from 'react'
|
||||
|
||||
import {
|
||||
SiApple,
|
||||
SiBilibili,
|
||||
SiDiscord,
|
||||
SiGmail,
|
||||
SiHomeassistant,
|
||||
SiMatrix,
|
||||
SiMattermost,
|
||||
SiQq,
|
||||
SiSignal,
|
||||
SiTelegram,
|
||||
SiWechat,
|
||||
SiWhatsapp
|
||||
} from '@icons-pack/react-simple-icons'
|
||||
|
||||
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// We render simpleicons.org brand glyphs for platforms whose owners publish a
|
||||
// usable mark (telegram, discord, matrix, ...). A few brands — Slack, Dingtalk,
|
||||
// Feishu, WeCom — have been removed from Simple Icons at the brand owner's
|
||||
// request, so we fall back to a colored letter monogram for those.
|
||||
//
|
||||
// `iconColor` is the brand's hex from simpleicons.org so we can paint each
|
||||
// glyph in its native color on top of a soft tint. The fallback monogram uses
|
||||
// the same hex to keep visual consistency.
|
||||
type IconKind = 'brand' | 'generic'
|
||||
|
||||
interface PlatformIconSpec {
|
||||
Icon: ComponentType<SVGProps<SVGSVGElement>>
|
||||
color: string
|
||||
kind: IconKind
|
||||
}
|
||||
|
||||
const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
|
||||
telegram: { Icon: SiTelegram, color: '#26A5E4', kind: 'brand' },
|
||||
discord: { Icon: SiDiscord, color: '#5865F2', kind: 'brand' },
|
||||
// Slack removed from Simple Icons by Salesforce request — letter monogram.
|
||||
mattermost: { Icon: SiMattermost, color: '#0058CC', kind: 'brand' },
|
||||
matrix: { Icon: SiMatrix, color: '#000000', kind: 'brand' },
|
||||
signal: { Icon: SiSignal, color: '#3A76F0', kind: 'brand' },
|
||||
whatsapp: { Icon: SiWhatsapp, color: '#25D366', kind: 'brand' },
|
||||
bluebubbles: { Icon: SiApple, color: '#0BD318', kind: 'brand' },
|
||||
homeassistant: { Icon: SiHomeassistant, color: '#18BCF2', kind: 'brand' },
|
||||
email: { Icon: SiGmail, color: '#EA4335', kind: 'brand' },
|
||||
sms: { Icon: MessageSquareText, color: '#F43F5E', kind: 'generic' },
|
||||
webhook: { Icon: LinkIcon, color: '#71717A', kind: 'generic' },
|
||||
api_server: { Icon: Globe, color: '#64748B', kind: 'generic' },
|
||||
weixin: { Icon: SiWechat, color: '#07C160', kind: 'brand' },
|
||||
qqbot: { Icon: SiQq, color: '#EB1923', kind: 'brand' },
|
||||
yuanbao: { Icon: SiBilibili, color: '#FB7299', kind: 'brand' }
|
||||
}
|
||||
|
||||
interface PlatformAvatarProps {
|
||||
platformId: string
|
||||
platformName: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PlatformAvatar({ className, platformId, platformName }: PlatformAvatarProps) {
|
||||
const spec = PLATFORM_ICONS[platformId]
|
||||
|
||||
const baseClass = cn(
|
||||
'inline-grid size-6 shrink-0 place-items-center rounded-md text-[length:var(--conversation-caption-font-size)] font-medium',
|
||||
className
|
||||
)
|
||||
|
||||
if (!spec) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
|
||||
>
|
||||
{platformName.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const { Icon, color } = spec
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={baseClass}
|
||||
style={{
|
||||
// 16% tint of the brand color so the glyph reads against any surface
|
||||
// without the avatar dominating the row.
|
||||
backgroundColor: `color-mix(in srgb, ${color} 16%, transparent)`,
|
||||
color
|
||||
}}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -28,10 +28,16 @@ export function PageSearchShell({
|
|||
{...props}
|
||||
className={cn('flex h-full min-w-0 flex-col overflow-hidden bg-(--ui-chat-surface-background)', className)}
|
||||
>
|
||||
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5">
|
||||
{/*
|
||||
This header sits in the titlebar row, so it overlaps the OS window-drag
|
||||
region painted by the shell. Without `-webkit-app-region: no-drag` on
|
||||
the search row, mousedown on the input gets intercepted as a window-
|
||||
drag start and the input never receives focus (visible as "I can't
|
||||
click the search box" on the messaging/cron/etc pages).
|
||||
*/}
|
||||
<div className="relative z-10 grid gap-2 border-b border-(--ui-stroke-tertiary) px-3 py-2.5 [-webkit-app-region:no-drag]">
|
||||
{/* Reserve the top-right titlebar tools + native window-controls
|
||||
footprint so the full-width search input never slides under them
|
||||
(this header sits in the titlebar row at the window top). */}
|
||||
footprint so the full-width search input never slides under them. */}
|
||||
<div
|
||||
style={{
|
||||
paddingRight:
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
setMessages,
|
||||
setSelectedStoredSessionId,
|
||||
setSessions,
|
||||
setSessionsTotal,
|
||||
setSessionStartedAt,
|
||||
setTurnStartedAt
|
||||
} from '@/store/session'
|
||||
|
|
@ -689,6 +690,9 @@ export function useSessionActions({
|
|||
const previousPinned = $pinnedSessionIds.get()
|
||||
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
// Keep $sessionsTotal in sync so the sidebar's "Load N more" footer
|
||||
// doesn't keep claiming the removed row is still on the server.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
|
||||
// Tear down before awaiting so the route effect can't resume the
|
||||
|
|
@ -711,6 +715,7 @@ export function useSessionActions({
|
|||
} catch (err) {
|
||||
if (removed) {
|
||||
setSessions(prev => [removed, ...prev])
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
|
|
@ -763,6 +768,10 @@ export function useSessionActions({
|
|||
|
||||
// Soft-hide: drop from the sidebar immediately, keep the data.
|
||||
setSessions(prev => prev.filter(s => s.id !== storedSessionId))
|
||||
// Archived sessions are hidden by the listSessions(min_messages=1) query
|
||||
// on the next refresh, so they count as "removed" for the load-more
|
||||
// footer math.
|
||||
setSessionsTotal(prev => Math.max(0, prev - 1))
|
||||
$pinnedSessionIds.set(previousPinned.filter(id => id !== storedSessionId))
|
||||
|
||||
if (wasSelected) {
|
||||
|
|
@ -775,6 +784,7 @@ export function useSessionActions({
|
|||
} catch (err) {
|
||||
if (archived) {
|
||||
setSessions(prev => [archived, ...prev.filter(s => s.id !== storedSessionId)])
|
||||
setSessionsTotal(prev => prev + 1)
|
||||
}
|
||||
|
||||
$pinnedSessionIds.set(previousPinned)
|
||||
|
|
|
|||
|
|
@ -4,7 +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 { $busy, $messages, setSessionWorking } from '@/store/session'
|
||||
import { $busy, $messages, noteSessionActivity, setSessionWorking } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
|
|
@ -140,6 +140,13 @@ export function useSessionStateCache({
|
|||
}
|
||||
|
||||
setSessionWorking(next.storedSessionId, next.busy)
|
||||
// Every state update is effectively a "still alive" heartbeat for
|
||||
// streaming events. The session-store watchdog uses this to keep the
|
||||
// working flag alive during long-running turns and to clear it once
|
||||
// the stream goes silent.
|
||||
if (next.busy) {
|
||||
noteSessionActivity(next.storedSessionId)
|
||||
}
|
||||
syncSessionStateToView(sessionId, next)
|
||||
|
||||
return next
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { CheckCircle2, ExternalLink, Loader2, RefreshCw, Sparkles } from '@/lib/icons'
|
||||
|
|
@ -10,7 +10,8 @@ import {
|
|||
$updateChecking,
|
||||
$updateStatus,
|
||||
checkUpdates,
|
||||
openUpdatesWindow
|
||||
openUpdatesWindow,
|
||||
refreshDesktopVersion
|
||||
} from '@/store/updates'
|
||||
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
|
|
@ -46,6 +47,14 @@ export function AboutSettings() {
|
|||
const checking = useStore($updateChecking)
|
||||
const [justChecked, setJustChecked] = useState(false)
|
||||
|
||||
// The version atom is loaded once at app boot, which makes About show a
|
||||
// stale number after a self-update (the running binary is current, the
|
||||
// displayed string is not). Re-read on mount so opening About always
|
||||
// reflects the running build.
|
||||
useEffect(() => {
|
||||
void refreshDesktopVersion()
|
||||
}, [])
|
||||
|
||||
const behind = status?.behind ?? 0
|
||||
const supported = status?.supported !== false
|
||||
const applying = apply.applying || apply.stage === 'restart'
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ import {
|
|||
import { LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
import type { EnvPatch, EnvRowProps, ProviderGroup, SearchProps } from './types'
|
||||
|
||||
const SHOW_ADVANCED_STORAGE_KEY = 'desktop.settings.keys.show_advanced'
|
||||
|
||||
interface EnvActionsProps {
|
||||
varKey: string
|
||||
info: EnvVarInfo
|
||||
|
|
@ -186,8 +184,11 @@ function EnvProviderGroup({
|
|||
group: ProviderGroup
|
||||
rowProps: Omit<EnvRowProps, 'varKey' | 'info'>
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const setCount = group.entries.filter(([, info]) => info.is_set).length
|
||||
// Default-expand providers that already have at least one key set; the
|
||||
// user is much more likely to be coming back to edit those than to start
|
||||
// configuring a fresh provider from scratch.
|
||||
const [expanded, setExpanded] = useState(setCount > 0)
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl bg-background/60">
|
||||
|
|
@ -222,27 +223,17 @@ export function KeysSettings({ query }: SearchProps) {
|
|||
const [revealed, setRevealed] = useState<Record<string, string>>({})
|
||||
const [saving, setSaving] = useState<string | null>(null)
|
||||
|
||||
const [showAdvanced, setShowAdvanced] = useState<boolean>(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(SHOW_ADVANCED_STORAGE_KEY)
|
||||
|
||||
if (stored === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
return stored === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
// We used to hide ~80% of rows behind a global "Show advanced" toggle, but
|
||||
// everything in this view is configuration-level — "advanced" was a poor
|
||||
// distinction. The full list is rendered now and provider groups
|
||||
// default-collapsed-unless-set keep the surface manageable.
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(SHOW_ADVANCED_STORAGE_KEY, showAdvanced ? 'true' : 'false')
|
||||
window.localStorage.removeItem('desktop.settings.keys.show_advanced')
|
||||
} catch {
|
||||
// Ignore persistence failures and keep in-memory preference.
|
||||
// Ignore — old key cleanup is best-effort.
|
||||
}
|
||||
}, [showAdvanced])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -262,28 +253,21 @@ export function KeysSettings({ query }: SearchProps) {
|
|||
return () => void (cancelled = true)
|
||||
}, [])
|
||||
|
||||
const filterEnv = useCallback(
|
||||
(info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
const filterEnv = useCallback((info: EnvVarInfo, key: string, q: string, cat: string, extra?: string) => {
|
||||
if (asText(info.category) !== cat) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!showAdvanced && Boolean(info.advanced)) {
|
||||
return false
|
||||
}
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!q) {
|
||||
return true
|
||||
}
|
||||
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
},
|
||||
[showAdvanced]
|
||||
)
|
||||
return (
|
||||
key.toLowerCase().includes(q) ||
|
||||
includesQuery(info.description, q) ||
|
||||
Boolean(extra && extra.toLowerCase().includes(q))
|
||||
)
|
||||
}, [])
|
||||
|
||||
const providerGroups = useMemo<ProviderGroup[]>(() => {
|
||||
if (!vars) {
|
||||
|
|
@ -415,12 +399,6 @@ export function KeysSettings({ query }: SearchProps) {
|
|||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button onClick={() => setShowAdvanced(s => !s)} size="sm" variant="outline">
|
||||
{showAdvanced ? 'Hide advanced' : 'Show advanced'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<SectionHeading
|
||||
icon={Zap}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { deleteSession, listSessions, setSessionArchived } from '@/hermes'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Archive, ArchiveOff, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { Archive, ArchiveOff, FolderOpen, Loader2, Trash2 } from '@/lib/icons'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
|
@ -105,6 +105,8 @@ export function SessionsSettings({ query }: SearchProps) {
|
|||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<DefaultProjectDirSetting />
|
||||
|
||||
<SectionHeading
|
||||
icon={Archive}
|
||||
meta={sessions.length ? String(sessions.length) : undefined}
|
||||
|
|
@ -166,3 +168,104 @@ export function SessionsSettings({ query }: SearchProps) {
|
|||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
// Lets the user pin the default cwd for new sessions. Without this, packaged
|
||||
// builds on Windows used to spawn sessions in the install dir (`win-unpacked`
|
||||
// / Program Files), which buried any files Hermes wrote there.
|
||||
function DefaultProjectDirSetting() {
|
||||
const [dir, setDir] = useState<null | string>(null)
|
||||
const [fallback, setFallback] = useState<string>('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// The bridge is only present when running inside Electron. In a Vitest
|
||||
// / Storybook / non-Electron context `window.hermesDesktop` is
|
||||
// undefined, so guard the WHOLE call chain rather than chaining
|
||||
// `?.settings.getDefaultProjectDir().then(...)` (the latter would
|
||||
// short-circuit to `undefined.then(...)` and throw at runtime).
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) {
|
||||
return
|
||||
}
|
||||
|
||||
let alive = true
|
||||
|
||||
void settings.getDefaultProjectDir().then(result => {
|
||||
if (!alive) return
|
||||
setDir(result.dir)
|
||||
setFallback(result.defaultLabel)
|
||||
})
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const choose = useCallback(async () => {
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) return
|
||||
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
const picked = await settings.pickDefaultProjectDir()
|
||||
|
||||
if (picked.canceled || !picked.dir) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = await settings.setDefaultProjectDir(picked.dir)
|
||||
setDir(result.dir)
|
||||
notify({ durationMs: 2_000, kind: 'success', message: 'Default project directory updated' })
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not update default directory')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clear = useCallback(async () => {
|
||||
const settings = window.hermesDesktop?.settings
|
||||
|
||||
if (!settings) return
|
||||
|
||||
setBusy(true)
|
||||
|
||||
try {
|
||||
await settings.setDefaultProjectDir(null)
|
||||
setDir(null)
|
||||
} catch (err) {
|
||||
notifyError(err, 'Could not clear default directory')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<SectionHeading icon={FolderOpen} title="Default project directory" />
|
||||
<p className="mb-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
New sessions start in this folder unless you pick another. Leave it unset to use your home directory.
|
||||
</p>
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button disabled={busy} onClick={() => void choose()} size="sm" type="button" variant="outline">
|
||||
<FolderOpen className="size-3.5" />
|
||||
<span>{dir ? 'Change' : 'Choose'}</span>
|
||||
</Button>
|
||||
{dir && (
|
||||
<Button disabled={busy} onClick={() => void clear()} size="sm" type="button" variant="ghost">
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={dir || `Defaults to ${fallback || '~/hermes-projects'}.`}
|
||||
title={dir ? dir : 'Not set'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,14 +54,18 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
|
|||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
|
||||
{/* `overflow-x-clip` (not `overflow-x-auto`) so a wide status item — for
|
||||
example "Connecting…" on a fresh/untitled session — can't paint a
|
||||
horizontal scrollbar across the bottom of the window. Items already
|
||||
`truncate` their labels, so clipping is the right behavior. */}
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{leftItems
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
<StatusbarItemView item={item} key={`left:${item.id}`} navigate={navigate} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-auto">
|
||||
<div className="flex min-w-0 items-stretch gap-0.5 overflow-x-clip">
|
||||
{items
|
||||
.filter(item => !item.hidden)
|
||||
.map(item => (
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export const TITLEBAR_FALLBACK_WINDOW_BUTTON_X = 24
|
|||
export const TITLEBAR_EDGE_INSET = 14
|
||||
|
||||
export const titlebarButtonClass =
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] rounded-md text-muted-foreground/85 transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground'
|
||||
'h-[var(--titlebar-control-height)] w-[var(--titlebar-control-size)] cursor-pointer rounded-md text-muted-foreground/85 transition-colors 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))]'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue