fmt(js): npm run fix on merge (#119573)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
hermes-seaeye[bot]
2026-09-22 21:50:23 +00:00
committed by GitHub
parent 8adb99fabd
commit ec21bd7674
11 changed files with 137 additions and 77 deletions

View File

@@ -42,10 +42,15 @@ export function bindLayoutSides() {
const $leftEdgeOpen = computed([$panesFlipped, $sidebarOpen, $fileBrowserOpen], (flipped, sidebar, files) =>
flipped ? files : sidebar
)
const $rightEdgeOpen = computed([$panesFlipped, $sidebarOpen, $fileBrowserOpen], (flipped, sidebar, files) =>
flipped ? sidebar : files
)
bindTreeSideVisibility('left', $leftEdgeOpen, open => ($panesFlipped.get() ? setFileBrowserOpen : setSidebarOpen)(open))
bindTreeSideVisibility('right', $rightEdgeOpen, open => ($panesFlipped.get() ? setSidebarOpen : setFileBrowserOpen)(open))
bindTreeSideVisibility('left', $leftEdgeOpen, open =>
($panesFlipped.get() ? setFileBrowserOpen : setSidebarOpen)(open)
)
bindTreeSideVisibility('right', $rightEdgeOpen, open =>
($panesFlipped.get() ? setSidebarOpen : setFileBrowserOpen)(open)
)
}

View File

@@ -28,6 +28,7 @@ async function boot() {
] as const) {
registry.register({ id, area: 'panes', data: { placement }, render: () => null })
}
registry.register({
id: 'bots',
area: 'panes',
@@ -56,6 +57,7 @@ async function boot() {
const preset = registry.getArea('layouts').find(p => p.id === id)!
presets.applyLayoutPreset(id, preset.data as LayoutNode)
}
const snapshot = () => ({
tree: structuredClone(tree.$layoutTree.get()),
panes: structuredClone(panes.$paneStates.get()),
@@ -114,7 +116,10 @@ it('restores independently customized modes through real pane bindings and reloa
// A moved/customized tree, dismissals and an unfinished resize survive too.
mode.setInterfaceMode('advanced')
app.apply('default')
tree.moveTreePane('files', { groupId: model.findGroupOfPane(tree.$layoutTree.get()!, 'workspace')!.id, pos: 'center' })
tree.moveTreePane('files', {
groupId: model.findGroupOfPane(tree.$layoutTree.get()!, 'workspace')!.id,
pos: 'center'
})
tree.dismissTreePane('review')
const root = tree.$layoutTree.get()!
@@ -122,7 +127,10 @@ it('restores independently customized modes through real pane bindings and reloa
throw new Error('fixture must have a split')
}
tree.setTreeSplitWeights(root.id, root.weights.map((weight, i) => weight + i))
tree.setTreeSplitWeights(
root.id,
root.weights.map((weight, i) => weight + i)
)
const advanced = app.snapshot()
mode.setInterfaceMode('simple')
const simple = app.snapshot()
@@ -139,47 +147,53 @@ it('restores independently customized modes through real pane bindings and reloa
expect(app.model.allPaneIds(app.tree.$layoutTree.get()!)).toContain('new-shared-pane')
})
it.each(['advanced', 'simple'] as const)('keeps legacy %s data and never re-inherits it after migration', async initialMode => {
const { group, split, normalize } = await import('./model')
const legacyTree = split(
'row',
[group(['sessions', 'bots']), group(['workspace']), group(['files', 'review', 'terminal'])],
[2, 8, 3]
)
const legacyPanes = { 'chat-sidebar': { open: false, widthOverride: 301 }, 'file-browser': { open: true } }
const raw = JSON.stringify(legacyTree)
window.localStorage.setItem('hermes.desktop.layoutTree.v2', raw)
window.localStorage.setItem('hermes.desktop.paneStates.v1', JSON.stringify(legacyPanes))
window.localStorage.setItem('hermes.desktop.layoutPreset.active', 'custom')
window.localStorage.setItem('hermes.desktop.hiddenStripTabs.v1', '["bots"]')
if (initialMode === 'simple') {
window.localStorage.setItem('hermes.desktop.interfaceMode.v1', 'simple')
it.each(['advanced', 'simple'] as const)(
'keeps legacy %s data and never re-inherits it after migration',
async initialMode => {
const { group, split, normalize } = await import('./model')
const legacyTree = split(
'row',
[group(['sessions', 'bots']), group(['workspace']), group(['files', 'review', 'terminal'])],
[2, 8, 3]
)
const legacyPanes = { 'chat-sidebar': { open: false, widthOverride: 301 }, 'file-browser': { open: true } }
const raw = JSON.stringify(legacyTree)
window.localStorage.setItem('hermes.desktop.layoutTree.v2', raw)
window.localStorage.setItem('hermes.desktop.paneStates.v1', JSON.stringify(legacyPanes))
window.localStorage.setItem('hermes.desktop.layoutPreset.active', 'custom')
window.localStorage.setItem('hermes.desktop.hiddenStripTabs.v1', '["bots"]')
if (initialMode === 'simple') {
window.localStorage.setItem('hermes.desktop.interfaceMode.v1', 'simple')
}
const { mode, tree, panes, layout } = await boot()
expect(mode.$interfaceMode.get()).toBe(initialMode)
expect(panes.$paneStates.get()).toEqual(legacyPanes)
expect(tree.$hiddenStripTabs.get().has('bots')).toBe(true)
// Boot collapse may update the tree, so preserve the settled arrangement.
const settled = structuredClone(tree.$layoutTree.get())
mode.setInterfaceMode(initialMode === 'simple' ? 'advanced' : 'simple')
expect(tree.$collapsedTreeSides.get().has(layout.sidebarSide())).toBe(!layout.$sidebarOpen.get())
expect(tree.$collapsedTreeSides.get().has(layout.fileBrowserSide())).toBe(!layout.$fileBrowserOpen.get())
mode.setInterfaceMode(initialMode)
expect(tree.$layoutTree.get()).toEqual(settled)
expect(panes.$paneStates.get()).toEqual(legacyPanes)
mode.setInterfaceMode('simple')
tree.setStripTabHidden('bots', false)
panes.setPaneWidthOverride('chat-sidebar', 210)
vi.resetModules()
const reloaded = await boot()
expect(reloaded.tree.$hiddenStripTabs.get().size).toBe(0)
expect(reloaded.panes.$paneStates.get()['chat-sidebar'].widthOverride).toBe(210)
reloaded.mode.setInterfaceMode('advanced')
expect(reloaded.panes.$paneStates.get()).toEqual(legacyPanes)
const restoredTree = reloaded.tree.$layoutTree.get()
const expectedTree = normalize(legacyTree)
assert(restoredTree && expectedTree)
expect(reloaded.model.allPaneIds(restoredTree)).toEqual(reloaded.model.allPaneIds(expectedTree))
}
const { mode, tree, panes, layout } = await boot()
expect(mode.$interfaceMode.get()).toBe(initialMode)
expect(panes.$paneStates.get()).toEqual(legacyPanes)
expect(tree.$hiddenStripTabs.get().has('bots')).toBe(true)
// Boot collapse may update the tree, so preserve the settled arrangement.
const settled = structuredClone(tree.$layoutTree.get())
mode.setInterfaceMode(initialMode === 'simple' ? 'advanced' : 'simple')
expect(tree.$collapsedTreeSides.get().has(layout.sidebarSide())).toBe(!layout.$sidebarOpen.get())
expect(tree.$collapsedTreeSides.get().has(layout.fileBrowserSide())).toBe(!layout.$fileBrowserOpen.get())
mode.setInterfaceMode(initialMode)
expect(tree.$layoutTree.get()).toEqual(settled)
expect(panes.$paneStates.get()).toEqual(legacyPanes)
mode.setInterfaceMode('simple')
tree.setStripTabHidden('bots', false)
panes.setPaneWidthOverride('chat-sidebar', 210)
vi.resetModules()
const reloaded = await boot()
expect(reloaded.tree.$hiddenStripTabs.get().size).toBe(0)
expect(reloaded.panes.$paneStates.get()['chat-sidebar'].widthOverride).toBe(210)
reloaded.mode.setInterfaceMode('advanced')
expect(reloaded.panes.$paneStates.get()).toEqual(legacyPanes)
const restoredTree = reloaded.tree.$layoutTree.get()
const expectedTree = normalize(legacyTree)
assert(restoredTree && expectedTree)
expect(reloaded.model.allPaneIds(restoredTree)).toEqual(reloaded.model.allPaneIds(expectedTree))
})
)

View File

@@ -10,6 +10,7 @@ it.each([false, true])('restores floating geometry and collapse with keep-alive=
const { registry } = await import('@/contrib/registry')
const { setInterfaceMode } = await import('@/store/interface-mode')
const { FloatingPanes } = await import('./floating-panes')
const dispose = registry.register({
area: 'panes',
id: 'card',
@@ -17,6 +18,7 @@ it.each([false, true])('restores floating geometry and collapse with keep-alive=
data: { placement: 'floating', width: 240, height: 180, lifecycleKeepAlive: keepAlive },
render: () => <input aria-label="Floating draft" defaultValue="original" />
})
try {
const view = render(<FloatingPanes />)
const card = () => view.container.querySelector<HTMLElement>('[data-floating-pane="card"]')!
@@ -33,10 +35,12 @@ it.each([false, true])('restores floating geometry and collapse with keep-alive=
expect(card().style.left).toBe('37px')
expect(card().style.top).toBe('71px')
expect(view.getByRole('textbox')).toBeDefined()
if (keepAlive) {
expect(view.getByRole('textbox')).toBe(input)
expect(input.value).toBe('unsaved')
}
act(() => setInterfaceMode('simple'))
expect(card().style.left).toBe(simplePosition)
expect(view.queryByRole('textbox')).toBeNull()

View File

@@ -80,10 +80,12 @@ function FloatingPane({ pane }: { pane: Contribution }) {
(update: (current: FloatingRect) => FloatingRect) => {
const positions = $positions.get()
const current = positions[pane.id]
const next = update({
...anchoredRect(anchor, { width: size.width, height: size.height }, viewport.current),
...current
})
$positions.set({ ...positions, [pane.id]: { x: next.x, y: next.y, collapsed: current?.collapsed } })
},
[pane.id, anchor, size.width, size.height]
@@ -124,22 +126,25 @@ function FloatingPane({ pane }: { pane: Contribution }) {
event.preventDefault()
}, [])
const onPointerMove = useCallback((event: ReactPointerEvent<HTMLElement>) => {
const from = drag.current
const onPointerMove = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
const from = drag.current
if (!from) {
return
}
if (!from) {
return
}
drag.current = { x: event.clientX, y: event.clientY }
drag.current = { x: event.clientX, y: event.clientY }
setRect(current =>
clampFloatingRect(
{ ...current, x: current.x + event.clientX - from.x, y: current.y + event.clientY - from.y },
viewport.current
setRect(current =>
clampFloatingRect(
{ ...current, x: current.x + event.clientX - from.x, y: current.y + event.clientY - from.y },
viewport.current
)
)
)
}, [setRect])
},
[setRect]
)
const onPointerUp = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {

View File

@@ -27,6 +27,7 @@ import { LayoutTreeRoot } from '.'
// A native guest has a lifetime beyond React. DOM identity alone misses a
// detach/reparent (even moveBefore destroys Electron's webview guest).
const disconnected: HTMLElement[] = []
class LiveGuest extends HTMLElement {
disconnectedCallback() {
disconnected.push(this)
@@ -37,6 +38,7 @@ customElements.define('pane-live-guest', LiveGuest)
// jsdom has no layout. Deliver real observer notifications to the production
// shared observer; the browser harness separately checks CSS anchor geometry.
const observers = new Set<ResizeObserverProbe>()
class ResizeObserverProbe {
targets = new Set<Element>()
constructor(readonly callback: ResizeObserverCallback) {
@@ -55,6 +57,7 @@ class ResizeObserverProbe {
function resize(target: HTMLElement, width: number, height: number) {
const size = { inlineSize: width, blockSize: height }
const entry: ResizeObserverEntry = {
target,
contentRect: new DOMRectReadOnly(0, 0, width, height),
@@ -108,6 +111,7 @@ function registerPane(id: string, keepAlive = true, data: Record<string, unknown
data: { ...data, lifecycleKeepAlive: keepAlive },
render: Probe
})
disposers.push(dispose)
return dispose
@@ -150,10 +154,12 @@ it('keeps a live body continuously connected across replacement IDs, ancestry an
const input = page.querySelector('input')!
input.value = 'unsaved page state'
setTree(split('row', [
group(['plain'], { id: 'chat' }),
split('column', [group(['live'], { id: 'replacement' }), group([], { id: 'empty' })])
]))
setTree(
split('row', [
group(['plain'], { id: 'chat' }),
split('column', [group(['live'], { id: 'replacement' }), group([], { id: 'empty' })])
])
)
expect(guest()).toBe(page)
expect(page.dataset.group).toBe('replacement')
expect(page.dataset.visible).toBe('true')
@@ -206,9 +212,14 @@ it('destroys removed contributions and does not revive their old activation on r
it('gates hidden side guests without mounting never-activated tabs or disconnecting live ones', () => {
registerPane('live')
registerPane('background')
disposers.push(registry.register({
area: 'panes', id: 'workspace', data: { placement: 'main' }, render: () => <div />
}))
disposers.push(
registry.register({
area: 'panes',
id: 'workspace',
data: { placement: 'main' },
render: () => <div />
})
)
setTree(split('row', [group(['workspace']), group(['live', 'background'], { id: 'side' })]))
$collapsedTreeSides.set(new Set(['right']))
render(<LayoutTreeRoot />)
@@ -240,7 +251,11 @@ it('routes DOM group targeting to the new zone without adding duplicate drop zon
expect($activeTreeGroup.get()).toBe('new-zone')
fireEvent.pointerOver(page)
expect($hoveredTreeGroup.get()).toBe('new-zone')
expect(snapshotZones().map(zone => zone.id).sort()).toEqual(['new-zone', 'other'])
expect(
snapshotZones()
.map(zone => zone.id)
.sort()
).toEqual(['new-zone', 'other'])
})
it('reloads only the explicit epoch target, even while it is hidden', () => {
@@ -275,6 +290,7 @@ it('keeps live tabs outside the bounded cache while ordinary tabs still park', (
setTree(group(panes, { id: 'zone' }))
render(<LayoutTreeRoot />)
const page = guest()!
for (const active of ['a', 'b', 'c', 'd']) {
setTree(group(panes, { id: 'zone', active }))
}

View File

@@ -45,6 +45,7 @@ export function KeepAlivePanes({ children }: { children: ReactNode }) {
const panes = useContributions('panes')
const epochs = useStore($treePaneEpochs)
const [placements, setPlacements] = useState<Placements>(() => new Map())
const place = useCallback<PlacePane>((id, placement) => {
setPlacements(previous => {
// Restored background tabs are lazy until first activation.
@@ -73,6 +74,7 @@ export function KeepAlivePanes({ children }: { children: ReactNode }) {
const present = new Set(
panes.filter(pane => pane.render && paneChrome(pane).lifecycleKeepAlive).map(pane => pane.id)
)
const retained = [...previous].filter(([id]) => present.has(id))
return retained.length === previous.size ? previous : new Map(retained)
@@ -105,6 +107,7 @@ interface KeepAlivePaneHostProps {
const KeepAlivePaneHost = memo(function KeepAlivePaneHost({ pane, placement, epoch }: KeepAlivePaneHostProps) {
const ref = useRef<HTMLDivElement>(null)
// Retain native guest coordinates while its placement is absent/minimized.
// CSS follows the slot during resize; recording dimensions never re-renders
// a guest or moves its connected host.
@@ -119,6 +122,7 @@ const KeepAlivePaneHost = memo(function KeepAlivePaneHost({ pane, placement, epo
},
[placement.visible]
)
useResizeObserver(rememberSize, ref)
return (
@@ -190,10 +194,6 @@ export function KeepAlivePaneSlot({
)
return (
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{ anchorName: anchor } as CSSProperties}
/>
<div aria-hidden className="pointer-events-none absolute inset-0" style={{ anchorName: anchor } as CSSProperties} />
)
}

View File

@@ -33,6 +33,7 @@ export function NarrowOverlays() {
const stableHosts = useStablePaneHosts()
const hiddenPanes = useStore($hiddenTreePanes)
const [reveal, setReveal] = useState<{ id: string; pinned: boolean } | null>(null)
const onMouseLeave = useCallback<MouseEventHandler<HTMLDivElement>>(event => {
// The overlay's chrome and its stable guest are DOM siblings, but one
// hover boundary. Crossing between them must not dismiss an unpinned pane.

View File

@@ -368,6 +368,7 @@ export function TreeGroup({
const mountedPanes = node.minimized
? keptPanes.filter(id => Boolean(paneChrome(paneFor(id)).lifecycleKeepAlive))
: keptPanes
const hostedPanes = stableHosts ? node.panes.filter(id => paneChrome(paneFor(id)).lifecycleKeepAlive) : []
const inlinePanes = mountedPanes.filter(id => !stableHosts || !paneChrome(paneFor(id)).lifecycleKeepAlive)

View File

@@ -70,7 +70,9 @@ function persist(tree: LayoutNode | null) {
export const $layoutTree = modeLayout.atom<LayoutNode | null>(
LAYOUT_KEYS.tree,
() => defaultTrees[modeLayout.mode],
Codecs.json(parsed => (isLayoutNode(parsed) ? normalize(migratePersistedTree(parsed)) : defaultTrees[modeLayout.mode])),
Codecs.json(parsed =>
isLayoutNode(parsed) ? normalize(migratePersistedTree(parsed)) : defaultTrees[modeLayout.mode]
),
true
)
@@ -196,7 +198,11 @@ const paneSetCodec = {
encode: (value: ReadonlySet<string>) => Codecs.stringArray.encode([...value])
}
export const $dismissedPanes = modeLayout.atom<ReadonlySet<string>>(LAYOUT_KEYS.dismissed, () => new Set(), paneSetCodec)
export const $dismissedPanes = modeLayout.atom<ReadonlySet<string>>(
LAYOUT_KEYS.dismissed,
() => new Set(),
paneSetCodec
)
function setDismissed(paneId: string, dismissed: boolean) {
const next = toggledSet($dismissedPanes.get(), paneId, dismissed)
@@ -288,7 +294,11 @@ function recalledEdgeWeights(paneId: string): [number, number] | undefined {
// Persisted separately from `$hiddenTreePanes` (whose persistence each side
// binding owns) so a hidden Bots tab stays hidden across launches even though
// dock enforcement re-adopts the pane into the sessions zone every boot.
export const $hiddenStripTabs = modeLayout.atom<ReadonlySet<string>>(LAYOUT_KEYS.hiddenTabs, () => new Set(), paneSetCodec)
export const $hiddenStripTabs = modeLayout.atom<ReadonlySet<string>>(
LAYOUT_KEYS.hiddenTabs,
() => new Set(),
paneSetCodec
)
export function isStripTabHidden(paneId: string): boolean {
return $hiddenStripTabs.get().has(paneId)
@@ -1137,6 +1147,7 @@ export function bindTreeSideVisibility(
if (!hasPersistedSides) {
setTreeSideCollapsed(side, !$open.get())
}
$open.listen(open => setTreeSideCollapsed(side, !open))
}

View File

@@ -51,6 +51,7 @@ it('retains legacy data in memory when migration writes fail and retries on the
if (key === 'hermes.desktop.layoutModeScopes.v1') {
throw new DOMException('Full', 'QuotaExceededError')
}
storage.setItem(key, value)
}
})

View File

@@ -77,7 +77,7 @@ export function createLayoutPersistence(initialMode: InterfaceMode, persistent:
if (persistent) {
// Explicit empties keep migration retries from reviving cleared state.
writeKey(keyFor(key), mode === 'simple' ? raw ?? 'null' : raw)
writeKey(keyFor(key), mode === 'simple' ? (raw ?? 'null') : raw)
}
}
}
@@ -89,12 +89,13 @@ export function createLayoutPersistence(initialMode: InterfaceMode, persistent:
manual = false
): WritableAtom<T> {
const memory = new Map<InterfaceMode, T>()
const load = (): T => {
if (memory.has(mode)) {
return memory.get(mode)!
}
const raw = persistent ? readKey(keyFor(key)) ?? (mode === 'simple' ? legacy.get(key) ?? null : null) : null
const raw = persistent ? (readKey(keyFor(key)) ?? (mode === 'simple' ? (legacy.get(key) ?? null) : null)) : null
if (raw !== null) {
try {
@@ -143,6 +144,7 @@ export function createLayoutPersistence(initialMode: InterfaceMode, persistent:
},
onRestore(listener: () => void) {
listeners.add(listener)
return () => void listeners.delete(listener)
},
write,