feat(desktop): reload a tab from its right-click menu

Right-click any tab and pick Reload: the pane's content remounts in
place — effects re-run, state resets, measurements are retaken — while
the tab keeps its slot and every other tab is untouched.

A per-pane epoch atom keys the contribution inside the zone body, so
reload never rewrites the layout tree. Both tab menus offer it: the
zone strip menu (tool panels, the file tree, a fresh draft's main tab)
and the session tab menu (tiles + the loaded main tab).
This commit is contained in:
Brooklyn Nicholson
2026-08-02 22:08:09 -05:00
parent a4a91610b0
commit 17bdc99db4
10 changed files with 84 additions and 5 deletions
@@ -7,6 +7,7 @@ import {
closeAllTreeTabs,
closeOtherTreeTabs,
closeTreeTabsToRight,
reloadTreePane,
treeTabCloseTargets
} from '@/components/pane-shell/tree/store'
import {
@@ -239,12 +240,24 @@ function useSessionActions({
})
]
// TAB — close verbs that act on the strip (tabs only; a row isn't a tab).
// TAB — verbs that act on the strip (tabs only; a row isn't a tab).
const closeTargets = surface === 'tab' && tabPaneId ? treeTabCloseTargets(tabPaneId) : null
const tabCloseItems: ActionItemSpec[] =
const tabItems: ActionItemSpec[] =
surface === 'tab'
? [
...(tabPaneId
? [
spec({
icon: 'refresh',
label: t.zones.reload,
onSelect: () => {
triggerHaptic('selection')
reloadTreePane(tabPaneId)
}
})
]
: []),
...(onClose
? [
spec({
@@ -342,10 +355,10 @@ function useSessionActions({
/>
<kit.Separator />
{workItems.map(item => renderActionItem(kit, item))}
{tabCloseItems.length > 0 && (
{tabItems.length > 0 && (
<>
<kit.Separator />
{tabCloseItems.map(item => renderActionItem(kit, item))}
{tabItems.map(item => renderActionItem(kit, item))}
</>
)}
<kit.Separator />
@@ -0,0 +1,28 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Right-click a tab -> Reload remounts THAT pane's content: its epoch (the
// React key the zone renderer hands the contribution) advances, and no other
// pane's does. The layout tree itself must never move.
describe('reloadTreePane', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
it('advances only the reloaded pane epoch and leaves the tree alone', async () => {
const tree = await import('@/components/pane-shell/tree/store')
const model = await import('@/components/pane-shell/tree/model')
tree.declareDefaultTree(model.group(['workspace', 'files'], { active: 'workspace', id: 'grp-main' }))
const before = tree.$layoutTree.get()
tree.reloadTreePane('workspace')
tree.reloadTreePane('workspace')
expect(tree.$treePaneEpochs.get().workspace).toBe(2)
expect(tree.$treePaneEpochs.get().files).toBeUndefined()
expect(tree.$layoutTree.get()).toBe(before)
})
})
@@ -33,6 +33,7 @@ import {
$newSessionTabAction,
$panesWithCloser,
$treeDragging,
$treePaneEpochs,
activateTreePane,
closeAllTreeTabs,
closeOtherTreeTabs,
@@ -42,6 +43,7 @@ import {
isCollapsePane,
isSessionStripPane,
noteActiveTreeGroup,
reloadTreePane,
restoreTreePane,
SESSION_TILE_DRAG,
setTreeGroupHeaderHidden,
@@ -96,6 +98,12 @@ function ZoneMenu({
return (
<>
{renderActionItem(kit, {
icon: 'refresh',
label: t.zones.reload,
onSelect: () => reloadTreePane(targetPane())
})}
<kit.Separator />
{paneId !== undefined &&
renderActionItem(kit, {
icon: 'close',
@@ -178,6 +186,9 @@ export function TreeGroup({
const narrow = useStore($narrowViewport)
const newSessionTabAction = useStore($newSessionTabAction)
const panesWithCloser = useStore($panesWithCloser)
// Reload epochs: only an explicit tab-menu Reload writes here, so this
// subscription costs nothing on a normal render.
const paneEpochs = useStore($treePaneEpochs)
const paneFor = (id: string) => panes.find(p => p.id === id)
@@ -557,9 +568,14 @@ export function TreeGroup({
// can gate its hot (per-token) subscriptions while hidden;
// the group id identifies the ZONE it lives in, for state
// that is per-zone rather than per-tab (composer pop-out).
// The reload epoch keys the CONTENT, not this layer: a
// Reload remounts the contribution (effects re-run, state
// resets) while the layer — and every other tab — stays.
<PaneGroupContext.Provider value={node.id}>
<PaneVisibleContext.Provider value={isActive}>
<ContribBoundary id={pane.id}>{pane.render()}</ContribBoundary>
<ContribBoundary id={pane.id} key={paneEpochs[paneId] ?? 0}>
{pane.render()}
</ContribBoundary>
</PaneVisibleContext.Provider>
</PaneGroupContext.Provider>
) : (
@@ -450,6 +450,22 @@ export function treeTabCloseTargets(paneId: string): { all: number; others: numb
return { all: others.length + (isUncloseablePane(paneId) ? 0 : 1), others: others.length, right: right.length }
}
/**
* RELOAD — a pane's remount counter, the tab menu's Reload (browser parity:
* right-click a tab, reload what's in it). The zone renderer keys a pane's
* body layer on its epoch, so bumping it unmounts the contribution and mounts
* it fresh — data effects re-run, measurements are retaken — while the layout
* tree, the tab's position, and every other tab stay exactly as they were.
* Absent until a pane is first reloaded (no key churn on a normal boot).
*/
export const $treePaneEpochs = atom<Readonly<Record<string, number>>>({})
export function reloadTreePane(paneId: string): void {
const epochs = $treePaneEpochs.get()
$treePaneEpochs.set({ ...epochs, [paneId]: (epochs[paneId] ?? 0) + 1 })
}
/** Close a tab the way its kind expects: a tool panel leaves the strip (and
* syncs its toggle), everything else routes through its owning Close. */
export function closeTabPane(paneId: string) {
+1
View File
@@ -2236,6 +2236,7 @@ export const ar = defineLocale({
closeRunningBody:
'هذه المحادثة ما زالت تعمل (أو تنتظر إدخالك). إغلاق التبويب يخفيها فقط — ستحتفظ الجلسة بتقدمها ويمكن إعادة فتحها من الشريط الجانبي.',
closeRunningConfirm: 'إغلاق التبويب',
reload: 'إعادة التحميل',
closeOthers: 'إغلاق الأخرى',
closeToRight: 'إغلاق ما على اليمين',
closeAll: 'إغلاق الكل',
+1
View File
@@ -2663,6 +2663,7 @@ export const en: Translations = {
closeRunningBody:
'This chat is still working (or waiting on your input). Closing the tab hides it — the session keeps its progress and can be reopened from the sidebar.',
closeRunningConfirm: 'Close tab',
reload: 'Reload',
closeOthers: 'Close others',
closeToRight: 'Close to the right',
closeAll: 'Close all',
+1
View File
@@ -2489,6 +2489,7 @@ export const ja = defineLocale({
hideHeader: 'ヘッダーを隠す',
minimize: '最小化',
restore: '復元',
reload: '再読み込み',
closeOthers: '他を閉じる',
closeToRight: '右側を閉じる',
closeAll: 'すべて閉じる',
+1
View File
@@ -2262,6 +2262,7 @@ export interface Translations {
closeRunningTitle: string
closeRunningBody: string
closeRunningConfirm: string
reload: string
closeOthers: string
closeToRight: string
closeAll: string
+1
View File
@@ -2409,6 +2409,7 @@ export const zhHant = defineLocale({
hideHeader: '隱藏標題列',
minimize: '最小化',
restore: '還原',
reload: '重新載入',
closeOthers: '關閉其他',
closeToRight: '關閉右側',
closeAll: '全部關閉',
+1
View File
@@ -2841,6 +2841,7 @@ export const zh: Translations = {
closeRunningTitle: '关闭正在运行的标签?',
closeRunningBody: '此对话仍在运行(或正在等待你的输入)。关闭标签只会隐藏它——会话将保留进度,可从侧边栏重新打开。',
closeRunningConfirm: '关闭标签',
reload: '重新加载',
closeOthers: '关闭其他',
closeToRight: '关闭右侧',
closeAll: '全部关闭',