perf(header): cap session tabs at 20; tooltip data loads on hover only
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.
This commit is contained in:
@@ -97,6 +97,81 @@ const useTabProjectLabel = (directory: string | null): string | null =>
|
|||||||
return null;
|
return null;
|
||||||
}, [directory]));
|
}, [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 (
|
||||||
|
<div className="flex min-w-44 flex-col gap-1.5 text-left text-xs">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="min-w-0 truncate font-medium text-foreground">{title}</span>
|
||||||
|
{sessionTimestamp ? (
|
||||||
|
<span className="flex-shrink-0 text-muted-foreground" title={formatSessionDateLabel(sessionTimestamp)}>
|
||||||
|
{formatSessionCompactDateLabel(sessionTimestamp)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{projectLabel ? (
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Icon name="folder" className="h-3 w-3 flex-shrink-0" />
|
||||||
|
<span className="min-w-0 truncate">{projectLabel}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{branchLabel ? (
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
|
||||||
|
<Icon name="git-branch" className="h-3 w-3 flex-shrink-0" style={prIconColor ? { color: prIconColor } : undefined} />
|
||||||
|
<span className="min-w-0 truncate">{branchLabel}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{prSummary && prStatusLabel ? (
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<Icon name="git-pull-request" className="h-3 w-3 flex-shrink-0" style={prIconColor ? { color: prIconColor } : undefined} />
|
||||||
|
<span className="min-w-0 truncate" style={prIconColor ? { color: prIconColor } : undefined}>
|
||||||
|
#{prSummary.number} · {prStatusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One tab, active or not. The tab drags to reorder; the menu and close
|
* 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,
|
* 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.active')
|
||||||
: t('sessions.sidebar.session.status.unread');
|
: 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 => ({
|
const menuArgsFor = (components: SessionTabMenuComponents): SessionTabMenuArgs => ({
|
||||||
session: tab.session,
|
session: tab.session,
|
||||||
isActive,
|
isActive,
|
||||||
@@ -309,36 +349,7 @@ const SessionTabItem: React.FC<{
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{!anyMenuOpen && !isDragging ? (
|
{!anyMenuOpen && !isDragging ? (
|
||||||
<TooltipContent side="bottom" sideOffset={8} className="max-w-xs text-left">
|
<TooltipContent side="bottom" sideOffset={8} className="max-w-xs text-left">
|
||||||
<div className="flex min-w-44 flex-col gap-1.5 text-left text-xs">
|
<SessionTabTooltipBody tab={tab} title={title} />
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="min-w-0 truncate font-medium text-foreground">{title}</span>
|
|
||||||
{sessionTimestamp ? (
|
|
||||||
<span className="flex-shrink-0 text-muted-foreground" title={formatSessionDateLabel(sessionTimestamp)}>
|
|
||||||
{formatSessionCompactDateLabel(sessionTimestamp)}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{projectLabel ? (
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Icon name="folder" className="h-3 w-3 flex-shrink-0" />
|
|
||||||
<span className="min-w-0 truncate">{projectLabel}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{branchLabel ? (
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
|
|
||||||
<Icon name="git-branch" className="h-3 w-3 flex-shrink-0" style={prIconColor ? { color: prIconColor } : undefined} />
|
|
||||||
<span className="min-w-0 truncate">{branchLabel}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{prSummary && prStatusLabel ? (
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<Icon name="git-pull-request" className="h-3 w-3 flex-shrink-0" style={prIconColor ? { color: prIconColor } : undefined} />
|
|
||||||
<span className="min-w-0 truncate" style={prIconColor ? { color: prIconColor } : undefined}>
|
|
||||||
#{prSummary.number} · {prStatusLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
) : null}
|
) : null}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
"border": "#242323",
|
"border": "#242323",
|
||||||
"borderHover": "#504e4c",
|
"borderHover": "#504e4c",
|
||||||
"borderFocus": "#da7c47",
|
"borderFocus": "#da7c47",
|
||||||
"selection": "#b9a5992b",
|
"selection": "#c8c6c52b",
|
||||||
"selectionForeground": "#c9c5ba",
|
"selectionForeground": "#c9c5ba",
|
||||||
"focus": "#da7c47",
|
"focus": "#da7c47",
|
||||||
"focusRing": "#da7c4755",
|
"focusRing": "#da7c4755",
|
||||||
|
|||||||
@@ -32,6 +32,15 @@ describe('useSessionTabsStore', () => {
|
|||||||
expect(useSessionTabsStore.getState().tabIds).toBe(before);
|
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', () => {
|
test('removeTabs drops only confirmed-gone ids and no-ops otherwise', () => {
|
||||||
useSessionTabsStore.setState({ tabIds: ['a', 'b'] });
|
useSessionTabsStore.setState({ tabIds: ['a', 'b'] });
|
||||||
const before = useSessionTabsStore.getState().tabIds;
|
const before = useSessionTabsStore.getState().tabIds;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ interface SessionTabsStore {
|
|||||||
removeTabs: (sessionIds: readonly string[]) => void;
|
removeTabs: (sessionIds: readonly string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_SESSION_TABS = 20;
|
||||||
|
|
||||||
type PersistedSessionTabs = { tabIds: string[] };
|
type PersistedSessionTabs = { tabIds: string[] };
|
||||||
|
|
||||||
export const useSessionTabsStore = create<SessionTabsStore>()(
|
export const useSessionTabsStore = create<SessionTabsStore>()(
|
||||||
@@ -36,7 +38,11 @@ export const useSessionTabsStore = create<SessionTabsStore>()(
|
|||||||
if (!sessionId) return;
|
if (!sessionId) return;
|
||||||
const { tabIds } = get();
|
const { tabIds } = get();
|
||||||
if (tabIds.includes(sessionId)) return;
|
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) => {
|
closeTab: (sessionId) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user