From dcacf4b3fcfaecfab31915f59dd73534c662bd78 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 24 Aug 2026 20:47:51 +0300 Subject: [PATCH] perf(header): cap session tabs at 20; tooltip data loads on hover only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-add means the strip only grows, so past 20 tabs the oldest one leaves the working set (the newly opened tab is always the survivor). The branch/worktree/PR/project subscriptions that feed the hover tooltip moved into the tooltip body component, which mounts only while the tooltip is open — a resting tab now subscribes only to its status dot's session status and unread count. --- .../components/layout/SessionTabsStrip.tsx | 141 ++++++++++-------- .../lib/theme/themes/openchamber-dark.json | 2 +- .../ui/src/stores/useSessionTabsStore.test.ts | 9 ++ packages/ui/src/stores/useSessionTabsStore.ts | 8 +- 4 files changed, 93 insertions(+), 67 deletions(-) diff --git a/packages/ui/src/components/layout/SessionTabsStrip.tsx b/packages/ui/src/components/layout/SessionTabsStrip.tsx index f4bbe964..100d4216 100644 --- a/packages/ui/src/components/layout/SessionTabsStrip.tsx +++ b/packages/ui/src/components/layout/SessionTabsStrip.tsx @@ -97,6 +97,81 @@ const useTabProjectLabel = (directory: string | null): string | null => return null; }, [directory])); +/** + * Tooltip body for one tab. Lives in its own component so the branch, + * worktree, PR and project subscriptions exist only while the tooltip is + * open — the resting tab pays only for its status dot. + */ +const SessionTabTooltipBody: React.FC<{ tab: SessionTab; title: string }> = ({ tab, title }) => { + const { t } = useI18n(); + const directory = normalizePath(resolveGlobalSessionDirectory(tab.session) ?? null); + const projectLabel = useTabProjectLabel(directory); + const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); + const allBranches = useGitAllBranches(); + const branchLabel = React.useMemo(() => { + const meta = worktreeMetadata.get(tab.id); + if (meta?.branch?.trim()) return meta.branch.trim(); + if (directory) return allBranches.get(directory)?.trim() || null; + return null; + }, [worktreeMetadata, allBranches, tab.id, directory]); + const prSummary = usePrVisualSummary(directory && branchLabel ? getGitHubPrStatusKey(directory, branchLabel) : null); + const prIconColor = prSummary ? `var(--pr-${prSummary.visualState})` : undefined; + const prStatusLabel = React.useMemo(() => { + if (!prSummary) return null; + switch (prSummary.visualState) { + case 'merged': + return t('sessions.sidebar.group.pr.status.merged'); + case 'open': + return (prSummary.canMerge === true || prSummary.mergeableState === 'clean' || prSummary.checks?.state === 'success') + ? t('sessions.sidebar.group.pr.status.readyToMerge') + : t('sessions.sidebar.group.pr.status.open'); + case 'blocked': + return prSummary.mergeableState === 'dirty' + ? t('sessions.sidebar.group.pr.status.mergeConflicts') + : t('sessions.sidebar.group.pr.status.mergeBlocked'); + case 'draft': + return t('sessions.sidebar.group.pr.status.draft'); + case 'closed': + return t('sessions.sidebar.group.pr.status.closed'); + default: + return null; + } + }, [prSummary, t]); + const sessionTimestamp = tab.session.time?.updated || tab.session.time?.created || 0; + return ( +
+
+ {title} + {sessionTimestamp ? ( + + {formatSessionCompactDateLabel(sessionTimestamp)} + + ) : null} +
+ {projectLabel ? ( +
+ + {projectLabel} +
+ ) : null} + {branchLabel ? ( +
+ + {branchLabel} +
+ ) : null} + {prSummary && prStatusLabel ? ( +
+ + + #{prSummary.number} · {prStatusLabel} + +
+ ) : null} +
+ ); +}; + /** * One tab, active or not. The tab drags to reorder; the menu and close * controls sit in a hover-revealed overlay at the tab's end (menu first, @@ -139,41 +214,6 @@ const SessionTabItem: React.FC<{ ? t('sessions.sidebar.session.status.active') : t('sessions.sidebar.session.status.unread'); - const directory = normalizePath(resolveGlobalSessionDirectory(tab.session) ?? null); - const projectLabel = useTabProjectLabel(directory); - const worktreeMetadata = useSessionUIStore((state) => state.worktreeMetadata); - const allBranches = useGitAllBranches(); - const branchLabel = React.useMemo(() => { - const meta = worktreeMetadata.get(tab.id); - if (meta?.branch?.trim()) return meta.branch.trim(); - if (directory) return allBranches.get(directory)?.trim() || null; - return null; - }, [worktreeMetadata, allBranches, tab.id, directory]); - const prSummary = usePrVisualSummary(directory && branchLabel ? getGitHubPrStatusKey(directory, branchLabel) : null); - const prIconColor = prSummary ? `var(--pr-${prSummary.visualState})` : undefined; - const prStatusLabel = React.useMemo(() => { - if (!prSummary) return null; - switch (prSummary.visualState) { - case 'merged': - return t('sessions.sidebar.group.pr.status.merged'); - case 'open': - return (prSummary.canMerge === true || prSummary.mergeableState === 'clean' || prSummary.checks?.state === 'success') - ? t('sessions.sidebar.group.pr.status.readyToMerge') - : t('sessions.sidebar.group.pr.status.open'); - case 'blocked': - return prSummary.mergeableState === 'dirty' - ? t('sessions.sidebar.group.pr.status.mergeConflicts') - : t('sessions.sidebar.group.pr.status.mergeBlocked'); - case 'draft': - return t('sessions.sidebar.group.pr.status.draft'); - case 'closed': - return t('sessions.sidebar.group.pr.status.closed'); - default: - return null; - } - }, [prSummary, t]); - const sessionTimestamp = tab.session.time?.updated || tab.session.time?.created || 0; - const menuArgsFor = (components: SessionTabMenuComponents): SessionTabMenuArgs => ({ session: tab.session, isActive, @@ -309,36 +349,7 @@ const SessionTabItem: React.FC<{ {!anyMenuOpen && !isDragging ? ( -
-
- {title} - {sessionTimestamp ? ( - - {formatSessionCompactDateLabel(sessionTimestamp)} - - ) : null} -
- {projectLabel ? ( -
- - {projectLabel} -
- ) : null} - {branchLabel ? ( -
- - {branchLabel} -
- ) : null} - {prSummary && prStatusLabel ? ( -
- - - #{prSummary.number} · {prStatusLabel} - -
- ) : null} -
+
) : null} diff --git a/packages/ui/src/lib/theme/themes/openchamber-dark.json b/packages/ui/src/lib/theme/themes/openchamber-dark.json index 22d083d3..648412f6 100644 --- a/packages/ui/src/lib/theme/themes/openchamber-dark.json +++ b/packages/ui/src/lib/theme/themes/openchamber-dark.json @@ -37,7 +37,7 @@ "border": "#242323", "borderHover": "#504e4c", "borderFocus": "#da7c47", - "selection": "#b9a5992b", + "selection": "#c8c6c52b", "selectionForeground": "#c9c5ba", "focus": "#da7c47", "focusRing": "#da7c4755", diff --git a/packages/ui/src/stores/useSessionTabsStore.test.ts b/packages/ui/src/stores/useSessionTabsStore.test.ts index 5e9d9547..5df0aa31 100644 --- a/packages/ui/src/stores/useSessionTabsStore.test.ts +++ b/packages/ui/src/stores/useSessionTabsStore.test.ts @@ -32,6 +32,15 @@ describe('useSessionTabsStore', () => { expect(useSessionTabsStore.getState().tabIds).toBe(before); }); + test('caps the working set at 20, evicting the oldest tab', () => { + useSessionTabsStore.setState({ tabIds: Array.from({ length: 20 }, (_, i) => `s${i}`) }); + useSessionTabsStore.getState().ensureTab('s-new'); + const ids = useSessionTabsStore.getState().tabIds; + expect(ids).toHaveLength(20); + expect(ids[0]).toBe('s1'); + expect(ids.at(-1)).toBe('s-new'); + }); + test('removeTabs drops only confirmed-gone ids and no-ops otherwise', () => { useSessionTabsStore.setState({ tabIds: ['a', 'b'] }); const before = useSessionTabsStore.getState().tabIds; diff --git a/packages/ui/src/stores/useSessionTabsStore.ts b/packages/ui/src/stores/useSessionTabsStore.ts index b2c6b3d2..7c1219ac 100644 --- a/packages/ui/src/stores/useSessionTabsStore.ts +++ b/packages/ui/src/stores/useSessionTabsStore.ts @@ -24,6 +24,8 @@ interface SessionTabsStore { removeTabs: (sessionIds: readonly string[]) => void; } +const MAX_SESSION_TABS = 20; + type PersistedSessionTabs = { tabIds: string[] }; export const useSessionTabsStore = create()( @@ -36,7 +38,11 @@ export const useSessionTabsStore = create()( if (!sessionId) return; const { tabIds } = get(); if (tabIds.includes(sessionId)) return; - set({ tabIds: [...tabIds, sessionId] }); + // Soft cap: with auto-add the strip only ever grows, so past the cap + // the oldest tab (never the one being opened, which lands last) + // leaves the working set. + const next = [...tabIds, sessionId]; + set({ tabIds: next.length > MAX_SESSION_TABS ? next.slice(next.length - MAX_SESSION_TABS) : next }); }, closeTab: (sessionId) => {