feat: file tabs

This commit is contained in:
Brooklyn Nicholson
2026-05-05 13:17:40 -05:00
parent 5ec0667fb3
commit 5269012c51
27 changed files with 763 additions and 133 deletions
+9 -2
View File
@@ -14,9 +14,10 @@ const SIDEBAR_PINNED_STORAGE_KEY = 'hermes.desktop.pinnedSessions'
export const CHAT_SIDEBAR_PANE_ID = 'chat-sidebar'
export const FILE_BROWSER_PANE_ID = 'file-browser'
export const RIGHT_RAIL_PREVIEW_TAB_ID = 'preview'
export type RightRailTabId = typeof RIGHT_RAIL_PREVIEW_TAB_ID | `file:${string}`
// Pre-register app chrome panes so legacy callers see the correct initial
// values whether or not the user has any persisted state.
ensurePaneRegistered(CHAT_SIDEBAR_PANE_ID, { open: true })
ensurePaneRegistered(FILE_BROWSER_PANE_ID, { open: false })
@@ -30,6 +31,8 @@ export const $fileBrowserOpen: ReadableAtom<boolean> = computed(
states => states[FILE_BROWSER_PANE_ID]?.open ?? false
)
export const $rightRailActiveTabId = atom<RightRailTabId>(RIGHT_RAIL_PREVIEW_TAB_ID)
export const $sidebarWidth: ReadableAtom<number> = computed($paneStates, states => {
const override = states[CHAT_SIDEBAR_PANE_ID]?.widthOverride
@@ -60,6 +63,10 @@ export function toggleFileBrowserOpen() {
togglePane(FILE_BROWSER_PANE_ID)
}
export function selectRightRailTab(id: RightRailTabId) {
$rightRailActiveTabId.set(id)
}
export function setSidebarPinsOpen(open: boolean) {
$sidebarPinsOpen.set(open)
}
+5
View File
@@ -16,6 +16,7 @@ function isSnapshot(value: unknown): value is PaneStateSnapshot {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
if (typeof r.open !== 'boolean') {
@@ -60,6 +61,7 @@ function persist(states: Record<string, PaneStateSnapshot>) {
if (typeof window === 'undefined') {
return
}
const minimal: Record<string, { open: boolean }> = {}
for (const [id, s] of Object.entries(states)) {
@@ -107,6 +109,7 @@ export function ensurePaneRegistered(id: string, defaults: PaneRegisterDefaults)
if (current[id] !== undefined) {
return
}
$paneStates.set({ ...current, [id]: { open: defaults.open, widthOverride: defaults.widthOverride } })
}
@@ -117,6 +120,7 @@ export function setPaneOpen(id: string, open: boolean) {
if (existing?.open === open) {
return
}
$paneStates.set({ ...current, [id]: { open, widthOverride: existing?.widthOverride } })
}
@@ -133,6 +137,7 @@ export function setPaneWidthOverride(id: string, width: number | undefined) {
if (existing.widthOverride === width) {
return
}
$paneStates.set({ ...current, [id]: { open: existing.open, widthOverride: width } })
}
+5 -1
View File
@@ -1,6 +1,8 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from './layout'
import {
$filePreviewTabs,
$filePreviewTarget,
$previewServerRestart,
$previewServerRestartStatus,
@@ -118,14 +120,16 @@ describe('preview store', () => {
expect($previewTarget.get()).toEqual(withRenderMode(preview, 'preview'))
})
it('clears file inspection when a live preview opens', () => {
it('keeps file tabs when a live preview opens', () => {
const file = previewTarget('/work/file.html')
const live = previewTarget('/work/live.html')
setCurrentSessionPreviewTarget(file, 'manual')
setCurrentSessionPreviewTarget(live, 'tool-result')
expect($filePreviewTabs.get().map(tab => tab.target)).toEqual([withRenderMode(file, 'source')])
expect($filePreviewTarget.get()).toBeNull()
expect($rightRailActiveTabId.get()).toBe(RIGHT_RAIL_PREVIEW_TAB_ID)
expect($previewTarget.get()).toEqual(withRenderMode(live, 'preview'))
})
})
+89 -10
View File
@@ -1,5 +1,6 @@
import { atom, computed } from 'nanostores'
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID, type RightRailTabId, selectRightRailTab } from './layout'
import { $activeSessionId, $selectedStoredSessionId } from './session'
export interface PreviewTarget {
@@ -39,12 +40,27 @@ export interface SessionPreviewRecord {
type SessionPreviewRegistry = Record<string, SessionPreviewRecord[]>
export interface FilePreviewTab {
id: `file:${string}`
target: PreviewTarget
}
const REGISTRY_STORAGE_KEY = 'hermes.desktop.sessionPreviews.v1'
const MAX_RECORDS_PER_SESSION = 1
const MAX_SESSIONS = 120
export const $previewTarget = atom<PreviewTarget | null>(null)
export const $filePreviewTarget = atom<PreviewTarget | null>(null)
export const $filePreviewTabs = atom<FilePreviewTab[]>([])
export const $filePreviewTarget = computed([$filePreviewTabs, $rightRailActiveTabId], (tabs, activeTabId) => {
if (!activeTabId.startsWith('file:')) {
return null
}
return tabs.find(tab => tab.id === activeTabId)?.target ?? null
})
export const $rightRailHasContent = computed([$previewTarget, $filePreviewTabs], (target, tabs) =>
Boolean(target || tabs.length)
)
export const $previewReloadRequest = atom(0)
export const $previewServerRestart = atom<PreviewServerRestart | null>(null)
export const $previewServerRestartStatus = computed($previewServerRestart, restart => restart?.status ?? 'idle')
@@ -72,18 +88,32 @@ function isSamePreviewTarget(a: PreviewTarget | null, b: PreviewTarget | null):
export function setPreviewTarget(target: PreviewTarget | null) {
if (isSamePreviewTarget($previewTarget.get(), target)) {
if (target) {
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
return
}
$previewTarget.set(target)
if (target) {
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
}
function setFilePreviewTarget(target: PreviewTarget | null) {
if (isSamePreviewTarget($filePreviewTarget.get(), target)) {
return
}
export function filePreviewTabId(target: PreviewTarget): `file:${string}` {
return `file:${target.url}`
}
$filePreviewTarget.set(target)
function openFilePreviewTarget(target: PreviewTarget) {
const id = filePreviewTabId(target)
const current = $filePreviewTabs.get()
const index = current.findIndex(tab => tab.id === id)
const tab: FilePreviewTab = { id, target }
$filePreviewTabs.set(index === -1 ? [...current, tab] : current.map((item, i) => (i === index ? tab : item)))
selectRightRailTab(id)
}
// Manual/file-browser opens are "peeking at a file" → source view in the file
@@ -104,7 +134,8 @@ function tryOpenFilePreview(target: PreviewTarget, source: PreviewRecordSource):
if (target.kind !== 'file' || !isFilePreviewSource(source)) {
return false
}
setFilePreviewTarget(previewTargetForSource(target, source))
openFilePreviewTarget(previewTargetForSource(target, source))
return true
}
@@ -113,6 +144,7 @@ function isPreviewTarget(value: unknown): value is PreviewTarget {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
return (
@@ -127,6 +159,7 @@ function isPreviewRecord(value: unknown): value is SessionPreviewRecord {
if (!value || typeof value !== 'object') {
return false
}
const r = value as Record<string, unknown>
return (
@@ -151,17 +184,20 @@ function loadSessionPreviewRegistry(): SessionPreviewRegistry {
if (!raw) {
return {}
}
const parsed = JSON.parse(raw) as unknown
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
}
const valid = records.filter(isPreviewRecord).slice(0, MAX_RECORDS_PER_SESSION)
if (valid.length > 0) {
@@ -258,7 +294,6 @@ export function setSessionPreviewTarget(
const record = registerSessionPreview(sessionId, target, source, rawTarget)
setFilePreviewTarget(null)
setPreviewTarget(record?.normalized ?? previewTargetForSource(target, source))
return record
@@ -288,12 +323,14 @@ export function dismissSessionPreview(sessionId: string | null | undefined, url?
if (!id) {
return
}
const current = $sessionPreviewRegistry.get()
const records = current[id]
if (!records?.length) {
return
}
const now = Date.now()
const targetUrl = url || records.find(record => !record.dismissedAt)?.normalized.url
@@ -325,16 +362,58 @@ export function dismissPreviewTarget() {
}
$previewTarget.set(null)
if ($rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID) {
selectRightRailTab($filePreviewTabs.get()[0]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
}
}
export function closeFilePreviewTab(tabId: RightRailTabId) {
if (!tabId.startsWith('file:')) {
return
}
const current = $filePreviewTabs.get()
const index = current.findIndex(tab => tab.id === tabId)
if (index === -1) {
return
}
const next = current.filter(tab => tab.id !== tabId)
$filePreviewTabs.set(next)
if ($rightRailActiveTabId.get() === tabId) {
selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? RIGHT_RAIL_PREVIEW_TAB_ID)
}
}
export function dismissFilePreviewTarget() {
setFilePreviewTarget(null)
closeFilePreviewTab($rightRailActiveTabId.get())
}
export function closeActiveRightRailTab() {
const activeTabId = $rightRailActiveTabId.get()
if (activeTabId === RIGHT_RAIL_PREVIEW_TAB_ID) {
if ($previewTarget.get()) {
dismissPreviewTarget()
}
return
}
if (activeTabId.startsWith('file:')) {
closeFilePreviewTab(activeTabId)
}
}
export function clearSessionPreviewRegistry() {
$sessionPreviewRegistry.set({})
setPreviewTarget(null)
setFilePreviewTarget(null)
$filePreviewTabs.set([])
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
export function requestPreviewReload() {
+2
View File
@@ -33,6 +33,7 @@ function loadToolDisclosureStates(): ToolDisclosureStates {
if (!raw) {
return {}
}
const parsed = JSON.parse(raw) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
@@ -67,6 +68,7 @@ export function setToolDisclosureOpen(id: string, open: boolean) {
if (!id) {
return
}
const current = $toolDisclosureStates.get()
if (current[id] === open) {