mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
refactor(desktop): retire desktop-controller for the contribution shell; views as contributions
This commit is contained in:
parent
10fbade64b
commit
369d0eeeff
11 changed files with 114 additions and 1422 deletions
|
|
@ -54,8 +54,8 @@ import {
|
|||
} from '../overlays/panel'
|
||||
import type { SetStatusbarItemGroup } from '../shell/statusbar-controls'
|
||||
|
||||
import { jobState, jobTitle, STATE_DOT } from './job-state'
|
||||
import { cronEditorUpdates, jobIsScriptOnly, validateCronEditor } from './cron-job-model'
|
||||
import { jobState, jobTitle, STATE_DOT } from './job-state'
|
||||
|
||||
const DEFAULT_DELIVER = 'local'
|
||||
|
||||
|
|
@ -398,6 +398,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
|
|||
notify({ kind: 'success', title: c.created, message: truncate(jobTitle(created), 60) })
|
||||
} else if (editor.mode === 'edit') {
|
||||
const scriptOnlyJob = jobIsScriptOnly(editor.job)
|
||||
|
||||
const updated = await updateCronJob(
|
||||
editor.job.id,
|
||||
cronEditorUpdates(values, { scriptOnlyJob })
|
||||
|
|
@ -756,6 +757,7 @@ function CronEditorDialog({
|
|||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
|
||||
const validationError = validateCronEditor({
|
||||
prompt,
|
||||
schedule,
|
||||
|
|
|
|||
|
|
@ -1,31 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SessionInfo } from '@/hermes'
|
||||
|
||||
import { sameCronSignature } from './desktop-controller-utils'
|
||||
|
||||
const session = (id: string, title: string | null): SessionInfo => ({ id, title }) as SessionInfo
|
||||
|
||||
describe('sameCronSignature', () => {
|
||||
it('is false when the lengths differ', () => {
|
||||
expect(sameCronSignature([session('a', 't')], [])).toBe(false)
|
||||
})
|
||||
|
||||
it('is true when ids and titles match in order', () => {
|
||||
const a = [session('a', 'one'), session('b', 'two')]
|
||||
const b = [session('a', 'one'), session('b', 'two')]
|
||||
expect(sameCronSignature(a, b)).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when a title changed', () => {
|
||||
const a = [session('a', 'one')]
|
||||
const b = [session('a', 'renamed')]
|
||||
expect(sameCronSignature(a, b)).toBe(false)
|
||||
})
|
||||
|
||||
it('is false when order differs', () => {
|
||||
const a = [session('a', 't'), session('b', 't')]
|
||||
const b = [session('b', 't'), session('a', 't')]
|
||||
expect(sameCronSignature(a, b)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import type { SessionInfo } from '@/hermes'
|
||||
|
||||
// Cheap signature compare so a poll only swaps the atom (and re-renders the
|
||||
// sidebar) when the visible rows actually changed.
|
||||
export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
return a.every((session, i) => {
|
||||
const other = b[i]
|
||||
|
||||
return (
|
||||
other != null &&
|
||||
session.id === other.id &&
|
||||
session._lineage_root_id === other._lineage_root_id &&
|
||||
session.title === other.title &&
|
||||
session.source === other.source &&
|
||||
session.profile === other.profile &&
|
||||
session.preview === other.preview &&
|
||||
session.message_count === other.message_count &&
|
||||
session.last_active === other.last_active &&
|
||||
session.ended_at === other.ended_at
|
||||
)
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,8 +1,10 @@
|
|||
import { type ReactNode, useEffect } from 'react'
|
||||
import { type CSSProperties, type ReactNode, useEffect } from 'react'
|
||||
|
||||
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { ESCAPE_PRIORITY, isTopEscapeLayer, pushEscapeLayer } from '@/lib/escape-layers'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -32,8 +34,10 @@ export function OverlayView({
|
|||
// stop propagation themselves, so opening (e.g.) the model picker inside
|
||||
// Settings still closes the picker first instead of the underlying overlay.
|
||||
useEffect(() => {
|
||||
const releaseLayer = pushEscapeLayer(ESCAPE_PRIORITY.overlay)
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || !isTopEscapeLayer(ESCAPE_PRIORITY.overlay)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -44,7 +48,10 @@ export function OverlayView({
|
|||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
releaseLayer()
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
return (
|
||||
|
|
@ -64,6 +71,12 @@ export function OverlayView({
|
|||
}
|
||||
}}
|
||||
role="presentation"
|
||||
// Window-level chrome: overlays always clear the real titlebar. The
|
||||
// contrib shell zeroes --titlebar-height for CONTENT areas (panes sit
|
||||
// below its in-flow title bar), and CSS vars inherit through the DOM —
|
||||
// so a fixed overlay mounted inside a zone would read 0 and bleed to
|
||||
// the edges. Re-pin the real height at the overlay root.
|
||||
style={{ '--titlebar-height': `${TITLEBAR_HEIGHT}px` } as CSSProperties}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -84,7 +84,9 @@ export function ReviewPane() {
|
|||
{(loading || isRepo) && (
|
||||
<RightSidebarSectionHeader data-suppress-pane-reveal-side="">
|
||||
<div className="flex min-w-0 flex-1">
|
||||
<SidebarPanelLabel>{c.review}</SidebarPanelLabel>
|
||||
{/* Pure self-naming label — redundant under a zone tab that already
|
||||
says "review", so the zone header hides it (styles.css). */}
|
||||
<SidebarPanelLabel data-pane-self-label="">{c.review}</SidebarPanelLabel>
|
||||
</div>
|
||||
<Tip label={treeMode === 'tree' ? c.viewAsList : c.viewAsTree}>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -461,11 +461,36 @@ export function McpTab({ gateway }: { gateway: HermesGateway | null }) {
|
|||
const draftSeeded = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (config && !draftSeeded.current) {
|
||||
// profilePending: config still holds the PREVIOUS profile's record right
|
||||
// after a switch — seeding from it would latch the wrong profile's doc.
|
||||
if (!config || profilePending) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!draftSeeded.current) {
|
||||
draftSeeded.current = true
|
||||
resetDraft(getServers(config))
|
||||
|
||||
return
|
||||
}
|
||||
}, [config])
|
||||
|
||||
if (dirty || names.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Heal the early-boot race: the first config snapshot can land before the
|
||||
// backend has mcp_servers assembled, seeding (and latching) an empty doc
|
||||
// while later refetches fill the list — saving would then wipe the real
|
||||
// servers. A PRISTINE empty draft reseeds when servers arrive; any user
|
||||
// edit (dirty) still always wins.
|
||||
try {
|
||||
if (Object.keys(parseServersDoc(draft)).length === 0) {
|
||||
resetDraft(servers)
|
||||
}
|
||||
} catch {
|
||||
// Mid-edit / invalid JSON — the user's text wins.
|
||||
}
|
||||
}, [config, dirty, draft, names, profilePending, servers])
|
||||
|
||||
// Bumped on every profile switch. Async probe/auth completions capture the
|
||||
// epoch at call time and bail if it changed, so a slow profile-A request can't
|
||||
|
|
@ -698,15 +723,17 @@ export function McpTab({ gateway }: { gateway: HermesGateway | null }) {
|
|||
return
|
||||
}
|
||||
|
||||
const next = withEnabled(servers[serverName], enabled)
|
||||
|
||||
try {
|
||||
if (!(await persist({ ...servers, [serverName]: withEnabled(servers[serverName], enabled) }))) {
|
||||
if (!(await persist({ ...servers, [serverName]: next }))) {
|
||||
return
|
||||
}
|
||||
|
||||
if (dirty) {
|
||||
patchDraft(doc => (doc[serverName] ? { ...doc, [serverName]: withEnabled(doc[serverName], enabled) } : doc))
|
||||
} else {
|
||||
resetDraft({ ...servers, [serverName]: withEnabled(servers[serverName], enabled) })
|
||||
resetDraft({ ...servers, [serverName]: next })
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type * as React from 'react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import type { UsageStats } from '@/types/hermes'
|
||||
|
||||
export interface ContextSuggestion {
|
||||
text: string
|
||||
|
|
@ -125,7 +126,8 @@ export type CommandDispatchResponse =
|
|||
export type SidebarNavId = 'artifacts' | 'command-center' | 'messaging' | 'new-session' | 'settings' | 'skills'
|
||||
|
||||
export interface SidebarNavItem {
|
||||
id: SidebarNavId
|
||||
/** Built-in view id, or a contributed row's namespaced contribution id. */
|
||||
id: SidebarNavId | (string & {})
|
||||
label: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
route?: string
|
||||
|
|
@ -158,4 +160,8 @@ export interface ClientSessionState {
|
|||
* focused, and switching sessions doesn't zero a still-running turn's clock.
|
||||
* The global $turnStartedAt mirrors whichever session is currently viewed. */
|
||||
turnStartedAt: number | null
|
||||
/** Cumulative token usage, updated per completed turn. Per-session twin of
|
||||
* the primary-only $currentUsage — the statusbar reads it for a focused
|
||||
* tile's context count. Null until the first turn reports. */
|
||||
usage: null | UsageStats
|
||||
}
|
||||
|
|
|
|||
|
|
@ -522,6 +522,13 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* A pane's self-naming label ("REVIEW" atop the review pane) is redundant
|
||||
when its zone header is showing a tab with the same name — hide it there.
|
||||
Headerless zones (and the pane used outside the tree) keep the label. */
|
||||
[data-tree-group][data-zone-header] [data-pane-self-label] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:root:not([style*='--theme-asset-bg:']) .theme-default-filler {
|
||||
display: block;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { $registryVersion } from '@/contrib/registry'
|
||||
import { matchesQuery, useMediaQuery } from '@/hooks/use-media-query'
|
||||
import { persistString, persistStringRecord, storedString, storedStringRecord } from '@/lib/storage'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
|
|
@ -19,7 +20,7 @@ import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
|||
import { hexToRgb, mix, readableOn } from './color'
|
||||
import { BUILTIN_THEME_LIST, BUILTIN_THEMES, DEFAULT_SKIN_NAME, DEFAULT_TYPOGRAPHY, nousTheme } from './presets'
|
||||
import type { DesktopTheme, DesktopThemeColors } from './types'
|
||||
import { $userThemes, resolveTheme } from './user-themes'
|
||||
import { $userThemes, listAllThemes, resolveTheme } from './user-themes'
|
||||
|
||||
// Legacy global skin (pre per-profile themes). Still the inheritance fallback
|
||||
// for any profile without its own assignment, so single-profile users and old
|
||||
|
|
@ -314,18 +315,22 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
// behavior is unchanged.
|
||||
const profileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
|
||||
// Built-ins + user-installed themes. Reactive so an import shows up live in
|
||||
// the palette, settings grid, and `/skin` without a reload.
|
||||
// Built-ins + user-installed + registry-contributed themes. Reactive so an
|
||||
// import or a plugin registration shows up live in the palette, settings
|
||||
// grid, and `/skin` without a reload.
|
||||
const userThemes = useStore($userThemes)
|
||||
const registryVersion = useStore($registryVersion)
|
||||
|
||||
const availableThemes = useMemo(
|
||||
() =>
|
||||
[...Object.values(BUILTIN_THEMES), ...Object.values(userThemes)].map(({ name, label, description }) => ({
|
||||
listAllThemes().map(({ name, label, description }) => ({
|
||||
name,
|
||||
label,
|
||||
description
|
||||
})),
|
||||
[userThemes]
|
||||
// userThemes + registryVersion ARE listAllThemes' reactivity.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[userThemes, registryVersion]
|
||||
)
|
||||
|
||||
const [themeName, setThemeNameState] = useState(() =>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@
|
|||
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { registry } from '@/contrib/registry'
|
||||
|
||||
import { BUILTIN_THEMES } from './presets'
|
||||
import type { DesktopTheme, DesktopThemeColors } from './types'
|
||||
|
||||
|
|
@ -142,12 +144,41 @@ export const $marketplaceInstalls = computed($userThemes, themes => {
|
|||
return map
|
||||
})
|
||||
|
||||
/** Resolve a theme by name across the merged registry (built-in + user). */
|
||||
export function resolveTheme(name: string): DesktopTheme | undefined {
|
||||
return BUILTIN_THEMES[name] ?? $userThemes.get()[name]
|
||||
// ── Contributed themes — the `themes` registry area ─────────────────────────
|
||||
// A data contribution IS a DesktopTheme. Same validity bar as an installed
|
||||
// theme; built-in names can't be shadowed, and user-installed themes win over
|
||||
// contributed ones of the same name (the user's explicit install is intent).
|
||||
|
||||
export const THEMES_AREA = 'themes'
|
||||
|
||||
export function contributedThemes(): DesktopTheme[] {
|
||||
const seen = new Set<string>()
|
||||
const out: DesktopTheme[] = []
|
||||
|
||||
for (const c of registry.getArea(THEMES_AREA)) {
|
||||
const theme = c.data as DesktopTheme | undefined
|
||||
|
||||
if (theme && isValidTheme(theme) && !BUILTIN_THEMES[theme.name] && !seen.has(theme.name)) {
|
||||
seen.add(theme.name)
|
||||
out.push(theme)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** Built-ins first (stable order), then user themes by install order. */
|
||||
export function listAllThemes(): DesktopTheme[] {
|
||||
return [...Object.values(BUILTIN_THEMES), ...Object.values($userThemes.get())]
|
||||
/** Resolve a theme by name across the merged set (built-in + user + contributed). */
|
||||
export function resolveTheme(name: string): DesktopTheme | undefined {
|
||||
return BUILTIN_THEMES[name] ?? $userThemes.get()[name] ?? contributedThemes().find(theme => theme.name === name)
|
||||
}
|
||||
|
||||
/** Built-ins first (stable order), then contributed, then user installs. */
|
||||
export function listAllThemes(): DesktopTheme[] {
|
||||
const user = $userThemes.get()
|
||||
|
||||
return [
|
||||
...Object.values(BUILTIN_THEMES),
|
||||
...contributedThemes().filter(theme => !user[theme.name]),
|
||||
...Object.values(user)
|
||||
]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue