refactor(desktop): retire desktop-controller for the contribution shell; views as contributions

This commit is contained in:
Brooklyn Nicholson 2026-07-13 17:28:32 -04:00
parent 10fbade64b
commit 369d0eeeff
11 changed files with 114 additions and 1422 deletions

View file

@ -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,

View file

@ -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)
})
})

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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) {

View file

@ -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
}

View file

@ -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;
}

View file

@ -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(() =>

View file

@ -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)
]
}