From 17bdc99db4e6faa70ea2a9781f8d84c5734174bd Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 2 Aug 2026 22:08:09 -0500 Subject: [PATCH] feat(desktop): reload a tab from its right-click menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../app/chat/sidebar/session-actions-menu.tsx | 21 +++++++++++--- .../pane-shell/tree/pane-reload.test.ts | 28 +++++++++++++++++++ .../pane-shell/tree/renderer/tree-group.tsx | 18 +++++++++++- .../src/components/pane-shell/tree/store.ts | 16 +++++++++++ apps/desktop/src/i18n/ar.ts | 1 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + 10 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/components/pane-shell/tree/pane-reload.test.ts diff --git a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx index 73f741a905..57d8a9f962 100644 --- a/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx +++ b/apps/desktop/src/app/chat/sidebar/session-actions-menu.tsx @@ -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({ /> {workItems.map(item => renderActionItem(kit, item))} - {tabCloseItems.length > 0 && ( + {tabItems.length > 0 && ( <> - {tabCloseItems.map(item => renderActionItem(kit, item))} + {tabItems.map(item => renderActionItem(kit, item))} )} diff --git a/apps/desktop/src/components/pane-shell/tree/pane-reload.test.ts b/apps/desktop/src/components/pane-shell/tree/pane-reload.test.ts new file mode 100644 index 0000000000..8c78d5d391 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/pane-reload.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index dcf5c05840..4f7d7ada11 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -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()) + })} + {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. - {pane.render()} + + {pane.render()} + ) : ( diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 42267459b9..105fdb0df9 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -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>>({}) + +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) { diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index d71aab8858..6091c916c3 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2236,6 +2236,7 @@ export const ar = defineLocale({ closeRunningBody: 'هذه المحادثة ما زالت تعمل (أو تنتظر إدخالك). إغلاق التبويب يخفيها فقط — ستحتفظ الجلسة بتقدمها ويمكن إعادة فتحها من الشريط الجانبي.', closeRunningConfirm: 'إغلاق التبويب', + reload: 'إعادة التحميل', closeOthers: 'إغلاق الأخرى', closeToRight: 'إغلاق ما على اليمين', closeAll: 'إغلاق الكل', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 5d72bd090e..fe764eb310 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 13bfd18c3b..16d58834c6 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2489,6 +2489,7 @@ export const ja = defineLocale({ hideHeader: 'ヘッダーを隠す', minimize: '最小化', restore: '復元', + reload: '再読み込み', closeOthers: '他を閉じる', closeToRight: '右側を閉じる', closeAll: 'すべて閉じる', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 79b56030d6..b82feb4001 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2262,6 +2262,7 @@ export interface Translations { closeRunningTitle: string closeRunningBody: string closeRunningConfirm: string + reload: string closeOthers: string closeToRight: string closeAll: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 7f0fcfde88..31a0b62a53 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2409,6 +2409,7 @@ export const zhHant = defineLocale({ hideHeader: '隱藏標題列', minimize: '最小化', restore: '還原', + reload: '重新載入', closeOthers: '關閉其他', closeToRight: '關閉右側', closeAll: '全部關閉', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index a9ccfee506..10b4727245 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2841,6 +2841,7 @@ export const zh: Translations = { closeRunningTitle: '关闭正在运行的标签?', closeRunningBody: '此对话仍在运行(或正在等待你的输入)。关闭标签只会隐藏它——会话将保留进度,可从侧边栏重新打开。', closeRunningConfirm: '关闭标签', + reload: '重新加载', closeOthers: '关闭其他', closeToRight: '关闭右侧', closeAll: '全部关闭',