fix(desktop): read the preview zone the user is looking at

read_preview followed the global right-rail tab. follow() copied the
interacted zone into that id, so one group overwrote an explicit open
in another. Resolve from the hovered or focused zone, and skip that
copy when the explicit open lives in a different group. When more
than one preview is mounted, include active_tab_id and the open tabs.
This commit is contained in:
brooklyn!
2026-09-24 06:38:06 -05:00
parent 476ff4ddc5
commit 78e4e0cc6c
6 changed files with 317 additions and 20 deletions

View File

@@ -33,11 +33,12 @@ import {
popOutBrowserTab,
type PreviewTarget
} from '@/store/preview'
import { explicitOpenBlocksZone, PREVIEW_TILE_PREFIX } from '@/store/preview-explicit'
import { canOpenBrowserWindow } from '@/store/windows'
import { paneMirror } from './pane-mirror'
import { PreviewTilePane } from './right-rail/preview'
import { forgetPreviewConsole } from './right-rail/preview-console-store'
import { PreviewTilePane } from './right-rail/preview'
/** The target behind a tile id, or null once its tab is gone. */
function targetFor(tabId: string): PreviewTarget | null {
@@ -169,8 +170,6 @@ function PreviewTabLead({ tabId }: { tabId: string }) {
return <FileTypeIcon className="opacity-70" path={target.path || target.url} size="0.6875rem" />
}
const PREVIEW_TILE_PREFIX = 'preview-tile'
const previewPaneId = (tabId: string) => `${PREVIEW_TILE_PREFIX}:${tabId}`
/** The pane a NEW preview tile should stack into: another preview tile already
@@ -226,6 +225,13 @@ export function watchPreviewTiles(): void {
const follow = () => {
const tree = $layoutTree.get()
const groupId = $activeTreeGroup.get()
// Do not copy this zone over an explicit open that lives in a different
// group. A focus change after that open lifts the guard.
if (explicitOpenBlocksZone(groupId, $previewTabs.get().map(tab => tab.id))) {
return
}
const active = groupId && tree ? findGroup(tree, groupId)?.active : undefined
if (!active?.startsWith(`${PREVIEW_TILE_PREFIX}:`)) {

View File

@@ -1,8 +1,15 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { group, split } from '@/components/pane-shell/tree/model'
import {
$layoutTree,
noteActiveTreeGroup,
noteHoveredTreeGroup
} from '@/components/pane-shell/tree/store'
import { $rightRailActiveTabId, selectRightRailTab } from '@/store/layout'
import { closeRightRail, openPreview, type PreviewTarget } from '@/store/preview'
import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview'
import { watchPreviewTiles } from '../preview-tile'
import { PREVIEW_READ_MAX_CHARS, readActivePreview, registerPreviewPageReader } from './preview-reader'
function urlTarget(url: string): PreviewTarget {
@@ -34,6 +41,9 @@ describe('readActivePreview (read_preview tool)', () => {
cleanups = []
closeRightRail()
window.localStorage.clear()
noteActiveTreeGroup(null)
noteHoveredTreeGroup(null)
$layoutTree.set(null)
})
it('answers null when nothing is open, so the tool reports it cleanly', async () => {
@@ -137,4 +147,100 @@ describe('readActivePreview (read_preview tool)', () => {
expect(await readActivePreview()).toMatchObject({ text: 'second' })
})
it('reads the hovered preview zone instead of the global right-rail tab', async () => {
openPreview(fileTarget('/work/a.md'))
const fileId = $rightRailActiveTabId.get()!
openPreview(urlTarget('https://example.com/tickets'))
const browserId = $rightRailActiveTabId.get()!
selectRightRailTab(fileId)
mountSplit(fileId, browserId)
noteHoveredTreeGroup('grp-browser')
expect(await readActivePreview()).toMatchObject({ kind: 'url', url: 'https://example.com/tickets' })
})
it('reads the focused preview zone instead of a stale global file tab', async () => {
openPreview(fileTarget('/work/a.md'))
const fileId = $rightRailActiveTabId.get()!
openPreview(urlTarget('https://example.com/tickets'))
const browserId = $rightRailActiveTabId.get()!
selectRightRailTab(fileId)
mountSplit(fileId, browserId)
noteActiveTreeGroup('grp-browser')
expect(await readActivePreview()).toMatchObject({ kind: 'url', url: 'https://example.com/tickets' })
})
it('reads the hovered file when that zone is what the user is looking at', async () => {
openPreview(fileTarget('/work/a.md'))
const fileId = $rightRailActiveTabId.get()!
openPreview(urlTarget('https://example.com/tickets'))
const browserId = $rightRailActiveTabId.get()!
mountSplit(fileId, browserId)
noteHoveredTreeGroup('grp-file')
expect(await readActivePreview()).toMatchObject({ kind: 'file', path: '/work/a.md' })
})
it('returns active_tab_id and the open tab list when more than one preview is mounted', async () => {
openPreview(fileTarget('/work/project-network.html'))
const fileId = $rightRailActiveTabId.get()!
openPreview(urlTarget('https://example.com/tickets'))
const browserId = $rightRailActiveTabId.get()!
expect(await readActivePreview()).toMatchObject({
active_tab_id: browserId,
kind: 'url',
tabs: [
{ id: fileId, kind: 'file', label: '/work/project-network.html', url: 'file:///work/project-network.html' },
{ id: browserId, kind: 'url', label: 'Browser', url: 'https://example.com/tickets' }
],
url: 'https://example.com/tickets'
})
expect($previewTabs.get()).toHaveLength(2)
})
})
describe('follow() does not overwrite an explicit open in another group', () => {
beforeEach(() => {
closeRightRail()
window.localStorage.clear()
noteActiveTreeGroup(null)
noteHoveredTreeGroup(null)
$layoutTree.set(null)
})
it('keeps the opened URL when the other group is still the interacted zone', async () => {
watchPreviewTiles()
openPreview(fileTarget('/work/project-network.html'))
const fileId = $rightRailActiveTabId.get()!
openPreview(urlTarget('about:blank'))
const browserId = $rightRailActiveTabId.get()!
mountSplit(fileId, browserId)
noteActiveTreeGroup('grp-file')
openPreview(urlTarget('https://example.com/tickets'))
// reveal may not commit when the pane is already fronted; the layout
// listener is what copies the interacted zone. Fire that same listener.
$layoutTree.set(mountSplit(fileId, browserId))
expect($rightRailActiveTabId.get()).toBe(browserId)
expect(await readActivePreview()).toMatchObject({
active_tab_id: browserId,
kind: 'url',
url: 'https://example.com/tickets'
})
})
})
function mountSplit(fileId: string, browserId: string) {
const tree = split('row', [
group([`preview-tile:${browserId}`], { active: `preview-tile:${browserId}`, id: 'grp-browser' }),
group([`preview-tile:${fileId}`], { active: `preview-tile:${fileId}`, id: 'grp-file' })
])
$layoutTree.set(tree)
return tree
}

View File

@@ -5,15 +5,19 @@
*
* A URL/HTML preview renders in a sandboxed <webview> owned by PreviewPane;
* that pane registers a PAGE READER here (url + title + rendered text), keyed
* by tab id. `readActivePreview` resolves the ACTIVE tab from the store and
* owns the windowing: a registered reader answers with the live page's text;
* by tab id. `readActivePreview` resolves the preview the user is looking at
* (hovered zone, else focused zone, else the store) and owns the windowing:
* a registered reader answers with the live page's text;
* a tab with no reader (a file peek, an artifact) still answers with its
* identity and a note pointing the agent at the tool that reads that content
* directly (read_file / the conversation's artifact).
*/
import { findGroup } from '@/components/pane-shell/tree/model'
import { $activeTreeGroup, $hoveredTreeGroup, $layoutTree } from '@/components/pane-shell/tree/store'
import { $rightRailActiveTabId } from '@/store/layout'
import { $previewTabs } from '@/store/preview'
import { $previewTabs, type PreviewTab } from '@/store/preview'
import { explicitOpenBlocksZone, PREVIEW_TILE_PREFIX } from '@/store/preview-explicit'
import { nudgeOverlay } from './preview-nudge'
@@ -24,12 +28,23 @@ export interface PreviewReadOptions {
start?: number
}
export interface PreviewReadTabSummary {
id: string
kind: string
label: string
url: string
}
export interface PreviewReadResult {
/** Set when more than one preview is mounted — the tab this read used. */
active_tab_id?: string
end: number
kind: string
note?: string
path?: string
start: number
/** Open preview tabs, set when more than one is mounted. */
tabs?: PreviewReadTabSummary[]
text: string
title: string
total_chars: number
@@ -75,10 +90,85 @@ function windowText(
return { ...base, end: to, start: from, text: text.slice(from, to), total_chars: total }
}
/** Read the ACTIVE preview tab. Null only when no tab is open at all. */
export async function readActivePreview(opts: PreviewReadOptions = {}): Promise<PreviewReadResult | null> {
function tabIdFromPreviewPane(paneId: string | undefined): null | string {
if (!paneId?.startsWith(`${PREVIEW_TILE_PREFIX}:`)) {
return null
}
return paneId.slice(PREVIEW_TILE_PREFIX.length + 1)
}
/** Active preview tab in a layout zone, if that tab is still open. */
function openTabInGroup(groupId: null | string, tabs: PreviewTab[]): null | PreviewTab {
const tree = $layoutTree.get()
if (!tree || !groupId) {
return null
}
const tabId = tabIdFromPreviewPane(findGroup(tree, groupId)?.active)
if (!tabId) {
return null
}
return tabs.find(tab => tab.id === tabId) ?? null
}
/**
* The preview the user is looking at: hovered zone, else focused zone, else
* the store. A focused zone that is still the pre-open zone does not override
* an explicit open living in a different group — that is follow()'s clobber,
* not a look.
*/
export function resolveActivePreviewTab(tabs: PreviewTab[] = $previewTabs.get()): null | PreviewTab {
if (tabs.length === 0) {
return null
}
const hovered = openTabInGroup($hoveredTreeGroup.get(), tabs)
if (hovered) {
return hovered
}
const focusedId = $activeTreeGroup.get()
const focused = openTabInGroup(focusedId, tabs)
const openIds = tabs.map(tab => tab.id)
if (focused && !explicitOpenBlocksZone(focusedId, openIds)) {
return focused
}
return tabs.find(tab => tab.id === $rightRailActiveTabId.get()) ?? tabs[0] ?? null
}
function tabSummary(tab: PreviewTab): PreviewReadTabSummary {
return { id: tab.id, kind: tab.target.kind, label: tab.target.label, url: tab.target.url }
}
function zoneMeta(tab: PreviewTab, tabs: PreviewTab[]): { active_tab_id?: string; tabs?: PreviewReadTabSummary[] } {
if (tabs.length < 2) {
return {}
}
return { active_tab_id: tab.id, tabs: tabs.map(tabSummary) }
}
function withMultiNote(note: string | undefined, multi: boolean): string | undefined {
if (!multi) {
return note
}
const extra = 'Multiple preview tabs are open; this read used the hovered or focused preview (see tabs).'
return note ? `${note} ${extra}` : extra
}
/** Read the preview the user is looking at. Null only when no tab is open at all. */
export async function readActivePreview(opts: PreviewReadOptions = {}): Promise<null | PreviewReadResult> {
const tabs = $previewTabs.get()
const tab = tabs.find(t => t.id === $rightRailActiveTabId.get()) ?? tabs[0]
const tab = resolveActivePreviewTab(tabs)
if (!tab) {
return null
@@ -86,6 +176,8 @@ export async function readActivePreview(opts: PreviewReadOptions = {}): Promise<
const { target } = tab
const reader = readers.get(tab.id)
const multi = tabs.length > 1
const meta = zoneMeta(tab, tabs)
if (reader) {
try {
@@ -99,7 +191,14 @@ export async function readActivePreview(opts: PreviewReadOptions = {}): Promise<
nudgeOverlay('read')
return windowText(
{ kind: target.kind, path: target.path, title: page.title || target.label, url: page.url || target.url },
{
...meta,
kind: target.kind,
note: withMultiNote(undefined, multi),
path: target.path,
title: page.title || target.label,
url: page.url || target.url
},
page.text,
opts
)
@@ -112,15 +211,18 @@ export async function readActivePreview(opts: PreviewReadOptions = {}): Promise<
// No live webview behind the tab (a file peek, an artifact, or a page still
// booting): answer with the tab's identity so the agent knows what's on
// screen and which of its own tools reads the content directly.
const identity =
target.kind === 'file'
? 'File preview — read the file itself with read_file.'
: target.kind === 'artifact'
? 'Generated artifact — its content is in the conversation that produced it.'
: 'The page has not finished loading — retry in a moment.'
return windowText(
{
...meta,
kind: target.kind,
note:
target.kind === 'file'
? 'File preview — read the file itself with read_file.'
: target.kind === 'artifact'
? 'Generated artifact — its content is in the conversation that produced it.'
: 'The page has not finished loading — retry in a moment.',
note: withMultiNote(identity, multi),
path: target.path,
title: target.label,
url: target.url

View File

@@ -0,0 +1,69 @@
/**
* An explicit preview open (tool or user `openPreview`) must survive a later
* `follow()` from a zone the user was already in. `follow()` copies that
* zone's tab into `$rightRailActiveTabId`; when the opened pane lives in a
* different group, that copy is the desync `read_preview` used to report.
*
* A focus change after the open is a new look, so follow may sync then.
* Hover is not tracked here — the reader treats the pointer as a live look.
*/
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
import { $activeTreeGroup, $layoutTree } from '@/components/pane-shell/tree/store'
import type { RightRailTabId } from '@/store/layout'
/** Pane id prefix for a preview tab. Must match `preview-tile.tsx`. */
export const PREVIEW_TILE_PREFIX = 'preview-tile'
type ExplicitOpen = {
tabId: RightRailTabId
/** `$activeTreeGroup` when the open happened. Unchanged means follow is
* still the pre-open zone, not a zone the user moved to afterwards. */
activeGroupAtSelect: null | string
}
let explicit: ExplicitOpen | null = null
/** Stamp a tool/user open. Call before `selectRightRailTab` so a synchronous
* follow from that select already sees the guard. */
export function noteExplicitPreviewOpen(tabId: RightRailTabId): void {
explicit = { activeGroupAtSelect: $activeTreeGroup.get(), tabId }
}
export function clearExplicitPreviewOpen(): void {
explicit = null
}
function paneGroupId(tabId: string): null | string {
const tree = $layoutTree.get()
if (!tree) {
return null
}
return findGroupOfPane(tree, `${PREVIEW_TILE_PREFIX}:${tabId}`)?.id ?? null
}
/**
* True when copying `sourceGroupId`'s preview would overwrite an explicit
* open that lives in a different group, and the user has not focused a
* different zone since that open.
*/
export function explicitOpenBlocksZone(sourceGroupId: null | string, openTabIds: readonly string[]): boolean {
if (!explicit || !openTabIds.includes(explicit.tabId)) {
return false
}
if (sourceGroupId !== explicit.activeGroupAtSelect) {
return false
}
const openedIn = paneGroupId(explicit.tabId)
// Not placed yet: the stale zone must not win the race with reveal.
if (!openedIn) {
return true
}
return openedIn !== sourceGroupId
}

View File

@@ -4,6 +4,7 @@ import { readJson, readKey, writeKey } from '@/lib/storage'
import { normalize } from '@/lib/text'
import { $rightRailActiveTabId, type RightRailTabId, selectRightRailTab } from './layout'
import { clearExplicitPreviewOpen, noteExplicitPreviewOpen } from './preview-explicit'
import { normalizeProfileKey } from './profile'
import { canOpenBrowserWindow, openBrowserInNewWindow } from './windows'
@@ -544,6 +545,7 @@ export function openPreview(target: PreviewTarget) {
const tab: PreviewTab = { id, target: withRenderMode(target, current[index]?.target) }
$previewTabs.set(index === -1 ? [...current, tab] : current.map((item, i) => (i === index ? tab : item)))
noteExplicitPreviewOpen(id)
selectRightRailTab(id)
}
@@ -565,6 +567,7 @@ export function newBrowserTab() {
const id = mintBrowserTabId()
$previewTabs.set([...$previewTabs.get(), { id, target: blankPage() }])
noteExplicitPreviewOpen(id)
selectRightRailTab(id)
}
@@ -581,7 +584,15 @@ export function closeRightRailTab(tabId: string) {
$previewTabs.set(next)
if ($rightRailActiveTabId.get() === tabId) {
selectRightRailTab(next[Math.min(index, next.length - 1)]?.id ?? null)
const nextId = next[Math.min(index, next.length - 1)]?.id ?? null
if (nextId) {
noteExplicitPreviewOpen(nextId)
} else {
clearExplicitPreviewOpen()
}
selectRightRailTab(nextId)
}
if (next.length === 0) {
@@ -631,6 +642,7 @@ export function closeArtifactPreviewTabs() {
/** Close every tab so the rail's panes leave the tree. */
export function closeRightRail() {
clearExplicitPreviewOpen()
$previewTabs.set([])
selectRightRailTab(null)
}

View File

@@ -35,8 +35,10 @@ READ_PREVIEW_SCHEMA = {
"Read what's currently shown in the in-app browser / preview pane of the "
"Hermes desktop GUI (the pane open_preview opens beside this chat). Call "
"with no arguments for the first window of the active tab's content. "
"Returns JSON {kind, url, title, text, start, end, total_chars, note?}: "
"a URL (Browser) tab's text is the rendered page's visible text — page "
"Returns JSON {kind, url, title, text, start, end, total_chars, note?}. "
"When more than one preview is mounted, the JSON also includes active_tab_id and tabs "
"[{id, kind, label, url}] for the hovered or focused zone, not a stale "
"global tab. A URL (Browser) tab's text is the rendered page's visible text — page "
"through longer pages with `start`/`count` (character offsets, capped "
"per read); a file tab answers identity only (read the file with "
"read_file); an artifact tab points back at the conversation. Use after "