feat: file tabs
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 } })
|
||||
}
|
||||
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user