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:
committed by
GitHub
parent
5787ea5d49
commit
1291cde5c2
@@ -1300,6 +1300,37 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
return trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : 'Untitled Session';
|
||||
}, [activeProjectLabel, currentSession?.title, currentSessionId]);
|
||||
|
||||
// Full-page surfaces (Scheduled, Archive, Worktrees, Multi-run) replace the
|
||||
// chat area; while one is open the header shows the surface identity
|
||||
// instead of the session switcher.
|
||||
const isScheduledSurfaceOpen = useUIStore((state) => state.isScheduledTasksDialogOpen);
|
||||
const isArchiveSurfaceOpen = useUIStore((state) => state.isArchivePageOpen);
|
||||
const worktreesSurfaceProjectId = useUIStore((state) => state.worktreesPageProjectId);
|
||||
const isMultiRunSurfaceOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
|
||||
const worktreesSurfaceProjectLabel = useProjectsStore((state) => {
|
||||
if (!worktreesSurfaceProjectId) return null;
|
||||
const project = state.projects.find((entry) => entry.id === worktreesSurfaceProjectId);
|
||||
return project?.label?.trim() || project?.path?.split('/').pop() || null;
|
||||
});
|
||||
const activeSurfaceHeader = React.useMemo<{ title: string; subtitle: string | null } | null>(() => {
|
||||
if (isScheduledSurfaceOpen) {
|
||||
return { title: t('sessions.scheduledTasks.dialog.title'), subtitle: null };
|
||||
}
|
||||
if (isArchiveSurfaceOpen) {
|
||||
return { title: t('sessions.archivePage.title'), subtitle: null };
|
||||
}
|
||||
if (worktreesSurfaceProjectId) {
|
||||
return {
|
||||
title: t('sessions.worktreesPage.title', { project: worktreesSurfaceProjectLabel ?? '' }),
|
||||
subtitle: null,
|
||||
};
|
||||
}
|
||||
if (isMultiRunSurfaceOpen) {
|
||||
return { title: t('sessions.sidebar.header.actions.newMultiRun'), subtitle: null };
|
||||
}
|
||||
return null;
|
||||
}, [isArchiveSurfaceOpen, isMultiRunSurfaceOpen, isScheduledSurfaceOpen, t, worktreesSurfaceProjectId, worktreesSurfaceProjectLabel]);
|
||||
|
||||
|
||||
const actionDirectory = React.useMemo(() => {
|
||||
return normalize(openDirectory || activeProject?.path || '');
|
||||
@@ -2039,6 +2070,18 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
TitlebarLeftControls overlay; the header reserves matching left space
|
||||
via padding (see headerStyle) when the sidebar is collapsed. */}
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
{activeSurfaceHeader ? (
|
||||
<div className="mr-3 flex min-w-0 flex-col items-start px-1 py-0.5 -my-0.5 text-left">
|
||||
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
|
||||
{activeSurfaceHeader.title}
|
||||
</span>
|
||||
{activeSurfaceHeader.subtitle ? (
|
||||
<span className="truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75 max-w-full">
|
||||
{activeSurfaceHeader.subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<SessionSwitcherDropdown>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2070,6 +2113,7 @@ export const Header: React.FC<HeaderProps> = ({
|
||||
) : null}
|
||||
</button>
|
||||
</SessionSwitcherDropdown>
|
||||
)}
|
||||
|
||||
{tabs.length > 0 && (
|
||||
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)]/50 p-1">
|
||||
|
||||
@@ -13,6 +13,9 @@ import { HelpDialog } from '../ui/HelpDialog';
|
||||
import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog';
|
||||
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
||||
import { SessionDialogs } from '@/components/session/SessionDialogs';
|
||||
import { ScheduledTasksDialog } from '@/components/session/ScheduledTasksDialog';
|
||||
import { ArchiveView } from '@/components/views/ArchiveView';
|
||||
import { WorktreesView } from '@/components/views/WorktreesView';
|
||||
import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
|
||||
import { MultiRunLauncher } from '@/components/multirun';
|
||||
import { TerminalView } from '@/components/views/TerminalView';
|
||||
@@ -37,7 +40,6 @@ import { PlanView } from '@/components/views/PlanView';
|
||||
const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView })));
|
||||
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||
const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow })));
|
||||
const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow })));
|
||||
|
||||
export const MainLayout: React.FC = () => {
|
||||
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
||||
@@ -49,6 +51,32 @@ export const MainLayout: React.FC = () => {
|
||||
const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
|
||||
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
|
||||
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
|
||||
const isScheduledTasksPageOpen = useUIStore((state) => state.isScheduledTasksDialogOpen);
|
||||
const isArchivePageOpen = useUIStore((state) => state.isArchivePageOpen);
|
||||
const worktreesPageProjectId = useUIStore((state) => state.worktreesPageProjectId);
|
||||
// Any full-page surface replacing the chat area. While open, the chat and
|
||||
// secondary views are fully hidden (not just covered) so none of their
|
||||
// floating chrome bleeds through, and selecting a session / draft / main
|
||||
// tab anywhere closes the surface.
|
||||
const isSurfacePageOpen = isScheduledTasksPageOpen || isArchivePageOpen || Boolean(worktreesPageProjectId) || isMultiRunLauncherOpen;
|
||||
|
||||
React.useEffect(() => {
|
||||
const closeSurfacePages = () => useUIStore.getState().closeMainSurfaces();
|
||||
const unsubscribeSession = useSessionUIStore.subscribe((state, prev) => {
|
||||
const sessionSelected = Boolean(state.currentSessionId) && state.currentSessionId !== prev.currentSessionId;
|
||||
// Draft identity change covers re-opening a draft while one is
|
||||
// already open (the boolean alone never transitions then).
|
||||
const draftOpened = Boolean(state.newSessionDraft?.open) && state.newSessionDraft !== prev.newSessionDraft;
|
||||
if (sessionSelected || draftOpened) closeSurfacePages();
|
||||
});
|
||||
const unsubscribeTab = useUIStore.subscribe((state, prev) => {
|
||||
if (state.activeMainTab !== prev.activeMainTab) closeSurfacePages();
|
||||
});
|
||||
return () => {
|
||||
unsubscribeSession();
|
||||
unsubscribeTab();
|
||||
};
|
||||
}, []);
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const mobilePanelsResetRef = React.useRef(false);
|
||||
|
||||
@@ -306,11 +334,11 @@ export const MainLayout: React.FC = () => {
|
||||
)}
|
||||
>
|
||||
<main className="w-full h-full overflow-hidden bg-background relative" data-page-scroll-lock="true">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen} /></ErrorBoundary>
|
||||
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}>
|
||||
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
|
||||
</div>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}>
|
||||
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
@@ -325,6 +353,9 @@ export const MainLayout: React.FC = () => {
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
<ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary>
|
||||
<ErrorBoundary><ArchiveView /></ErrorBoundary>
|
||||
<ErrorBoundary><WorktreesView /></ErrorBoundary>
|
||||
{/* Always mount SessionSidebar on mobile to match desktop behavior.
|
||||
Conditional mount (mobileLeftDrawerVisible && ...) caused a
|
||||
data-loading cascade on every drawer open: paginated sessions
|
||||
@@ -395,14 +426,31 @@ export const MainLayout: React.FC = () => {
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden" data-page-scroll-lock="true">
|
||||
<div className="relative flex flex-1 min-h-0 min-w-0 overflow-hidden" data-page-scroll-lock="true">
|
||||
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
|
||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
||||
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen} /></ErrorBoundary>
|
||||
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}>
|
||||
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
|
||||
</div>
|
||||
{secondaryView && (
|
||||
<div className="absolute inset-0">
|
||||
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}>
|
||||
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
{isMultiRunLauncherOpen && (
|
||||
<div className="absolute inset-0 z-10 bg-background">
|
||||
<ErrorBoundary>
|
||||
{/* isWindowed: the app Header already shows the surface
|
||||
title, so skip the launcher's own title bar. */}
|
||||
<MultiRunLauncher
|
||||
isWindowed
|
||||
initialPrompt={multiRunLauncherPrefillPrompt}
|
||||
onCreated={() => setMultiRunLauncherOpen(false)}
|
||||
onCancel={() => setMultiRunLauncherOpen(false)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
<ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary>
|
||||
<ErrorBoundary><ArchiveView /></ErrorBoundary>
|
||||
<ErrorBoundary><WorktreesView /></ErrorBoundary>
|
||||
</main>
|
||||
<ContextPanel />
|
||||
</div>
|
||||
@@ -422,13 +470,6 @@ export const MainLayout: React.FC = () => {
|
||||
onOpenChange={setSettingsDialogOpen}
|
||||
/>
|
||||
</React.Suspense>
|
||||
<React.Suspense fallback={null}>
|
||||
<MultiRunWindow
|
||||
open={isMultiRunLauncherOpen}
|
||||
onOpenChange={setMultiRunLauncherOpen}
|
||||
initialPrompt={multiRunLauncherPrefillPrompt}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ProjectEditDialog: React.FC<ProjectEditDialogProps> = ({
|
||||
<ScrollableOverlay outerClassName="max-h-[min(90vh,48rem)]" className="w-full bg-background">
|
||||
<div className="w-full p-3 sm:p-6 sm:pt-8">
|
||||
{open && project ? (
|
||||
<ProjectSettingsPanel project={project} onIdentitySave={onSave} />
|
||||
<ProjectSettingsPanel project={project} onIdentitySave={onSave} showWorktrees={false} />
|
||||
) : null}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
|
||||
@@ -674,14 +674,18 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
||||
<span className="truncate">{t('multirun.launcher.project.gitRequired')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('multirun.launcher.actions.cancel')}
|
||||
</Button>
|
||||
{/* On the full-page surface (isWindowed) there is nothing to
|
||||
"cancel" — you leave via the sidebar like any other page. */}
|
||||
{!isWindowed && onCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('multirun.launcher.actions.cancel')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="submit" size="sm" disabled={!isValid || isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
t('multirun.launcher.actions.creating')
|
||||
|
||||
@@ -31,11 +31,17 @@ import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export interface WorktreeSectionContentProps {
|
||||
projectRef?: { id: string; path: string } | null;
|
||||
/**
|
||||
* 'all' renders setup commands + the worktree list (settings panel);
|
||||
* 'list-only' renders just the list (the Worktrees page — setup commands
|
||||
* stay a settings concern).
|
||||
*/
|
||||
sections?: 'all' | 'list-only';
|
||||
}
|
||||
|
||||
const SETUP_COMMANDS_SAVE_DELAY_MS = 450;
|
||||
|
||||
export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({ projectRef: projectRefProp = null }) => {
|
||||
export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({ projectRef: projectRefProp = null, sections = 'all' }) => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile, isTablet } = useDeviceInfo();
|
||||
const alwaysShowActions = isMobile || isTablet;
|
||||
@@ -369,6 +375,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{sections === 'all' ? (
|
||||
<ProjectSettingsSubsection
|
||||
title={t('settings.projects.page.section.worktree')}
|
||||
settingsItem="projects.worktree"
|
||||
@@ -428,6 +435,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</ProjectSettingsSubsection>
|
||||
) : null}
|
||||
|
||||
<ProjectSettingsSubsection
|
||||
title={t('settings.openchamber.worktrees.list.title')}
|
||||
@@ -440,7 +448,9 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
{t('settings.openchamber.worktrees.list.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className={cn('space-y-1', PROJECT_SETTINGS_CONTROL_WIDTH)}>
|
||||
// The settings panel keeps its narrow control column; the full-page
|
||||
// Worktrees surface lets rows use the whole content width.
|
||||
<div className={cn('space-y-1', sections === 'all' && PROJECT_SETTINGS_CONTROL_WIDTH)}>
|
||||
{availableWorktrees.map((worktree) => (
|
||||
<div
|
||||
key={worktree.path}
|
||||
@@ -451,9 +461,6 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
||||
<p className="typography-meta min-w-0 truncate text-foreground">
|
||||
{worktree.label || worktree.branch || t('settings.openchamber.worktrees.list.detachedHead')}
|
||||
</p>
|
||||
<span className="typography-micro flex-shrink-0 self-center rounded bg-sidebar-accent/40 px-1.5 py-[1px] leading-none text-muted-foreground/60">
|
||||
OpenCode
|
||||
</span>
|
||||
</div>
|
||||
<p className="typography-micro truncate text-muted-foreground/60">
|
||||
{formatPathForDisplay(worktree.path, homeDirectory)}
|
||||
|
||||
@@ -12,11 +12,18 @@ import type { ProjectEntry } from '@/lib/api/types';
|
||||
type ProjectSettingsPanelProps = {
|
||||
project: ProjectEntry | null;
|
||||
onIdentitySave: (data: ProjectIdentitySaveData) => void | Promise<void>;
|
||||
/**
|
||||
* The project-edit dialog hides the worktree section — worktrees have
|
||||
* their own full-page surface (project menu → Manage worktrees). Settings
|
||||
* keeps the full panel.
|
||||
*/
|
||||
showWorktrees?: boolean;
|
||||
};
|
||||
|
||||
export const ProjectSettingsPanel: React.FC<ProjectSettingsPanelProps> = ({
|
||||
project,
|
||||
onIdentitySave,
|
||||
showWorktrees = true,
|
||||
}) => {
|
||||
const form = useProjectIdentityForm(project);
|
||||
|
||||
@@ -41,7 +48,7 @@ export const ProjectSettingsPanel: React.FC<ProjectSettingsPanelProps> = ({
|
||||
<div className="space-y-0">
|
||||
<ProjectIdentityFields form={form} />
|
||||
<ProjectActionsSection projectRef={projectRef} />
|
||||
<WorktreeSectionContent projectRef={projectRef} />
|
||||
{showWorktrees ? <WorktreeSectionContent projectRef={projectRef} /> : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -17,6 +10,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { formatTimeForPreference } from '@/lib/timeFormat';
|
||||
import type { TimeFormatPreference } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
@@ -360,18 +354,25 @@ export function ScheduledTasksDialog() {
|
||||
}
|
||||
setMutatingTaskID(task.id);
|
||||
try {
|
||||
await runScheduledTaskNow(selectedProjectID, task.id);
|
||||
const { sessionId } = await runScheduledTaskNow(selectedProjectID, task.id);
|
||||
await Promise.all([
|
||||
reloadTasks(selectedProjectID, { silent: true }),
|
||||
refreshGlobalSessions(),
|
||||
]);
|
||||
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
|
||||
if (sessionId) {
|
||||
// Jump straight into the started session; selecting it also closes
|
||||
// this surface (MainLayout closes surfaces on session selection).
|
||||
const project = projects.find((entry) => entry.id === selectedProjectID);
|
||||
useSessionUIStore.getState().setCurrentSession(sessionId, project?.path ?? null);
|
||||
useUIStore.getState().setActiveMainTab('chat');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.runFailed'));
|
||||
} finally {
|
||||
setMutatingTaskID(null);
|
||||
}
|
||||
}, [selectedProjectID, reloadTasks, t]);
|
||||
}, [selectedProjectID, projects, reloadTasks, t]);
|
||||
|
||||
const projectSelector = (
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
@@ -412,19 +413,16 @@ export function ScheduledTasksDialog() {
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const tasksContent = (
|
||||
<div className="space-y-4">
|
||||
{!isMobile ? (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
{projectSelector}
|
||||
<Button onClick={openNewTaskEditor} disabled={!selectedProjectID}>
|
||||
<Icon name="add" className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.newTask')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
projectSelector
|
||||
)}
|
||||
const selectProject = (nextProjectID: string) => {
|
||||
setSelectedProjectID(nextProjectID);
|
||||
if (nextProjectID) {
|
||||
void reloadTasks(nextProjectID);
|
||||
} else {
|
||||
setTasks([]);
|
||||
}
|
||||
};
|
||||
|
||||
const tasksList = (
|
||||
<div className="min-h-[280px]">
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
|
||||
@@ -579,6 +577,12 @@ export function ScheduledTasksDialog() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const tasksContent = (
|
||||
<div className="space-y-4">
|
||||
{projectSelector}
|
||||
{tasksList}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -613,18 +617,53 @@ export function ScheduledTasksDialog() {
|
||||
>
|
||||
{tasksContent}
|
||||
</MobileOverlayPanel>
|
||||
) : (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('sessions.scheduledTasks.dialog.title')}</DialogTitle>
|
||||
<DialogDescription>{t('sessions.scheduledTasks.dialog.description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{tasksContent}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
) : open ? (
|
||||
// Full-page surface replacing the chat area (mounted inside <main>).
|
||||
// Master-detail: a scrollable project filter panel at the left, the
|
||||
// selected project's tasks at the right. The app Header shows the
|
||||
// surface title, so the page itself only carries the close affordance.
|
||||
<div className="absolute inset-0 z-10 flex flex-col bg-background">
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="flex w-60 flex-shrink-0 flex-col border-r border-border/50">
|
||||
<div className="flex-1 space-y-0.5 overflow-y-auto p-2">
|
||||
{projects.length === 0 ? (
|
||||
<div className="px-2 py-2 typography-meta text-muted-foreground">
|
||||
{t('sessions.scheduledTasks.dialog.project.empty')}
|
||||
</div>
|
||||
) : projects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
type="button"
|
||||
onClick={() => selectProject(project.id)}
|
||||
className={cn(
|
||||
'flex w-full min-w-0 items-center rounded-md px-2 py-1.5 text-left typography-ui-label focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
selectedProjectID === project.id
|
||||
? 'bg-interactive-selection text-foreground'
|
||||
: 'text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{renderProjectLabel(project)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Pages have no close button: you leave by picking a session,
|
||||
a draft, or another surface in the sidebar. */}
|
||||
<div className="flex items-center px-6 pt-3">
|
||||
<Button size="sm" onClick={openNewTaskEditor} disabled={!selectedProjectID}>
|
||||
<Icon name="add" className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.newTask')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
{tasksList}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ScheduledTaskEditorDialog
|
||||
open={editorOpen}
|
||||
|
||||
@@ -3,10 +3,17 @@ import { cn } from '@/lib/utils';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
|
||||
|
||||
interface SessionFolderItemProps<TSessionNode> {
|
||||
folder: SessionFolder;
|
||||
/**
|
||||
* Optional display label override. Flat folder rendering shows nested
|
||||
* folders at the top level with a "Parent / Child" path instead of
|
||||
* indentation.
|
||||
*/
|
||||
displayName?: string;
|
||||
sessions: TSessionNode[];
|
||||
/** Sub-folders that belong directly to this folder */
|
||||
subFolderItems?: React.ReactNode;
|
||||
@@ -46,8 +53,6 @@ interface SessionFolderItemProps<TSessionNode> {
|
||||
isDropTarget?: boolean;
|
||||
/** Create a new session scoped to this folder */
|
||||
onNewSession?: () => void;
|
||||
/** Create a new sub-folder inside this folder */
|
||||
onNewSubFolder?: () => void;
|
||||
/** Visual indent depth (0 = root folder, 1 = sub-folder) */
|
||||
depth?: number;
|
||||
/** Hide folder action buttons (rename/delete/new) */
|
||||
@@ -58,6 +63,7 @@ interface SessionFolderItemProps<TSessionNode> {
|
||||
|
||||
const SessionFolderItemBase = <TSessionNode,>({
|
||||
folder,
|
||||
displayName,
|
||||
sessions,
|
||||
subFolderItems,
|
||||
isCollapsed,
|
||||
@@ -78,7 +84,6 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
droppableRef,
|
||||
isDropTarget = false,
|
||||
onNewSession,
|
||||
onNewSubFolder,
|
||||
depth = 0,
|
||||
hideActions = false,
|
||||
archivedBucket = false,
|
||||
@@ -145,10 +150,10 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
}, [isRenaming]);
|
||||
|
||||
const folderIconName = isCollapsed ? 'folder' : 'folder-open';
|
||||
const isSubFolder = depth > 0;
|
||||
void depth;
|
||||
|
||||
return (
|
||||
<div className={cn('oc-folder', isSubFolder && 'ml-3')}>
|
||||
<div className="oc-folder">
|
||||
{/* Folder header – also acts as a drop zone when droppableRef is provided */}
|
||||
<div
|
||||
ref={droppableRef}
|
||||
@@ -157,7 +162,10 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
'cursor-pointer',
|
||||
isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30',
|
||||
)}
|
||||
onClick={renaming ? undefined : onToggle}
|
||||
onClick={renaming ? undefined : (event) => {
|
||||
(event.currentTarget as HTMLElement).blur();
|
||||
onToggle();
|
||||
}}
|
||||
role={renaming ? undefined : 'button'}
|
||||
tabIndex={renaming ? undefined : 0}
|
||||
onKeyDown={
|
||||
@@ -178,7 +186,10 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
'min-w-0 flex items-center gap-1.5 pl-1.5 flex-1 transition-[padding]',
|
||||
archivedBucket
|
||||
? (alwaysShowActions ? 'pr-7' : 'group-hover/folder:pr-7 group-focus-within/folder:pr-7')
|
||||
: '',
|
||||
// Actions overlay on hover (new session, rename, delete = three
|
||||
// 24px buttons anchored at the right edge); reserve room only
|
||||
// while they are revealed, mirroring session-row behavior.
|
||||
: (alwaysShowActions ? 'pr-20' : 'group-hover/folder:pr-20 group-focus-within/folder:pr-20'),
|
||||
)}>
|
||||
<Icon name={folderIconName} className={cn('h-3.5 w-3.5 flex-shrink-0', isDropTarget ? 'text-primary' : 'text-muted-foreground')} />
|
||||
|
||||
@@ -238,9 +249,16 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
</form>
|
||||
) : (
|
||||
<div className="min-w-0 flex items-center gap-1.5 flex-1">
|
||||
<span className={cn('typography-ui-label font-semibold truncate', isDropTarget ? 'text-primary' : 'text-muted-foreground')}>
|
||||
{folder.name}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn('typography-ui-label font-semibold truncate', isDropTarget ? 'text-primary' : 'text-muted-foreground')}>
|
||||
{displayName ?? folder.name}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8} className="max-w-xs">
|
||||
{displayName ?? folder.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="typography-micro text-muted-foreground/70 flex-shrink-0">
|
||||
• {sessions.length}
|
||||
</span>
|
||||
@@ -256,14 +274,15 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
|
||||
{/* Action buttons */}
|
||||
{!renaming && (!hideActions || archivedBucket) ? (
|
||||
<div className="flex items-center gap-0.5 px-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-0.5 transition-opacity',
|
||||
alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100',
|
||||
archivedBucket && 'absolute right-0.5 top-1/2 z-10 -translate-y-1/2 px-0',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0.5 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5 transition-opacity',
|
||||
alwaysShowActions
|
||||
? 'opacity-100'
|
||||
: 'opacity-0 pointer-events-none group-hover/folder:opacity-100 group-hover/folder:pointer-events-auto group-focus-within/folder:opacity-100 group-focus-within/folder:pointer-events-auto',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{!archivedBucket && onNewSession ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -278,21 +297,6 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
<Icon name="add" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
{/* Only allow sub-folders at depth 0 (one level deep max) */}
|
||||
{!archivedBucket && onNewSubFolder && depth === 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onNewSubFolder();
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('sessions.sidebar.folderItem.newSubfolderAria', { folderName: folder.name })}
|
||||
title={t('sessions.sidebar.folderItem.newSubfolder')}
|
||||
>
|
||||
<Icon name="folder-add" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
{!archivedBucket ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -326,7 +330,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
|
||||
{/* Folder body */}
|
||||
{!isCollapsed ? (
|
||||
<div className="pb-1 pl-2">
|
||||
<div className="pb-1">
|
||||
{/* Sub-folders first */}
|
||||
{subFolderItems}
|
||||
{/* Then sessions */}
|
||||
|
||||
@@ -17,14 +17,15 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||
import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { NewWorktreeDialog } from './NewWorktreeDialog';
|
||||
import { ScheduledTasksDialog } from './ScheduledTasksDialog';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||
import { useArchivedAutoFolders } from './sidebar/hooks/useArchivedAutoFolders';
|
||||
import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering';
|
||||
import { useSessionSidebarSections } from './sidebar/hooks/useSessionSidebarSections';
|
||||
import { ProjectSessionSelectionEffect } from './sidebar/hooks/useProjectSessionSelection';
|
||||
import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering';
|
||||
import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping';
|
||||
import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects';
|
||||
import { useSessionActions } from './sidebar/hooks/useSessionActions';
|
||||
@@ -34,11 +35,11 @@ import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists';
|
||||
import { useAuthoritativeSessionCleanup } from './sidebar/hooks/useAuthoritativeSessionCleanup';
|
||||
import { createSessionOwnershipIndex } from './sidebar/sessionOwnership';
|
||||
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
||||
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||
import { SessionGroupSection } from './sidebar/SessionGroupSection';
|
||||
import { SidebarHeader } from './sidebar/SidebarHeader';
|
||||
import { SidebarNav } from './sidebar/SidebarNav';
|
||||
import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
|
||||
import { SidebarFooter } from './sidebar/SidebarFooter';
|
||||
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
|
||||
@@ -86,10 +87,13 @@ import {
|
||||
resolveGlobalSessionDirectory,
|
||||
useGlobalSessionsStore,
|
||||
} from '@/stores/useGlobalSessionsStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useNotificationStore } from '@/sync/notification-store';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||
import { buildSessionBootstrapDemands } from './sidebar/sessionBootstrapDemands';
|
||||
import { recordWorktreesSeen } from './sidebar/worktreeFirstSeen';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { streamPerfCount, streamPerfMark } from '@/stores/utils/streamDebug';
|
||||
|
||||
@@ -103,32 +107,6 @@ const PROJECT_ACTIVE_SESSION_STORAGE_KEY = 'oc.sessions.activeSessionByProject';
|
||||
// mixed contexts and is intentionally not migrated.
|
||||
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents.v3';
|
||||
|
||||
type PrVisualState = 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
|
||||
|
||||
type PrIndicator = {
|
||||
visualState: PrVisualState;
|
||||
number: number;
|
||||
url: string | null;
|
||||
state: 'open' | 'closed' | 'merged';
|
||||
draft: boolean;
|
||||
title: string | null;
|
||||
base: string | null;
|
||||
head: string | null;
|
||||
checks: {
|
||||
state: 'success' | 'failure' | 'pending' | 'unknown';
|
||||
total: number;
|
||||
success: number;
|
||||
failure: number;
|
||||
pending: number;
|
||||
} | null;
|
||||
canMerge: boolean | null;
|
||||
mergeableState: string | null;
|
||||
repo: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const buildKnownSessionDirectories = (
|
||||
projects: Array<{ path: string }>,
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
|
||||
@@ -230,6 +208,57 @@ const SidebarBootstrapDemandEffect: React.FC<{
|
||||
return null;
|
||||
};
|
||||
|
||||
// Aggregated activity/attention dot for a collapsed project header. Only
|
||||
// mounted while the project is collapsed, so the per-status-event scans stay
|
||||
// rare and bounded by the project's directory count.
|
||||
const ProjectAggregateStatusIndicator: React.FC<{ directories: Array<string | null> }> = ({ directories }) => {
|
||||
const { t } = useI18n();
|
||||
const directorySet = React.useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
directories.forEach((directory) => {
|
||||
const normalized = normalizePath(directory)?.toLowerCase();
|
||||
if (normalized) set.add(normalized);
|
||||
});
|
||||
return set;
|
||||
}, [directories]);
|
||||
const hasBusySession = useGlobalSessionStatusStore(React.useCallback((state) => {
|
||||
for (const entry of state.statusById.values()) {
|
||||
if (entry.status.type !== 'busy' && entry.status.type !== 'retry') continue;
|
||||
const directory = normalizePath(entry.directory)?.toLowerCase();
|
||||
if (directory && directorySet.has(directory)) return true;
|
||||
}
|
||||
return false;
|
||||
}, [directorySet]));
|
||||
const hasUnseenNotification = useNotificationStore(React.useCallback((state) => {
|
||||
for (const [directory, count] of Object.entries(state.index.project.unseenCount)) {
|
||||
if (!count) continue;
|
||||
const normalized = normalizePath(directory)?.toLowerCase();
|
||||
if (normalized && directorySet.has(normalized)) return true;
|
||||
}
|
||||
return false;
|
||||
}, [directorySet]));
|
||||
|
||||
if (hasBusySession) {
|
||||
return (
|
||||
<Icon
|
||||
name="loader-4"
|
||||
className="h-3 w-3 animate-spin text-primary"
|
||||
aria-label={t('sessions.sidebar.session.status.active')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (hasUnseenNotification) {
|
||||
return (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full bg-[var(--status-info)]"
|
||||
aria-label={t('sessions.sidebar.session.status.unread')}
|
||||
title={t('sessions.sidebar.session.status.unread')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
isVisible = true,
|
||||
mobileVariant = false,
|
||||
@@ -247,7 +276,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const [sessionSearchQuery, setSessionSearchQuery] = React.useState('');
|
||||
const sessionSearchContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const sessionSearchInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const retriedNoPrStatusKeysRef = React.useRef<Set<string>>(new Set());
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = React.useState('');
|
||||
const [editingProjectDialogId, setEditingProjectDialogId] = React.useState<string | null>(null);
|
||||
@@ -351,6 +379,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const setAboutDialogOpen = useUIStore((state) => state.setAboutDialogOpen);
|
||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||
const setScheduledTasksDialogOpen = useUIStore((state) => state.setScheduledTasksDialogOpen);
|
||||
const setArchivePageOpen = useUIStore((state) => state.setArchivePageOpen);
|
||||
const setWorktreesPageProjectId = useUIStore((state) => state.setWorktreesPageProjectId);
|
||||
const openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
|
||||
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
|
||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||
@@ -434,6 +464,9 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
// is driven by the openchamber:navigate event, so switch to chat explicitly
|
||||
// (a no-op in the expanded side-by-side layout, which is always showing chat).
|
||||
const openNewSessionDraftFromTree = React.useCallback<typeof openNewSessionDraft>((options) => {
|
||||
// Starting a draft always leaves any full-page surface, even when a
|
||||
// draft was already open (no store transition fires in that case).
|
||||
useUIStore.getState().closeMainSurfaces();
|
||||
openNewSessionDraft(options);
|
||||
if (isVSCode) {
|
||||
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } }));
|
||||
@@ -575,6 +608,9 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
}
|
||||
}
|
||||
const allWorktrees = [...worktreesByProject.values()].flat();
|
||||
// Newly appearing worktrees sort to the top of their project's
|
||||
// worktree list (see worktreeFirstSeen.ts).
|
||||
recordWorktreesSeen(allWorktrees.map((worktree) => worktree.path), Date.now());
|
||||
|
||||
// Skip update if nothing changed — see worktreeMapsEqual JSDoc.
|
||||
if (!worktreeMapsEqual(worktreesByProject, currentByProject)) {
|
||||
@@ -898,9 +934,21 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Collapse/expand covers both levels: projects and their worktree groups.
|
||||
const projectSectionsRef = React.useRef<typeof projectSections>([]);
|
||||
|
||||
const collapseAllProjects = React.useCallback(() => {
|
||||
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||
setVisibleSessionCountByGroup(new Map());
|
||||
setCollapsedGroups(() => {
|
||||
const allGroupKeys = new Set<string>();
|
||||
projectSectionsRef.current.forEach((section) => {
|
||||
section.groups.forEach((group) => {
|
||||
if (!group.isMain) allGroupKeys.add(`${section.project.id}:${group.id}`);
|
||||
});
|
||||
});
|
||||
return allGroupKeys;
|
||||
});
|
||||
setCollapsedProjects(() => {
|
||||
const allIds = new Set(projects.map((p) => p.id));
|
||||
try {
|
||||
@@ -916,6 +964,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const expandAllProjects = React.useCallback(() => {
|
||||
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||
setVisibleSessionCountByGroup(new Map());
|
||||
setCollapsedGroups(new Set());
|
||||
setCollapsedProjects(() => {
|
||||
const empty = new Set<string>();
|
||||
try {
|
||||
@@ -1131,7 +1180,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
deleteFolderConfirm,
|
||||
bulkDeleteConfirm,
|
||||
collapsedGroups,
|
||||
groupOrderByProject,
|
||||
};
|
||||
const previousSidebarRenderSourcesRef = React.useRef<typeof sidebarRenderSources | null>(null);
|
||||
const previousSidebarRenderSources = previousSidebarRenderSourcesRef.current;
|
||||
@@ -1191,6 +1239,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
projectSections,
|
||||
groupSearchDataByGroup,
|
||||
sectionsForRender,
|
||||
flatSectionsForRender,
|
||||
searchMatchCount,
|
||||
} = useSessionSidebarSections({
|
||||
normalizedProjects: sortedProjects,
|
||||
@@ -1208,6 +1257,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
foldersMap,
|
||||
});
|
||||
|
||||
projectSectionsRef.current = projectSections;
|
||||
|
||||
const searchEmptyState = React.useMemo(() => (
|
||||
<div className="py-6 text-center text-muted-foreground">
|
||||
<p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noMatches.title')}</p>
|
||||
@@ -1344,41 +1395,30 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
);
|
||||
|
||||
|
||||
// Web/desktop route archived sessions to the Archive page; only the VS Code
|
||||
// compact webview keeps inline archived buckets behind its toggle.
|
||||
const showInlineArchived = isVSCode && showArchivedSessions;
|
||||
// 'by-worktree' renders the worktree-grouped sections (parallel-work
|
||||
// overview); 'flat' renders the merged per-project list. VS Code has no
|
||||
// worktree groups, so both resolve to the same shape — use flat there.
|
||||
const sessionGroupingMode = useSessionDisplayStore((state) => state.sessionGroupingMode);
|
||||
const useGroupedSections = sessionGroupingMode === 'by-worktree' && !isVSCode;
|
||||
const sectionsForSidebarRender = React.useMemo(() => {
|
||||
return showArchivedSessions
|
||||
? sectionsForRender
|
||||
: sectionsForRender.map((section) => ({
|
||||
...section,
|
||||
groups: section.groups.filter((group) => !group.isArchivedBucket),
|
||||
}));
|
||||
}, [sectionsForRender, showArchivedSessions]);
|
||||
|
||||
const prLookup = React.useMemo(() => {
|
||||
if (!isVisible) {
|
||||
return {
|
||||
keys: EMPTY_STRING_ARRAY,
|
||||
displayKeyByLookupKey: new Map<string, string>(),
|
||||
};
|
||||
}
|
||||
const keys = new Set<string>();
|
||||
const displayKeyByLookupKey = new Map<string, string>();
|
||||
sectionsForSidebarRender.forEach((section) => {
|
||||
section.groups.forEach((group) => {
|
||||
const directory = normalizePath(group.directory ?? null);
|
||||
const branch = group.branch?.trim() || gitBranches.get(directory || '')?.trim();
|
||||
if (!directory || !branch) {
|
||||
return;
|
||||
}
|
||||
const lookupKey = getGitHubPrStatusKey(directory, branch);
|
||||
keys.add(lookupKey);
|
||||
displayKeyByLookupKey.set(lookupKey, `${directory}::${branch}`);
|
||||
});
|
||||
});
|
||||
return { keys: [...keys], displayKeyByLookupKey };
|
||||
}, [gitBranches, isVisible, sectionsForSidebarRender]);
|
||||
|
||||
const prVisualSummaryMap = usePrVisualSummaryByKeys(prLookup.keys);
|
||||
const source = useGroupedSections ? sectionsForRender : flatSectionsForRender;
|
||||
return showInlineArchived
|
||||
? source
|
||||
: source.map((section) => (
|
||||
section.groups.some((group) => group.isArchivedBucket)
|
||||
? { ...section, groups: section.groups.filter((group) => !group.isArchivedBucket) }
|
||||
: section
|
||||
));
|
||||
}, [flatSectionsForRender, sectionsForRender, showInlineArchived, useGroupedSections]);
|
||||
|
||||
// Discover/refresh PR status for expanded projects' worktree branches so
|
||||
// session rows can tint their branch marker and show PR state in tooltips.
|
||||
// The data source is the worktree-grouped projectSections (data layer), not
|
||||
// the flat display sections.
|
||||
const retriedNoPrStatusKeysRef = React.useRef<Set<string>>(new Set());
|
||||
React.useEffect(() => {
|
||||
if (!isVisible || !githubAuthChecked || !githubAuthStatus?.connected || !github) {
|
||||
return;
|
||||
@@ -1387,12 +1427,15 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
const targetsByKey = new Map<string, { directory: string; branch: string }>();
|
||||
const now = Date.now();
|
||||
|
||||
sectionsForSidebarRender.forEach((section) => {
|
||||
projectSections.forEach((section) => {
|
||||
if (collapsedProjects.has(section.project.id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
section.groups.forEach((group) => {
|
||||
if (group.isArchivedBucket || group.isMain) {
|
||||
return;
|
||||
}
|
||||
const directory = normalizePath(group.directory ?? null);
|
||||
const branch = group.branch?.trim() || gitBranches.get(directory || '')?.trim();
|
||||
if (!directory || !branch) {
|
||||
@@ -1452,8 +1495,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
githubAuthStatus?.connected,
|
||||
isVisible,
|
||||
gitBranches,
|
||||
projectSections,
|
||||
refreshPrStatusTargets,
|
||||
sectionsForSidebarRender,
|
||||
setPrStatusParams,
|
||||
]);
|
||||
|
||||
@@ -1529,6 +1572,29 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
),
|
||||
);
|
||||
|
||||
// Selection scope is the project id; bulk folder actions need the project's
|
||||
// directory scopes (root + worktrees) to resolve folders across worktrees.
|
||||
const folderScopesByProject = React.useMemo(() => {
|
||||
const map = new Map<string, Array<{ scopeKey: string; directory: string | null }>>();
|
||||
flatSectionsForRender.forEach((section) => {
|
||||
const flatGroup = section.groups.find((group) => !group.isArchivedBucket);
|
||||
if (flatGroup?.folderScopes && flatGroup.folderScopes.length > 0) {
|
||||
map.set(section.project.id, flatGroup.folderScopes);
|
||||
}
|
||||
});
|
||||
return map;
|
||||
}, [flatSectionsForRender]);
|
||||
|
||||
const renderProjectStatusIndicator = React.useCallback((_projectId: string, groups: SessionGroup[]) => {
|
||||
const directories: Array<string | null> = [];
|
||||
groups.forEach((group) => {
|
||||
if (group.isArchivedBucket) return;
|
||||
directories.push(group.directory);
|
||||
group.folderScopes?.forEach((scope) => directories.push(scope.directory));
|
||||
});
|
||||
return <ProjectAggregateStatusIndicator directories={directories} />;
|
||||
}, []);
|
||||
|
||||
const toggleCollapsedGroup = React.useCallback((key: string) => {
|
||||
resetGroupSessionLimit(key);
|
||||
setCollapsedGroups((prev) => {
|
||||
@@ -1539,31 +1605,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
});
|
||||
}, [resetGroupSessionLimit]);
|
||||
|
||||
const prVisualStateByDirectoryBranch = React.useMemo(() => {
|
||||
const result = new Map<string, PrIndicator>();
|
||||
for (const [key, summary] of prVisualSummaryMap) {
|
||||
const displayKey = prLookup.displayKeyByLookupKey.get(key);
|
||||
if (!displayKey) {
|
||||
continue;
|
||||
}
|
||||
result.set(displayKey, {
|
||||
visualState: summary.visualState as PrVisualState,
|
||||
number: summary.number,
|
||||
url: summary.url,
|
||||
state: summary.prState as 'open' | 'closed' | 'merged',
|
||||
draft: summary.draft,
|
||||
title: summary.title,
|
||||
base: summary.base,
|
||||
head: summary.head,
|
||||
checks: summary.checks as PrIndicator['checks'],
|
||||
canMerge: summary.canMerge,
|
||||
mergeableState: summary.mergeableState,
|
||||
repo: summary.repo,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}, [prLookup.displayKeyByLookupKey, prVisualSummaryMap]);
|
||||
|
||||
const renderGroupSessions = React.useCallback(
|
||||
(
|
||||
group: SessionGroup,
|
||||
@@ -1579,6 +1620,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
groupKey={groupKey}
|
||||
projectId={projectId}
|
||||
hideGroupLabel={hideGroupLabel}
|
||||
dragHandleProps={dragHandleProps}
|
||||
compactBodyPadding={compactBodyPadding}
|
||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||
@@ -1593,8 +1635,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
showDeletionDialog={showDeletionDialog}
|
||||
setDeleteFolderConfirm={setDeleteFolderConfirm}
|
||||
renderSessionNode={renderSessionNode}
|
||||
projectRepoStatus={projectRepoStatus}
|
||||
lastRepoStatus={lastRepoStatusRef.current}
|
||||
showMoreGroupSessions={showMoreGroupSessions}
|
||||
resetGroupSessionLimit={resetGroupSessionLimit}
|
||||
mobileVariant={mobileVariant}
|
||||
@@ -1616,9 +1656,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
editingId={editingId}
|
||||
editTitle={editTitle}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
|
||||
onToggleCollapsedGroup={toggleCollapsedGroup}
|
||||
dragHandleProps={dragHandleProps}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
/>
|
||||
),
|
||||
@@ -1635,7 +1673,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
deleteFolder,
|
||||
showDeletionDialog,
|
||||
renderSessionNode,
|
||||
projectRepoStatus,
|
||||
showMoreGroupSessions,
|
||||
resetGroupSessionLimit,
|
||||
mobileVariant,
|
||||
@@ -1655,7 +1692,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
editingId,
|
||||
editTitle,
|
||||
openSidebarMenuKey,
|
||||
prVisualStateByDirectoryBranch,
|
||||
toggleCollapsedGroup,
|
||||
],
|
||||
);
|
||||
@@ -1694,6 +1730,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
isInlineEditing,
|
||||
showDeletionDialog,
|
||||
foldersMap,
|
||||
folderScopesByProject,
|
||||
addSessionsToFolder,
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
@@ -1710,6 +1747,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
}, [mobileVariant, openMultiRunLauncher, setActiveMainTab, setSessionSwitcherOpen]);
|
||||
|
||||
const handleOpenNewSessionDraftFromHeader = React.useCallback(() => {
|
||||
useUIStore.getState().closeMainSurfaces();
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) {
|
||||
setSessionSwitcherOpen(false);
|
||||
@@ -1718,6 +1756,13 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
|
||||
|
||||
return (
|
||||
// One shared tooltip provider for the whole sidebar: session tooltips open
|
||||
// instantly, and moving between rows hands the tooltip over (grouping)
|
||||
// instead of replaying the exit/enter animation for each row.
|
||||
// closeDelay bridges the small gap between rows: the tooltip survives the
|
||||
// pointer crossing row margins, and the grouping timeout hands it over to
|
||||
// the next row without an exit/enter cycle.
|
||||
<TooltipProvider delay={0} closeDelay={150} timeout={600}>
|
||||
<div
|
||||
ref={sessionSearchContainerRef}
|
||||
className={cn(
|
||||
@@ -1751,13 +1796,24 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
recentSessions={recentSessions}
|
||||
prefetchSession={sync.prefetchSession}
|
||||
/>
|
||||
{!hideDirectoryControls && !isVSCode ? (
|
||||
<SidebarNav onNewSession={handleOpenNewSessionDraftFromHeader} />
|
||||
) : null}
|
||||
|
||||
<SidebarHeader
|
||||
hideDirectoryControls={hideDirectoryControls}
|
||||
showRecentControls={!isVSCode}
|
||||
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
|
||||
openNewSessionDraft={handleOpenNewSessionDraftFromHeader}
|
||||
onOpenScheduled={() => {
|
||||
if (mobileVariant) setSessionSwitcherOpen(false);
|
||||
setScheduledTasksDialogOpen(true);
|
||||
}}
|
||||
onOpenMultiRun={handleOpenMultiRunFromHeader}
|
||||
canOpenMultiRun={projects.length > 0}
|
||||
openMultiRunLauncher={handleOpenMultiRunFromHeader}
|
||||
onOpenArchive={() => {
|
||||
if (mobileVariant) setSessionSwitcherOpen(false);
|
||||
setArchivePageOpen(true);
|
||||
}}
|
||||
headerActionIconClass={headerActionIconClass}
|
||||
headerActionButtonClass={headerActionButtonClass}
|
||||
isSessionSearchOpen={isSessionSearchOpen}
|
||||
@@ -1769,7 +1825,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
searchMatchCount={searchMatchCount}
|
||||
collapseAllProjects={collapseAllProjects}
|
||||
expandAllProjects={expandAllProjects}
|
||||
openScheduledTasksDialog={() => setScheduledTasksDialogOpen(true)}
|
||||
selectionModeEnabled={selectionModeEnabled}
|
||||
onToggleSelectionMode={handleToggleSelectionMode}
|
||||
/>
|
||||
@@ -1799,6 +1854,10 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
setSessionSwitcherOpen={setSessionSwitcherOpen}
|
||||
openNewSessionDraft={openNewSessionDraftFromTree}
|
||||
openNewWorktreeDialog={openNewWorktreeDialog}
|
||||
openWorktreesPage={(projectId) => {
|
||||
if (mobileVariant) setSessionSwitcherOpen(false);
|
||||
setWorktreesPageProjectId(projectId);
|
||||
}}
|
||||
openProjectEditDialog={setEditingProjectDialogId}
|
||||
removeProject={removeProject}
|
||||
projectHeaderSentinelRefs={projectHeaderSentinelRefs}
|
||||
@@ -1806,6 +1865,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
projectSortOrder={projectSortOrder}
|
||||
getOrderedGroups={getOrderedGroups}
|
||||
setGroupOrderByProject={setGroupOrderByProject}
|
||||
renderProjectStatusIndicator={renderProjectStatusIndicator}
|
||||
openSidebarMenuKey={openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||
isInlineEditing={isInlineEditing}
|
||||
@@ -1875,8 +1935,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<ScheduledTasksDialog />
|
||||
|
||||
<SessionDeleteConfirmDialog
|
||||
value={deleteSessionConfirm}
|
||||
setValue={setDeleteSessionConfirm}
|
||||
@@ -1899,6 +1957,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||
onConfirm={confirmBulkDelete}
|
||||
/>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -3,18 +3,16 @@
|
||||
## Refactor result
|
||||
|
||||
- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components.
|
||||
- Sidebar is now a single multi-project tree: `recent` top section, then projects, then worktrees/archived groups, then sessions.
|
||||
- `NavRail` is no longer part of sidebar/navigation flow.
|
||||
- Project headers now own root sessions directly; there is no separate rendered `project root` subgroup.
|
||||
- Active/hover row styling is text-first; selected sessions use primary text instead of background fills.
|
||||
- Archived groups are collapsed by default and support bulk deletion at group/folder level.
|
||||
- Session rows support compact inline dates in minimal mode and simplified metadata in default mode.
|
||||
- Layout (web/desktop): top navigation (`SidebarNav`: New session, Scheduled, Multi-run, Archive), then the `recent` zone, then one zone per project with a **flat** session list. There is no rendered worktree grouping level.
|
||||
- **Two grouping display modes** (`useSessionDisplayStore.sessionGroupingMode`, toggled in the view dropdown): `'by-worktree'` (default) renders the worktree-grouped `sectionsForRender` with slim PR-aware branch sub-headers inside each project zone; `'flat'` renders `flatSectionsForRender` — one merged non-archived group per project (`id: 'flat'`, `folderScopes` listing every contributing scope) with per-row branch markers. Both derive from the same `projectSections` data layer, which alone feeds bootstrap demand planning and PR polling.
|
||||
- Project headers are sticky background "zone" bands (`SortableProjectItem`); the `recent` section header uses the same band styling. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
|
||||
- Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread.
|
||||
- Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project).
|
||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Unarchive is not possible through the upstream OpenCode HTTP API (`session.update` can only set a finite `time.archived`).
|
||||
- Scheduled tasks (`ScheduledTasksDialog`, now a full-page surface on web/desktop) and per-project worktree management (`WorktreesView`, opened from the project menu) render as overlays inside `<main>` in `MainLayout`; the sidebar no longer mounts them.
|
||||
- Group-level PR-status polling/indicators and worktree-group drag-to-reorder were removed together with the worktree grouping level; `oc.sessions.groupOrder` is no longer read or written. Worktree PR/branch context lives in the Worktrees surface.
|
||||
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
|
||||
- Directory loading is demand-driven: the sidebar publishes one complete priority plan for all known project/worktree directories, while the sync layer owns bounded execution.
|
||||
- New extractions in latest pass reduced local effect/callback bulk further:
|
||||
- project session list builders
|
||||
- authoritative deletion cleanup
|
||||
- sticky project header observer
|
||||
|
||||
## VS Code grouping
|
||||
|
||||
@@ -26,14 +24,15 @@
|
||||
|
||||
### Components
|
||||
|
||||
- `SidebarHeader.tsx`: Top header UI for add-project, session search, and display mode.
|
||||
- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only.
|
||||
- `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all).
|
||||
- `SidebarNav.tsx`: Text navigation rows above the tree (New session, Scheduled, Multi-run, Archive); hidden in VS Code.
|
||||
- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only, styled as a zone header.
|
||||
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
|
||||
- `SidebarProjectsList.tsx`: Main scrollable tree renderer for projects, root sessions, worktrees/groups, and empty/search states.
|
||||
- `SessionGroupSection.tsx`: Renders a single worktree/archived group, collapse/expand, folder subtree, group-level controls, and explicit loading/error/retry state for empty groups.
|
||||
- `SessionNodeItem.tsx`: Renders one session row/tree node with inline metadata, menu actions, minimal/default variants, and nested children. Rows do not initiate directory bootstrap on mount.
|
||||
- `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder.
|
||||
- `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows.
|
||||
- `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Rows do not initiate directory bootstrap on mount.
|
||||
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
|
||||
- `sortableItems.tsx`: DnD sortable wrappers for project and group ordering plus project-row action affordances.
|
||||
- `sortableItems.tsx`: DnD sortable wrapper for project ordering plus the sticky zone-band project header and its action affordances.
|
||||
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
|
||||
- `sessionOwnership.ts`: Resolves session directories once into shared project/worktree ownership and folder-scope indexes.
|
||||
|
||||
@@ -45,7 +44,6 @@
|
||||
- `hooks/useSessionGrouping.ts`: Builds grouped session structures and search text/filter helpers.
|
||||
- `hooks/useSessionSidebarSections.ts`: Composes final per-project sections and group search metadata for rendering.
|
||||
- `hooks/useProjectSessionSelection.ts`: Resolves active/current project-session selection logic and session-directory context.
|
||||
- `hooks/useGroupOrdering.ts`: Applies persisted/custom group order with stable fallback ordering; archived groups are reorderable.
|
||||
- `hooks/useArchivedAutoFolders.ts`: Maintains archived auto-folder structure and assignment behavior.
|
||||
- `hooks/useSidebarPersistence.ts`: Persists sidebar UI state (expanded/collapsed/pinned/group order/active session) to storage + desktop settings.
|
||||
- `hooks/useProjectRepoStatus.ts`: Tracks per-project git-repo state and root branch metadata.
|
||||
|
||||
@@ -14,8 +14,8 @@ import { cn } from '@/lib/utils';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { SessionFolderItem } from '../SessionFolderItem';
|
||||
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
|
||||
import type { SortableDragHandleProps } from './sortableItems';
|
||||
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
|
||||
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
|
||||
import { isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import { compareSessionsByLifecycleOrder, EMPTY_SESSION_ORDER_RANKS } from '@/sync/session-ordering';
|
||||
@@ -28,11 +28,8 @@ import {
|
||||
selectFolderRootNodes,
|
||||
} from './sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useChildStoreManager } from '@/sync/sync-context';
|
||||
|
||||
@@ -71,8 +68,6 @@ type Props = {
|
||||
renderContext?: 'project' | 'recent',
|
||||
renderExtras?: SessionNodeRenderExtras,
|
||||
) => React.ReactNode;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
lastRepoStatus: boolean;
|
||||
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void;
|
||||
resetGroupSessionLimit: (groupKey: string) => void;
|
||||
mobileVariant: boolean;
|
||||
@@ -94,29 +89,6 @@ type Props = {
|
||||
editingId: string | null;
|
||||
editTitle: string;
|
||||
openSidebarMenuKey: string | null;
|
||||
prVisualStateByDirectoryBranch: Map<string, {
|
||||
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
|
||||
number: number;
|
||||
url: string | null;
|
||||
state: 'open' | 'closed' | 'merged';
|
||||
draft: boolean;
|
||||
title: string | null;
|
||||
base: string | null;
|
||||
head: string | null;
|
||||
checks: {
|
||||
state: 'success' | 'failure' | 'pending' | 'unknown';
|
||||
total: number;
|
||||
success: number;
|
||||
failure: number;
|
||||
pending: number;
|
||||
} | null;
|
||||
canMerge: boolean | null;
|
||||
mergeableState: string | null;
|
||||
repo: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
} | null;
|
||||
}>;
|
||||
onToggleCollapsedGroup: (groupKey: string) => void;
|
||||
dragHandleProps?: SortableDragHandleProps | null;
|
||||
compactBodyPadding?: boolean;
|
||||
@@ -176,13 +148,6 @@ const groupHasExpansionMembershipChange = (
|
||||
return group.sessions.some(visit);
|
||||
};
|
||||
|
||||
const getProjectRepoStatusValue = (props: Props): boolean | null | undefined => {
|
||||
if (!props.projectId) return undefined;
|
||||
return props.projectRepoStatus.has(props.projectId)
|
||||
? props.projectRepoStatus.get(props.projectId)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
// Bail on Object.is for the props that drive the most work: the group
|
||||
// itself, its key, and the group-level chrome. These change rarely and
|
||||
@@ -200,11 +165,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.projectRepoStatus !== next.projectRepoStatus
|
||||
&& getProjectRepoStatusValue(prev) !== getProjectRepoStatusValue(next)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.pinnedSessionIds !== next.pinnedSessionIds
|
||||
&& groupHasPinnedMembershipChange(next.group, prev.pinnedSessionIds, next.pinnedSessionIds)) {
|
||||
return false;
|
||||
@@ -236,20 +196,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
if (prevMenuSessionId || nextMenuSessionId) return false;
|
||||
}
|
||||
|
||||
// Per-row / per-state props. The PR-visual-state map flips frequently
|
||||
// during bootstrap but a single group's value is usually stable, so we
|
||||
// compare only the value this group actually consumes instead of the
|
||||
// whole map reference.
|
||||
if (prev.prVisualStateByDirectoryBranch !== next.prVisualStateByDirectoryBranch) {
|
||||
const prevVal = prev.group?.directory && prev.group?.branch
|
||||
? prev.prVisualStateByDirectoryBranch.get(`${prev.group.directory}::${prev.group.branch.trim()}`)
|
||||
: undefined;
|
||||
const nextVal = next.group?.directory && next.group?.branch
|
||||
? next.prVisualStateByDirectoryBranch.get(`${next.group.directory}::${next.group.branch.trim()}`)
|
||||
: undefined;
|
||||
if (!Object.is(prevVal, nextVal)) return false;
|
||||
}
|
||||
|
||||
// Other props are typically stable references from the parent. Default
|
||||
// to reference equality (the cheap path) and only re-render when the
|
||||
// parent actually swapped something.
|
||||
@@ -264,7 +210,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
&& prev.showDeletionDialog === next.showDeletionDialog
|
||||
&& prev.setDeleteFolderConfirm === next.setDeleteFolderConfirm
|
||||
&& prev.renderSessionNode === next.renderSessionNode
|
||||
&& prev.lastRepoStatus === next.lastRepoStatus
|
||||
&& prev.showMoreGroupSessions === next.showMoreGroupSessions
|
||||
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit
|
||||
&& prev.mobileVariant === next.mobileVariant
|
||||
@@ -306,8 +251,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
showDeletionDialog,
|
||||
setDeleteFolderConfirm,
|
||||
renderSessionNode,
|
||||
projectRepoStatus,
|
||||
lastRepoStatus,
|
||||
showMoreGroupSessions,
|
||||
resetGroupSessionLimit,
|
||||
mobileVariant,
|
||||
@@ -328,7 +271,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
sessionOrderIndex,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
prVisualStateByDirectoryBranch,
|
||||
onToggleCollapsedGroup,
|
||||
dragHandleProps,
|
||||
compactBodyPadding = false,
|
||||
@@ -347,11 +289,17 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}, [pinnedSessionIds, sessionOrderIndex]);
|
||||
|
||||
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
||||
// VS Code always uses the expanded layout (see SessionNodeItem).
|
||||
const isMinimalMode = displayMode === 'minimal' && !isVSCodeRuntime();
|
||||
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
|
||||
// PR state for the worktree sub-header (grouped display mode).
|
||||
const groupPrKey = React.useMemo(() => {
|
||||
if (group.isMain || group.isArchivedBucket || hideGroupLabel) return null;
|
||||
const directory = normalizePath(group.directory ?? null);
|
||||
const branch = group.branch?.trim();
|
||||
return directory && branch ? getGitHubPrStatusKey(directory, branch) : null;
|
||||
}, [group.branch, group.directory, group.isArchivedBucket, group.isMain, hideGroupLabel]);
|
||||
const groupPrSummary = usePrVisualSummary(groupPrKey);
|
||||
const groupPrColor = groupPrSummary ? `var(--pr-${groupPrSummary.visualState})` : undefined;
|
||||
const childStores = useChildStoreManager();
|
||||
const bootstrapDirectory = normalizePath(group.directory ?? null);
|
||||
const bootstrapState = React.useSyncExternalStore(
|
||||
@@ -375,9 +323,16 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
[compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents],
|
||||
);
|
||||
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
||||
// Merged flat groups list every contributing scope; single-scope groups
|
||||
// (archived buckets, VS Code workspaces) fall back to folderScopeKey.
|
||||
const folderScopes = React.useMemo<Array<{ scopeKey: string; directory: string | null }>>(() => {
|
||||
if (group.folderScopes && group.folderScopes.length > 0) return group.folderScopes;
|
||||
return folderScopeKey ? [{ scopeKey: folderScopeKey, directory: group.directory ?? null }] : [];
|
||||
}, [folderScopeKey, group.directory, group.folderScopes]);
|
||||
const scopeFolders = React.useMemo(
|
||||
() => folderScopeKey ? (foldersMap[folderScopeKey] ?? []) : [],
|
||||
[folderScopeKey, foldersMap]
|
||||
() => folderScopes.flatMap(({ scopeKey, directory }) =>
|
||||
(foldersMap[scopeKey] ?? []).map((folder) => ({ folder, scopeKey, scopeDirectory: directory }))),
|
||||
[folderScopes, foldersMap]
|
||||
);
|
||||
|
||||
const nodeBySessionId = React.useMemo(() => {
|
||||
@@ -394,9 +349,9 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
return map;
|
||||
}, [sourceGroupNodes]);
|
||||
|
||||
const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map((folder) => {
|
||||
const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map(({ folder, scopeKey, scopeDirectory }) => {
|
||||
const nodes = selectFolderRootNodes(folder.sessionIds, nodeBySessionId).sort(compareSessionNodes);
|
||||
return { folder, nodes };
|
||||
return { folder, scopeKey, scopeDirectory, nodes };
|
||||
}), [scopeFolders, nodeBySessionId, compareSessionNodes]);
|
||||
|
||||
const allFoldersForGroup = React.useMemo(() => {
|
||||
@@ -707,87 +662,15 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isGitProject = projectId && projectRepoStatus.has(projectId)
|
||||
? Boolean(projectRepoStatus.get(projectId))
|
||||
: lastRepoStatus;
|
||||
const groupDirectoryKey = normalizePath(group.directory ?? null);
|
||||
const groupBranchKey = group.branch?.trim() ?? null;
|
||||
const prIndicator = groupDirectoryKey && groupBranchKey
|
||||
? (prVisualStateByDirectoryBranch.get(`${groupDirectoryKey}::${groupBranchKey}`) ?? null)
|
||||
const showBranchSubtitle = !group.isMain && Boolean(group.branch);
|
||||
const statusLine = group.branch && isBranchDifferentFromLabel(group.branch, group.label)
|
||||
? { label: group.branch, color: null as string | null }
|
||||
: null;
|
||||
const showInlinePrTitle = Boolean(prIndicator && group.branch);
|
||||
const showBranchSubtitle = !prIndicator && !group.isMain && Boolean(group.branch);
|
||||
const prVisualState = prIndicator?.visualState ?? null;
|
||||
const checksSummary = prIndicator && prIndicator.state === 'open' && prIndicator.checks
|
||||
? t('sessions.sidebar.group.pr.checksPassed', {
|
||||
success: prIndicator.checks.success,
|
||||
total: prIndicator.checks.total,
|
||||
})
|
||||
: null;
|
||||
const checksTail = prIndicator && prIndicator.state === 'open' && prIndicator.checks
|
||||
? [
|
||||
prIndicator.checks.failure > 0
|
||||
? t('sessions.sidebar.group.pr.failingCount', { count: prIndicator.checks.failure })
|
||||
: null,
|
||||
prIndicator.checks.pending > 0
|
||||
? t('sessions.sidebar.group.pr.pendingCount', { count: prIndicator.checks.pending })
|
||||
: null,
|
||||
].filter((item): item is string => Boolean(item)).join(', ')
|
||||
: null;
|
||||
const mergeabilityLabel = prIndicator && prIndicator.state === 'open'
|
||||
? (prIndicator.mergeableState === 'blocked' || prIndicator.mergeableState === 'dirty'
|
||||
? t('sessions.sidebar.group.pr.conflictsOrBlocked')
|
||||
: (prIndicator.mergeableState === 'clean' || prIndicator.canMerge === true ? t('sessions.sidebar.group.pr.mergeable') : null))
|
||||
: null;
|
||||
const mergeStateLabel = prIndicator && prIndicator.state === 'open' && prIndicator.mergeableState
|
||||
? t('sessions.sidebar.group.pr.mergeState', { state: prIndicator.mergeableState })
|
||||
: null;
|
||||
const baseBranchLabel = prIndicator?.base ?? null;
|
||||
const headBranchLabel = prIndicator?.head ?? null;
|
||||
const statusLine = (() => {
|
||||
if (!prIndicator) {
|
||||
return group.branch && isBranchDifferentFromLabel(group.branch, group.label)
|
||||
? { label: group.branch, color: null as string | null }
|
||||
: null;
|
||||
}
|
||||
switch (prIndicator.visualState) {
|
||||
case 'merged':
|
||||
return { label: t('sessions.sidebar.group.pr.status.merged'), color: 'var(--pr-merged)' };
|
||||
case 'open':
|
||||
return (prIndicator.canMerge === true || prIndicator.mergeableState === 'clean' || prIndicator.checks?.state === 'success')
|
||||
? { label: t('sessions.sidebar.group.pr.status.readyToMerge'), color: 'var(--pr-open)' }
|
||||
: { label: t('sessions.sidebar.group.pr.status.open'), color: 'var(--pr-open)' };
|
||||
case 'blocked':
|
||||
return {
|
||||
label: prIndicator.mergeableState === 'dirty'
|
||||
? t('sessions.sidebar.group.pr.status.mergeConflicts')
|
||||
: t('sessions.sidebar.group.pr.status.mergeBlocked'),
|
||||
color: 'var(--pr-blocked)',
|
||||
};
|
||||
case 'draft':
|
||||
return { label: t('sessions.sidebar.group.pr.status.draft'), color: 'var(--pr-draft)' };
|
||||
case 'closed':
|
||||
return { label: t('sessions.sidebar.group.pr.status.closed'), color: 'var(--pr-closed)' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const branchIconColor = statusLine?.color ?? (prVisualState ? `var(--pr-${prVisualState})` : undefined);
|
||||
const handlePrLinkClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const url = prIndicator?.url;
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
void openExternalUrl(url);
|
||||
};
|
||||
|
||||
const renderOneFolderItem = (folder: SessionFolder, nodes: SessionNode[], depth: number): React.ReactNode => {
|
||||
const directSubFolders = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id);
|
||||
const subFolderItems = directSubFolders.length > 0
|
||||
? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}</>
|
||||
: undefined;
|
||||
type FolderEntry = (typeof allFoldersForGroup)[number];
|
||||
|
||||
const renderOneFolderItem = (entry: FolderEntry, displayName: string): React.ReactNode => {
|
||||
const { folder, scopeKey, scopeDirectory, nodes } = entry;
|
||||
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
|
||||
|
||||
return (
|
||||
@@ -795,12 +678,12 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
{(droppableRef, isDropTarget) => (
|
||||
<SessionFolderItem
|
||||
folder={folder}
|
||||
displayName={displayName}
|
||||
sessions={nodes}
|
||||
subFolderItems={subFolderItems}
|
||||
isCollapsed={hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id)}
|
||||
onToggle={() => toggleFolderCollapse(folder.id)}
|
||||
onRename={(name) => {
|
||||
if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name);
|
||||
renameFolder(scopeKey, folder.id, name);
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (group.isArchivedBucket) {
|
||||
@@ -812,15 +695,14 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!folderScopeKey) return;
|
||||
if (!showDeletionDialog) {
|
||||
deleteFolder(folderScopeKey, folder.id);
|
||||
deleteFolder(scopeKey, folder.id);
|
||||
return;
|
||||
}
|
||||
const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length;
|
||||
const sessionCount = nodes.length;
|
||||
setDeleteFolderConfirm({
|
||||
scopeKey: folderScopeKey,
|
||||
scopeKey,
|
||||
folderId: folder.id,
|
||||
folderName: folder.name,
|
||||
subFolderCount,
|
||||
@@ -836,7 +718,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
childRenderExtrasFor,
|
||||
})
|
||||
: undefined}
|
||||
groupDirectory={group.directory}
|
||||
groupDirectory={scopeDirectory ?? group.directory}
|
||||
projectId={projectId}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
@@ -845,8 +727,8 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
onRenameDraftChange={(value) => setRenameFolderDraft(value)}
|
||||
onRenameSave={() => {
|
||||
const trimmed = renameFolderDraft.trim();
|
||||
if (trimmed && folderScopeKey) {
|
||||
renameFolder(folderScopeKey, folder.id, trimmed);
|
||||
if (trimmed) {
|
||||
renameFolder(scopeKey, folder.id, trimmed);
|
||||
}
|
||||
setRenamingFolderId(null);
|
||||
setRenameFolderDraft('');
|
||||
@@ -857,17 +739,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}}
|
||||
droppableRef={droppableRef}
|
||||
isDropTarget={isDropTarget}
|
||||
depth={depth}
|
||||
depth={0}
|
||||
onNewSession={() => {
|
||||
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) setSessionSwitcherOpen(false);
|
||||
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: group.directory, targetFolderId: folder.id });
|
||||
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: scopeDirectory ?? group.directory, targetFolderId: folder.id });
|
||||
}}
|
||||
onNewSubFolder={depth === 0 ? () => {
|
||||
if (!folderScopeKey) return;
|
||||
createFolderAndStartRename(folderScopeKey, folder.id);
|
||||
} : undefined}
|
||||
hideActions={false}
|
||||
archivedBucket={group.isArchivedBucket === true}
|
||||
/>
|
||||
@@ -876,24 +754,55 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
);
|
||||
};
|
||||
|
||||
const renderFolderItems = () => rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0));
|
||||
// Folders render flat: nested folders keep their data-model parent link but
|
||||
// display at the same level with a "Parent / Child" path label, so sessions
|
||||
// never gain extra indentation. Collapsing a folder hides its whole subtree.
|
||||
const renderFolderItems = () => {
|
||||
const childEntriesByParentId = new Map<string, FolderEntry[]>();
|
||||
for (const entry of allFoldersForGroup) {
|
||||
const parentId = entry.folder.parentId;
|
||||
if (!parentId) continue;
|
||||
const existing = childEntriesByParentId.get(parentId);
|
||||
if (existing) existing.push(entry);
|
||||
else childEntriesByParentId.set(parentId, [entry]);
|
||||
}
|
||||
const out: React.ReactNode[] = [];
|
||||
const visit = (entry: FolderEntry, parentPath: string) => {
|
||||
const displayName = parentPath ? `${parentPath} / ${entry.folder.name}` : entry.folder.name;
|
||||
out.push(renderOneFolderItem(entry, displayName));
|
||||
const isFolderCollapsed = !hasSessionSearchQuery && collapsedFolderIds.has(entry.folder.id);
|
||||
if (isFolderCollapsed) return;
|
||||
(childEntriesByParentId.get(entry.folder.id) ?? []).forEach((child) => visit(child, displayName));
|
||||
};
|
||||
rootFolders.forEach((entry) => visit(entry, ''));
|
||||
return out;
|
||||
};
|
||||
// Reserve room for the hover-revealed header actions (new draft + delete
|
||||
// worktree) so they never overlap the label / PR badge.
|
||||
const hasWorktreeDeleteAction = Boolean(!group.isMain && group.worktree);
|
||||
const groupHeaderRightPadding = alwaysShowActions
|
||||
? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7')
|
||||
: isMinimalMode
|
||||
? (hasWorktreeDeleteAction
|
||||
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
||||
: 'pr-2')
|
||||
: (hasWorktreeDeleteAction
|
||||
? 'pr-5 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
||||
: 'pr-5');
|
||||
: (hasWorktreeDeleteAction
|
||||
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
||||
: 'pr-2 group-hover/gh:pr-7 group-focus-within/gh:pr-7');
|
||||
|
||||
const body = (
|
||||
<SessionFolderDndScope
|
||||
scopeKey={folderScopeKey}
|
||||
scopeKey={folderScopes[0]?.scopeKey ?? folderScopeKey}
|
||||
hasFolders={allFoldersForGroup.length > 0}
|
||||
onSessionDroppedOnFolder={(sessionId, folderId) => {
|
||||
if (folderScopeKey) addSessionToFolder(folderScopeKey, folderId, sessionId);
|
||||
const targetEntry = allFoldersForGroup.find(({ folder }) => folder.id === folderId);
|
||||
if (!targetEntry) return;
|
||||
// Clear membership in other scopes first — the store only dedupes
|
||||
// within one scope, and a session must live in a single folder.
|
||||
const foldersStore = useSessionFoldersStore.getState();
|
||||
for (const { scopeKey } of folderScopes) {
|
||||
if (scopeKey === targetEntry.scopeKey) continue;
|
||||
if (foldersStore.getSessionFolderId(scopeKey, sessionId)) {
|
||||
foldersStore.removeSessionFromFolder(scopeKey, sessionId);
|
||||
}
|
||||
}
|
||||
addSessionToFolder(targetEntry.scopeKey, folderId, sessionId);
|
||||
}}
|
||||
>
|
||||
{renderFolderItems()}
|
||||
@@ -964,7 +873,9 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}))
|
||||
)}
|
||||
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
|
||||
<div className="py-1 text-left typography-micro text-muted-foreground">
|
||||
// pl-[26px] lines the text up with the worktree sub-header label
|
||||
// (gutter + icon + gap).
|
||||
<div className="py-1 pl-[26px] text-left typography-micro text-muted-foreground">
|
||||
{group.isArchivedBucket
|
||||
? t('sessions.sidebar.group.empty.noArchivedSessions')
|
||||
: bootstrapState === 'queued' || bootstrapState === 'running'
|
||||
@@ -999,7 +910,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showMoreGroupSessions(groupKey, visibleSessions.length)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{t('sessions.sidebar.group.showMore')}
|
||||
</button>
|
||||
@@ -1008,7 +919,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetGroupSessionLimit(groupKey)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{t('sessions.sidebar.group.showFewer')}
|
||||
</button>
|
||||
@@ -1016,7 +927,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
</SessionFolderDndScope>
|
||||
);
|
||||
|
||||
const groupBodyPaddingClass = compactBodyPadding ? 'pb-2 pl-1' : 'pb-3 pl-4';
|
||||
// Rows own their left gutter (aligned with the zone-header text), so the
|
||||
// group body adds no extra indentation.
|
||||
void compactBodyPadding;
|
||||
// Folder nesting is legacy-only: existing sub-folders keep working (path
|
||||
// labels), but the UI no longer offers creating new ones.
|
||||
void createFolderAndStartRename;
|
||||
const groupBodyPaddingClass = 'pb-2';
|
||||
|
||||
if (hideGroupLabel) {
|
||||
return <div className="oc-group"><div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div></div>;
|
||||
@@ -1043,74 +960,16 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
<div
|
||||
ref={dragHandleProps?.setActivatorNodeRef}
|
||||
className={cn(
|
||||
'min-w-0 flex flex-1 items-start gap-1 overflow-hidden pl-0.5 transition-[padding] cursor-grab active:cursor-grabbing',
|
||||
// pl-1.5 lines the branch icon up with the project-zone header
|
||||
// icon (container pl-2.5 + 6px = band pl-4 past its -ml-2.5).
|
||||
'min-w-0 flex flex-1 items-start gap-1 overflow-hidden pl-1.5 transition-[padding]',
|
||||
groupHeaderRightPadding,
|
||||
)}
|
||||
{...(dragHandleProps?.listeners ?? {})}
|
||||
>
|
||||
<div className="min-w-0 flex flex-1 flex-col justify-center gap-0.5 overflow-hidden">
|
||||
<p className="text-[14px] font-normal truncate text-foreground/92">
|
||||
{showInlinePrTitle && prIndicator ? (
|
||||
<span className="inline-flex min-w-0 max-w-full items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 leading-none align-middle">
|
||||
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<Icon name="git-branch"
|
||||
className={cn('h-3.5 w-3.5 shrink-0', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')}
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-muted-foreground h-3.5 w-3.5 items-center justify-center',
|
||||
alwaysShowActions ? 'inline-flex' : 'hidden group-hover/gh:inline-flex',
|
||||
)}>
|
||||
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
</span>
|
||||
{prIndicator.url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center leading-none"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={handlePrLinkClick}
|
||||
>
|
||||
#{prIndicator.number}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex shrink-0 items-center leading-none">#{prIndicator.number}</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
|
||||
<div className="space-y-1 text-xs">
|
||||
{(baseBranchLabel || headBranchLabel) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{baseBranchLabel && headBranchLabel ? (
|
||||
<>
|
||||
<span>{baseBranchLabel}</span>
|
||||
<Icon name="arrow-left-long" className="mx-0.5 inline h-3 w-3 align-[-2px]" />
|
||||
<span>{headBranchLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
|
||||
{(mergeabilityLabel || checksSummary) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{mergeabilityLabel ?? ''}
|
||||
{mergeabilityLabel && checksSummary ? ' • ' : ''}
|
||||
{checksSummary ?? ''}
|
||||
{checksTail ? ` (${checksTail})` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="ml-1 min-w-0 flex-1 truncate leading-none align-middle">{group.branch}</span>
|
||||
</span>
|
||||
) : group.isArchivedBucket ? (
|
||||
{group.isArchivedBucket ? (
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
|
||||
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<Icon name="archive" className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')} />
|
||||
@@ -1124,11 +983,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
|
||||
</span>
|
||||
) : (!group.isMain || group.worktree) ? (
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
|
||||
// Worktree sub-header in the flat visual language: slim
|
||||
// folder-style row with a PR-tinted branch icon and PR badge.
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5">
|
||||
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<Icon name="git-branch"
|
||||
className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')}
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}
|
||||
className={cn('h-3.5 w-3.5 shrink-0', !groupPrColor && 'text-muted-foreground', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')}
|
||||
style={groupPrColor ? { color: groupPrColor } : undefined}
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-muted-foreground h-3.5 w-3.5 items-center justify-center',
|
||||
@@ -1137,7 +998,17 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
|
||||
<span className="min-w-0 truncate typography-ui-label font-semibold text-muted-foreground">
|
||||
{renderHighlightedText(group.label, normalizedSessionSearchQuery)}
|
||||
</span>
|
||||
{groupPrSummary ? (
|
||||
<span
|
||||
className="flex-shrink-0 text-[0.72rem] font-medium leading-none"
|
||||
style={groupPrColor ? { color: groupPrColor } : undefined}
|
||||
>
|
||||
#{groupPrSummary.number}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
renderHighlightedText(group.label, normalizedSessionSearchQuery)
|
||||
@@ -1147,51 +1018,10 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 leading-tight">
|
||||
{group.isArchivedBucket ? (
|
||||
<Icon name="archive" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (!group.isMain || isGitProject) ? (
|
||||
showInlinePrTitle && prIndicator ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
<Icon name="git-branch" className="h-3.5 w-3.5 text-muted-foreground"
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
|
||||
<div className="space-y-1 text-xs">
|
||||
{(baseBranchLabel || headBranchLabel) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{baseBranchLabel && headBranchLabel ? (
|
||||
<>
|
||||
<span>{baseBranchLabel}</span>
|
||||
<Icon name="arrow-left-long" className="mx-0.5 inline h-3 w-3 align-[-2px]" />
|
||||
<span>{headBranchLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
|
||||
{(mergeabilityLabel || checksSummary) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{mergeabilityLabel ?? ''}
|
||||
{mergeabilityLabel && checksSummary ? ' • ' : ''}
|
||||
{checksSummary ?? ''}
|
||||
{checksTail ? ` (${checksTail})` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Icon name="git-branch" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}/>
|
||||
)
|
||||
) : null}
|
||||
<span
|
||||
className={cn('min-w-0 truncate text-[11px] font-medium', !statusLine.color && 'text-muted-foreground/80')}
|
||||
style={statusLine.color ? { color: statusLine.color } : undefined}
|
||||
>
|
||||
) : (
|
||||
<Icon name="git-branch" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate text-[11px] font-medium text-muted-foreground/80">
|
||||
{statusLine.label}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from '@/components/ui/dropdown-menu.styles';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -29,8 +29,10 @@ import { DraggableSessionRow } from './sessionFolderDnd';
|
||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils';
|
||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from './types';
|
||||
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -129,6 +131,14 @@ type Props = {
|
||||
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
|
||||
};
|
||||
|
||||
// Shared row geometry: the gutter edge matches the zone-header band padding
|
||||
// (px-1.5 = 6px), the marker slot is icon-wide (14px) with a 6px gap, so row
|
||||
// text starts exactly where the zone-header label starts. Nested children
|
||||
// shift by one gutter step per depth level.
|
||||
const ROW_GUTTER_LEFT_PX = 6;
|
||||
const ROW_DEPTH_STEP_PX = 14;
|
||||
const ROW_TEXT_LEFT_PX = ROW_GUTTER_LEFT_PX + 14 + 6;
|
||||
|
||||
const cancelScrollAnchorByContainer = new WeakMap<HTMLElement, () => void>();
|
||||
|
||||
const holdSessionRowPosition = (target: HTMLElement): void => {
|
||||
@@ -284,15 +294,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
menuOpenSessionId,
|
||||
childRenderExtrasFor,
|
||||
} = props;
|
||||
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
|
||||
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
|
||||
|
||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
// VS Code always uses the minimal (single-line) layout: sessions are grouped
|
||||
// under workspace project headers, so the second metadata row (project/branch)
|
||||
// is redundant. The display-mode toggle is hidden there, so force it on.
|
||||
const isMinimalMode = displayMode === 'minimal' || isVSCode;
|
||||
const isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []);
|
||||
const runtimeApis = React.useContext(RuntimeAPIContext);
|
||||
const revealOnHoverClass = isVSCode
|
||||
@@ -303,25 +306,22 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
: 'group-hover:opacity-0 group-focus-within:opacity-0';
|
||||
const showOpenInEditorAction = isVSCode;
|
||||
const showQuickArchiveAction = !archivedBucket && !mobileVariant;
|
||||
const revealPaddingClass = isMinimalMode
|
||||
? (isVSCode
|
||||
// VS Code minimal rows reveal up to three actions on hover
|
||||
// (open-in-editor + quick-archive + menu, each h-4). The date sits in the
|
||||
// row flow, so the title must shrink enough to clear the actions or they
|
||||
// overlap the timestamp. Open-in-editor is always present in VS Code.
|
||||
? (showQuickArchiveAction && showOpenInEditorAction
|
||||
? 'group-hover:pr-18'
|
||||
: showQuickArchiveAction || showOpenInEditorAction
|
||||
? 'group-hover:pr-14'
|
||||
: 'group-hover:pr-8')
|
||||
: 'group-hover:pr-2 group-focus-within:pr-2')
|
||||
: (isVSCode
|
||||
? (showQuickArchiveAction && showOpenInEditorAction
|
||||
? 'group-hover:pr-18'
|
||||
: showQuickArchiveAction || showOpenInEditorAction
|
||||
? 'group-hover:pr-12'
|
||||
: 'group-hover:pr-5')
|
||||
: (showQuickArchiveAction ? 'group-hover:pr-12 group-focus-within:pr-12' : 'group-hover:pr-5 group-focus-within:pr-5'));
|
||||
const revealPaddingClass = isVSCode
|
||||
// VS Code rows reveal up to three actions on hover
|
||||
// (open-in-editor + quick-archive + menu, each h-4). The date sits in the
|
||||
// row flow, so the title must shrink enough to clear the actions or they
|
||||
// overlap the timestamp. Open-in-editor is always present in VS Code.
|
||||
? (showQuickArchiveAction && showOpenInEditorAction
|
||||
? 'group-hover:pr-18'
|
||||
: showQuickArchiveAction || showOpenInEditorAction
|
||||
? 'group-hover:pr-14'
|
||||
: 'group-hover:pr-8')
|
||||
// Reserve just enough room for the hover-revealed actions (two 16px
|
||||
// buttons + gap, anchored at the row edge past the title's own end) so
|
||||
// they never overlap the title without leaving a large hole.
|
||||
: (showQuickArchiveAction
|
||||
? 'group-hover:pr-7 group-focus-within:pr-7'
|
||||
: 'group-hover:pr-3 group-focus-within:pr-3');
|
||||
const alwaysActionPaddingClass = showQuickArchiveAction ? 'pr-13' : 'pr-7';
|
||||
const suppressNextSelectRef = React.useRef(false);
|
||||
const [isTouchPressed, setIsTouchPressed] = React.useState(false);
|
||||
@@ -338,11 +338,63 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
|
||||
const session = node.session;
|
||||
const resolvedSession = session;
|
||||
// Tooltip context: recent rows receive project/branch via secondaryMeta;
|
||||
// project rows resolve them from the row's own props/node instead.
|
||||
const projectLabelFromStore = useProjectsStore(
|
||||
React.useCallback((state) => {
|
||||
if (secondaryMeta?.projectLabel || !projectId) return null;
|
||||
const project = state.projects.find((entry) => entry.id === projectId);
|
||||
if (!project) return null;
|
||||
return project.label?.trim() || formatDirectoryName(normalizePath(project.path) ?? project.path, null) || project.path;
|
||||
}, [projectId, secondaryMeta?.projectLabel]),
|
||||
);
|
||||
const tooltipProjectLabel = secondaryMeta?.projectLabel
|
||||
?? (projectLabelFromStore ? formatProjectLabel(projectLabelFromStore) : null);
|
||||
const tooltipBranchLabel = secondaryMeta?.branchLabel ?? node.worktree?.branch ?? null;
|
||||
const prLookupKey = React.useMemo(() => {
|
||||
if (isVSCode) return null;
|
||||
const branch = node.worktree?.branch?.trim();
|
||||
const directory = normalizePath(node.worktree?.path ?? null);
|
||||
return branch && directory ? getGitHubPrStatusKey(directory, branch) : null;
|
||||
}, [isVSCode, node.worktree]);
|
||||
const prSummary = usePrVisualSummary(prLookupKey);
|
||||
const prIconColor = prSummary ? `var(--pr-${prSummary.visualState})` : undefined;
|
||||
const sessionGroupingMode = useSessionDisplayStore((state) => state.sessionGroupingMode);
|
||||
// In by-worktree grouping the project tree already shows the branch on the
|
||||
// group sub-header, so the per-row marker only appears in flat mode and in
|
||||
// the mixed-context recent list.
|
||||
const showInlineBranchMarker = Boolean(tooltipBranchLabel)
|
||||
&& (renderContext === 'recent' || sessionGroupingMode === 'flat');
|
||||
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 isActive = useSessionUIStore((state) => state.currentSessionId === session.id);
|
||||
|
||||
const sessionDirectory =
|
||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(groupDirectory ?? null);
|
||||
// Multi-select scope: sessions are flat per project, so selection groups by
|
||||
// project (falling back to the directory when no project is known) — a
|
||||
// selection must survive mixing sessions from different worktrees.
|
||||
const selectionScopeKey = projectId ?? sessionDirectory ?? null;
|
||||
// Directory bootstrap is scheduled once at sidebar level. A row only needs
|
||||
// the lightweight store reference for scoped state and export actions.
|
||||
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: false });
|
||||
@@ -418,8 +470,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const isSessionMenuOpen = isMenuOpen || isContextMenuOpen;
|
||||
const isMultiRunLikeSession = React.useMemo(() => parseMultiRunSessionTitle(resolvedSession.title) !== null, [resolvedSession.title]);
|
||||
const [fusionDialogOpen, setFusionDialogOpen] = React.useState(false);
|
||||
const metadataSubsessionChevron = isVSCode && renderContext === 'recent' && !isMinimalMode;
|
||||
const inlineSubsessionChevron = isVSCode && renderContext === 'recent' && isMinimalMode;
|
||||
|
||||
const descendantCount = React.useMemo(() => collectNodeDescendantIds(node).length, [collectNodeDescendantIds, node]);
|
||||
|
||||
@@ -526,7 +576,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
React.useEffect(() => {
|
||||
if (editingId !== session.id) return;
|
||||
const handleDocMouseDown = (e: MouseEvent) => {
|
||||
if (formRef.current && !formRef.current.contains(e.target as Node)) {
|
||||
// The same session can be rendered twice (recent + project), each with
|
||||
// its own rename form. A click inside ANY rename form for this session
|
||||
// must not count as "outside", or the sibling instance would save and
|
||||
// exit the rename mid-edit.
|
||||
const target = e.target as HTMLElement | null;
|
||||
const withinRenameForm = target?.closest?.(`[data-session-rename-form="${CSS.escape(session.id)}"]`);
|
||||
if (formRef.current && !withinRenameForm) {
|
||||
handleSaveEditRef.current(renameDraftRef.current);
|
||||
}
|
||||
};
|
||||
@@ -550,11 +606,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn('group relative flex items-center rounded-sm px-1.5 py-1', depth > 0 && 'pl-[20px]')}
|
||||
style={{ paddingLeft: ROW_TEXT_LEFT_PX + depth * ROW_DEPTH_STEP_PX }}
|
||||
// my-0.5 matches the normal row box so entering rename mode does not
|
||||
// shift the row vertically.
|
||||
className="group relative my-0.5 flex items-center rounded-sm py-1 pr-1.5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0">
|
||||
<form
|
||||
ref={formRef}
|
||||
data-session-rename-form={session.id}
|
||||
className="flex w-full items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -593,16 +653,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<Icon name="close" className="size-4" />
|
||||
</button>
|
||||
</form>
|
||||
{!isMinimalMode ? (
|
||||
<div className="flex items-center justify-between gap-3 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
|
||||
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
{hasChildren ? <span className="inline-flex items-center justify-center flex-shrink-0">{isExpanded ? <Icon name="arrow-down-s" className="h-3 w-3" /> : <Icon name="arrow-right-s" className="h-3 w-3" />}</span> : null}
|
||||
<span className="flex-shrink-0">{sessionUpdatedLabel}</span>
|
||||
{hasSecondaryProjectLabel ? <span className="truncate">{secondaryMeta?.projectLabel}</span> : null}
|
||||
{hasSecondaryBranchLabel ? <span className="inline-flex min-w-0 items-center gap-0.5"><Icon name="git-branch" className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" /><span className="truncate">{secondaryMeta?.branchLabel}</span></span> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -615,10 +665,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const showStatusMarker = isStreaming || showUnreadStatus;
|
||||
const statusMarkerContent = isStreaming
|
||||
? (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full bg-primary animate-busy-pulse"
|
||||
<Icon
|
||||
name="loader-4"
|
||||
className="h-3 w-3 animate-spin text-primary"
|
||||
aria-label={t('sessions.sidebar.session.status.active')}
|
||||
title={t('sessions.sidebar.session.status.active')}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
@@ -639,9 +689,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
);
|
||||
const leadingIndicators = isMovingToWorktree || showStatusMarker || showPinnedMarker ? (
|
||||
<span
|
||||
style={{ left: ROW_GUTTER_LEFT_PX + depth * ROW_DEPTH_STEP_PX }}
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0.5 inline-flex h-3.5 w-3.5 items-center justify-center transition-opacity',
|
||||
isMinimalMode ? 'top-1/2 -translate-y-1/2' : 'top-[14.5px] -translate-y-1/2',
|
||||
'pointer-events-none absolute top-1/2 inline-flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center transition-opacity',
|
||||
hideLeadingIndicatorOnHover ? 'opacity-100 group-hover:opacity-0 group-focus-within:opacity-0' : '',
|
||||
)}
|
||||
>
|
||||
@@ -661,6 +711,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
// Blur mouse-click focus so the hover-only chevron/indicator swap
|
||||
// resets on mouse-leave instead of sticking via :focus-within.
|
||||
event.currentTarget.blur();
|
||||
toggleParent(expansionKey);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
@@ -670,15 +723,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
toggleParent(expansionKey);
|
||||
}
|
||||
}}
|
||||
style={{ minWidth: 14, minHeight: 14 }}
|
||||
style={{ minWidth: 14, minHeight: 14, left: ROW_GUTTER_LEFT_PX + depth * ROW_DEPTH_STEP_PX }}
|
||||
className={cn(
|
||||
'inline-flex h-3.5 w-3.5 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||
metadataSubsessionChevron
|
||||
? 'absolute left-1.5 bottom-1'
|
||||
: inlineSubsessionChevron
|
||||
? 'relative mr-0.5 shrink-0'
|
||||
: cn('absolute left-0.5', isMinimalMode ? 'top-1/2 -translate-y-1/2' : 'top-[14.5px] -translate-y-1/2'),
|
||||
!metadataSubsessionChevron && !inlineSubsessionChevron && hideChevronUntilHover
|
||||
'absolute top-1/2 inline-flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||
hideChevronUntilHover
|
||||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto'
|
||||
: '',
|
||||
)}
|
||||
@@ -788,10 +836,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const currentAnchor = useSessionMultiSelectStore.getState().anchorId;
|
||||
const descendantsById = new Map<string, string[]>();
|
||||
descendantsById.set(session.id, collectNodeDescendantIds(node));
|
||||
setRowRange(currentAnchor, session.id, orderedIds, sessionDirectory ?? null, descendantsById);
|
||||
setRowRange(currentAnchor, session.id, orderedIds, selectionScopeKey, descendantsById);
|
||||
return;
|
||||
}
|
||||
toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node));
|
||||
toggleRowSelected(session.id, selectionScopeKey, collectNodeDescendantIds(node));
|
||||
return;
|
||||
}
|
||||
if (event?.currentTarget) holdSessionRowPosition(event.currentTarget);
|
||||
@@ -918,31 +966,72 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
) : null}
|
||||
|
||||
{sessionDirectory && !archivedBucket ? (() => {
|
||||
const scopeFolders = getFoldersForScope(sessionDirectory);
|
||||
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
|
||||
// Folders are flat per project: list folders from every scope of the
|
||||
// owning project (root + all worktrees) so sessions can be filed
|
||||
// across worktrees. Each action targets the folder's owning scope,
|
||||
// and moving between scopes clears the previous membership first.
|
||||
const scopes: string[] = [];
|
||||
const pushScope = (candidate: string | null | undefined) => {
|
||||
const normalized = normalizePath(candidate ?? null);
|
||||
if (normalized && !scopes.includes(normalized)) scopes.push(normalized);
|
||||
};
|
||||
if (projectId && !isVSCode) {
|
||||
const project = useProjectsStore.getState().projects.find((entry) => entry.id === projectId);
|
||||
const projectRoot = normalizePath(project?.path ?? null);
|
||||
pushScope(projectRoot);
|
||||
if (projectRoot) {
|
||||
(useSessionUIStore.getState().availableWorktreesByProject.get(projectRoot) ?? [])
|
||||
.forEach((worktree) => pushScope(worktree.path));
|
||||
}
|
||||
}
|
||||
pushScope(sessionDirectory);
|
||||
const folderEntries = scopes.flatMap((scope) =>
|
||||
getFoldersForScope(scope).map((folder) => ({ scope, folder })));
|
||||
const currentEntry = folderEntries.find(({ scope, folder }) =>
|
||||
getSessionFolderId(scope, session.id) === folder.id) ?? null;
|
||||
const defaultScope = scopes[0] ?? sessionDirectory;
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<Sub>
|
||||
<SubTrigger className="[&>svg]:mr-1"><Icon name="folder" className="h-4 w-4" />{t('sessions.sidebar.folders.moveToFolder')}</SubTrigger>
|
||||
<SubContent className="min-w-[180px]">
|
||||
{scopeFolders.length === 0 ? (
|
||||
{folderEntries.length === 0 ? (
|
||||
<Item disabled className="text-muted-foreground">{t('sessions.sidebar.folders.none')}</Item>
|
||||
) : (
|
||||
scopeFolders.map((folder) => (
|
||||
<Item key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
{currentFolderId === folder.id ? <Icon name="check" className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
|
||||
</Item>
|
||||
))
|
||||
folderEntries.map(({ scope, folder }) => {
|
||||
const isCurrent = currentEntry?.folder.id === folder.id;
|
||||
return (
|
||||
<Item key={folder.id} onClick={() => {
|
||||
if (isCurrent) {
|
||||
removeSessionFromFolder(scope, session.id);
|
||||
return;
|
||||
}
|
||||
if (currentEntry && currentEntry.scope !== scope) {
|
||||
removeSessionFromFolder(currentEntry.scope, session.id);
|
||||
}
|
||||
addSessionToFolder(scope, folder.id, session.id);
|
||||
}}>
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
{isCurrent ? <Icon name="check" className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
|
||||
</Item>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<Separator />
|
||||
<Item onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
|
||||
<Item onClick={() => {
|
||||
const newFolder = createFolderAndStartRename(defaultScope);
|
||||
if (!newFolder) return;
|
||||
if (currentEntry && currentEntry.scope !== defaultScope) {
|
||||
removeSessionFromFolder(currentEntry.scope, session.id);
|
||||
}
|
||||
addSessionToFolder(defaultScope, newFolder.id, session.id);
|
||||
}}>
|
||||
<Icon name="add" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.folders.newFolderEllipsis')}
|
||||
</Item>
|
||||
{currentFolderId ? (
|
||||
<Item onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
|
||||
{currentEntry ? (
|
||||
<Item onClick={() => { removeSessionFromFolder(currentEntry.scope, session.id); }} className="text-destructive focus:text-destructive">
|
||||
<Icon name="close" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.folders.removeFromFolder')}
|
||||
</Item>
|
||||
@@ -1067,17 +1156,16 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
render={
|
||||
<div
|
||||
data-session-row={session.id}
|
||||
data-session-scope={sessionDirectory ?? ''}
|
||||
data-session-scope={selectionScopeKey ?? ''}
|
||||
data-session-archived={archivedBucket ? '1' : '0'}
|
||||
onClick={handleRowBackgroundClick}
|
||||
// Row geometry mirrors the zone-header band: full container
|
||||
// width, px-1.5 inner edge, a 14px icon-wide gutter (status
|
||||
// marker / chevron) plus a 6px gap, so the title starts at the
|
||||
// same x as the header text. Children indent one gutter step.
|
||||
style={{ paddingLeft: ROW_TEXT_LEFT_PX + depth * ROW_DEPTH_STEP_PX }}
|
||||
className={cn(
|
||||
'group relative my-0.5 flex cursor-pointer items-center rounded-md py-1 pr-1.5',
|
||||
// Pull the row box left into the container gutter so the
|
||||
// selection highlight covers the chevron/status markers
|
||||
// (which sit in that gutter), then re-pad so the title text
|
||||
// stays put.
|
||||
'-ml-3',
|
||||
depth > 0 ? 'pl-[32px]' : 'pl-[18px]',
|
||||
// Active (currently open) session gets a subtle primary tint;
|
||||
// multi-select highlight takes precedence when both apply.
|
||||
isActive && !isRowSelected && 'bg-primary/10',
|
||||
@@ -1089,7 +1177,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
{leadingIndicators}
|
||||
{subsessionChevron}
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
{isMinimalMode ? (
|
||||
{(
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -1111,24 +1199,41 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
: revealPaddingClass,
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
|
||||
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||
<div className="flex w-full items-center min-w-0 flex-1 gap-1 overflow-hidden">
|
||||
{/* Unread emphasis is color-only: a font-weight change
|
||||
would reflow the truncated title and cause a micro
|
||||
horizontal shift when the status flips. */}
|
||||
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : needsAttention ? 'text-foreground' : 'text-foreground/80')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||
{alwaysShowActions ? (
|
||||
// Touch runtimes have no hover tooltip, so the compact
|
||||
// date stays inline there.
|
||||
<span className="ml-2 inline-flex flex-shrink-0 items-center gap-1 text-[0.72rem] text-muted-foreground/75">
|
||||
{sessionGoalGlyph}
|
||||
{showInlineBranchMarker ? (
|
||||
<Icon
|
||||
name="git-branch"
|
||||
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
|
||||
style={prIconColor ? { color: prIconColor } : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{sessionCompactUpdatedLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{!alwaysShowActions ? (
|
||||
<div className="relative ml-1 flex h-4 min-w-4 flex-shrink-0 items-center justify-end">
|
||||
) : (sessionGoalGlyph || showInlineBranchMarker) ? (
|
||||
<div className="relative ml-1 flex h-4 flex-shrink-0 items-center justify-end">
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1 whitespace-nowrap text-right text-[0.72rem] text-muted-foreground/75 transition-opacity duration-150',
|
||||
'inline-flex items-center gap-1 whitespace-nowrap text-right transition-opacity duration-150',
|
||||
isSessionMenuOpen
|
||||
? 'opacity-0'
|
||||
: hideOnHoverClass,
|
||||
)}>
|
||||
{sessionGoalGlyph}
|
||||
{sessionCompactUpdatedLabel}
|
||||
{showInlineBranchMarker ? (
|
||||
<Icon
|
||||
name="git-branch"
|
||||
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
|
||||
style={prIconColor ? { color: prIconColor } : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1145,68 +1250,40 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
the per-row metadata tooltip is redundant noise there. */}
|
||||
{!isVSCode ? (
|
||||
<TooltipContent side="right" sideOffset={8} className="max-w-xs text-left">
|
||||
<div className="flex flex-col gap-1 text-left text-xs">
|
||||
<div className={cn('flex items-center gap-3 text-left text-muted-foreground', secondaryMeta?.projectLabel ? 'justify-between' : 'justify-start')}>
|
||||
{secondaryMeta?.projectLabel ? <div className="min-w-0 truncate">{secondaryMeta.projectLabel}</div> : null}
|
||||
<div className="flex-shrink-0">{sessionUpdatedLabel}</div>
|
||||
<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">{sessionTitle}</span>
|
||||
<span className="flex-shrink-0 text-muted-foreground" title={sessionUpdatedLabel}>{sessionCompactUpdatedLabel}</span>
|
||||
</div>
|
||||
{secondaryMeta?.branchLabel ? (
|
||||
<div className="flex items-center gap-3 text-left text-muted-foreground justify-start">
|
||||
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
<span className="inline-flex min-w-0 items-center gap-0.5"><Icon name="git-branch" className="h-3 w-3 flex-shrink-0" /><span className="truncate">{secondaryMeta.branchLabel}</span></span>
|
||||
</div>
|
||||
{tooltipProjectLabel ? (
|
||||
<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">{tooltipProjectLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{tooltipBranchLabel ? (
|
||||
<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">{tooltipBranchLabel}</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>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={handleRowPointerDown}
|
||||
onPointerUp={handleRowPointerEnd}
|
||||
onPointerCancel={handleRowPointerEnd}
|
||||
onMouseDown={handleRowMouseDown}
|
||||
onClick={(event) => handleRowSelect(event)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSessionDoubleClick(session.id, sessionTitle);
|
||||
}}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]',
|
||||
isTouchPressed && 'bg-interactive-hover/70',
|
||||
alwaysShowActions
|
||||
? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass)
|
||||
: revealPaddingClass
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
|
||||
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}>
|
||||
<Icon name="shield" className="h-3 w-3" />
|
||||
<span className="leading-none">{pendingPermissionCount}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isMinimalMode ? (
|
||||
<div className="flex items-center justify-between gap-3 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
|
||||
<div className={cn('flex min-w-0 items-center gap-1.5 overflow-hidden', metadataSubsessionChevron && hasChildren ? 'pl-4' : '')}>
|
||||
{sessionGoalGlyph}
|
||||
<span className="flex-shrink-0">{sessionUpdatedLabel}</span>
|
||||
{hasSecondaryProjectLabel ? <span className="truncate">{secondaryMeta?.projectLabel}</span> : null}
|
||||
{hasSecondaryBranchLabel ? <span className="inline-flex min-w-0 items-center gap-0.5"><Icon name="git-branch" className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" /><span className="truncate">{secondaryMeta?.branchLabel}</span></span> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{streamingIndicator && !mobileVariant ? (
|
||||
<div className={cn('absolute top-1/2 -translate-y-1/2 z-10', isMinimalMode ? 'right-0' : 'right-[30px]')}>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 z-10">
|
||||
{streamingIndicator}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1223,8 +1300,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<QuickSessionAction
|
||||
archiveLabel={t('sessions.sidebar.bulkActions.archive')}
|
||||
deleteLabel={t('sessions.sidebar.bulkActions.delete')}
|
||||
buttonSizeClass={isMinimalMode && !alwaysShowActions ? 'h-4 w-4' : 'h-6 w-6'}
|
||||
iconSizeClass={isMinimalMode && !alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5'}
|
||||
buttonSizeClass={!alwaysShowActions ? 'h-4 w-4' : 'h-6 w-6'}
|
||||
iconSizeClass={!alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5'}
|
||||
onPointerDown={handleQuickArchivePointerDown}
|
||||
onMouseDown={handleQuickArchiveMouseDown}
|
||||
onArchive={handleQuickArchiveClick}
|
||||
@@ -1238,7 +1315,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||
isMinimalMode && !alwaysShowActions ? 'h-4 w-4' : 'h-6 w-6',
|
||||
!alwaysShowActions ? 'h-4 w-4' : 'h-6 w-6',
|
||||
)}
|
||||
aria-label={t('sessions.sidebar.session.actions.openInEditor')}
|
||||
onPointerDown={handleOpenInEditorPointerDown}
|
||||
@@ -1246,7 +1323,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
onClick={handleOpenInEditorClick}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Icon name="external-link" className={cn(isMinimalMode && !alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
||||
<Icon name="external-link" className={cn(!alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={8}>
|
||||
@@ -1260,7 +1337,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||
isMinimalMode && !alwaysShowActions
|
||||
!alwaysShowActions
|
||||
? (isSessionMenuOpen
|
||||
? 'h-4 w-4 opacity-100'
|
||||
: cn('h-4 w-4 opacity-0', revealOnHoverClass))
|
||||
@@ -1272,7 +1349,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
onClick={handleMenuTriggerClick}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Icon name="more-2" className={cn(isMinimalMode && !alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
||||
<Icon name="more-2" className={cn(!alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
{sessionMenuContent}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SessionNode } from './types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import {
|
||||
collectSubtreeContainingId,
|
||||
@@ -61,6 +62,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
||||
const flatVariant = variant === 'flat';
|
||||
@@ -132,7 +134,9 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(flatVariant ? 'space-y-0.5 pb-2' : 'space-y-2 pb-2 pt-1')}>
|
||||
// No top padding: the recent header must start flush with the scroll
|
||||
// edge, otherwise it visually "bumps" a few pixels before sticking.
|
||||
<div className={cn(flatVariant ? 'space-y-0.5 pb-2' : 'space-y-2 pb-2')}>
|
||||
{visibleSections.map((section) => {
|
||||
const isCollapsed = collapsed.has(section.key);
|
||||
const visibleLimit = Math.max(
|
||||
@@ -162,7 +166,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{t('sessions.sidebar.group.showMore')}
|
||||
</button>
|
||||
@@ -172,26 +176,36 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={section.key} className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection(section.key)}
|
||||
className="group flex w-full items-center gap-1 rounded-md px-0.5 py-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<span className="inline-flex h-4 w-4 items-center justify-center text-muted-foreground">
|
||||
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<span className="text-[14px] font-normal text-foreground/95">{section.title}</span>
|
||||
</button>
|
||||
<div key={section.key} className="relative space-y-1">
|
||||
{/* Zone header styled like a project header band; sticky with a
|
||||
solid sidebar backing so rows never show through. */}
|
||||
<div className={cn(
|
||||
'-ml-2.5 -mr-2',
|
||||
stickyZoneHeaders && 'oc-zone-header-backing sticky top-0 z-20 bg-sidebar',
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection(section.key)}
|
||||
className="group flex w-full items-center gap-1.5 py-1 pl-4 pr-3.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<span className="inline-flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Icon name="history" className={cn('h-3.5 w-3.5 text-muted-foreground/80', 'group-hover:hidden')} />
|
||||
<span className="hidden h-3.5 w-3.5 items-center justify-center text-muted-foreground group-hover:inline-flex">
|
||||
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[14px] font-semibold lowercase text-foreground">{section.title}</span>
|
||||
</button>
|
||||
</div>
|
||||
{!isCollapsed ? (
|
||||
<div className={cn('space-y-0.5 pl-7')}>
|
||||
<div className={cn('space-y-0.5')}>
|
||||
{visibleItems.map(renderItem)}
|
||||
{remainingCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{t('sessions.sidebar.group.showMore')}
|
||||
</button>
|
||||
@@ -200,7 +214,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetSectionLimit(section.key)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{t('sessions.sidebar.group.showFewer')}
|
||||
</button>
|
||||
|
||||
@@ -3,24 +3,25 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type Props = {
|
||||
hideDirectoryControls: boolean;
|
||||
showRecentControls: boolean;
|
||||
handleOpenDirectoryDialog: () => void;
|
||||
openNewSessionDraft: () => void;
|
||||
onOpenScheduled: () => void;
|
||||
onOpenMultiRun: () => void;
|
||||
canOpenMultiRun: boolean;
|
||||
openMultiRunLauncher: () => void;
|
||||
onOpenArchive: () => void;
|
||||
headerActionIconClass: string;
|
||||
headerActionButtonClass: string;
|
||||
isSessionSearchOpen: boolean;
|
||||
@@ -32,7 +33,6 @@ type Props = {
|
||||
searchMatchCount: number;
|
||||
collapseAllProjects: () => void;
|
||||
expandAllProjects: () => void;
|
||||
openScheduledTasksDialog: () => void;
|
||||
selectionModeEnabled: boolean;
|
||||
onToggleSelectionMode: () => void;
|
||||
};
|
||||
@@ -43,9 +43,10 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
hideDirectoryControls,
|
||||
showRecentControls,
|
||||
handleOpenDirectoryDialog,
|
||||
openNewSessionDraft,
|
||||
onOpenScheduled,
|
||||
onOpenMultiRun,
|
||||
canOpenMultiRun,
|
||||
openMultiRunLauncher,
|
||||
onOpenArchive,
|
||||
headerActionIconClass,
|
||||
headerActionButtonClass,
|
||||
isSessionSearchOpen,
|
||||
@@ -57,21 +58,18 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
searchMatchCount,
|
||||
collapseAllProjects,
|
||||
expandAllProjects,
|
||||
openScheduledTasksDialog,
|
||||
selectionModeEnabled,
|
||||
onToggleSelectionMode,
|
||||
} = props;
|
||||
|
||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
|
||||
const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions);
|
||||
const setDisplayMode = useSessionDisplayStore((state) => state.setDisplayMode);
|
||||
const toggleRecentSection = useSessionDisplayStore((state) => state.toggleRecentSection);
|
||||
const toggleArchivedSessions = useSessionDisplayStore((state) => state.toggleArchivedSessions);
|
||||
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
|
||||
const setProjectSortOrder = useSessionDisplayStore((state) => state.setProjectSortOrder);
|
||||
// VS Code forces the expanded layout, so the mode toggle is meaningless there.
|
||||
const showDisplayModeToggle = !isVSCodeRuntime();
|
||||
const sessionGroupingMode = useSessionDisplayStore((state) => state.sessionGroupingMode);
|
||||
const setSessionGroupingMode = useSessionDisplayStore((state) => state.setSessionGroupingMode);
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
const toggleStickyZoneHeaders = useSessionDisplayStore((state) => state.toggleStickyZoneHeaders);
|
||||
|
||||
if (hideDirectoryControls) {
|
||||
return null;
|
||||
@@ -81,13 +79,17 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<div className="select-none flex-shrink-0 px-2.5 py-1">
|
||||
<div className="flex h-auto min-h-8 flex-col gap-1">
|
||||
<div className="flex h-8 items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Quiet toolbar under the New-session CTA: project/surface entry
|
||||
points at left, list controls at right. ml-[3px] compensates the
|
||||
icon inset inside the 24px buttons so the first glyph lines up
|
||||
with the New-session icon above (16px from the sidebar edge). */}
|
||||
<div className="ml-[3px] flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenDirectoryDialog}
|
||||
className={headerActionButtonClass}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||
aria-label={t('sessions.sidebar.header.actions.addProject')}
|
||||
>
|
||||
<Icon name="folder-add" className={headerActionIconClass} />
|
||||
@@ -100,22 +102,22 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewSessionDraft}
|
||||
className={headerActionButtonClass}
|
||||
aria-label={t('sessions.sidebar.header.actions.newSession')}
|
||||
onClick={onOpenScheduled}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||
aria-label={t('sessions.sidebar.header.actions.scheduledTasks')}
|
||||
>
|
||||
<Icon name="chat-new" className={headerActionIconClass} />
|
||||
<Icon name="calendar-schedule" className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.newSession')}</p></TooltipContent>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.scheduledTasks')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openMultiRunLauncher}
|
||||
className={headerActionButtonClass}
|
||||
onClick={onOpenMultiRun}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||
aria-label={t('sessions.sidebar.header.actions.newMultiRun')}
|
||||
disabled={!canOpenMultiRun}
|
||||
>
|
||||
@@ -129,14 +131,14 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openScheduledTasksDialog}
|
||||
className={headerActionButtonClass}
|
||||
aria-label={t('sessions.sidebar.header.actions.scheduledTasks')}
|
||||
onClick={onOpenArchive}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||
aria-label={t('sessions.sidebar.nav.archive')}
|
||||
>
|
||||
<Icon name="calendar-schedule" className={headerActionIconClass} />
|
||||
<Icon name="archive" className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.scheduledTasks')}</p></TooltipContent>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.nav.archive')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +148,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSessionSearchOpen((prev) => !prev)}
|
||||
className={headerActionButtonClass}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||
aria-label={t('sessions.sidebar.header.actions.searchSessions')}
|
||||
aria-expanded={isSessionSearchOpen}
|
||||
>
|
||||
@@ -161,7 +163,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSelectionMode}
|
||||
className={cn(headerActionButtonClass, selectionModeEnabled && 'bg-interactive-hover text-primary')}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent', selectionModeEnabled && 'bg-interactive-hover text-primary')}
|
||||
aria-label={selectionModeEnabled
|
||||
? t('sessions.sidebar.header.actions.exitSelection')
|
||||
: t('sessions.sidebar.header.actions.selectSessions')}
|
||||
@@ -183,62 +185,8 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={headerActionButtonClass}
|
||||
aria-label={t('sessions.sidebar.header.actions.sortProjects')}
|
||||
>
|
||||
<Icon name="sort-desc" className={headerActionIconClass} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.sortProjects')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[160px]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setProjectSortOrder('manual')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.projectSort.manual')}</span>
|
||||
{projectSortOrder === 'manual' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setProjectSortOrder('a-z')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.projectSort.aToZ')}</span>
|
||||
{projectSortOrder === 'a-z' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setProjectSortOrder('z-a')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.projectSort.zToA')}</span>
|
||||
{projectSortOrder === 'z-a' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setProjectSortOrder('date-added')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.projectSort.dateAdded')}</span>
|
||||
{projectSortOrder === 'date-added' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setProjectSortOrder('recent')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.projectSort.recent')}</span>
|
||||
{projectSortOrder === 'recent' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={headerActionButtonClass}
|
||||
aria-label={t('sessions.sidebar.header.actions.sessionDisplayMode')}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||
aria-label={t('sessions.sidebar.header.displayMode.label')}
|
||||
>
|
||||
<Icon name="equalizer-2" className={headerActionIconClass} />
|
||||
</button>
|
||||
@@ -246,44 +194,56 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.displayMode.label')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[160px]">
|
||||
{showDisplayModeToggle ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('default')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.displayMode.default')}</span>
|
||||
{displayMode === 'default' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('minimal')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.displayMode.minimal')}</span>
|
||||
{displayMode === 'minimal' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
<DropdownMenuLabel>{t('sessions.sidebar.header.actions.sortProjects')}</DropdownMenuLabel>
|
||||
{([
|
||||
['manual', 'sessions.sidebar.header.projectSort.manual'],
|
||||
['a-z', 'sessions.sidebar.header.projectSort.aToZ'],
|
||||
['z-a', 'sessions.sidebar.header.projectSort.zToA'],
|
||||
['date-added', 'sessions.sidebar.header.projectSort.dateAdded'],
|
||||
['recent', 'sessions.sidebar.header.projectSort.recent'],
|
||||
] as const).map(([order, labelKey]) => (
|
||||
<DropdownMenuItem
|
||||
key={order}
|
||||
onClick={() => setProjectSortOrder(order)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t(labelKey)}</span>
|
||||
{projectSortOrder === order ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t('sessions.sidebar.header.grouping.label')}</DropdownMenuLabel>
|
||||
{([
|
||||
['by-worktree', 'sessions.sidebar.header.grouping.byWorktree'],
|
||||
['flat', 'sessions.sidebar.header.grouping.flat'],
|
||||
] as const).map(([mode, labelKey]) => (
|
||||
<DropdownMenuItem
|
||||
key={mode}
|
||||
onClick={() => setSessionGroupingMode(mode)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t(labelKey)}</span>
|
||||
{sessionGroupingMode === mode ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{showRecentControls ? (
|
||||
<>
|
||||
{showDisplayModeToggle ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
onClick={toggleRecentSection}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.displayMode.showRecent')}</span>
|
||||
{showRecentSection ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={toggleArchivedSessions}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.displayMode.showArchived')}</span>
|
||||
{showArchivedSessions ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuItem
|
||||
onClick={toggleRecentSection}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.displayMode.showRecent')}</span>
|
||||
{showRecentSection ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onClick={toggleStickyZoneHeaders}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.displayMode.stickyHeaders')}</span>
|
||||
{stickyZoneHeaders ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={collapseAllProjects} className="flex items-center gap-2">
|
||||
<Icon name="contract-up-down" className="h-4 w-4" />
|
||||
@@ -293,7 +253,6 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<Icon name="expand-up-down" className="h-4 w-4" />
|
||||
<span>{t('sessions.sidebar.header.displayMode.expandAll')}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// Primary sidebar action: starting a session is the one control worth its own
|
||||
// row; it keeps the quiet text-row form so the top reads as content, while
|
||||
// every other control lives in the icon toolbar below.
|
||||
type Props = {
|
||||
onNewSession: () => void;
|
||||
};
|
||||
|
||||
export function SidebarNav(props: Props): React.ReactNode {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="select-none flex-shrink-0 px-2.5 pt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onNewSession}
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-left typography-ui-label font-normal text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
<Icon name="chat-new" className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{t('sessions.sidebar.header.actions.newSession')}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,9 @@ type Props = {
|
||||
compactBodyPadding?: boolean,
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>,
|
||||
) => React.ReactNode;
|
||||
getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[];
|
||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||
homeDirectory: string | null;
|
||||
collapsedProjects: Set<string>;
|
||||
hideDirectoryControls: boolean;
|
||||
@@ -67,13 +70,12 @@ type Props = {
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
openNewWorktreeDialog: () => void;
|
||||
openWorktreesPage: (id: string) => void;
|
||||
openProjectEditDialog: (id: string) => void;
|
||||
removeProject: (id: string) => void;
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
projectSortOrder: ProjectSortOrder;
|
||||
getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[];
|
||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
isInlineEditing: boolean;
|
||||
@@ -90,20 +92,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
|
||||
// Threaded into SessionGroupSection so the archived-bucket virtualizer
|
||||
// can resolve the scrolling ancestor synchronously (no getComputedStyle
|
||||
// walk) and skip the cost of a style recalc on every render.
|
||||
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
// Memoize the result of getOrderedGroups. The callback is stable
|
||||
// (deps: [groupOrderByProject]) and `section.groups` is a stable
|
||||
// reference from useSessionSidebarSections, but the caller discards
|
||||
// the result on every render and the callback allocates a new array
|
||||
// each time. With many projects and many sidebar re-renders this
|
||||
// builds O(P) arrays per render. The cache returns the same array
|
||||
// reference when the inputs haven't changed, so the downstream
|
||||
// orderedGroups.filter/find work and any consumer-memoization see a
|
||||
// stable reference.
|
||||
// Memoize getOrderedGroups per project so downstream consumers see a stable
|
||||
// array reference while inputs are unchanged (avoids O(P) fresh arrays per
|
||||
// list render invalidating the memoized group subtrees).
|
||||
const orderedGroupsCacheRef = React.useRef<Map<string, { groups: SessionGroup[]; ordered: SessionGroup[] }>>(new Map());
|
||||
const orderedGroupsCacheGetOrderedGroupsRef = React.useRef<typeof props.getOrderedGroups>(props.getOrderedGroups);
|
||||
if (orderedGroupsCacheGetOrderedGroupsRef.current !== props.getOrderedGroups) {
|
||||
@@ -118,9 +109,6 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
}
|
||||
const ordered = props.getOrderedGroups(projectId, groups);
|
||||
cache.set(projectId, { groups, ordered });
|
||||
// Bound the cache so re-ordering projects (which replaces the
|
||||
// projects list and invalidates every projectId) doesn't grow
|
||||
// unboundedly.
|
||||
if (cache.size > 256) {
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (firstKey !== undefined) cache.delete(firstKey);
|
||||
@@ -128,6 +116,11 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
return ordered;
|
||||
};
|
||||
|
||||
// Threaded into SessionGroupSection so the archived-bucket virtualizer
|
||||
// can resolve the scrolling ancestor synchronously (no getComputedStyle
|
||||
// walk) and skip the cost of a style recalc on every render.
|
||||
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
if (props.sharedSessionsOnly) {
|
||||
return (
|
||||
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pr-2', props.mobileVariant ? '' : '')}>
|
||||
@@ -151,7 +144,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
// button) and holds it in place, which makes newly revealed sessions look
|
||||
// like they insert upward. With anchoring off, scrollTop stays put and new
|
||||
// rows appear below naturally.
|
||||
<ScrollableOverlay ref={scrollContainerRef} useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', props.mobileVariant ? '' : '')}>
|
||||
<ScrollableOverlay ref={scrollContainerRef} useScrollShadow hideTopScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('oc-sidebar-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', props.mobileVariant ? '' : '')}>
|
||||
{props.topContent}
|
||||
{props.showOnlyMainWorkspace ? (
|
||||
<div className="space-y-[0.6rem] py-1">
|
||||
@@ -186,85 +179,87 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DndContext
|
||||
sensors={projectSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
if (props.isInlineEditing) return;
|
||||
// Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes
|
||||
if (props.projectSortOrder !== 'manual') return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
||||
const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
props.reorderProjects(oldIndex, newIndex);
|
||||
}}
|
||||
>
|
||||
<SortableContext items={props.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{props.sectionsForRender.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = formatProjectLabel(
|
||||
project.label?.trim()
|
||||
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|
||||
|| project.normalizedPath,
|
||||
);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||||
const isCollapsed = props.collapsedProjects.has(projectKey);
|
||||
const isActiveProject = projectKey === props.activeProjectId;
|
||||
const isRepo = props.projectRepoStatus.get(projectKey);
|
||||
const orderedGroups = cachedGetOrderedGroups(projectKey, section.groups);
|
||||
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
|
||||
const nestedGroups = rootGroup
|
||||
? orderedGroups.filter((group) => group.id !== rootGroup.id)
|
||||
: orderedGroups;
|
||||
<DndContext
|
||||
sensors={projectSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
if (props.isInlineEditing) return;
|
||||
// Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes
|
||||
if (props.projectSortOrder !== 'manual') return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
||||
const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
props.reorderProjects(oldIndex, newIndex);
|
||||
}}
|
||||
>
|
||||
<SortableContext items={props.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{props.sectionsForRender.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = formatProjectLabel(
|
||||
project.label?.trim()
|
||||
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|
||||
|| project.normalizedPath,
|
||||
);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||||
const isCollapsed = props.collapsedProjects.has(projectKey);
|
||||
const isActiveProject = projectKey === props.activeProjectId;
|
||||
const isRepo = props.projectRepoStatus.get(projectKey);
|
||||
|
||||
return (
|
||||
<SortableProjectItem
|
||||
key={projectKey}
|
||||
id={projectKey}
|
||||
disabled={props.projectSortOrder !== 'manual'}
|
||||
projectLabel={projectLabel}
|
||||
projectDescription={projectDescription}
|
||||
projectIcon={project.icon}
|
||||
projectColor={project.color}
|
||||
projectIconImage={project.iconImage}
|
||||
projectIconBackground={project.iconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
isActiveProject={isActiveProject}
|
||||
isRepo={Boolean(isRepo)}
|
||||
isDesktopShell={props.isDesktopShellRuntime}
|
||||
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
||||
hideDirectoryControls={props.hideDirectoryControls}
|
||||
mobileVariant={props.mobileVariant}
|
||||
alwaysShowActions={props.alwaysShowActions}
|
||||
onToggle={() => props.toggleProject(projectKey)}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||
props.openNewSessionDraft({
|
||||
selectedProjectId: projectKey,
|
||||
directoryOverride: project.normalizedPath,
|
||||
});
|
||||
}}
|
||||
onNewWorktreeSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
props.openNewWorktreeDialog();
|
||||
}}
|
||||
onRenameStart={() => props.openProjectEditDialog(projectKey)}
|
||||
onClose={() => props.removeProject(projectKey)}
|
||||
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
showCreateButtons
|
||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||
>
|
||||
{!isCollapsed ? (
|
||||
<div className="space-y-0 pt-0 pb-0.5 pl-3">
|
||||
{section.groups.length > 0 ? (
|
||||
return (
|
||||
<SortableProjectItem
|
||||
key={projectKey}
|
||||
id={projectKey}
|
||||
disabled={props.projectSortOrder !== 'manual'}
|
||||
projectLabel={projectLabel}
|
||||
projectDescription={projectDescription}
|
||||
projectIcon={project.icon}
|
||||
projectColor={project.color}
|
||||
projectIconImage={project.iconImage}
|
||||
projectIconBackground={project.iconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
isActiveProject={isActiveProject}
|
||||
isRepo={Boolean(isRepo)}
|
||||
isDesktopShell={props.isDesktopShellRuntime}
|
||||
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
||||
hideDirectoryControls={props.hideDirectoryControls}
|
||||
mobileVariant={props.mobileVariant}
|
||||
alwaysShowActions={props.alwaysShowActions}
|
||||
statusIndicator={isCollapsed ? props.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
||||
onToggle={() => props.toggleProject(projectKey)}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||
props.openNewSessionDraft({
|
||||
selectedProjectId: projectKey,
|
||||
directoryOverride: project.normalizedPath,
|
||||
});
|
||||
}}
|
||||
onNewWorktreeSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
props.openNewWorktreeDialog();
|
||||
}}
|
||||
onManageWorktrees={() => props.openWorktreesPage(projectKey)}
|
||||
onRenameStart={() => props.openProjectEditDialog(projectKey)}
|
||||
onClose={() => props.removeProject(projectKey)}
|
||||
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
showCreateButtons
|
||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||
>
|
||||
{!isCollapsed ? (
|
||||
<div className="space-y-0 pt-0.5 pb-0.5">
|
||||
{(() => {
|
||||
const orderedGroups = cachedGetOrderedGroups(projectKey, section.groups);
|
||||
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
|
||||
const nestedGroups = rootGroup
|
||||
? orderedGroups.filter((group) => group.id !== rootGroup.id)
|
||||
: orderedGroups;
|
||||
return (
|
||||
<DndContext
|
||||
sensors={groupSensors}
|
||||
collisionDetection={closestCenter}
|
||||
@@ -284,6 +279,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
});
|
||||
}}
|
||||
>
|
||||
{/* Root/flat sessions render directly under the
|
||||
project zone header; worktree and archived
|
||||
groups keep their own slim sortable sub-header. */}
|
||||
{rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true, null, undefined, scrollContainerRef) : null}
|
||||
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
|
||||
{nestedGroups.map((group) => {
|
||||
@@ -297,18 +295,16 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="py-1 text-left typography-micro text-muted-foreground">{t('sessions.sidebar.empty.noSessions.title')}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</SortableProjectItem>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
</SortableProjectItem>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,8 @@ const getSessionUpdatedAt = (session: Session): number => {
|
||||
|
||||
// Recent contains non-archived root sessions that are active now or were
|
||||
// updated within the retention window. The caller applies shared lifecycle
|
||||
// ordering after this membership filter.
|
||||
// ordering after this membership filter; batching ("Show more") handles long
|
||||
// windows in the UI.
|
||||
export const deriveRecentSessions = (
|
||||
sessions: Session[],
|
||||
activeSessionIds: ReadonlySet<string>,
|
||||
|
||||
@@ -17,12 +17,18 @@ export const useGroupOrdering = (groupOrderByProject: Map<string, string[]>) =>
|
||||
groupById.delete(id);
|
||||
}
|
||||
});
|
||||
// Groups unknown to the saved order are NEW worktrees — surface them at
|
||||
// the top of the worktree list (the root/main group is positioned by
|
||||
// the renderer regardless of this ordering). Archived buckets keep
|
||||
// appending at the end.
|
||||
const newGroups: SessionGroup[] = [];
|
||||
const trailingGroups: SessionGroup[] = [];
|
||||
groups.forEach((group) => {
|
||||
if (groupById.has(group.id)) {
|
||||
ordered.push(group);
|
||||
}
|
||||
if (!groupById.has(group.id)) return;
|
||||
if (group.isArchivedBucket) trailingGroups.push(group);
|
||||
else newGroups.push(group);
|
||||
});
|
||||
return ordered;
|
||||
return [...newGroups, ...ordered, ...trailingGroups];
|
||||
},
|
||||
[groupOrderByProject],
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { streamPerfMark } from '@/stores/utils/streamDebug';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
@@ -65,6 +66,9 @@ export const useSessionActions = (args: Args) => {
|
||||
const handleSessionSelect = React.useCallback(
|
||||
(sessionId: string, sessionDirectory?: string | null) => {
|
||||
streamPerfMark('navigation.session_select');
|
||||
// Selecting a session always leaves any full-page surface, even when
|
||||
// the session is already the current one (no store transition fires).
|
||||
useUIStore.getState().closeMainSurfaces();
|
||||
const resetSessionSearch = () => {
|
||||
if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { compareSessionsByLifecycleOrder, getSessionLifecycleOrderValue } from '
|
||||
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { getWorktreeFirstSeenAt } from '../worktreeFirstSeen';
|
||||
|
||||
type Args = {
|
||||
homeDirectory: string | null;
|
||||
@@ -196,7 +197,16 @@ export const useSessionGrouping = (args: Args) => {
|
||||
return bInfo.lastUpdatedAt - aInfo.lastUpdatedAt;
|
||||
}
|
||||
|
||||
// Third priority: for inactive worktrees, sort by label (asc)
|
||||
// Third priority: for inactive worktrees, most recently discovered
|
||||
// first (a worktree created mid-session surfaces at the top of the
|
||||
// list; startup discovery ties and falls through to labels).
|
||||
const aSeen = getWorktreeFirstSeenAt(a.path);
|
||||
const bSeen = getWorktreeFirstSeenAt(b.path);
|
||||
if (aSeen !== bSeen) {
|
||||
return bSeen - aSeen;
|
||||
}
|
||||
|
||||
// Fourth priority: sort by label (asc)
|
||||
const aLabel = (a.label || a.branch || a.name || a.path || '').toLowerCase();
|
||||
const bLabel = (b.label || b.branch || b.name || b.path || '').toLowerCase();
|
||||
return aLabel.localeCompare(bLabel);
|
||||
|
||||
@@ -201,6 +201,70 @@ export const useSessionSidebarSections = (args: Args) => {
|
||||
|
||||
const sectionsForRender = hasSessionSearchQuery ? searchableProjectSections : visibleProjectSections;
|
||||
|
||||
// Flat display sections: one merged group per project containing every
|
||||
// non-archived session from the project root and all of its worktrees.
|
||||
// Worktree grouping stays available in `projectSections` for data consumers
|
||||
// (bootstrap demand planning, ownership); rendering is flat.
|
||||
// The per-section cache keeps merged group references stable so the
|
||||
// memoized SessionGroupSection subtree skips unrelated update waves.
|
||||
const flatSectionCacheRef = React.useRef<WeakMap<ProjectSection, { query: string; section: ProjectSection }>>(new WeakMap());
|
||||
const flatSectionsForRender = React.useMemo<ProjectSection[]>(() => {
|
||||
const cache = flatSectionCacheRef.current;
|
||||
return sectionsForRender.map((section) => {
|
||||
const cached = cache.get(section);
|
||||
if (cached && cached.query === normalizedSessionSearchQuery) {
|
||||
return cached.section;
|
||||
}
|
||||
|
||||
const nonArchivedGroups = section.groups.filter((group) => !group.isArchivedBucket);
|
||||
const archivedGroups = section.groups.filter((group) => group.isArchivedBucket);
|
||||
const sessions = nonArchivedGroups.flatMap((group) => hasSessionSearchQuery
|
||||
? (groupSearchDataByGroup.get(group)?.filteredNodes ?? [])
|
||||
: group.sessions);
|
||||
const folderScopes = nonArchivedGroups
|
||||
.map((group) => ({
|
||||
scopeKey: group.folderScopeKey ?? normalizePath(group.directory ?? null),
|
||||
directory: group.directory ?? null,
|
||||
}))
|
||||
.filter((scope): scope is { scopeKey: string; directory: string | null } => Boolean(scope.scopeKey));
|
||||
const rootGroup = nonArchivedGroups.find((group) => group.isMain) ?? null;
|
||||
|
||||
const flatGroup: SessionGroup = {
|
||||
id: 'flat',
|
||||
label: rootGroup?.label ?? '',
|
||||
branch: rootGroup?.branch ?? null,
|
||||
description: rootGroup?.description ?? null,
|
||||
isMain: true,
|
||||
isArchivedBucket: false,
|
||||
worktree: null,
|
||||
directory: rootGroup?.directory ?? section.project.normalizedPath,
|
||||
folderScopeKey: rootGroup?.folderScopeKey ?? section.project.normalizedPath,
|
||||
folderScopes,
|
||||
sessions,
|
||||
};
|
||||
|
||||
if (hasSessionSearchQuery) {
|
||||
const merged = nonArchivedGroups
|
||||
.map((group) => groupSearchDataByGroup.get(group))
|
||||
.filter((data): data is GroupSearchData => Boolean(data));
|
||||
groupSearchDataByGroup.set(flatGroup, {
|
||||
filteredNodes: sessions,
|
||||
matchedSessionCount: merged.reduce((total, data) => total + data.matchedSessionCount, 0),
|
||||
folderNameMatchCount: merged.reduce((total, data) => total + data.folderNameMatchCount, 0),
|
||||
groupMatches: merged.some((data) => data.groupMatches),
|
||||
hasMatch: merged.some((data) => data.hasMatch),
|
||||
});
|
||||
}
|
||||
|
||||
const flatSection: ProjectSection = {
|
||||
project: section.project,
|
||||
groups: [flatGroup, ...archivedGroups],
|
||||
};
|
||||
cache.set(section, { query: normalizedSessionSearchQuery, section: flatSection });
|
||||
return flatSection;
|
||||
});
|
||||
}, [groupSearchDataByGroup, hasSessionSearchQuery, normalizedSessionSearchQuery, sectionsForRender]);
|
||||
|
||||
const searchMatchCount = React.useMemo(() => {
|
||||
if (!hasSessionSearchQuery) {
|
||||
return 0;
|
||||
@@ -224,6 +288,7 @@ export const useSessionSidebarSections = (args: Args) => {
|
||||
groupSearchDataByGroup,
|
||||
searchableProjectSections,
|
||||
sectionsForRender,
|
||||
flatSectionsForRender,
|
||||
searchMatchCount,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,6 +8,12 @@ type Args = {
|
||||
isInlineEditing: boolean;
|
||||
showDeletionDialog: boolean;
|
||||
foldersMap: Record<string, SessionFolder[]>;
|
||||
/**
|
||||
* Selection scope is the project id (flat per-project session list); this
|
||||
* map resolves it to the project's folder scopes (root + worktrees). When
|
||||
* the scope is missing here it is treated as a plain directory scope.
|
||||
*/
|
||||
folderScopesByProject: Map<string, Array<{ scopeKey: string; directory: string | null }>>;
|
||||
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
|
||||
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
@@ -39,6 +45,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
isInlineEditing,
|
||||
showDeletionDialog,
|
||||
foldersMap,
|
||||
folderScopesByProject,
|
||||
addSessionsToFolder,
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
@@ -89,38 +96,74 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
return null;
|
||||
}, [hasSelection, selectedIds, selectionScopeKey]);
|
||||
|
||||
const bulkScopeFolders = React.useMemo(() => {
|
||||
// The selection scope is a project id; folders live per directory scope
|
||||
// (project root + each worktree). Resolve all of them, in project order.
|
||||
const selectionFolderScopes = React.useMemo<string[]>(() => {
|
||||
if (!derivedSelectionScope) return [];
|
||||
return foldersMap[derivedSelectionScope] ?? [];
|
||||
}, [foldersMap, derivedSelectionScope]);
|
||||
const projectScopes = folderScopesByProject.get(derivedSelectionScope);
|
||||
if (projectScopes && projectScopes.length > 0) {
|
||||
return projectScopes.map((scope) => scope.scopeKey);
|
||||
}
|
||||
// Fallback: the scope is already a directory (e.g. VS Code workspaces).
|
||||
return [derivedSelectionScope];
|
||||
}, [derivedSelectionScope, folderScopesByProject]);
|
||||
|
||||
const bulkScopeFolders = React.useMemo(() => {
|
||||
return selectionFolderScopes.flatMap((scope) => foldersMap[scope] ?? []);
|
||||
}, [foldersMap, selectionFolderScopes]);
|
||||
|
||||
const resolveFolderScope = React.useCallback((folderId: string): string | null => {
|
||||
for (const scope of selectionFolderScopes) {
|
||||
if ((foldersMap[scope] ?? []).some((folder) => folder.id === folderId)) return scope;
|
||||
}
|
||||
return null;
|
||||
}, [foldersMap, selectionFolderScopes]);
|
||||
|
||||
const bulkCanRemoveFromFolder = React.useMemo(() => {
|
||||
if (!derivedSelectionScope || !hasSelection) return false;
|
||||
const scopeFolders = foldersMap[derivedSelectionScope] ?? [];
|
||||
for (const folder of scopeFolders) {
|
||||
for (const id of folder.sessionIds) {
|
||||
if (selectedIds.has(id)) return true;
|
||||
if (!hasSelection) return false;
|
||||
for (const scope of selectionFolderScopes) {
|
||||
for (const folder of foldersMap[scope] ?? []) {
|
||||
for (const id of folder.sessionIds) {
|
||||
if (selectedIds.has(id)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [foldersMap, derivedSelectionScope, hasSelection, selectedIds]);
|
||||
}, [foldersMap, selectionFolderScopes, hasSelection, selectedIds]);
|
||||
|
||||
const moveSelectionToFolder = React.useCallback((targetScope: string, folderId: string) => {
|
||||
const ids = Array.from(selectedIds);
|
||||
// Clear memberships in every other scope first — the store only dedupes
|
||||
// within one scope, and a session must live in a single folder.
|
||||
for (const scope of selectionFolderScopes) {
|
||||
if (scope === targetScope) continue;
|
||||
removeSessionsFromFolders(scope, ids);
|
||||
}
|
||||
addSessionsToFolder(targetScope, folderId, ids);
|
||||
}, [addSessionsToFolder, removeSessionsFromFolders, selectedIds, selectionFolderScopes]);
|
||||
|
||||
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
|
||||
if (!derivedSelectionScope || !hasSelection) return;
|
||||
addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds));
|
||||
}, [addSessionsToFolder, selectedIds, derivedSelectionScope, hasSelection]);
|
||||
if (!hasSelection) return;
|
||||
const targetScope = resolveFolderScope(folderId);
|
||||
if (!targetScope) return;
|
||||
moveSelectionToFolder(targetScope, folderId);
|
||||
}, [hasSelection, moveSelectionToFolder, resolveFolderScope]);
|
||||
|
||||
const handleBulkCreateFolderAndMove = React.useCallback(() => {
|
||||
if (!derivedSelectionScope || !hasSelection) return;
|
||||
const newFolder = createFolderAndStartRename(derivedSelectionScope);
|
||||
const targetScope = selectionFolderScopes[0];
|
||||
if (!targetScope || !hasSelection) return;
|
||||
const newFolder = createFolderAndStartRename(targetScope);
|
||||
if (!newFolder) return;
|
||||
addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds));
|
||||
}, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope, hasSelection]);
|
||||
moveSelectionToFolder(targetScope, newFolder.id);
|
||||
}, [createFolderAndStartRename, hasSelection, moveSelectionToFolder, selectionFolderScopes]);
|
||||
|
||||
const handleBulkRemoveFromFolder = React.useCallback(() => {
|
||||
if (!derivedSelectionScope || !hasSelection) return;
|
||||
removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds));
|
||||
}, [removeSessionsFromFolders, selectedIds, derivedSelectionScope, hasSelection]);
|
||||
if (!hasSelection) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
for (const scope of selectionFolderScopes) {
|
||||
removeSessionsFromFolders(scope, ids);
|
||||
}
|
||||
}, [removeSessionsFromFolders, selectedIds, selectionFolderScopes, hasSelection]);
|
||||
|
||||
const executeBulkDelete = React.useCallback(async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
|
||||
@@ -13,8 +13,14 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type SortableDragHandleProps = {
|
||||
listeners: ReturnType<typeof useSortable>['listeners'];
|
||||
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
|
||||
};
|
||||
|
||||
export interface SortableProjectItemProps {
|
||||
id: string;
|
||||
disabled?: boolean;
|
||||
@@ -35,6 +41,7 @@ export interface SortableProjectItemProps {
|
||||
onToggle: () => void;
|
||||
onNewSession: () => void;
|
||||
onNewWorktreeSession?: () => void;
|
||||
onManageWorktrees?: () => void;
|
||||
onRenameStart: () => void;
|
||||
onClose: () => void;
|
||||
sentinelRef: (el: HTMLDivElement | null) => void;
|
||||
@@ -43,13 +50,10 @@ export interface SortableProjectItemProps {
|
||||
hideHeader?: boolean;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
/** Aggregated activity/attention indicator shown while the project is collapsed. */
|
||||
statusIndicator?: React.ReactNode;
|
||||
}
|
||||
|
||||
export type SortableDragHandleProps = {
|
||||
listeners: ReturnType<typeof useSortable>['listeners'];
|
||||
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
|
||||
};
|
||||
|
||||
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
id,
|
||||
disabled = false,
|
||||
@@ -69,6 +73,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
onToggle,
|
||||
onNewSession,
|
||||
onNewWorktreeSession,
|
||||
onManageWorktrees,
|
||||
onRenameStart,
|
||||
onClose,
|
||||
sentinelRef,
|
||||
@@ -77,9 +82,11 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
hideHeader = false,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
statusIndicator = null,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -110,6 +117,12 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
{t('sessions.sidebar.project.actions.newSession')}
|
||||
</Item>
|
||||
)}
|
||||
{isRepo && !hideDirectoryControls && onManageWorktrees && (
|
||||
<Item onClick={onManageWorktrees}>
|
||||
<Icon name="node-tree" className="mr-1.5 h-4 w-4" />
|
||||
{t('sessions.sidebar.project.actions.manageWorktrees')}
|
||||
</Item>
|
||||
)}
|
||||
<Item onClick={onRenameStart}>
|
||||
<Icon name="pencil-ai" className="mr-1.5 h-4 w-4" />
|
||||
{t('sessions.sidebar.project.actions.edit')}
|
||||
@@ -139,7 +152,12 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleClick = React.useCallback(() => {
|
||||
const handleToggleClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
// Drop mouse-click focus so hover-revealed chrome (chevron, actions)
|
||||
// hides again on mouse-leave instead of sticking via :focus-within.
|
||||
// Keyboard users keep their focus-visible ring (blur only fires here
|
||||
// for pointer interactions that produced a click).
|
||||
event.currentTarget.blur();
|
||||
if (suppressNextToggleRef.current) {
|
||||
suppressNextToggleRef.current = false;
|
||||
return;
|
||||
@@ -167,9 +185,19 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
|
||||
<ContextMenuTrigger
|
||||
render={
|
||||
// Sticky zone header: this trigger div is a direct child of
|
||||
// the project wrapper (which spans header + sessions), so it
|
||||
// can stick for the whole zone. The solid sidebar backing
|
||||
// keeps scrolled session rows from showing through the
|
||||
// translucent band.
|
||||
// Full-bleed band: pull past the list container's padding so
|
||||
// the section band spans the entire sidebar width (ref: edge-
|
||||
// to-edge section headers, not rounded pills).
|
||||
<div
|
||||
className={cn('w-full text-left group/project select-none')}
|
||||
style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }}
|
||||
className={cn(
|
||||
'-ml-2.5 -mr-2 text-left group/project select-none',
|
||||
stickyZoneHeaders && 'oc-zone-header-backing sticky top-0 z-20 bg-sidebar',
|
||||
)}
|
||||
onContextMenu={(event) => {
|
||||
// VS Code hides project actions entirely (hideDirectoryControls).
|
||||
if (hideDirectoryControls) return;
|
||||
@@ -179,7 +207,17 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="relative flex items-center gap-1 px-0.5 py-0.5" {...attributes}>
|
||||
<div
|
||||
className={cn(
|
||||
// pl-4 keeps the icon/text aligned with the padded rows below
|
||||
// (container pl-2.5 + band px-1.5 it replaces).
|
||||
'relative flex items-center gap-1 py-1 pl-4 pr-3.5',
|
||||
// Desktop shell reports when the header is actually stuck;
|
||||
// a subtle elevation makes the pinned state readable.
|
||||
isStuck && 'shadow-md',
|
||||
)}
|
||||
{...attributes}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -230,11 +268,14 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-[14px] font-normal truncate lowercase',
|
||||
'text-[14px] font-semibold truncate lowercase',
|
||||
isActiveProject ? 'text-foreground' : 'text-foreground group-hover/project:text-foreground',
|
||||
)}>
|
||||
{projectLabel}
|
||||
</span>
|
||||
{statusIndicator ? (
|
||||
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
@@ -381,3 +422,4 @@ const SortableGroupItemBase: React.FC<{
|
||||
};
|
||||
|
||||
export const SortableGroupItem = React.memo(SortableGroupItemBase);
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ export type SessionNode = {
|
||||
worktree: WorktreeMetadata | null;
|
||||
};
|
||||
|
||||
export type SessionGroupFolderScope = {
|
||||
scopeKey: string;
|
||||
directory: string | null;
|
||||
};
|
||||
|
||||
export type SessionGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -17,6 +22,13 @@ export type SessionGroup = {
|
||||
worktree: WorktreeMetadata | null;
|
||||
directory: string | null;
|
||||
folderScopeKey?: string | null;
|
||||
/**
|
||||
* Flat display groups merge sessions from the project root and every
|
||||
* worktree; their folders come from all of these scopes. When present, the
|
||||
* group section gathers folders across every listed scope (in order)
|
||||
* instead of reading the single folderScopeKey.
|
||||
*/
|
||||
folderScopes?: SessionGroupFolderScope[];
|
||||
sessions: SessionNode[];
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { normalizePath } from './utils';
|
||||
|
||||
// In-memory first-seen tracker for worktree directories. Worktree metadata
|
||||
// carries no creation time, so we record when a path first appears during
|
||||
// this app run: a worktree created mid-session sorts to the top of its
|
||||
// project's empty-worktree tail, while everything discovered at startup ties
|
||||
// (same tick) and falls back to alphabetical order.
|
||||
const firstSeenAtByPath = new Map<string, number>();
|
||||
|
||||
export const recordWorktreesSeen = (paths: Iterable<string | null | undefined>, seenAt: number): void => {
|
||||
for (const path of paths) {
|
||||
const normalized = normalizePath(path ?? null);
|
||||
if (normalized && !firstSeenAtByPath.has(normalized)) {
|
||||
firstSeenAtByPath.set(normalized, seenAt);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getWorktreeFirstSeenAt = (path: string | null | undefined): number => {
|
||||
const normalized = normalizePath(path ?? null);
|
||||
return normalized ? (firstSeenAtByPath.get(normalized) ?? 0) : 0;
|
||||
};
|
||||
@@ -16,6 +16,8 @@ type ScrollableOverlayProps = React.HTMLAttributes<HTMLElement> & {
|
||||
preventOverscroll?: boolean;
|
||||
useScrollShadow?: boolean;
|
||||
scrollShadowSize?: number;
|
||||
/** Suppress the top fade (e.g. when sticky headers sit at the top edge). */
|
||||
hideTopScrollShadow?: boolean;
|
||||
userIntentOnly?: boolean;
|
||||
/** Forwarded to the inner element (e.g. textarea). */
|
||||
disabled?: boolean;
|
||||
@@ -37,6 +39,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
||||
preventOverscroll = false,
|
||||
useScrollShadow = false,
|
||||
scrollShadowSize,
|
||||
hideTopScrollShadow = false,
|
||||
userIntentOnly = false,
|
||||
...rest
|
||||
}, ref) => {
|
||||
@@ -57,6 +60,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
||||
as={Component}
|
||||
ref={containerRef as React.Ref<HTMLElement>}
|
||||
size={scrollShadowSize}
|
||||
hideTopShadow={hideTopScrollShadow}
|
||||
className={cn(
|
||||
"overlay-scrollbar-target overlay-scrollbar-container",
|
||||
preventOverscroll && "overscroll-none",
|
||||
|
||||
@@ -274,7 +274,10 @@ function TooltipContent({
|
||||
<BaseTooltip.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"bg-[var(--surface-elevated)] text-[var(--surface-elevated-foreground)] border border-border/60 transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 z-50 w-fit origin-[var(--transform-origin)] rounded-xl px-3 py-1.5 typography-meta text-balance overflow-hidden",
|
||||
// data-instant is set when moving between grouped tooltips
|
||||
// (shared TooltipProvider): reposition without replaying the
|
||||
// full exit/enter animation.
|
||||
"bg-[var(--surface-elevated)] text-[var(--surface-elevated-foreground)] border border-border/60 transition-all duration-150 ease-out data-[starting-style]:opacity-0 data-[starting-style]:scale-95 data-[ending-style]:opacity-0 data-[ending-style]:scale-95 data-[instant]:transition-none data-[instant]:duration-0 z-50 w-fit origin-[var(--transform-origin)] rounded-xl px-3 py-1.5 typography-meta text-balance overflow-hidden",
|
||||
className
|
||||
)}
|
||||
style={{ ...style }}
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { formatSessionDateLabel, normalizePath } from '@/components/session/sidebar/utils';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
type DirectoryBucket = {
|
||||
directory: string;
|
||||
label: string;
|
||||
sessions: Session[];
|
||||
};
|
||||
|
||||
// Bound the mounted DOM: archives grow into the hundreds; batch rendering
|
||||
// keeps the list responsive without a virtualizer.
|
||||
const PAGE_SIZE = 100;
|
||||
|
||||
export function ArchiveView(): React.ReactNode {
|
||||
const { t } = useI18n();
|
||||
const open = useUIStore((state) => state.isArchivePageOpen);
|
||||
const setOpen = useUIStore((state) => state.setArchivePageOpen);
|
||||
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
|
||||
const archivedSessions = useGlobalSessionsStore(useShallow((state) => open ? state.archivedSessions : []));
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [selectedDirectory, setSelectedDirectory] = React.useState<string | null>(null);
|
||||
const [visibleCount, setVisibleCount] = React.useState(PAGE_SIZE);
|
||||
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
const sortedSessions = React.useMemo(() => {
|
||||
if (!open) return [];
|
||||
return [...archivedSessions].sort((a, b) => (b.time?.archived ?? 0) - (a.time?.archived ?? 0));
|
||||
}, [archivedSessions, open]);
|
||||
|
||||
const buckets = React.useMemo<DirectoryBucket[]>(() => {
|
||||
const byDirectory = new Map<string, DirectoryBucket>();
|
||||
for (const session of sortedSessions) {
|
||||
const directory = normalizePath(resolveGlobalSessionDirectory(session)) ?? '';
|
||||
const existing = byDirectory.get(directory);
|
||||
if (existing) {
|
||||
existing.sessions.push(session);
|
||||
continue;
|
||||
}
|
||||
byDirectory.set(directory, {
|
||||
directory,
|
||||
label: directory
|
||||
? (formatDirectoryName(directory, homeDirectory) || directory)
|
||||
: t('sessions.archivePage.otherProjects'),
|
||||
sessions: [session],
|
||||
});
|
||||
}
|
||||
return [...byDirectory.values()].sort((a, b) => b.sessions.length - a.sessions.length);
|
||||
}, [homeDirectory, sortedSessions, t]);
|
||||
|
||||
// Search spans every archived session; the directory filter applies only
|
||||
// while not searching.
|
||||
const filteredSessions = React.useMemo(() => {
|
||||
if (normalizedQuery) {
|
||||
return sortedSessions.filter((session) => (session.title ?? '').toLowerCase().includes(normalizedQuery));
|
||||
}
|
||||
if (selectedDirectory === null) return sortedSessions;
|
||||
return buckets.find((bucket) => bucket.directory === selectedDirectory)?.sessions ?? [];
|
||||
}, [buckets, normalizedQuery, selectedDirectory, sortedSessions]);
|
||||
|
||||
const visibleSessions = filteredSessions.slice(0, visibleCount);
|
||||
const remainingCount = filteredSessions.length - visibleSessions.length;
|
||||
const totalCount = archivedSessions.length;
|
||||
|
||||
const selectDirectory = React.useCallback((directory: string | null) => {
|
||||
setSelectedDirectory(directory);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}, []);
|
||||
|
||||
const openSession = React.useCallback((session: Session) => {
|
||||
const directory = normalizePath(resolveGlobalSessionDirectory(session));
|
||||
setCurrentSession(session.id, directory ?? undefined);
|
||||
setActiveMainTab('chat');
|
||||
setOpen(false);
|
||||
}, [setActiveMainTab, setCurrentSession, setOpen]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const renderDirectoryItem = (
|
||||
key: string,
|
||||
label: string,
|
||||
count: number,
|
||||
isSelected: boolean,
|
||||
onSelect: () => void,
|
||||
fullPath?: string,
|
||||
sessionsForDelete?: Session[],
|
||||
) => (
|
||||
<div key={key} className="group/dir relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSelect}
|
||||
title={fullPath}
|
||||
className={cn(
|
||||
'flex w-full min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left typography-ui-label transition-[padding] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
||||
sessionsForDelete ? 'group-hover/dir:pr-8 group-focus-within/dir:pr-8' : '',
|
||||
isSelected
|
||||
? 'bg-interactive-selection text-foreground'
|
||||
: 'text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<span className="flex-shrink-0 typography-micro text-muted-foreground/70">{count}</span>
|
||||
</button>
|
||||
{sessionsForDelete ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sessionEvents.requestDelete({ sessions: sessionsForDelete, mode: 'session' })}
|
||||
className="absolute right-1 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover/dir:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('sessions.archivePage.deleteProjectAria', { label })}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>{t('sessions.archivePage.deleteProject')}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 flex flex-col bg-background">
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Directory filter panel */}
|
||||
<div className="flex w-64 flex-shrink-0 flex-col border-r border-border/50">
|
||||
<div className="flex-1 space-y-0.5 overflow-y-auto p-2">
|
||||
{renderDirectoryItem(
|
||||
'__all__',
|
||||
t('sessions.archivePage.allDirectories'),
|
||||
totalCount,
|
||||
selectedDirectory === null,
|
||||
() => selectDirectory(null),
|
||||
)}
|
||||
{buckets.map((bucket) => renderDirectoryItem(
|
||||
bucket.directory || '__none__',
|
||||
bucket.label,
|
||||
bucket.sessions.length,
|
||||
selectedDirectory === bucket.directory,
|
||||
() => selectDirectory(bucket.directory),
|
||||
bucket.directory || undefined,
|
||||
bucket.sessions,
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session list */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-3 px-6 pt-3">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Icon name="search" className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}}
|
||||
placeholder={t('sessions.archivePage.searchPlaceholder')}
|
||||
className="h-8 w-full rounded-md border border-border bg-transparent pl-8 pr-3 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
{/* Pages have no close button: you leave via the sidebar. */}
|
||||
<span className="flex-shrink-0 typography-micro text-muted-foreground">
|
||||
{filteredSessions.length === 1
|
||||
? t('sessions.archivePage.countSingle', { count: filteredSessions.length })
|
||||
: t('sessions.archivePage.countPlural', { count: filteredSessions.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-3">
|
||||
<div className="mx-auto w-full max-w-3xl space-y-0.5">
|
||||
{visibleSessions.length === 0 ? (
|
||||
<div className="py-10 text-center text-muted-foreground">
|
||||
<p className="typography-ui-label font-semibold">
|
||||
{normalizedQuery ? t('sessions.archivePage.empty.noMatches') : t('sessions.archivePage.empty.noArchived')}
|
||||
</p>
|
||||
</div>
|
||||
) : visibleSessions.map((session) => {
|
||||
const sessionDirectory = normalizePath(resolveGlobalSessionDirectory(session)) ?? '';
|
||||
const directoryLabel = sessionDirectory
|
||||
? (formatDirectoryName(sessionDirectory, homeDirectory) || sessionDirectory)
|
||||
: null;
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className="group relative flex cursor-pointer items-center gap-3 rounded-md py-1 pl-2 pr-2 transition-[padding] hover:bg-interactive-hover/40 hover:pr-8 focus-within:pr-8"
|
||||
onClick={() => openSession(session)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
openSession(session);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label text-foreground">
|
||||
{session.title || t('sessions.sidebar.session.untitled')}
|
||||
</span>
|
||||
{normalizedQuery && directoryLabel ? (
|
||||
<span className="max-w-40 flex-shrink-0 truncate text-[0.72rem] text-muted-foreground/70" title={sessionDirectory}>
|
||||
{directoryLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex-shrink-0 text-[0.72rem] text-muted-foreground/75">
|
||||
{formatSessionDateLabel(session.time?.archived ?? session.time?.updated ?? session.time?.created ?? Date.now())}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
sessionEvents.requestDelete({ sessions: [session], mode: 'session' });
|
||||
}}
|
||||
className="absolute right-1 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity pointer-events-none hover:text-destructive group-hover:opacity-100 group-hover:pointer-events-auto focus-visible:opacity-100 focus-visible:pointer-events-auto focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={t('sessions.archivePage.deleteSessionAria', { title: session.title || t('sessions.sidebar.session.untitled') })}
|
||||
>
|
||||
<Icon name="delete-bin" className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{remainingCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisibleCount((count) => count + PAGE_SIZE)}
|
||||
className="mt-1 flex items-center justify-start rounded-md px-2 py-1 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{t('sessions.sidebar.group.showMore')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Dialog } from '@base-ui/react/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MultiRunLauncher } from '@/components/multirun';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
interface MultiRunWindowProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initialPrompt?: string;
|
||||
}
|
||||
|
||||
export const MultiRunWindow: React.FC<MultiRunWindowProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
initialPrompt,
|
||||
}) => {
|
||||
const descriptionId = React.useId();
|
||||
const { t } = useI18n();
|
||||
|
||||
const hasOpenFloatingMenu = React.useCallback(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
document.querySelector('[data-slot="dropdown-menu-content"][data-open], [data-slot="select-content"][data-open]')
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next && hasOpenFloatingMenu()) return;
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Backdrop
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 dark:bg-black/75',
|
||||
'transition-opacity duration-150 ease-out',
|
||||
'data-[starting-style]:opacity-0 data-[ending-style]:opacity-0',
|
||||
)}
|
||||
/>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center pointer-events-none">
|
||||
<Dialog.Popup
|
||||
aria-describedby={descriptionId}
|
||||
className={cn(
|
||||
'relative pointer-events-auto',
|
||||
'w-[90vw] max-w-[720px] h-[680px] max-h-[85vh]',
|
||||
'flex flex-col rounded-xl border shadow-none overflow-hidden origin-center',
|
||||
'bg-background',
|
||||
'transition-all duration-150 ease-out',
|
||||
'data-[starting-style]:opacity-0 data-[starting-style]:scale-[0.98]',
|
||||
'data-[ending-style]:opacity-0 data-[ending-style]:scale-[0.98]',
|
||||
)}
|
||||
>
|
||||
<div className="absolute right-0.5 top-0.5 z-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
aria-label={t('multiRun.window.actions.closeAria')}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md p-0.5 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<Icon name="close" className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<Dialog.Description id={descriptionId} className="sr-only">
|
||||
{t('multiRun.window.description')}
|
||||
</Dialog.Description>
|
||||
<MultiRunLauncher
|
||||
initialPrompt={initialPrompt}
|
||||
onCreated={() => onOpenChange(false)}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
isWindowed
|
||||
/>
|
||||
</Dialog.Popup>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { WorktreeSectionContent } from '@/components/sections/openchamber/WorktreeSectionContent';
|
||||
|
||||
// Full-page worktree management surface for a single project, opened from the
|
||||
// project menu in the sidebar. Renders only the worktree list (setup commands
|
||||
// stay in project settings); the New-worktree action leads the content flow.
|
||||
export function WorktreesView(): React.ReactNode {
|
||||
const { t } = useI18n();
|
||||
const projectId = useUIStore((state) => state.worktreesPageProjectId);
|
||||
const setNewWorktreeDialogOpen = useUIStore((state) => state.setNewWorktreeDialogOpen);
|
||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||
const project = useProjectsStore((state) => state.projects.find((entry) => entry.id === projectId) ?? null);
|
||||
|
||||
if (!projectId || !project) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 flex flex-col bg-background">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div className="mx-auto w-full max-w-4xl space-y-4">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setActiveProjectIdOnly(project.id);
|
||||
setNewWorktreeDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icon name="node-tree" className="mr-1 h-3.5 w-3.5" />
|
||||
{t('sessions.sidebar.project.actions.newWorktree')}
|
||||
</Button>
|
||||
</div>
|
||||
<WorktreeSectionContent projectRef={{ id: project.id, path: project.path }} sections="list-only" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user