feat: better composer etc

This commit is contained in:
Brooklyn Nicholson
2026-05-04 22:19:16 -05:00
parent 42db075e10
commit fcce49db3f
50 changed files with 1299 additions and 668 deletions
+2 -1
View File
@@ -54,7 +54,8 @@ export function buildRailTasks(
id: `preview:${previewRestart.taskId}`,
label: 'Preview restart',
detail: previewRestart.message || previewRestart.url,
status: previewRestart.status === 'error' ? 'error' : previewRestart.status === 'running' ? 'running' : 'success',
status:
previewRestart.status === 'error' ? 'error' : previewRestart.status === 'running' ? 'running' : 'success',
updatedAt: Date.now()
}
]
+3 -7
View File
@@ -2,17 +2,13 @@ import { atom, computed, type ReadableAtom } from 'nanostores'
import { arraysEqual, insertUniqueId, persistStringArray, storedStringArray } from '@/lib/storage'
import {
$paneStates,
ensurePaneRegistered,
setPaneOpen,
setPaneWidthOverride,
togglePane
} from './panes'
import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes'
export const SIDEBAR_DEFAULT_WIDTH = 224
export const SIDEBAR_MAX_WIDTH = 320
export const FILE_BROWSER_DEFAULT_WIDTH = '17rem'
export const FILE_BROWSER_MIN_WIDTH = '14rem'
export const FILE_BROWSER_MAX_WIDTH = '20rem'
const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
+32 -10
View File
@@ -13,16 +13,22 @@ export interface PaneRegisterDefaults {
const STORAGE_KEY = 'hermes.desktop.paneStates.v1'
function isSnapshot(value: unknown): value is PaneStateSnapshot {
if (!value || typeof value !== 'object') {return false}
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
if (typeof r.open !== 'boolean') {return false}
if (typeof r.open !== 'boolean') {
return false
}
return r.widthOverride === undefined || (typeof r.widthOverride === 'number' && Number.isFinite(r.widthOverride))
}
function load(): Record<string, PaneStateSnapshot> {
if (typeof window === 'undefined') {return {}}
if (typeof window === 'undefined') {
return {}
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
@@ -34,7 +40,9 @@ function load(): Record<string, PaneStateSnapshot> {
const out: Record<string, PaneStateSnapshot> = {}
for (const [id, value] of Object.entries(parsed as Record<string, unknown>)) {
if (isSnapshot(value)) {out[id] = { open: value.open, widthOverride: value.widthOverride }}
if (isSnapshot(value)) {
out[id] = { open: value.open, widthOverride: value.widthOverride }
}
}
return out
@@ -49,10 +57,14 @@ function load(): Record<string, PaneStateSnapshot> {
// widthOverride is in-memory only — phase 2 can add per-pane persistWidth opt-in.
function persist(states: Record<string, PaneStateSnapshot>) {
if (typeof window === 'undefined') {return}
if (typeof window === 'undefined') {
return
}
const minimal: Record<string, { open: boolean }> = {}
for (const [id, s] of Object.entries(states)) {minimal[id] = { open: s.open }}
for (const [id, s] of Object.entries(states)) {
minimal[id] = { open: s.open }
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(minimal))
@@ -66,7 +78,11 @@ export const $paneStates = atom<Record<string, PaneStateSnapshot>>(load())
$paneStates.subscribe(persist)
// Cached per-pane derived atoms keep useStore subscriptions referentially stable.
function memoized<T>(cache: Map<string, ReadableAtom<T>>, id: string, selector: (s: PaneStateSnapshot | undefined) => T) {
function memoized<T>(
cache: Map<string, ReadableAtom<T>>,
id: string,
selector: (s: PaneStateSnapshot | undefined) => T
) {
let cached = cache.get(id)
if (!cached) {
@@ -88,7 +104,9 @@ export const $paneWidthOverride = (id: string) => memoized(widthCache, id, s =>
export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults) {
const current = $paneStates.get()
if (current[id] !== undefined) {return}
if (current[id] !== undefined) {
return
}
$paneStates.set({ ...current, [id]: { open: defaults.open, widthOverride: defaults.widthOverride } })
}
@@ -96,7 +114,9 @@ export function setPaneOpen(id: string, open: boolean) {
const current = $paneStates.get()
const existing = current[id]
if (existing?.open === open) {return}
if (existing?.open === open) {
return
}
$paneStates.set({ ...current, [id]: { open, widthOverride: existing?.widthOverride } })
}
@@ -110,7 +130,9 @@ export function setPaneWidthOverride(id: string, width: number | undefined) {
const current = $paneStates.get()
const existing = current[id] ?? { open: false }
if (existing.widthOverride === width) {return}
if (existing.widthOverride === width) {
return
}
$paneStates.set({ ...current, [id]: { open: existing.open, widthOverride: width } })
}
+76 -57
View File
@@ -61,7 +61,13 @@ function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null):
return false
}
return a.kind === b.kind && a.label === b.label && a.renderMode === b.renderMode && a.source === b.source && a.url === b.url
return (
a.kind === b.kind &&
a.label === b.label &&
a.renderMode === b.renderMode &&
a.source === b.source &&
a.url === b.url
)
}
export function setPreviewTarget(target: PreviewTarget | null) {
@@ -72,7 +78,7 @@ export function setPreviewTarget(target: PreviewTarget | null) {
$previewTarget.set(target)
}
export function setFilePreviewTarget(target: PreviewTarget | null) {
function setFilePreviewTarget(target: PreviewTarget | null) {
if (isSamePreviewTarget($filePreviewTarget.get(), target)) {
return
}
@@ -80,8 +86,33 @@ export function setFilePreviewTarget(target: PreviewTarget | null) {
$filePreviewTarget.set(target)
}
// Manual/file-browser opens are "peeking at a file" → source view in the file
// pane. Tool/explicit-link opens are runnable artifacts → live preview pane.
function isFilePreviewSource(source: PreviewRecordSource): boolean {
return source === 'file-browser' || source === 'manual'
}
function previewTargetForSource(target: PreviewTarget, source: PreviewRecordSource): PreviewTarget {
if (target.kind !== 'file' || target.previewKind !== 'html') {
return target
}
return { ...target, renderMode: isFilePreviewSource(source) ? 'source' : 'preview' }
}
function tryOpenFilePreview(target: PreviewTarget, source: PreviewRecordSource): boolean {
if (target.kind !== 'file' || !isFilePreviewSource(source)) {
return false
}
setFilePreviewTarget(previewTargetForSource(target, source))
return true
}
function isPreviewTarget(value: unknown): value is PreviewTarget {
if (!value || typeof value !== 'object') {return false}
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
return (
@@ -93,7 +124,9 @@ function isPreviewTarget(value: unknown): value is PreviewTarget {
}
function isPreviewRecord(value: unknown): value is SessionPreviewRecord {
if (!value || typeof value !== 'object') {return false}
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
return (
@@ -108,22 +141,32 @@ function isPreviewRecord(value: unknown): value is SessionPreviewRecord {
}
function loadSessionPreviewRegistry(): SessionPreviewRegistry {
if (typeof window === 'undefined') {return {}}
if (typeof window === 'undefined') {
return {}
}
try {
const raw = window.localStorage.getItem(REGISTRY_STORAGE_KEY)
if (!raw) {return {}}
if (!raw) {
return {}
}
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object') {return {}}
if (!parsed || typeof parsed !== 'object') {
return {}
}
const out: SessionPreviewRegistry = {}
for (const [sessionId, records] of Object.entries(parsed as Record<string, unknown>)) {
if (!Array.isArray(records)) {continue}
if (!Array.isArray(records)) {
continue
}
const valid = records.filter(isPreviewRecord).slice(0, MAX_RECORDS_PER_SESSION)
if (valid.length > 0) {out[sessionId] = valid}
if (valid.length > 0) {
out[sessionId] = valid
}
}
return pruneRegistry(out)
@@ -133,7 +176,9 @@ function loadSessionPreviewRegistry(): SessionPreviewRegistry {
}
function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) {
if (typeof window === 'undefined') {return}
if (typeof window === 'undefined') {
return
}
try {
window.localStorage.setItem(REGISTRY_STORAGE_KEY, JSON.stringify(pruneRegistry(registry)))
@@ -144,10 +189,10 @@ function persistSessionPreviewRegistry(registry: SessionPreviewRegistry) {
function pruneRegistry(registry: SessionPreviewRegistry): SessionPreviewRegistry {
const entries = Object.entries(registry)
.map(([sessionId, records]) => [
sessionId,
[...records].sort((a, b) => b.createdAt - a.createdAt).slice(0, MAX_RECORDS_PER_SESSION)
] as const)
.map(
([sessionId, records]) =>
[sessionId, [...records].sort((a, b) => b.createdAt - a.createdAt).slice(0, MAX_RECORDS_PER_SESSION)] as const
)
.filter(([, records]) => records.length > 0)
.sort(([, a], [, b]) => (b[0]?.createdAt ?? 0) - (a[0]?.createdAt ?? 0))
.slice(0, MAX_SESSIONS)
@@ -171,7 +216,9 @@ export function registerSessionPreview(
): SessionPreviewRecord | null {
const id = sessionId?.trim()
if (!id) {return null}
if (!id) {
return null
}
const current = $sessionPreviewRegistry.get()
const now = Date.now()
@@ -199,38 +246,13 @@ export function registerSessionPreview(
return nextRecord
}
function previewTargetForSource(target: PreviewTarget, source: PreviewRecordSource): PreviewTarget {
if (target.kind !== 'file' || target.previewKind !== 'html') {
return target
}
return {
...target,
renderMode: source === 'file-browser' || source === 'manual' ? 'source' : 'preview'
}
}
function shouldOpenAsFilePreview(target: PreviewTarget, source: PreviewRecordSource): boolean {
return target.kind === 'file' && (source === 'file-browser' || source === 'manual')
}
export function registerCurrentSessionPreview(
target: PreviewTarget,
source: PreviewRecordSource,
rawTarget = target.source
): SessionPreviewRecord | null {
return registerSessionPreview(currentPreviewSessionId(), target, source, rawTarget)
}
export function setSessionPreviewTarget(
sessionId: string | null | undefined,
target: PreviewTarget,
source: PreviewRecordSource,
rawTarget = target.source
): SessionPreviewRecord | null {
if (shouldOpenAsFilePreview(target, source)) {
setFilePreviewTarget(previewTargetForSource(target, source))
if (tryOpenFilePreview(target, source)) {
return null
}
@@ -247,24 +269,15 @@ export function setCurrentSessionPreviewTarget(
source: PreviewRecordSource,
rawTarget = target.source
): SessionPreviewRecord | null {
if (shouldOpenAsFilePreview(target, source)) {
setFilePreviewTarget(previewTargetForSource(target, source))
return null
}
const record = registerCurrentSessionPreview(target, source, rawTarget)
setFilePreviewTarget(null)
setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source))
return record
return setSessionPreviewTarget(currentPreviewSessionId(), target, source, rawTarget)
}
export function getSessionPreviewRecord(sessionId: string | null | undefined): SessionPreviewRecord | null {
const id = sessionId?.trim()
if (!id) {return null}
if (!id) {
return null
}
return $sessionPreviewRegistry.get()[id]?.find(record => !record.dismissedAt && record.autoOpen !== false) ?? null
}
@@ -272,15 +285,21 @@ export function getSessionPreviewRecord(sessionId: string | null | undefined): S
export function dismissSessionPreview(sessionId: string | null | undefined, url?: string) {
const id = sessionId?.trim()
if (!id) {return}
if (!id) {
return
}
const current = $sessionPreviewRegistry.get()
const records = current[id]
if (!records?.length) {return}
if (!records?.length) {
return
}
const now = Date.now()
const targetUrl = url || records.find(record => !record.dismissedAt)?.normalized.url
if (!targetUrl) {return}
if (!targetUrl) {
return
}
// The preview rail is a single active file, not a back stack. Dismissing the
// current preview should leave the rail closed instead of revealing an older
+21 -7
View File
@@ -10,7 +10,9 @@ const TOOL_VIEW_TECHNICAL_STORAGE_KEY = 'hermes.desktop.toolView.technical'
const TOOL_DISCLOSURE_STORAGE_KEY = 'hermes.desktop.toolDisclosure.v1'
const MAX_DISCLOSURE_STATES = 240
export const $toolViewMode = atom<ToolViewMode>(storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product')
export const $toolViewMode = atom<ToolViewMode>(
storedBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, false) ? 'technical' : 'product'
)
export const $toolDisclosureStates = atom<ToolDisclosureStates>(loadToolDisclosureStates())
$toolViewMode.subscribe(mode => persistBoolean(TOOL_VIEW_TECHNICAL_STORAGE_KEY, mode === 'technical'))
@@ -21,15 +23,21 @@ export function setToolViewMode(mode: ToolViewMode) {
}
function loadToolDisclosureStates(): ToolDisclosureStates {
if (typeof window === 'undefined') {return {}}
if (typeof window === 'undefined') {
return {}
}
try {
const raw = window.localStorage.getItem(TOOL_DISCLOSURE_STORAGE_KEY)
if (!raw) {return {}}
if (!raw) {
return {}
}
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {return {}}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return {}
}
return Object.fromEntries(
Object.entries(parsed as Record<string, unknown>)
@@ -42,7 +50,9 @@ function loadToolDisclosureStates(): ToolDisclosureStates {
}
function persistToolDisclosureStates(states: ToolDisclosureStates) {
if (typeof window === 'undefined') {return}
if (typeof window === 'undefined') {
return
}
try {
const entries = Object.entries(states).slice(-MAX_DISCLOSURE_STATES)
@@ -54,10 +64,14 @@ function persistToolDisclosureStates(states: ToolDisclosureStates) {
}
export function setToolDisclosureOpen(id: string, open: boolean) {
if (!id) {return}
if (!id) {
return
}
const current = $toolDisclosureStates.get()
if (current[id] === open) {return}
if (current[id] === open) {
return
}
$toolDisclosureStates.set({ ...current, [id]: open })
}