feat(ui): sidebar redesign — project zones, grouping modes, full-page surfaces (#2480)

* checkpoint: flatten sidebar core (flat sessions, zones, single mode, folders flat)

* checkpoint: sidebar nav + scheduled/archive full-page surfaces, recent zone header

* checkpoint: worktrees management surface via project menu

* checkpoint: docs, i18n, validation for sidebar redesign

* checkpoint: unified row/zone geometry, recent backfill, tooltips everywhere, primary spinner

* checkpoint: branch icon marker, date moved to rich tooltip, recent back to pure time window

* checkpoint: PR state on branch markers + tooltip, no reserved right space, aligned show-more, instant tooltips

* checkpoint: reserve hover-action space so title text is not overlapped

* checkpoint: tighten hover-action reserve

* checkpoint: color-only unread emphasis to avoid title reflow

* checkpoint: drop new-subfolder action, folder actions overlay on hover

* checkpoint: fix sticky project headers (sticky on trigger div), stuck elevation

* checkpoint: full-bleed semibold zone headers, no top scroll fade

* checkpoint: headers without background tint (typography-only emphasis)

* checkpoint: recent header flush with scroll top (no pre-stick bump)

* checkpoint: shared tooltip provider with grouping (instant handoff between rows)

* checkpoint: blur pointer-click focus so hover chrome hides on mouse-leave

* checkpoint: tooltip closeDelay bridges inter-row gap

* checkpoint: nav above controls, merged view dropdown, project-scoped bulk selection, cross-worktree folders

* checkpoint: folder header tooltip with full path name

* checkpoint: true page surfaces (hidden chat, header title, close-on-select), multirun page, run-now jump, folders on top, controls row polish

* checkpoint: rename mode — dual-instance outside-click fix, no vertical shift

* checkpoint: session grouping mode toggle (by-worktree default, flat option)

* checkpoint: worktree header — hover padding reserve + delete worktree action

* checkpoint: frosted sticky header backing under desktop vibrancy

* checkpoint: align worktree sub-header with project header icon column

* checkpoint: restore worktree group DnD reorder; dense vibrancy header tint (Chromium mask+backdrop-filter)

* checkpoint: vibrancy — drop scroller mask so backdrop-filter samples rows (Chromium backdrop-root limitation)

* checkpoint: vibrancy headers use opaque sidebar tone (Electron transparent-window backdrop-filter bug)

* checkpoint: nav collapsed to one row (New session + surface icons), mirrors Add project row

* checkpoint: raise sticky zone headers above row action layers (z-20)

* checkpoint: New session as full-width CTA + single quiet toolbar row

* checkpoint: New session row back to quiet text form above the toolbar

* checkpoint: align toolbar left icon with New session icon column

* checkpoint: hoverless flat header controls (color-only hover states)

* checkpoint: sticky project headers toggle in view dropdown (default on)

* checkpoint: full-page surfaces adapted — archive directory filter panel, scheduled master-detail, multirun without duplicate title bar

* checkpoint: multirun joins mutually exclusive surface set

* checkpoint: surfaces leave via navigation only (no close/cancel buttons), robust close-on-new-session, drop dead MultiRunWindow

* checkpoint: no scheduled header description, aligned empty-worktree note, collapse/expand covers worktree groups

* checkpoint: overlay scrollbar above sticky zone headers

* checkpoint: archive delete icons reveal on hover with padding shift

* checkpoint: new worktrees surface at top of the worktree list

* checkpoint: no grab cursor on worktree headers

* checkpoint: worktrees page list-only with inline action, no worktrees in edit dialog, drop menu ellipsis

* checkpoint: worktrees page uses full content width

* checkpoint: worktrees header — 'in' instead of em dash, no description

* checkpoint: drop legacy OpenCode badge from worktree list, tooltip without OpenCode mention

* review: bound pr summary cache
This commit is contained in:
Bohdan Triapitsyn
2026-07-28 12:04:39 +03:00
committed by GitHub
parent 5787ea5d49
commit 1291cde5c2
56 changed files with 1902 additions and 1044 deletions
@@ -935,6 +935,36 @@ const summarySignature = (s: PrVisualSummary): string =>
let prKeyedCacheSigs = new Map<string, string>();
let prKeyedCacheResult: Map<string, PrVisualSummary> = new Map();
// Per-key summary cache so many independent row subscribers (one key each)
// keep referential stability without fighting over the multi-key cache above.
// Practically bounded by the number of worktree branches observed in a
// session; the explicit cap below guards long-running documents that rotate
// through many branches/runtimes (entries are tiny; insertion-order eviction
// only costs a one-frame identity change for the evicted key's subscriber).
const PR_SUMMARY_CACHE_MAX_ENTRIES = 300;
const prSummaryCacheByKey = new Map<string, { sig: string; summary: PrVisualSummary }>();
export const usePrVisualSummary = (key: string | null): PrVisualSummary | null => {
return useGitHubPrStatusStore((state) => {
if (!key) return null;
const entry = state.entries[key];
const summary = entry ? deriveSummary(entry) : null;
if (!summary) {
prSummaryCacheByKey.delete(key);
return null;
}
const sig = summarySignature(summary);
const cached = prSummaryCacheByKey.get(key);
if (cached && cached.sig === sig) return cached.summary;
if (!cached && prSummaryCacheByKey.size >= PR_SUMMARY_CACHE_MAX_ENTRIES) {
const oldestKey = prSummaryCacheByKey.keys().next().value;
if (oldestKey !== undefined) prSummaryCacheByKey.delete(oldestKey);
}
prSummaryCacheByKey.set(key, { sig, summary });
return summary;
});
};
export const usePrVisualSummaryByKeys = (keys: string[]) => {
return useGitHubPrStatusStore((state) => {
// Derive summaries for requested keys only
@@ -19,4 +19,16 @@ describe('useSessionDisplayStore project sorting', () => {
expect(migrated.projectSortOrder).toBe(projectSortOrder);
});
}
test('v3→v4 drops the removed displayMode key and keeps the rest', () => {
const migrated = migrateSessionDisplayState(
{ displayMode: 'default', projectSortOrder: 'a-z', showRecentSection: false, showArchivedSessions: true },
3,
);
expect('displayMode' in migrated).toBe(false);
expect(migrated.projectSortOrder).toBe('a-z');
expect(migrated.showRecentSection).toBe(false);
expect(migrated.showArchivedSessions).toBe(true);
});
});
@@ -1,16 +1,24 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type SessionDisplayMode = 'default' | 'minimal';
type ProjectSortOrder = 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent';
// 'by-worktree' keeps per-worktree sub-headers inside each project zone
// (parallel-work overview); 'flat' merges everything into one recency list.
type SessionGroupingMode = 'by-worktree' | 'flat';
type SessionDisplayStore = {
displayMode: SessionDisplayMode;
sessionGroupingMode: SessionGroupingMode;
setSessionGroupingMode: (mode: SessionGroupingMode) => void;
/** Project/recent zone headers stick to the top while their zone scrolls. */
stickyZoneHeaders: boolean;
toggleStickyZoneHeaders: () => void;
showRecentSection: boolean;
// VS Code only: the compact webview keeps archived buckets inline because it
// has no room for the full Archive page. Web/desktop ignore this flag and
// always route archived sessions to the Archive page instead.
showArchivedSessions: boolean;
projectSortOrder: ProjectSortOrder;
setDisplayMode: (mode: SessionDisplayMode) => void;
setShowRecentSection: (show: boolean) => void;
setShowArchivedSessions: (show: boolean) => void;
toggleRecentSection: () => void;
@@ -22,15 +30,19 @@ export const migrateSessionDisplayState = (
persisted: unknown,
version: number,
): Partial<SessionDisplayStore> => {
const state = (persisted ?? {}) as Partial<SessionDisplayStore>;
if (version < 1) {
return { ...state, displayMode: 'minimal', projectSortOrder: 'manual' };
}
const state = (persisted ?? {}) as Partial<SessionDisplayStore> & {
displayMode?: string;
};
if (version < 2) {
return { ...state, projectSortOrder: 'manual' };
state.projectSortOrder = 'manual';
}
if (version < 3 && state.projectSortOrder === 'recent') {
return { ...state, projectSortOrder: 'manual' };
state.projectSortOrder = 'manual';
}
if (version < 4) {
// v4 removes the default/minimal display mode: the sidebar now has a
// single row layout. Drop the stale key from persisted state.
delete state.displayMode;
}
return state;
};
@@ -38,15 +50,16 @@ export const migrateSessionDisplayState = (
export const useSessionDisplayStore = create<SessionDisplayStore>()(
persist(
(set) => ({
displayMode: 'minimal',
sessionGroupingMode: 'by-worktree',
setSessionGroupingMode: (mode) => set({ sessionGroupingMode: mode }),
stickyZoneHeaders: true,
toggleStickyZoneHeaders: () => set((state) => ({ stickyZoneHeaders: !state.stickyZoneHeaders })),
showRecentSection: true,
// Default to HIDDEN so the pre-hydration state matches the quiet/safe
// option: archived sessions must never flash visible on startup and then
// disappear once the persisted preference rehydrates. Users who opted into
// showing archived have `true` persisted, which is preserved on rehydrate.
// disappear once the persisted preference rehydrates.
showArchivedSessions: false,
projectSortOrder: 'manual',
setDisplayMode: (mode) => set({ displayMode: mode }),
setShowRecentSection: (show) => set({ showRecentSection: show }),
setShowArchivedSessions: (show) => set({ showArchivedSessions: show }),
toggleRecentSection: () => set((state) => ({ showRecentSection: !state.showRecentSection })),
@@ -55,15 +68,13 @@ export const useSessionDisplayStore = create<SessionDisplayStore>()(
}),
{
name: 'session-display-mode',
version: 3,
// v0 shipped 'default' as the only/initial mode, so most existing users
// have it persisted by accident rather than choice. Nudge everyone onto
// minimal once so the mode can be evaluated before removing it entirely.
version: 4,
// v1→v2 adds projectSortOrder using the canonical manual ordering.
// v2→v3 replaces the previously shipped recent default with manual.
// v3→v4 removes displayMode (single sidebar row layout).
migrate: migrateSessionDisplayState,
},
),
);
export type { ProjectSortOrder };
export type { ProjectSortOrder, SessionGroupingMode };
+46 -1
View File
@@ -595,6 +595,8 @@ interface UIStore {
openCodeStatusText: string;
isSessionCreateDialogOpen: boolean;
isScheduledTasksDialogOpen: boolean;
isArchivePageOpen: boolean;
worktreesPageProjectId: string | null;
isSettingsDialogOpen: boolean;
isNewWorktreeDialogOpen: boolean;
isModelSelectorOpen: boolean;
@@ -756,6 +758,10 @@ interface UIStore {
setOpenCodeStatusText: (text: string) => void;
setSessionCreateDialogOpen: (open: boolean) => void;
setScheduledTasksDialogOpen: (open: boolean) => void;
setArchivePageOpen: (open: boolean) => void;
setWorktreesPageProjectId: (projectId: string | null) => void;
/** Close every full-page surface (Scheduled, Archive, Worktrees, Multi-run). */
closeMainSurfaces: () => void;
setSettingsDialogOpen: (open: boolean) => void;
setNewWorktreeDialogOpen: (open: boolean) => void;
setModelSelectorOpen: (open: boolean) => void;
@@ -909,6 +915,8 @@ export const useUIStore = create<UIStore>()(
openCodeStatusText: '',
isSessionCreateDialogOpen: false,
isScheduledTasksDialogOpen: false,
isArchivePageOpen: false,
worktreesPageProjectId: null,
isSettingsDialogOpen: false,
isNewWorktreeDialogOpen: false,
isModelSelectorOpen: false,
@@ -1552,7 +1560,35 @@ export const useUIStore = create<UIStore>()(
},
setScheduledTasksDialogOpen: (open) => {
set({ isScheduledTasksDialogOpen: open });
set(open
? { isScheduledTasksDialogOpen: true, isArchivePageOpen: false, worktreesPageProjectId: null, isMultiRunLauncherOpen: false }
: { isScheduledTasksDialogOpen: false });
},
setArchivePageOpen: (open) => {
set(open
? { isArchivePageOpen: true, isScheduledTasksDialogOpen: false, worktreesPageProjectId: null, isMultiRunLauncherOpen: false }
: { isArchivePageOpen: false });
},
setWorktreesPageProjectId: (projectId) => {
set(projectId
? { worktreesPageProjectId: projectId, isScheduledTasksDialogOpen: false, isArchivePageOpen: false, isMultiRunLauncherOpen: false }
: { worktreesPageProjectId: null });
},
closeMainSurfaces: () => {
const state = get();
if (!state.isScheduledTasksDialogOpen && !state.isArchivePageOpen && !state.worktreesPageProjectId && !state.isMultiRunLauncherOpen) {
return;
}
set({
isScheduledTasksDialogOpen: false,
isArchivePageOpen: false,
worktreesPageProjectId: null,
isMultiRunLauncherOpen: false,
multiRunLauncherPrefillPrompt: '',
});
},
setSettingsDialogOpen: (open) => {
@@ -2016,10 +2052,13 @@ export const useUIStore = create<UIStore>()(
}
},
// Multi-run is one of the mutually exclusive full-page surfaces:
// opening it closes the other surfaces and vice versa.
setMultiRunLauncherOpen: (open) => {
set((state) => ({
isMultiRunLauncherOpen: open,
multiRunLauncherPrefillPrompt: open ? state.multiRunLauncherPrefillPrompt : '',
...(open ? { isScheduledTasksDialogOpen: false, isArchivePageOpen: false, worktreesPageProjectId: null } : {}),
}));
},
@@ -2028,6 +2067,9 @@ export const useUIStore = create<UIStore>()(
isMultiRunLauncherOpen: true,
multiRunLauncherPrefillPrompt: '',
isSessionSwitcherOpen: false,
isScheduledTasksDialogOpen: false,
isArchivePageOpen: false,
worktreesPageProjectId: null,
});
},
@@ -2036,6 +2078,9 @@ export const useUIStore = create<UIStore>()(
isMultiRunLauncherOpen: true,
multiRunLauncherPrefillPrompt: prompt,
isSessionSwitcherOpen: false,
isScheduledTasksDialogOpen: false,
isArchivePageOpen: false,
worktreesPageProjectId: null,
});
},