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) => {