feat: better composer etc
This commit is contained in:
@@ -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()
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user