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';
|
return trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : 'Untitled Session';
|
||||||
}, [activeProjectLabel, currentSession?.title, currentSessionId]);
|
}, [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(() => {
|
const actionDirectory = React.useMemo(() => {
|
||||||
return normalize(openDirectory || activeProject?.path || '');
|
return normalize(openDirectory || activeProject?.path || '');
|
||||||
@@ -2039,6 +2070,18 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
TitlebarLeftControls overlay; the header reserves matching left space
|
TitlebarLeftControls overlay; the header reserves matching left space
|
||||||
via padding (see headerStyle) when the sidebar is collapsed. */}
|
via padding (see headerStyle) when the sidebar is collapsed. */}
|
||||||
<div className="flex min-w-0 flex-1 items-center">
|
<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>
|
<SessionSwitcherDropdown>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -2070,6 +2113,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</SessionSwitcherDropdown>
|
</SessionSwitcherDropdown>
|
||||||
|
)}
|
||||||
|
|
||||||
{tabs.length > 0 && (
|
{tabs.length > 0 && (
|
||||||
<div className="flex items-center gap-1 rounded-lg bg-[var(--surface-muted)]/50 p-1">
|
<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 { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog';
|
||||||
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
import { SessionSidebar } from '@/components/session/SessionSidebar';
|
||||||
import { SessionDialogs } from '@/components/session/SessionDialogs';
|
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 { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider';
|
||||||
import { MultiRunLauncher } from '@/components/multirun';
|
import { MultiRunLauncher } from '@/components/multirun';
|
||||||
import { TerminalView } from '@/components/views/TerminalView';
|
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 DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView })));
|
||||||
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
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 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 = () => {
|
export const MainLayout: React.FC = () => {
|
||||||
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
||||||
@@ -49,6 +51,32 @@ export const MainLayout: React.FC = () => {
|
|||||||
const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
|
const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen);
|
||||||
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
|
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
|
||||||
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
|
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 { isMobile } = useDeviceInfo();
|
||||||
const mobilePanelsResetRef = React.useRef(false);
|
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">
|
<main className="w-full h-full overflow-hidden bg-background relative" data-page-scroll-lock="true">
|
||||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}>
|
||||||
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen} /></ErrorBoundary>
|
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
{secondaryView && (
|
{secondaryView && (
|
||||||
<div className="absolute inset-0">
|
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}>
|
||||||
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -325,6 +353,9 @@ export const MainLayout: React.FC = () => {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ErrorBoundary><ScheduledTasksDialog /></ErrorBoundary>
|
||||||
|
<ErrorBoundary><ArchiveView /></ErrorBoundary>
|
||||||
|
<ErrorBoundary><WorktreesView /></ErrorBoundary>
|
||||||
{/* Always mount SessionSidebar on mobile to match desktop behavior.
|
{/* Always mount SessionSidebar on mobile to match desktop behavior.
|
||||||
Conditional mount (mobileLeftDrawerVisible && ...) caused a
|
Conditional mount (mobileLeftDrawerVisible && ...) caused a
|
||||||
data-loading cascade on every drawer open: paginated sessions
|
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="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">
|
<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">
|
<main className="flex-1 overflow-hidden bg-background relative" data-page-scroll-lock="true">
|
||||||
<div className={cn('absolute inset-0', !isChatActive && 'invisible')}>
|
<div className={cn('absolute inset-0', (!isChatActive || isSurfacePageOpen) && 'invisible')}>
|
||||||
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen} /></ErrorBoundary>
|
<ErrorBoundary><ChatView active={isChatActive && !isSettingsDialogOpen && !isSurfacePageOpen} /></ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
{secondaryView && (
|
{secondaryView && (
|
||||||
<div className="absolute inset-0">
|
<div className={cn('absolute inset-0', isSurfacePageOpen && 'invisible')}>
|
||||||
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
<ErrorBoundary>{secondaryView}</ErrorBoundary>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
<ContextPanel />
|
<ContextPanel />
|
||||||
</div>
|
</div>
|
||||||
@@ -422,13 +470,6 @@ export const MainLayout: React.FC = () => {
|
|||||||
onOpenChange={setSettingsDialogOpen}
|
onOpenChange={setSettingsDialogOpen}
|
||||||
/>
|
/>
|
||||||
</React.Suspense>
|
</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">
|
<ScrollableOverlay outerClassName="max-h-[min(90vh,48rem)]" className="w-full bg-background">
|
||||||
<div className="w-full p-3 sm:p-6 sm:pt-8">
|
<div className="w-full p-3 sm:p-6 sm:pt-8">
|
||||||
{open && project ? (
|
{open && project ? (
|
||||||
<ProjectSettingsPanel project={project} onIdentitySave={onSave} />
|
<ProjectSettingsPanel project={project} onIdentitySave={onSave} showWorktrees={false} />
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
|
|||||||
@@ -674,14 +674,18 @@ export const MultiRunLauncher: React.FC<MultiRunLauncherProps> = ({
|
|||||||
<span className="truncate">{t('multirun.launcher.project.gitRequired')}</span>
|
<span className="truncate">{t('multirun.launcher.project.gitRequired')}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
{/* On the full-page surface (isWindowed) there is nothing to
|
||||||
type="button"
|
"cancel" — you leave via the sidebar like any other page. */}
|
||||||
variant="ghost"
|
{!isWindowed && onCancel ? (
|
||||||
size="sm"
|
<Button
|
||||||
onClick={onCancel}
|
type="button"
|
||||||
>
|
variant="ghost"
|
||||||
{t('multirun.launcher.actions.cancel')}
|
size="sm"
|
||||||
</Button>
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
{t('multirun.launcher.actions.cancel')}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button type="submit" size="sm" disabled={!isValid || isSubmitting}>
|
<Button type="submit" size="sm" disabled={!isValid || isSubmitting}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
t('multirun.launcher.actions.creating')
|
t('multirun.launcher.actions.creating')
|
||||||
|
|||||||
@@ -31,11 +31,17 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
|
|
||||||
export interface WorktreeSectionContentProps {
|
export interface WorktreeSectionContentProps {
|
||||||
projectRef?: { id: string; path: string } | null;
|
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;
|
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 { t } = useI18n();
|
||||||
const { isMobile, isTablet } = useDeviceInfo();
|
const { isMobile, isTablet } = useDeviceInfo();
|
||||||
const alwaysShowActions = isMobile || isTablet;
|
const alwaysShowActions = isMobile || isTablet;
|
||||||
@@ -369,6 +375,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{sections === 'all' ? (
|
||||||
<ProjectSettingsSubsection
|
<ProjectSettingsSubsection
|
||||||
title={t('settings.projects.page.section.worktree')}
|
title={t('settings.projects.page.section.worktree')}
|
||||||
settingsItem="projects.worktree"
|
settingsItem="projects.worktree"
|
||||||
@@ -428,6 +435,7 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ProjectSettingsSubsection>
|
</ProjectSettingsSubsection>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<ProjectSettingsSubsection
|
<ProjectSettingsSubsection
|
||||||
title={t('settings.openchamber.worktrees.list.title')}
|
title={t('settings.openchamber.worktrees.list.title')}
|
||||||
@@ -440,7 +448,9 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
|||||||
{t('settings.openchamber.worktrees.list.empty')}
|
{t('settings.openchamber.worktrees.list.empty')}
|
||||||
</p>
|
</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) => (
|
{availableWorktrees.map((worktree) => (
|
||||||
<div
|
<div
|
||||||
key={worktree.path}
|
key={worktree.path}
|
||||||
@@ -451,9 +461,6 @@ export const WorktreeSectionContent: React.FC<WorktreeSectionContentProps> = ({
|
|||||||
<p className="typography-meta min-w-0 truncate text-foreground">
|
<p className="typography-meta min-w-0 truncate text-foreground">
|
||||||
{worktree.label || worktree.branch || t('settings.openchamber.worktrees.list.detachedHead')}
|
{worktree.label || worktree.branch || t('settings.openchamber.worktrees.list.detachedHead')}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<p className="typography-micro truncate text-muted-foreground/60">
|
<p className="typography-micro truncate text-muted-foreground/60">
|
||||||
{formatPathForDisplay(worktree.path, homeDirectory)}
|
{formatPathForDisplay(worktree.path, homeDirectory)}
|
||||||
|
|||||||
@@ -12,11 +12,18 @@ import type { ProjectEntry } from '@/lib/api/types';
|
|||||||
type ProjectSettingsPanelProps = {
|
type ProjectSettingsPanelProps = {
|
||||||
project: ProjectEntry | null;
|
project: ProjectEntry | null;
|
||||||
onIdentitySave: (data: ProjectIdentitySaveData) => void | Promise<void>;
|
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> = ({
|
export const ProjectSettingsPanel: React.FC<ProjectSettingsPanelProps> = ({
|
||||||
project,
|
project,
|
||||||
onIdentitySave,
|
onIdentitySave,
|
||||||
|
showWorktrees = true,
|
||||||
}) => {
|
}) => {
|
||||||
const form = useProjectIdentityForm(project);
|
const form = useProjectIdentityForm(project);
|
||||||
|
|
||||||
@@ -41,7 +48,7 @@ export const ProjectSettingsPanel: React.FC<ProjectSettingsPanelProps> = ({
|
|||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
<ProjectIdentityFields form={form} />
|
<ProjectIdentityFields form={form} />
|
||||||
<ProjectActionsSection projectRef={projectRef} />
|
<ProjectActionsSection projectRef={projectRef} />
|
||||||
<WorktreeSectionContent projectRef={projectRef} />
|
{showWorktrees ? <WorktreeSectionContent projectRef={projectRef} /> : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -17,6 +10,7 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { formatTimeForPreference } from '@/lib/timeFormat';
|
import { formatTimeForPreference } from '@/lib/timeFormat';
|
||||||
import type { TimeFormatPreference } from '@/stores/useUIStore';
|
import type { TimeFormatPreference } from '@/stores/useUIStore';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
import { refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
|
import { refreshGlobalSessions } from '@/stores/useGlobalSessionsStore';
|
||||||
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||||
@@ -360,18 +354,25 @@ export function ScheduledTasksDialog() {
|
|||||||
}
|
}
|
||||||
setMutatingTaskID(task.id);
|
setMutatingTaskID(task.id);
|
||||||
try {
|
try {
|
||||||
await runScheduledTaskNow(selectedProjectID, task.id);
|
const { sessionId } = await runScheduledTaskNow(selectedProjectID, task.id);
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
reloadTasks(selectedProjectID, { silent: true }),
|
reloadTasks(selectedProjectID, { silent: true }),
|
||||||
refreshGlobalSessions(),
|
refreshGlobalSessions(),
|
||||||
]);
|
]);
|
||||||
toast.success(t('sessions.scheduledTasks.dialog.toast.started'));
|
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) {
|
} catch (error) {
|
||||||
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.runFailed'));
|
toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.runFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
setMutatingTaskID(null);
|
setMutatingTaskID(null);
|
||||||
}
|
}
|
||||||
}, [selectedProjectID, reloadTasks, t]);
|
}, [selectedProjectID, projects, reloadTasks, t]);
|
||||||
|
|
||||||
const projectSelector = (
|
const projectSelector = (
|
||||||
<div className="flex flex-col items-start gap-1">
|
<div className="flex flex-col items-start gap-1">
|
||||||
@@ -412,19 +413,16 @@ export function ScheduledTasksDialog() {
|
|||||||
setEditorOpen(true);
|
setEditorOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const tasksContent = (
|
const selectProject = (nextProjectID: string) => {
|
||||||
<div className="space-y-4">
|
setSelectedProjectID(nextProjectID);
|
||||||
{!isMobile ? (
|
if (nextProjectID) {
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
void reloadTasks(nextProjectID);
|
||||||
{projectSelector}
|
} else {
|
||||||
<Button onClick={openNewTaskEditor} disabled={!selectedProjectID}>
|
setTasks([]);
|
||||||
<Icon name="add" className="mr-1 h-4 w-4" /> {t('sessions.scheduledTasks.dialog.actions.newTask')}
|
}
|
||||||
</Button>
|
};
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
projectSelector
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
const tasksList = (
|
||||||
<div className="min-h-[280px]">
|
<div className="min-h-[280px]">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
|
<div className="flex items-center gap-2 typography-meta text-muted-foreground">
|
||||||
@@ -579,6 +577,12 @@ export function ScheduledTasksDialog() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tasksContent = (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{projectSelector}
|
||||||
|
{tasksList}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -613,18 +617,53 @@ export function ScheduledTasksDialog() {
|
|||||||
>
|
>
|
||||||
{tasksContent}
|
{tasksContent}
|
||||||
</MobileOverlayPanel>
|
</MobileOverlayPanel>
|
||||||
) : (
|
) : open ? (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
// Full-page surface replacing the chat area (mounted inside <main>).
|
||||||
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
// Master-detail: a scrollable project filter panel at the left, the
|
||||||
<DialogHeader>
|
// selected project's tasks at the right. The app Header shows the
|
||||||
<DialogTitle>{t('sessions.scheduledTasks.dialog.title')}</DialogTitle>
|
// surface title, so the page itself only carries the close affordance.
|
||||||
<DialogDescription>{t('sessions.scheduledTasks.dialog.description')}</DialogDescription>
|
<div className="absolute inset-0 z-10 flex flex-col bg-background">
|
||||||
</DialogHeader>
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<div className="flex w-60 flex-shrink-0 flex-col border-r border-border/50">
|
||||||
{tasksContent}
|
<div className="flex-1 space-y-0.5 overflow-y-auto p-2">
|
||||||
</DialogContent>
|
{projects.length === 0 ? (
|
||||||
</Dialog>
|
<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
|
<ScheduledTaskEditorDialog
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
|
|||||||
@@ -3,10 +3,17 @@ import { cn } from '@/lib/utils';
|
|||||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
|
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
|
||||||
|
|
||||||
interface SessionFolderItemProps<TSessionNode> {
|
interface SessionFolderItemProps<TSessionNode> {
|
||||||
folder: SessionFolder;
|
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[];
|
sessions: TSessionNode[];
|
||||||
/** Sub-folders that belong directly to this folder */
|
/** Sub-folders that belong directly to this folder */
|
||||||
subFolderItems?: React.ReactNode;
|
subFolderItems?: React.ReactNode;
|
||||||
@@ -46,8 +53,6 @@ interface SessionFolderItemProps<TSessionNode> {
|
|||||||
isDropTarget?: boolean;
|
isDropTarget?: boolean;
|
||||||
/** Create a new session scoped to this folder */
|
/** Create a new session scoped to this folder */
|
||||||
onNewSession?: () => void;
|
onNewSession?: () => void;
|
||||||
/** Create a new sub-folder inside this folder */
|
|
||||||
onNewSubFolder?: () => void;
|
|
||||||
/** Visual indent depth (0 = root folder, 1 = sub-folder) */
|
/** Visual indent depth (0 = root folder, 1 = sub-folder) */
|
||||||
depth?: number;
|
depth?: number;
|
||||||
/** Hide folder action buttons (rename/delete/new) */
|
/** Hide folder action buttons (rename/delete/new) */
|
||||||
@@ -58,6 +63,7 @@ interface SessionFolderItemProps<TSessionNode> {
|
|||||||
|
|
||||||
const SessionFolderItemBase = <TSessionNode,>({
|
const SessionFolderItemBase = <TSessionNode,>({
|
||||||
folder,
|
folder,
|
||||||
|
displayName,
|
||||||
sessions,
|
sessions,
|
||||||
subFolderItems,
|
subFolderItems,
|
||||||
isCollapsed,
|
isCollapsed,
|
||||||
@@ -78,7 +84,6 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
droppableRef,
|
droppableRef,
|
||||||
isDropTarget = false,
|
isDropTarget = false,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onNewSubFolder,
|
|
||||||
depth = 0,
|
depth = 0,
|
||||||
hideActions = false,
|
hideActions = false,
|
||||||
archivedBucket = false,
|
archivedBucket = false,
|
||||||
@@ -145,10 +150,10 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
}, [isRenaming]);
|
}, [isRenaming]);
|
||||||
|
|
||||||
const folderIconName = isCollapsed ? 'folder' : 'folder-open';
|
const folderIconName = isCollapsed ? 'folder' : 'folder-open';
|
||||||
const isSubFolder = depth > 0;
|
void depth;
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Folder header – also acts as a drop zone when droppableRef is provided */}
|
||||||
<div
|
<div
|
||||||
ref={droppableRef}
|
ref={droppableRef}
|
||||||
@@ -157,7 +162,10 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
'cursor-pointer',
|
'cursor-pointer',
|
||||||
isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30',
|
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'}
|
role={renaming ? undefined : 'button'}
|
||||||
tabIndex={renaming ? undefined : 0}
|
tabIndex={renaming ? undefined : 0}
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
@@ -178,7 +186,10 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
'min-w-0 flex items-center gap-1.5 pl-1.5 flex-1 transition-[padding]',
|
'min-w-0 flex items-center gap-1.5 pl-1.5 flex-1 transition-[padding]',
|
||||||
archivedBucket
|
archivedBucket
|
||||||
? (alwaysShowActions ? 'pr-7' : 'group-hover/folder:pr-7 group-focus-within/folder:pr-7')
|
? (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')} />
|
<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>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="min-w-0 flex items-center gap-1.5 flex-1">
|
<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')}>
|
<Tooltip>
|
||||||
{folder.name}
|
<TooltipTrigger asChild>
|
||||||
</span>
|
<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">
|
<span className="typography-micro text-muted-foreground/70 flex-shrink-0">
|
||||||
• {sessions.length}
|
• {sessions.length}
|
||||||
</span>
|
</span>
|
||||||
@@ -256,14 +274,15 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
{!renaming && (!hideActions || archivedBucket) ? (
|
{!renaming && (!hideActions || archivedBucket) ? (
|
||||||
<div className="flex items-center gap-0.5 px-0.5">
|
<div
|
||||||
<div
|
className={cn(
|
||||||
className={cn(
|
'absolute right-0.5 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5 transition-opacity',
|
||||||
'flex items-center gap-0.5 transition-opacity',
|
alwaysShowActions
|
||||||
alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100',
|
? 'opacity-100'
|
||||||
archivedBucket && 'absolute right-0.5 top-1/2 z-10 -translate-y-1/2 px-0',
|
: '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 ? (
|
{!archivedBucket && onNewSession ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -278,21 +297,6 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
<Icon name="add" className="h-3.5 w-3.5" />
|
<Icon name="add" className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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 ? (
|
{!archivedBucket ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -326,7 +330,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
|
|
||||||
{/* Folder body */}
|
{/* Folder body */}
|
||||||
{!isCollapsed ? (
|
{!isCollapsed ? (
|
||||||
<div className="pb-1 pl-2">
|
<div className="pb-1">
|
||||||
{/* Sub-folders first */}
|
{/* Sub-folders first */}
|
||||||
{subFolderItems}
|
{subFolderItems}
|
||||||
{/* Then sessions */}
|
{/* Then sessions */}
|
||||||
|
|||||||
@@ -17,14 +17,15 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||||
import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore';
|
import { useGitStore, useGitAllBranches, useGitRepoStatusMap } from '@/stores/useGitStore';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||||
|
import { Icon } from '@/components/icon/Icon';
|
||||||
|
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||||
import { NewWorktreeDialog } from './NewWorktreeDialog';
|
import { NewWorktreeDialog } from './NewWorktreeDialog';
|
||||||
import { ScheduledTasksDialog } from './ScheduledTasksDialog';
|
|
||||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
|
||||||
import { useArchivedAutoFolders } from './sidebar/hooks/useArchivedAutoFolders';
|
import { useArchivedAutoFolders } from './sidebar/hooks/useArchivedAutoFolders';
|
||||||
|
import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering';
|
||||||
import { useSessionSidebarSections } from './sidebar/hooks/useSessionSidebarSections';
|
import { useSessionSidebarSections } from './sidebar/hooks/useSessionSidebarSections';
|
||||||
import { ProjectSessionSelectionEffect } from './sidebar/hooks/useProjectSessionSelection';
|
import { ProjectSessionSelectionEffect } from './sidebar/hooks/useProjectSessionSelection';
|
||||||
import { useGroupOrdering } from './sidebar/hooks/useGroupOrdering';
|
|
||||||
import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping';
|
import { useSessionGrouping } from './sidebar/hooks/useSessionGrouping';
|
||||||
import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects';
|
import { useSessionSearchEffects } from './sidebar/hooks/useSessionSearchEffects';
|
||||||
import { useSessionActions } from './sidebar/hooks/useSessionActions';
|
import { useSessionActions } from './sidebar/hooks/useSessionActions';
|
||||||
@@ -34,11 +35,11 @@ import { useProjectSessionLists } from './sidebar/hooks/useProjectSessionLists';
|
|||||||
import { useAuthoritativeSessionCleanup } from './sidebar/hooks/useAuthoritativeSessionCleanup';
|
import { useAuthoritativeSessionCleanup } from './sidebar/hooks/useAuthoritativeSessionCleanup';
|
||||||
import { createSessionOwnershipIndex } from './sidebar/sessionOwnership';
|
import { createSessionOwnershipIndex } from './sidebar/sessionOwnership';
|
||||||
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
|
import { useStickyProjectHeaders } from './sidebar/hooks/useStickyProjectHeaders';
|
||||||
import { getGitHubPrStatusKey, usePrVisualSummaryByKeys, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
|
||||||
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
import { ProjectEditDialog } from '@/components/layout/ProjectEditDialog';
|
||||||
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
import { UpdateDialog } from '@/components/ui/UpdateDialog';
|
||||||
import { SessionGroupSection } from './sidebar/SessionGroupSection';
|
import { SessionGroupSection } from './sidebar/SessionGroupSection';
|
||||||
import { SidebarHeader } from './sidebar/SidebarHeader';
|
import { SidebarHeader } from './sidebar/SidebarHeader';
|
||||||
|
import { SidebarNav } from './sidebar/SidebarNav';
|
||||||
import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
|
import { SidebarActivitySections } from './sidebar/SidebarActivitySections';
|
||||||
import { SidebarFooter } from './sidebar/SidebarFooter';
|
import { SidebarFooter } from './sidebar/SidebarFooter';
|
||||||
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
|
import { SidebarProjectsList } from './sidebar/SidebarProjectsList';
|
||||||
@@ -86,10 +87,13 @@ import {
|
|||||||
resolveGlobalSessionDirectory,
|
resolveGlobalSessionDirectory,
|
||||||
useGlobalSessionsStore,
|
useGlobalSessionsStore,
|
||||||
} from '@/stores/useGlobalSessionsStore';
|
} from '@/stores/useGlobalSessionsStore';
|
||||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
|
||||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
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 { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||||
import { buildSessionBootstrapDemands } from './sidebar/sessionBootstrapDemands';
|
import { buildSessionBootstrapDemands } from './sidebar/sessionBootstrapDemands';
|
||||||
|
import { recordWorktreesSeen } from './sidebar/worktreeFirstSeen';
|
||||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
import { streamPerfCount, streamPerfMark } from '@/stores/utils/streamDebug';
|
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.
|
// mixed contexts and is intentionally not migrated.
|
||||||
const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents.v3';
|
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 = (
|
const buildKnownSessionDirectories = (
|
||||||
projects: Array<{ path: string }>,
|
projects: Array<{ path: string }>,
|
||||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
|
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
|
||||||
@@ -230,6 +208,57 @@ const SidebarBootstrapDemandEffect: React.FC<{
|
|||||||
return null;
|
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> = ({
|
const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
mobileVariant = false,
|
mobileVariant = false,
|
||||||
@@ -247,7 +276,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
const [sessionSearchQuery, setSessionSearchQuery] = React.useState('');
|
const [sessionSearchQuery, setSessionSearchQuery] = React.useState('');
|
||||||
const sessionSearchContainerRef = React.useRef<HTMLDivElement | null>(null);
|
const sessionSearchContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
const sessionSearchInputRef = React.useRef<HTMLInputElement | 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 [editingId, setEditingId] = React.useState<string | null>(null);
|
||||||
const [editTitle, setEditTitle] = React.useState('');
|
const [editTitle, setEditTitle] = React.useState('');
|
||||||
const [editingProjectDialogId, setEditingProjectDialogId] = React.useState<string | null>(null);
|
const [editingProjectDialogId, setEditingProjectDialogId] = React.useState<string | null>(null);
|
||||||
@@ -351,6 +379,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
const setAboutDialogOpen = useUIStore((state) => state.setAboutDialogOpen);
|
const setAboutDialogOpen = useUIStore((state) => state.setAboutDialogOpen);
|
||||||
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||||
const setScheduledTasksDialogOpen = useUIStore((state) => state.setScheduledTasksDialogOpen);
|
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 openMultiRunLauncher = useUIStore((state) => state.openMultiRunLauncher);
|
||||||
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
|
const notifyOnSubtasks = useUIStore((state) => state.notifyOnSubtasks);
|
||||||
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
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
|
// 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).
|
// (a no-op in the expanded side-by-side layout, which is always showing chat).
|
||||||
const openNewSessionDraftFromTree = React.useCallback<typeof openNewSessionDraft>((options) => {
|
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);
|
openNewSessionDraft(options);
|
||||||
if (isVSCode) {
|
if (isVSCode) {
|
||||||
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } }));
|
window.dispatchEvent(new CustomEvent('openchamber:navigate', { detail: { view: 'chat' } }));
|
||||||
@@ -575,6 +608,9 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const allWorktrees = [...worktreesByProject.values()].flat();
|
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.
|
// Skip update if nothing changed — see worktreeMapsEqual JSDoc.
|
||||||
if (!worktreeMapsEqual(worktreesByProject, currentByProject)) {
|
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(() => {
|
const collapseAllProjects = React.useCallback(() => {
|
||||||
ignoreIntersectionUntil.current = Date.now() + 150;
|
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||||
setVisibleSessionCountByGroup(new Map());
|
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(() => {
|
setCollapsedProjects(() => {
|
||||||
const allIds = new Set(projects.map((p) => p.id));
|
const allIds = new Set(projects.map((p) => p.id));
|
||||||
try {
|
try {
|
||||||
@@ -916,6 +964,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
const expandAllProjects = React.useCallback(() => {
|
const expandAllProjects = React.useCallback(() => {
|
||||||
ignoreIntersectionUntil.current = Date.now() + 150;
|
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||||
setVisibleSessionCountByGroup(new Map());
|
setVisibleSessionCountByGroup(new Map());
|
||||||
|
setCollapsedGroups(new Set());
|
||||||
setCollapsedProjects(() => {
|
setCollapsedProjects(() => {
|
||||||
const empty = new Set<string>();
|
const empty = new Set<string>();
|
||||||
try {
|
try {
|
||||||
@@ -1131,7 +1180,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
deleteFolderConfirm,
|
deleteFolderConfirm,
|
||||||
bulkDeleteConfirm,
|
bulkDeleteConfirm,
|
||||||
collapsedGroups,
|
collapsedGroups,
|
||||||
groupOrderByProject,
|
|
||||||
};
|
};
|
||||||
const previousSidebarRenderSourcesRef = React.useRef<typeof sidebarRenderSources | null>(null);
|
const previousSidebarRenderSourcesRef = React.useRef<typeof sidebarRenderSources | null>(null);
|
||||||
const previousSidebarRenderSources = previousSidebarRenderSourcesRef.current;
|
const previousSidebarRenderSources = previousSidebarRenderSourcesRef.current;
|
||||||
@@ -1191,6 +1239,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
projectSections,
|
projectSections,
|
||||||
groupSearchDataByGroup,
|
groupSearchDataByGroup,
|
||||||
sectionsForRender,
|
sectionsForRender,
|
||||||
|
flatSectionsForRender,
|
||||||
searchMatchCount,
|
searchMatchCount,
|
||||||
} = useSessionSidebarSections({
|
} = useSessionSidebarSections({
|
||||||
normalizedProjects: sortedProjects,
|
normalizedProjects: sortedProjects,
|
||||||
@@ -1208,6 +1257,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
foldersMap,
|
foldersMap,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
projectSectionsRef.current = projectSections;
|
||||||
|
|
||||||
const searchEmptyState = React.useMemo(() => (
|
const searchEmptyState = React.useMemo(() => (
|
||||||
<div className="py-6 text-center text-muted-foreground">
|
<div className="py-6 text-center text-muted-foreground">
|
||||||
<p className="typography-ui-label font-semibold">{t('sessions.sidebar.empty.noMatches.title')}</p>
|
<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(() => {
|
const sectionsForSidebarRender = React.useMemo(() => {
|
||||||
return showArchivedSessions
|
const source = useGroupedSections ? sectionsForRender : flatSectionsForRender;
|
||||||
? sectionsForRender
|
return showInlineArchived
|
||||||
: sectionsForRender.map((section) => ({
|
? source
|
||||||
...section,
|
: source.map((section) => (
|
||||||
groups: section.groups.filter((group) => !group.isArchivedBucket),
|
section.groups.some((group) => group.isArchivedBucket)
|
||||||
}));
|
? { ...section, groups: section.groups.filter((group) => !group.isArchivedBucket) }
|
||||||
}, [sectionsForRender, showArchivedSessions]);
|
: section
|
||||||
|
));
|
||||||
const prLookup = React.useMemo(() => {
|
}, [flatSectionsForRender, sectionsForRender, showInlineArchived, useGroupedSections]);
|
||||||
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);
|
|
||||||
|
|
||||||
|
// 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(() => {
|
React.useEffect(() => {
|
||||||
if (!isVisible || !githubAuthChecked || !githubAuthStatus?.connected || !github) {
|
if (!isVisible || !githubAuthChecked || !githubAuthStatus?.connected || !github) {
|
||||||
return;
|
return;
|
||||||
@@ -1387,12 +1427,15 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
const targetsByKey = new Map<string, { directory: string; branch: string }>();
|
const targetsByKey = new Map<string, { directory: string; branch: string }>();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
sectionsForSidebarRender.forEach((section) => {
|
projectSections.forEach((section) => {
|
||||||
if (collapsedProjects.has(section.project.id)) {
|
if (collapsedProjects.has(section.project.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
section.groups.forEach((group) => {
|
section.groups.forEach((group) => {
|
||||||
|
if (group.isArchivedBucket || group.isMain) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const directory = normalizePath(group.directory ?? null);
|
const directory = normalizePath(group.directory ?? null);
|
||||||
const branch = group.branch?.trim() || gitBranches.get(directory || '')?.trim();
|
const branch = group.branch?.trim() || gitBranches.get(directory || '')?.trim();
|
||||||
if (!directory || !branch) {
|
if (!directory || !branch) {
|
||||||
@@ -1452,8 +1495,8 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
githubAuthStatus?.connected,
|
githubAuthStatus?.connected,
|
||||||
isVisible,
|
isVisible,
|
||||||
gitBranches,
|
gitBranches,
|
||||||
|
projectSections,
|
||||||
refreshPrStatusTargets,
|
refreshPrStatusTargets,
|
||||||
sectionsForSidebarRender,
|
|
||||||
setPrStatusParams,
|
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) => {
|
const toggleCollapsedGroup = React.useCallback((key: string) => {
|
||||||
resetGroupSessionLimit(key);
|
resetGroupSessionLimit(key);
|
||||||
setCollapsedGroups((prev) => {
|
setCollapsedGroups((prev) => {
|
||||||
@@ -1539,31 +1605,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
});
|
});
|
||||||
}, [resetGroupSessionLimit]);
|
}, [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(
|
const renderGroupSessions = React.useCallback(
|
||||||
(
|
(
|
||||||
group: SessionGroup,
|
group: SessionGroup,
|
||||||
@@ -1579,6 +1620,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
groupKey={groupKey}
|
groupKey={groupKey}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
hideGroupLabel={hideGroupLabel}
|
hideGroupLabel={hideGroupLabel}
|
||||||
|
dragHandleProps={dragHandleProps}
|
||||||
compactBodyPadding={compactBodyPadding}
|
compactBodyPadding={compactBodyPadding}
|
||||||
hasSessionSearchQuery={hasSessionSearchQuery}
|
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||||
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||||
@@ -1593,8 +1635,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
showDeletionDialog={showDeletionDialog}
|
showDeletionDialog={showDeletionDialog}
|
||||||
setDeleteFolderConfirm={setDeleteFolderConfirm}
|
setDeleteFolderConfirm={setDeleteFolderConfirm}
|
||||||
renderSessionNode={renderSessionNode}
|
renderSessionNode={renderSessionNode}
|
||||||
projectRepoStatus={projectRepoStatus}
|
|
||||||
lastRepoStatus={lastRepoStatusRef.current}
|
|
||||||
showMoreGroupSessions={showMoreGroupSessions}
|
showMoreGroupSessions={showMoreGroupSessions}
|
||||||
resetGroupSessionLimit={resetGroupSessionLimit}
|
resetGroupSessionLimit={resetGroupSessionLimit}
|
||||||
mobileVariant={mobileVariant}
|
mobileVariant={mobileVariant}
|
||||||
@@ -1616,9 +1656,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
editingId={editingId}
|
editingId={editingId}
|
||||||
editTitle={editTitle}
|
editTitle={editTitle}
|
||||||
openSidebarMenuKey={openSidebarMenuKey}
|
openSidebarMenuKey={openSidebarMenuKey}
|
||||||
prVisualStateByDirectoryBranch={prVisualStateByDirectoryBranch}
|
|
||||||
onToggleCollapsedGroup={toggleCollapsedGroup}
|
onToggleCollapsedGroup={toggleCollapsedGroup}
|
||||||
dragHandleProps={dragHandleProps}
|
|
||||||
scrollContainerRef={scrollContainerRef}
|
scrollContainerRef={scrollContainerRef}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -1635,7 +1673,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
deleteFolder,
|
deleteFolder,
|
||||||
showDeletionDialog,
|
showDeletionDialog,
|
||||||
renderSessionNode,
|
renderSessionNode,
|
||||||
projectRepoStatus,
|
|
||||||
showMoreGroupSessions,
|
showMoreGroupSessions,
|
||||||
resetGroupSessionLimit,
|
resetGroupSessionLimit,
|
||||||
mobileVariant,
|
mobileVariant,
|
||||||
@@ -1655,7 +1692,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
editingId,
|
editingId,
|
||||||
editTitle,
|
editTitle,
|
||||||
openSidebarMenuKey,
|
openSidebarMenuKey,
|
||||||
prVisualStateByDirectoryBranch,
|
|
||||||
toggleCollapsedGroup,
|
toggleCollapsedGroup,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -1694,6 +1730,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
isInlineEditing,
|
isInlineEditing,
|
||||||
showDeletionDialog,
|
showDeletionDialog,
|
||||||
foldersMap,
|
foldersMap,
|
||||||
|
folderScopesByProject,
|
||||||
addSessionsToFolder,
|
addSessionsToFolder,
|
||||||
removeSessionsFromFolders,
|
removeSessionsFromFolders,
|
||||||
createFolderAndStartRename,
|
createFolderAndStartRename,
|
||||||
@@ -1710,6 +1747,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
}, [mobileVariant, openMultiRunLauncher, setActiveMainTab, setSessionSwitcherOpen]);
|
}, [mobileVariant, openMultiRunLauncher, setActiveMainTab, setSessionSwitcherOpen]);
|
||||||
|
|
||||||
const handleOpenNewSessionDraftFromHeader = React.useCallback(() => {
|
const handleOpenNewSessionDraftFromHeader = React.useCallback(() => {
|
||||||
|
useUIStore.getState().closeMainSurfaces();
|
||||||
setActiveMainTab('chat');
|
setActiveMainTab('chat');
|
||||||
if (mobileVariant) {
|
if (mobileVariant) {
|
||||||
setSessionSwitcherOpen(false);
|
setSessionSwitcherOpen(false);
|
||||||
@@ -1718,6 +1756,13 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
|
}, [mobileVariant, openNewSessionDraft, setActiveMainTab, setSessionSwitcherOpen]);
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
ref={sessionSearchContainerRef}
|
ref={sessionSearchContainerRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1751,13 +1796,24 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
recentSessions={recentSessions}
|
recentSessions={recentSessions}
|
||||||
prefetchSession={sync.prefetchSession}
|
prefetchSession={sync.prefetchSession}
|
||||||
/>
|
/>
|
||||||
|
{!hideDirectoryControls && !isVSCode ? (
|
||||||
|
<SidebarNav onNewSession={handleOpenNewSessionDraftFromHeader} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
<SidebarHeader
|
<SidebarHeader
|
||||||
hideDirectoryControls={hideDirectoryControls}
|
hideDirectoryControls={hideDirectoryControls}
|
||||||
showRecentControls={!isVSCode}
|
showRecentControls={!isVSCode}
|
||||||
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
|
handleOpenDirectoryDialog={handleOpenDirectoryDialog}
|
||||||
openNewSessionDraft={handleOpenNewSessionDraftFromHeader}
|
onOpenScheduled={() => {
|
||||||
|
if (mobileVariant) setSessionSwitcherOpen(false);
|
||||||
|
setScheduledTasksDialogOpen(true);
|
||||||
|
}}
|
||||||
|
onOpenMultiRun={handleOpenMultiRunFromHeader}
|
||||||
canOpenMultiRun={projects.length > 0}
|
canOpenMultiRun={projects.length > 0}
|
||||||
openMultiRunLauncher={handleOpenMultiRunFromHeader}
|
onOpenArchive={() => {
|
||||||
|
if (mobileVariant) setSessionSwitcherOpen(false);
|
||||||
|
setArchivePageOpen(true);
|
||||||
|
}}
|
||||||
headerActionIconClass={headerActionIconClass}
|
headerActionIconClass={headerActionIconClass}
|
||||||
headerActionButtonClass={headerActionButtonClass}
|
headerActionButtonClass={headerActionButtonClass}
|
||||||
isSessionSearchOpen={isSessionSearchOpen}
|
isSessionSearchOpen={isSessionSearchOpen}
|
||||||
@@ -1769,7 +1825,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
searchMatchCount={searchMatchCount}
|
searchMatchCount={searchMatchCount}
|
||||||
collapseAllProjects={collapseAllProjects}
|
collapseAllProjects={collapseAllProjects}
|
||||||
expandAllProjects={expandAllProjects}
|
expandAllProjects={expandAllProjects}
|
||||||
openScheduledTasksDialog={() => setScheduledTasksDialogOpen(true)}
|
|
||||||
selectionModeEnabled={selectionModeEnabled}
|
selectionModeEnabled={selectionModeEnabled}
|
||||||
onToggleSelectionMode={handleToggleSelectionMode}
|
onToggleSelectionMode={handleToggleSelectionMode}
|
||||||
/>
|
/>
|
||||||
@@ -1799,6 +1854,10 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
setSessionSwitcherOpen={setSessionSwitcherOpen}
|
setSessionSwitcherOpen={setSessionSwitcherOpen}
|
||||||
openNewSessionDraft={openNewSessionDraftFromTree}
|
openNewSessionDraft={openNewSessionDraftFromTree}
|
||||||
openNewWorktreeDialog={openNewWorktreeDialog}
|
openNewWorktreeDialog={openNewWorktreeDialog}
|
||||||
|
openWorktreesPage={(projectId) => {
|
||||||
|
if (mobileVariant) setSessionSwitcherOpen(false);
|
||||||
|
setWorktreesPageProjectId(projectId);
|
||||||
|
}}
|
||||||
openProjectEditDialog={setEditingProjectDialogId}
|
openProjectEditDialog={setEditingProjectDialogId}
|
||||||
removeProject={removeProject}
|
removeProject={removeProject}
|
||||||
projectHeaderSentinelRefs={projectHeaderSentinelRefs}
|
projectHeaderSentinelRefs={projectHeaderSentinelRefs}
|
||||||
@@ -1806,6 +1865,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
projectSortOrder={projectSortOrder}
|
projectSortOrder={projectSortOrder}
|
||||||
getOrderedGroups={getOrderedGroups}
|
getOrderedGroups={getOrderedGroups}
|
||||||
setGroupOrderByProject={setGroupOrderByProject}
|
setGroupOrderByProject={setGroupOrderByProject}
|
||||||
|
renderProjectStatusIndicator={renderProjectStatusIndicator}
|
||||||
openSidebarMenuKey={openSidebarMenuKey}
|
openSidebarMenuKey={openSidebarMenuKey}
|
||||||
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||||
isInlineEditing={isInlineEditing}
|
isInlineEditing={isInlineEditing}
|
||||||
@@ -1875,8 +1935,6 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScheduledTasksDialog />
|
|
||||||
|
|
||||||
<SessionDeleteConfirmDialog
|
<SessionDeleteConfirmDialog
|
||||||
value={deleteSessionConfirm}
|
value={deleteSessionConfirm}
|
||||||
setValue={setDeleteSessionConfirm}
|
setValue={setDeleteSessionConfirm}
|
||||||
@@ -1899,6 +1957,7 @@ const SessionSidebarComponent: React.FC<SessionSidebarProps> = ({
|
|||||||
onConfirm={confirmBulkDelete}
|
onConfirm={confirmBulkDelete}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,16 @@
|
|||||||
## Refactor result
|
## Refactor result
|
||||||
|
|
||||||
- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components.
|
- `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.
|
- 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.
|
||||||
- `NavRail` is no longer part of sidebar/navigation flow.
|
- **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 now own root sessions directly; there is no separate rendered `project root` subgroup.
|
- 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.
|
||||||
- Active/hover row styling is text-first; selected sessions use primary text instead of background fills.
|
- 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.
|
||||||
- Archived groups are collapsed by default and support bulk deletion at group/folder level.
|
- 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).
|
||||||
- Session rows support compact inline dates in minimal mode and simplified metadata in default mode.
|
- 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.
|
- 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.
|
- 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
|
## VS Code grouping
|
||||||
|
|
||||||
@@ -26,14 +24,15 @@
|
|||||||
|
|
||||||
### Components
|
### Components
|
||||||
|
|
||||||
- `SidebarHeader.tsx`: Top header UI for add-project, session search, and display mode.
|
- `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all).
|
||||||
- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only.
|
- `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.
|
- `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.
|
- `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 a single worktree/archived group, collapse/expand, folder subtree, group-level controls, and explicit loading/error/retry state for empty groups.
|
- `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 inline metadata, menu actions, minimal/default variants, and nested children. Rows do not initiate directory bootstrap on mount.
|
- `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.
|
- `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.
|
- `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.
|
- `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/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/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/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/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/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.
|
- `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 { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import type { MainTab } from '@/stores/useUIStore';
|
import type { MainTab } from '@/stores/useUIStore';
|
||||||
import { SessionFolderItem } from '../SessionFolderItem';
|
import { SessionFolderItem } from '../SessionFolderItem';
|
||||||
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
|
|
||||||
import type { SortableDragHandleProps } from './sortableItems';
|
import type { SortableDragHandleProps } from './sortableItems';
|
||||||
|
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
|
||||||
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
|
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
|
||||||
import { isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
import { isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
||||||
import { compareSessionsByLifecycleOrder, EMPTY_SESSION_ORDER_RANKS } from '@/sync/session-ordering';
|
import { compareSessionsByLifecycleOrder, EMPTY_SESSION_ORDER_RANKS } from '@/sync/session-ordering';
|
||||||
@@ -28,11 +28,8 @@ import {
|
|||||||
selectFolderRootNodes,
|
selectFolderRootNodes,
|
||||||
} from './sessionNodeItemUtils';
|
} from './sessionNodeItemUtils';
|
||||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
|
||||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||||
import { openExternalUrl } from '@/lib/url';
|
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { useChildStoreManager } from '@/sync/sync-context';
|
import { useChildStoreManager } from '@/sync/sync-context';
|
||||||
|
|
||||||
@@ -71,8 +68,6 @@ type Props = {
|
|||||||
renderContext?: 'project' | 'recent',
|
renderContext?: 'project' | 'recent',
|
||||||
renderExtras?: SessionNodeRenderExtras,
|
renderExtras?: SessionNodeRenderExtras,
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
projectRepoStatus: Map<string, boolean | null>;
|
|
||||||
lastRepoStatus: boolean;
|
|
||||||
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void;
|
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void;
|
||||||
resetGroupSessionLimit: (groupKey: string) => void;
|
resetGroupSessionLimit: (groupKey: string) => void;
|
||||||
mobileVariant: boolean;
|
mobileVariant: boolean;
|
||||||
@@ -94,29 +89,6 @@ type Props = {
|
|||||||
editingId: string | null;
|
editingId: string | null;
|
||||||
editTitle: string;
|
editTitle: string;
|
||||||
openSidebarMenuKey: string | null;
|
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;
|
onToggleCollapsedGroup: (groupKey: string) => void;
|
||||||
dragHandleProps?: SortableDragHandleProps | null;
|
dragHandleProps?: SortableDragHandleProps | null;
|
||||||
compactBodyPadding?: boolean;
|
compactBodyPadding?: boolean;
|
||||||
@@ -176,13 +148,6 @@ const groupHasExpansionMembershipChange = (
|
|||||||
return group.sessions.some(visit);
|
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 => {
|
const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||||
// Bail on Object.is for the props that drive the most work: the group
|
// 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
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prev.projectRepoStatus !== next.projectRepoStatus
|
|
||||||
&& getProjectRepoStatusValue(prev) !== getProjectRepoStatusValue(next)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prev.pinnedSessionIds !== next.pinnedSessionIds
|
if (prev.pinnedSessionIds !== next.pinnedSessionIds
|
||||||
&& groupHasPinnedMembershipChange(next.group, prev.pinnedSessionIds, next.pinnedSessionIds)) {
|
&& groupHasPinnedMembershipChange(next.group, prev.pinnedSessionIds, next.pinnedSessionIds)) {
|
||||||
return false;
|
return false;
|
||||||
@@ -236,20 +196,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
|||||||
if (prevMenuSessionId || nextMenuSessionId) return false;
|
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
|
// Other props are typically stable references from the parent. Default
|
||||||
// to reference equality (the cheap path) and only re-render when the
|
// to reference equality (the cheap path) and only re-render when the
|
||||||
// parent actually swapped something.
|
// parent actually swapped something.
|
||||||
@@ -264,7 +210,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
|||||||
&& prev.showDeletionDialog === next.showDeletionDialog
|
&& prev.showDeletionDialog === next.showDeletionDialog
|
||||||
&& prev.setDeleteFolderConfirm === next.setDeleteFolderConfirm
|
&& prev.setDeleteFolderConfirm === next.setDeleteFolderConfirm
|
||||||
&& prev.renderSessionNode === next.renderSessionNode
|
&& prev.renderSessionNode === next.renderSessionNode
|
||||||
&& prev.lastRepoStatus === next.lastRepoStatus
|
|
||||||
&& prev.showMoreGroupSessions === next.showMoreGroupSessions
|
&& prev.showMoreGroupSessions === next.showMoreGroupSessions
|
||||||
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit
|
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit
|
||||||
&& prev.mobileVariant === next.mobileVariant
|
&& prev.mobileVariant === next.mobileVariant
|
||||||
@@ -306,8 +251,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
showDeletionDialog,
|
showDeletionDialog,
|
||||||
setDeleteFolderConfirm,
|
setDeleteFolderConfirm,
|
||||||
renderSessionNode,
|
renderSessionNode,
|
||||||
projectRepoStatus,
|
|
||||||
lastRepoStatus,
|
|
||||||
showMoreGroupSessions,
|
showMoreGroupSessions,
|
||||||
resetGroupSessionLimit,
|
resetGroupSessionLimit,
|
||||||
mobileVariant,
|
mobileVariant,
|
||||||
@@ -328,7 +271,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
sessionOrderIndex,
|
sessionOrderIndex,
|
||||||
editingId,
|
editingId,
|
||||||
openSidebarMenuKey,
|
openSidebarMenuKey,
|
||||||
prVisualStateByDirectoryBranch,
|
|
||||||
onToggleCollapsedGroup,
|
onToggleCollapsedGroup,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
compactBodyPadding = false,
|
compactBodyPadding = false,
|
||||||
@@ -347,11 +289,17 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
}, [pinnedSessionIds, sessionOrderIndex]);
|
}, [pinnedSessionIds, sessionOrderIndex]);
|
||||||
|
|
||||||
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
||||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
|
||||||
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
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);
|
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 childStores = useChildStoreManager();
|
||||||
const bootstrapDirectory = normalizePath(group.directory ?? null);
|
const bootstrapDirectory = normalizePath(group.directory ?? null);
|
||||||
const bootstrapState = React.useSyncExternalStore(
|
const bootstrapState = React.useSyncExternalStore(
|
||||||
@@ -375,9 +323,16 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
[compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents],
|
[compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents],
|
||||||
);
|
);
|
||||||
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
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(
|
const scopeFolders = React.useMemo(
|
||||||
() => folderScopeKey ? (foldersMap[folderScopeKey] ?? []) : [],
|
() => folderScopes.flatMap(({ scopeKey, directory }) =>
|
||||||
[folderScopeKey, foldersMap]
|
(foldersMap[scopeKey] ?? []).map((folder) => ({ folder, scopeKey, scopeDirectory: directory }))),
|
||||||
|
[folderScopes, foldersMap]
|
||||||
);
|
);
|
||||||
|
|
||||||
const nodeBySessionId = React.useMemo(() => {
|
const nodeBySessionId = React.useMemo(() => {
|
||||||
@@ -394,9 +349,9 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
return map;
|
return map;
|
||||||
}, [sourceGroupNodes]);
|
}, [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);
|
const nodes = selectFolderRootNodes(folder.sessionIds, nodeBySessionId).sort(compareSessionNodes);
|
||||||
return { folder, nodes };
|
return { folder, scopeKey, scopeDirectory, nodes };
|
||||||
}), [scopeFolders, nodeBySessionId, compareSessionNodes]);
|
}), [scopeFolders, nodeBySessionId, compareSessionNodes]);
|
||||||
|
|
||||||
const allFoldersForGroup = React.useMemo(() => {
|
const allFoldersForGroup = React.useMemo(() => {
|
||||||
@@ -707,87 +662,15 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isGitProject = projectId && projectRepoStatus.has(projectId)
|
const showBranchSubtitle = !group.isMain && Boolean(group.branch);
|
||||||
? Boolean(projectRepoStatus.get(projectId))
|
const statusLine = group.branch && isBranchDifferentFromLabel(group.branch, group.label)
|
||||||
: lastRepoStatus;
|
? { label: group.branch, color: null as string | null }
|
||||||
const groupDirectoryKey = normalizePath(group.directory ?? null);
|
|
||||||
const groupBranchKey = group.branch?.trim() ?? null;
|
|
||||||
const prIndicator = groupDirectoryKey && groupBranchKey
|
|
||||||
? (prVisualStateByDirectoryBranch.get(`${groupDirectoryKey}::${groupBranchKey}`) ?? null)
|
|
||||||
: 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 => {
|
type FolderEntry = (typeof allFoldersForGroup)[number];
|
||||||
const directSubFolders = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id);
|
|
||||||
const subFolderItems = directSubFolders.length > 0
|
const renderOneFolderItem = (entry: FolderEntry, displayName: string): React.ReactNode => {
|
||||||
? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}</>
|
const { folder, scopeKey, scopeDirectory, nodes } = entry;
|
||||||
: undefined;
|
|
||||||
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
|
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -795,12 +678,12 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
{(droppableRef, isDropTarget) => (
|
{(droppableRef, isDropTarget) => (
|
||||||
<SessionFolderItem
|
<SessionFolderItem
|
||||||
folder={folder}
|
folder={folder}
|
||||||
|
displayName={displayName}
|
||||||
sessions={nodes}
|
sessions={nodes}
|
||||||
subFolderItems={subFolderItems}
|
|
||||||
isCollapsed={hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id)}
|
isCollapsed={hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id)}
|
||||||
onToggle={() => toggleFolderCollapse(folder.id)}
|
onToggle={() => toggleFolderCollapse(folder.id)}
|
||||||
onRename={(name) => {
|
onRename={(name) => {
|
||||||
if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name);
|
renameFolder(scopeKey, folder.id, name);
|
||||||
}}
|
}}
|
||||||
onDelete={() => {
|
onDelete={() => {
|
||||||
if (group.isArchivedBucket) {
|
if (group.isArchivedBucket) {
|
||||||
@@ -812,15 +695,14 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!folderScopeKey) return;
|
|
||||||
if (!showDeletionDialog) {
|
if (!showDeletionDialog) {
|
||||||
deleteFolder(folderScopeKey, folder.id);
|
deleteFolder(scopeKey, folder.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length;
|
const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length;
|
||||||
const sessionCount = nodes.length;
|
const sessionCount = nodes.length;
|
||||||
setDeleteFolderConfirm({
|
setDeleteFolderConfirm({
|
||||||
scopeKey: folderScopeKey,
|
scopeKey,
|
||||||
folderId: folder.id,
|
folderId: folder.id,
|
||||||
folderName: folder.name,
|
folderName: folder.name,
|
||||||
subFolderCount,
|
subFolderCount,
|
||||||
@@ -836,7 +718,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
childRenderExtrasFor,
|
childRenderExtrasFor,
|
||||||
})
|
})
|
||||||
: undefined}
|
: undefined}
|
||||||
groupDirectory={group.directory}
|
groupDirectory={scopeDirectory ?? group.directory}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
mobileVariant={mobileVariant}
|
mobileVariant={mobileVariant}
|
||||||
alwaysShowActions={alwaysShowActions}
|
alwaysShowActions={alwaysShowActions}
|
||||||
@@ -845,8 +727,8 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
onRenameDraftChange={(value) => setRenameFolderDraft(value)}
|
onRenameDraftChange={(value) => setRenameFolderDraft(value)}
|
||||||
onRenameSave={() => {
|
onRenameSave={() => {
|
||||||
const trimmed = renameFolderDraft.trim();
|
const trimmed = renameFolderDraft.trim();
|
||||||
if (trimmed && folderScopeKey) {
|
if (trimmed) {
|
||||||
renameFolder(folderScopeKey, folder.id, trimmed);
|
renameFolder(scopeKey, folder.id, trimmed);
|
||||||
}
|
}
|
||||||
setRenamingFolderId(null);
|
setRenamingFolderId(null);
|
||||||
setRenameFolderDraft('');
|
setRenameFolderDraft('');
|
||||||
@@ -857,17 +739,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
}}
|
}}
|
||||||
droppableRef={droppableRef}
|
droppableRef={droppableRef}
|
||||||
isDropTarget={isDropTarget}
|
isDropTarget={isDropTarget}
|
||||||
depth={depth}
|
depth={0}
|
||||||
onNewSession={() => {
|
onNewSession={() => {
|
||||||
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
|
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
|
||||||
setActiveMainTab('chat');
|
setActiveMainTab('chat');
|
||||||
if (mobileVariant) setSessionSwitcherOpen(false);
|
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}
|
hideActions={false}
|
||||||
archivedBucket={group.isArchivedBucket === true}
|
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 hasWorktreeDeleteAction = Boolean(!group.isMain && group.worktree);
|
||||||
const groupHeaderRightPadding = alwaysShowActions
|
const groupHeaderRightPadding = alwaysShowActions
|
||||||
? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7')
|
? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7')
|
||||||
: isMinimalMode
|
: (hasWorktreeDeleteAction
|
||||||
? (hasWorktreeDeleteAction
|
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
||||||
? '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');
|
||||||
: 'pr-2')
|
|
||||||
: (hasWorktreeDeleteAction
|
|
||||||
? 'pr-5 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
|
||||||
: 'pr-5');
|
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
<SessionFolderDndScope
|
<SessionFolderDndScope
|
||||||
scopeKey={folderScopeKey}
|
scopeKey={folderScopes[0]?.scopeKey ?? folderScopeKey}
|
||||||
hasFolders={allFoldersForGroup.length > 0}
|
hasFolders={allFoldersForGroup.length > 0}
|
||||||
onSessionDroppedOnFolder={(sessionId, folderId) => {
|
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()}
|
{renderFolderItems()}
|
||||||
@@ -964,7 +873,9 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
}))
|
}))
|
||||||
)}
|
)}
|
||||||
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
|
{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
|
{group.isArchivedBucket
|
||||||
? t('sessions.sidebar.group.empty.noArchivedSessions')
|
? t('sessions.sidebar.group.empty.noArchivedSessions')
|
||||||
: bootstrapState === 'queued' || bootstrapState === 'running'
|
: bootstrapState === 'queued' || bootstrapState === 'running'
|
||||||
@@ -999,7 +910,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => showMoreGroupSessions(groupKey, visibleSessions.length)}
|
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')}
|
{t('sessions.sidebar.group.showMore')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1008,7 +919,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => resetGroupSessionLimit(groupKey)}
|
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')}
|
{t('sessions.sidebar.group.showFewer')}
|
||||||
</button>
|
</button>
|
||||||
@@ -1016,7 +927,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
</SessionFolderDndScope>
|
</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) {
|
if (hideGroupLabel) {
|
||||||
return <div className="oc-group"><div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div></div>;
|
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
|
<div
|
||||||
ref={dragHandleProps?.setActivatorNodeRef}
|
ref={dragHandleProps?.setActivatorNodeRef}
|
||||||
className={cn(
|
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,
|
groupHeaderRightPadding,
|
||||||
)}
|
)}
|
||||||
{...(dragHandleProps?.listeners ?? {})}
|
{...(dragHandleProps?.listeners ?? {})}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex flex-1 flex-col justify-center gap-0.5 overflow-hidden">
|
<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">
|
<p className="text-[14px] font-normal truncate text-foreground/92">
|
||||||
{showInlinePrTitle && prIndicator ? (
|
{group.isArchivedBucket ? (
|
||||||
<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 ? (
|
|
||||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
|
<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">
|
<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')} />
|
<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 className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
|
||||||
</span>
|
</span>
|
||||||
) : (!group.isMain || group.worktree) ? (
|
) : (!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">
|
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||||
<Icon name="git-branch"
|
<Icon name="git-branch"
|
||||||
className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')}
|
className={cn('h-3.5 w-3.5 shrink-0', !groupPrColor && 'text-muted-foreground', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')}
|
||||||
style={branchIconColor ? { color: branchIconColor } : undefined}
|
style={groupPrColor ? { color: groupPrColor } : undefined}
|
||||||
/>
|
/>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'text-muted-foreground h-3.5 w-3.5 items-center justify-center',
|
'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" />}
|
{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>
|
</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>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
renderHighlightedText(group.label, normalizedSessionSearchQuery)
|
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">
|
<span className="inline-flex min-w-0 items-center gap-1.5 leading-tight">
|
||||||
{group.isArchivedBucket ? (
|
{group.isArchivedBucket ? (
|
||||||
<Icon name="archive" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
<Icon name="archive" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
) : (!group.isMain || isGitProject) ? (
|
) : (
|
||||||
showInlinePrTitle && prIndicator ? (
|
<Icon name="git-branch" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||||
<Tooltip>
|
)}
|
||||||
<TooltipTrigger asChild>
|
<span className="min-w-0 truncate text-[11px] font-medium text-muted-foreground/80">
|
||||||
<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}
|
|
||||||
>
|
|
||||||
{statusLine.label}
|
{statusLine.label}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from '@/components/ui/dropdown-menu.styles';
|
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from '@/components/ui/dropdown-menu.styles';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
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 { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop';
|
||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -29,8 +29,10 @@ import { DraggableSessionRow } from './sessionFolderDnd';
|
|||||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils';
|
import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils';
|
||||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||||
import type { SessionNode } from './types';
|
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 { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
|
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||||
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -129,6 +131,14 @@ type Props = {
|
|||||||
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
|
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 cancelScrollAnchorByContainer = new WeakMap<HTMLElement, () => void>();
|
||||||
|
|
||||||
const holdSessionRowPosition = (target: HTMLElement): void => {
|
const holdSessionRowPosition = (target: HTMLElement): void => {
|
||||||
@@ -284,15 +294,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
menuOpenSessionId,
|
menuOpenSessionId,
|
||||||
childRenderExtrasFor,
|
childRenderExtrasFor,
|
||||||
} = props;
|
} = props;
|
||||||
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
|
|
||||||
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
|
|
||||||
|
|
||||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
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 isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []);
|
||||||
const runtimeApis = React.useContext(RuntimeAPIContext);
|
const runtimeApis = React.useContext(RuntimeAPIContext);
|
||||||
const revealOnHoverClass = isVSCode
|
const revealOnHoverClass = isVSCode
|
||||||
@@ -303,25 +306,22 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
: 'group-hover:opacity-0 group-focus-within:opacity-0';
|
: 'group-hover:opacity-0 group-focus-within:opacity-0';
|
||||||
const showOpenInEditorAction = isVSCode;
|
const showOpenInEditorAction = isVSCode;
|
||||||
const showQuickArchiveAction = !archivedBucket && !mobileVariant;
|
const showQuickArchiveAction = !archivedBucket && !mobileVariant;
|
||||||
const revealPaddingClass = isMinimalMode
|
const revealPaddingClass = isVSCode
|
||||||
? (isVSCode
|
// VS Code rows reveal up to three actions on hover
|
||||||
// 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
|
||||||
// (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
|
||||||
// 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.
|
||||||
// overlap the timestamp. Open-in-editor is always present in VS Code.
|
? (showQuickArchiveAction && showOpenInEditorAction
|
||||||
? (showQuickArchiveAction && showOpenInEditorAction
|
? 'group-hover:pr-18'
|
||||||
? 'group-hover:pr-18'
|
: showQuickArchiveAction || showOpenInEditorAction
|
||||||
: showQuickArchiveAction || showOpenInEditorAction
|
? 'group-hover:pr-14'
|
||||||
? 'group-hover:pr-14'
|
: 'group-hover:pr-8')
|
||||||
: 'group-hover:pr-8')
|
// Reserve just enough room for the hover-revealed actions (two 16px
|
||||||
: 'group-hover:pr-2 group-focus-within:pr-2')
|
// buttons + gap, anchored at the row edge past the title's own end) so
|
||||||
: (isVSCode
|
// they never overlap the title without leaving a large hole.
|
||||||
? (showQuickArchiveAction && showOpenInEditorAction
|
: (showQuickArchiveAction
|
||||||
? 'group-hover:pr-18'
|
? 'group-hover:pr-7 group-focus-within:pr-7'
|
||||||
: showQuickArchiveAction || showOpenInEditorAction
|
: 'group-hover:pr-3 group-focus-within:pr-3');
|
||||||
? '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 alwaysActionPaddingClass = showQuickArchiveAction ? 'pr-13' : 'pr-7';
|
const alwaysActionPaddingClass = showQuickArchiveAction ? 'pr-13' : 'pr-7';
|
||||||
const suppressNextSelectRef = React.useRef(false);
|
const suppressNextSelectRef = React.useRef(false);
|
||||||
const [isTouchPressed, setIsTouchPressed] = React.useState(false);
|
const [isTouchPressed, setIsTouchPressed] = React.useState(false);
|
||||||
@@ -338,11 +338,63 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
|
|
||||||
const session = node.session;
|
const session = node.session;
|
||||||
const resolvedSession = 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 isActive = useSessionUIStore((state) => state.currentSessionId === session.id);
|
||||||
|
|
||||||
const sessionDirectory =
|
const sessionDirectory =
|
||||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||||
?? normalizePath(groupDirectory ?? 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
|
// Directory bootstrap is scheduled once at sidebar level. A row only needs
|
||||||
// the lightweight store reference for scoped state and export actions.
|
// the lightweight store reference for scoped state and export actions.
|
||||||
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: false });
|
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: false });
|
||||||
@@ -418,8 +470,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
const isSessionMenuOpen = isMenuOpen || isContextMenuOpen;
|
const isSessionMenuOpen = isMenuOpen || isContextMenuOpen;
|
||||||
const isMultiRunLikeSession = React.useMemo(() => parseMultiRunSessionTitle(resolvedSession.title) !== null, [resolvedSession.title]);
|
const isMultiRunLikeSession = React.useMemo(() => parseMultiRunSessionTitle(resolvedSession.title) !== null, [resolvedSession.title]);
|
||||||
const [fusionDialogOpen, setFusionDialogOpen] = React.useState(false);
|
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]);
|
const descendantCount = React.useMemo(() => collectNodeDescendantIds(node).length, [collectNodeDescendantIds, node]);
|
||||||
|
|
||||||
@@ -526,7 +576,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (editingId !== session.id) return;
|
if (editingId !== session.id) return;
|
||||||
const handleDocMouseDown = (e: MouseEvent) => {
|
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);
|
handleSaveEditRef.current(renameDraftRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -550,11 +606,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={session.id}
|
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">
|
<div className="flex min-w-0 flex-1 flex-col gap-0">
|
||||||
<form
|
<form
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
|
data-session-rename-form={session.id}
|
||||||
className="flex w-full items-center gap-2"
|
className="flex w-full items-center gap-2"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -593,16 +653,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
<Icon name="close" className="size-4" />
|
<Icon name="close" className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -615,10 +665,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
const showStatusMarker = isStreaming || showUnreadStatus;
|
const showStatusMarker = isStreaming || showUnreadStatus;
|
||||||
const statusMarkerContent = isStreaming
|
const statusMarkerContent = isStreaming
|
||||||
? (
|
? (
|
||||||
<span
|
<Icon
|
||||||
className="h-1.5 w-1.5 rounded-full bg-primary animate-busy-pulse"
|
name="loader-4"
|
||||||
|
className="h-3 w-3 animate-spin text-primary"
|
||||||
aria-label={t('sessions.sidebar.session.status.active')}
|
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 ? (
|
const leadingIndicators = isMovingToWorktree || showStatusMarker || showPinnedMarker ? (
|
||||||
<span
|
<span
|
||||||
|
style={{ left: ROW_GUTTER_LEFT_PX + depth * ROW_DEPTH_STEP_PX }}
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-none absolute left-0.5 inline-flex h-3.5 w-3.5 items-center justify-center transition-opacity',
|
'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',
|
||||||
isMinimalMode ? 'top-1/2 -translate-y-1/2' : 'top-[14.5px] -translate-y-1/2',
|
|
||||||
hideLeadingIndicatorOnHover ? 'opacity-100 group-hover:opacity-0 group-focus-within:opacity-0' : '',
|
hideLeadingIndicatorOnHover ? 'opacity-100 group-hover:opacity-0 group-focus-within:opacity-0' : '',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -661,6 +711,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
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);
|
toggleParent(expansionKey);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
@@ -670,15 +723,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
toggleParent(expansionKey);
|
toggleParent(expansionKey);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{ minWidth: 14, minHeight: 14 }}
|
style={{ minWidth: 14, minHeight: 14, left: ROW_GUTTER_LEFT_PX + depth * ROW_DEPTH_STEP_PX }}
|
||||||
className={cn(
|
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',
|
'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',
|
||||||
metadataSubsessionChevron
|
hideChevronUntilHover
|
||||||
? '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
|
|
||||||
? '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'
|
? '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 currentAnchor = useSessionMultiSelectStore.getState().anchorId;
|
||||||
const descendantsById = new Map<string, string[]>();
|
const descendantsById = new Map<string, string[]>();
|
||||||
descendantsById.set(session.id, collectNodeDescendantIds(node));
|
descendantsById.set(session.id, collectNodeDescendantIds(node));
|
||||||
setRowRange(currentAnchor, session.id, orderedIds, sessionDirectory ?? null, descendantsById);
|
setRowRange(currentAnchor, session.id, orderedIds, selectionScopeKey, descendantsById);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node));
|
toggleRowSelected(session.id, selectionScopeKey, collectNodeDescendantIds(node));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event?.currentTarget) holdSessionRowPosition(event.currentTarget);
|
if (event?.currentTarget) holdSessionRowPosition(event.currentTarget);
|
||||||
@@ -918,31 +966,72 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{sessionDirectory && !archivedBucket ? (() => {
|
{sessionDirectory && !archivedBucket ? (() => {
|
||||||
const scopeFolders = getFoldersForScope(sessionDirectory);
|
// Folders are flat per project: list folders from every scope of the
|
||||||
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
|
// 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Sub>
|
<Sub>
|
||||||
<SubTrigger className="[&>svg]:mr-1"><Icon name="folder" className="h-4 w-4" />{t('sessions.sidebar.folders.moveToFolder')}</SubTrigger>
|
<SubTrigger className="[&>svg]:mr-1"><Icon name="folder" className="h-4 w-4" />{t('sessions.sidebar.folders.moveToFolder')}</SubTrigger>
|
||||||
<SubContent className="min-w-[180px]">
|
<SubContent className="min-w-[180px]">
|
||||||
{scopeFolders.length === 0 ? (
|
{folderEntries.length === 0 ? (
|
||||||
<Item disabled className="text-muted-foreground">{t('sessions.sidebar.folders.none')}</Item>
|
<Item disabled className="text-muted-foreground">{t('sessions.sidebar.folders.none')}</Item>
|
||||||
) : (
|
) : (
|
||||||
scopeFolders.map((folder) => (
|
folderEntries.map(({ scope, folder }) => {
|
||||||
<Item key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
|
const isCurrent = currentEntry?.folder.id === folder.id;
|
||||||
<span className="flex-1 truncate">{folder.name}</span>
|
return (
|
||||||
{currentFolderId === folder.id ? <Icon name="check" className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
|
<Item key={folder.id} onClick={() => {
|
||||||
</Item>
|
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 />
|
<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" />
|
<Icon name="add" className="mr-1 h-4 w-4" />
|
||||||
{t('sessions.sidebar.folders.newFolderEllipsis')}
|
{t('sessions.sidebar.folders.newFolderEllipsis')}
|
||||||
</Item>
|
</Item>
|
||||||
{currentFolderId ? (
|
{currentEntry ? (
|
||||||
<Item onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
|
<Item onClick={() => { removeSessionFromFolder(currentEntry.scope, session.id); }} className="text-destructive focus:text-destructive">
|
||||||
<Icon name="close" className="mr-1 h-4 w-4" />
|
<Icon name="close" className="mr-1 h-4 w-4" />
|
||||||
{t('sessions.sidebar.folders.removeFromFolder')}
|
{t('sessions.sidebar.folders.removeFromFolder')}
|
||||||
</Item>
|
</Item>
|
||||||
@@ -1067,17 +1156,16 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
render={
|
render={
|
||||||
<div
|
<div
|
||||||
data-session-row={session.id}
|
data-session-row={session.id}
|
||||||
data-session-scope={sessionDirectory ?? ''}
|
data-session-scope={selectionScopeKey ?? ''}
|
||||||
data-session-archived={archivedBucket ? '1' : '0'}
|
data-session-archived={archivedBucket ? '1' : '0'}
|
||||||
onClick={handleRowBackgroundClick}
|
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(
|
className={cn(
|
||||||
'group relative my-0.5 flex cursor-pointer items-center rounded-md py-1 pr-1.5',
|
'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;
|
// Active (currently open) session gets a subtle primary tint;
|
||||||
// multi-select highlight takes precedence when both apply.
|
// multi-select highlight takes precedence when both apply.
|
||||||
isActive && !isRowSelected && 'bg-primary/10',
|
isActive && !isRowSelected && 'bg-primary/10',
|
||||||
@@ -1089,7 +1177,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
{leadingIndicators}
|
{leadingIndicators}
|
||||||
{subsessionChevron}
|
{subsessionChevron}
|
||||||
<div className="flex min-w-0 flex-1 items-center">
|
<div className="flex min-w-0 flex-1 items-center">
|
||||||
{isMinimalMode ? (
|
{(
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -1111,24 +1199,41 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
: revealPaddingClass,
|
: revealPaddingClass,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
|
<div className="flex w-full items-center min-w-0 flex-1 gap-1 overflow-hidden">
|
||||||
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
{/* 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 ? (
|
{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">
|
<span className="ml-2 inline-flex flex-shrink-0 items-center gap-1 text-[0.72rem] text-muted-foreground/75">
|
||||||
{sessionGoalGlyph}
|
{sessionGoalGlyph}
|
||||||
|
{showInlineBranchMarker ? (
|
||||||
|
<Icon
|
||||||
|
name="git-branch"
|
||||||
|
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
|
||||||
|
style={prIconColor ? { color: prIconColor } : undefined}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{sessionCompactUpdatedLabel}
|
{sessionCompactUpdatedLabel}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : (sessionGoalGlyph || showInlineBranchMarker) ? (
|
||||||
{!alwaysShowActions ? (
|
<div className="relative ml-1 flex h-4 flex-shrink-0 items-center justify-end">
|
||||||
<div className="relative ml-1 flex h-4 min-w-4 flex-shrink-0 items-center justify-end">
|
|
||||||
<span className={cn(
|
<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
|
isSessionMenuOpen
|
||||||
? 'opacity-0'
|
? 'opacity-0'
|
||||||
: hideOnHoverClass,
|
: hideOnHoverClass,
|
||||||
)}>
|
)}>
|
||||||
{sessionGoalGlyph}
|
{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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1145,68 +1250,40 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
the per-row metadata tooltip is redundant noise there. */}
|
the per-row metadata tooltip is redundant noise there. */}
|
||||||
{!isVSCode ? (
|
{!isVSCode ? (
|
||||||
<TooltipContent side="right" sideOffset={8} className="max-w-xs text-left">
|
<TooltipContent side="right" sideOffset={8} className="max-w-xs text-left">
|
||||||
<div className="flex flex-col gap-1 text-left text-xs">
|
<div className="flex min-w-44 flex-col gap-1.5 text-left text-xs">
|
||||||
<div className={cn('flex items-center gap-3 text-left text-muted-foreground', secondaryMeta?.projectLabel ? 'justify-between' : 'justify-start')}>
|
<div className="flex items-center justify-between gap-3">
|
||||||
{secondaryMeta?.projectLabel ? <div className="min-w-0 truncate">{secondaryMeta.projectLabel}</div> : null}
|
<span className="min-w-0 truncate font-medium text-foreground">{sessionTitle}</span>
|
||||||
<div className="flex-shrink-0">{sessionUpdatedLabel}</div>
|
<span className="flex-shrink-0 text-muted-foreground" title={sessionUpdatedLabel}>{sessionCompactUpdatedLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
{secondaryMeta?.branchLabel ? (
|
{tooltipProjectLabel ? (
|
||||||
<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 text-muted-foreground">
|
||||||
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
<Icon name="folder" className="h-3 w-3 flex-shrink-0" />
|
||||||
<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>
|
<span className="min-w-0 truncate">{tooltipProjectLabel}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
) : null}
|
) : null}
|
||||||
</Tooltip>
|
</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>
|
</div>
|
||||||
|
|
||||||
{streamingIndicator && !mobileVariant ? (
|
{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}
|
{streamingIndicator}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1223,8 +1300,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
<QuickSessionAction
|
<QuickSessionAction
|
||||||
archiveLabel={t('sessions.sidebar.bulkActions.archive')}
|
archiveLabel={t('sessions.sidebar.bulkActions.archive')}
|
||||||
deleteLabel={t('sessions.sidebar.bulkActions.delete')}
|
deleteLabel={t('sessions.sidebar.bulkActions.delete')}
|
||||||
buttonSizeClass={isMinimalMode && !alwaysShowActions ? 'h-4 w-4' : 'h-6 w-6'}
|
buttonSizeClass={!alwaysShowActions ? 'h-4 w-4' : 'h-6 w-6'}
|
||||||
iconSizeClass={isMinimalMode && !alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5'}
|
iconSizeClass={!alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5'}
|
||||||
onPointerDown={handleQuickArchivePointerDown}
|
onPointerDown={handleQuickArchivePointerDown}
|
||||||
onMouseDown={handleQuickArchiveMouseDown}
|
onMouseDown={handleQuickArchiveMouseDown}
|
||||||
onArchive={handleQuickArchiveClick}
|
onArchive={handleQuickArchiveClick}
|
||||||
@@ -1238,7 +1315,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
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',
|
'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')}
|
aria-label={t('sessions.sidebar.session.actions.openInEditor')}
|
||||||
onPointerDown={handleOpenInEditorPointerDown}
|
onPointerDown={handleOpenInEditorPointerDown}
|
||||||
@@ -1246,7 +1323,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
onClick={handleOpenInEditorClick}
|
onClick={handleOpenInEditorClick}
|
||||||
onKeyDown={(event) => event.stopPropagation()}
|
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>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="left" sideOffset={8}>
|
<TooltipContent side="left" sideOffset={8}>
|
||||||
@@ -1260,7 +1337,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
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',
|
'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
|
? (isSessionMenuOpen
|
||||||
? 'h-4 w-4 opacity-100'
|
? 'h-4 w-4 opacity-100'
|
||||||
: cn('h-4 w-4 opacity-0', revealOnHoverClass))
|
: cn('h-4 w-4 opacity-0', revealOnHoverClass))
|
||||||
@@ -1272,7 +1349,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
onClick={handleMenuTriggerClick}
|
onClick={handleMenuTriggerClick}
|
||||||
onKeyDown={(event) => event.stopPropagation()}
|
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>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
{sessionMenuContent}
|
{sessionMenuContent}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React from 'react';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { SessionNode } from './types';
|
import type { SessionNode } from './types';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import {
|
import {
|
||||||
collectSubtreeContainingId,
|
collectSubtreeContainingId,
|
||||||
@@ -61,6 +62,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
|||||||
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||||
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
||||||
const flatVariant = variant === 'flat';
|
const flatVariant = variant === 'flat';
|
||||||
@@ -132,7 +134,9 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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) => {
|
{visibleSections.map((section) => {
|
||||||
const isCollapsed = collapsed.has(section.key);
|
const isCollapsed = collapsed.has(section.key);
|
||||||
const visibleLimit = Math.max(
|
const visibleLimit = Math.max(
|
||||||
@@ -162,7 +166,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
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')}
|
{t('sessions.sidebar.group.showMore')}
|
||||||
</button>
|
</button>
|
||||||
@@ -172,26 +176,36 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={section.key} className="space-y-1">
|
<div key={section.key} className="relative space-y-1">
|
||||||
<button
|
{/* Zone header styled like a project header band; sticky with a
|
||||||
type="button"
|
solid sidebar backing so rows never show through. */}
|
||||||
onClick={() => toggleSection(section.key)}
|
<div className={cn(
|
||||||
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"
|
'-ml-2.5 -mr-2',
|
||||||
aria-expanded={!isCollapsed}
|
stickyZoneHeaders && 'oc-zone-header-backing sticky top-0 z-20 bg-sidebar',
|
||||||
>
|
)}>
|
||||||
<span className="inline-flex h-4 w-4 items-center justify-center text-muted-foreground">
|
<button
|
||||||
{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" />}
|
type="button"
|
||||||
</span>
|
onClick={() => toggleSection(section.key)}
|
||||||
<span className="text-[14px] font-normal text-foreground/95">{section.title}</span>
|
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"
|
||||||
</button>
|
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 ? (
|
{!isCollapsed ? (
|
||||||
<div className={cn('space-y-0.5 pl-7')}>
|
<div className={cn('space-y-0.5')}>
|
||||||
{visibleItems.map(renderItem)}
|
{visibleItems.map(renderItem)}
|
||||||
{remainingCount > 0 ? (
|
{remainingCount > 0 ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
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')}
|
{t('sessions.sidebar.group.showMore')}
|
||||||
</button>
|
</button>
|
||||||
@@ -200,7 +214,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => resetSectionLimit(section.key)}
|
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')}
|
{t('sessions.sidebar.group.showFewer')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,24 +3,25 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
|
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
hideDirectoryControls: boolean;
|
hideDirectoryControls: boolean;
|
||||||
showRecentControls: boolean;
|
showRecentControls: boolean;
|
||||||
handleOpenDirectoryDialog: () => void;
|
handleOpenDirectoryDialog: () => void;
|
||||||
openNewSessionDraft: () => void;
|
onOpenScheduled: () => void;
|
||||||
|
onOpenMultiRun: () => void;
|
||||||
canOpenMultiRun: boolean;
|
canOpenMultiRun: boolean;
|
||||||
openMultiRunLauncher: () => void;
|
onOpenArchive: () => void;
|
||||||
headerActionIconClass: string;
|
headerActionIconClass: string;
|
||||||
headerActionButtonClass: string;
|
headerActionButtonClass: string;
|
||||||
isSessionSearchOpen: boolean;
|
isSessionSearchOpen: boolean;
|
||||||
@@ -32,7 +33,6 @@ type Props = {
|
|||||||
searchMatchCount: number;
|
searchMatchCount: number;
|
||||||
collapseAllProjects: () => void;
|
collapseAllProjects: () => void;
|
||||||
expandAllProjects: () => void;
|
expandAllProjects: () => void;
|
||||||
openScheduledTasksDialog: () => void;
|
|
||||||
selectionModeEnabled: boolean;
|
selectionModeEnabled: boolean;
|
||||||
onToggleSelectionMode: () => void;
|
onToggleSelectionMode: () => void;
|
||||||
};
|
};
|
||||||
@@ -43,9 +43,10 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
|||||||
hideDirectoryControls,
|
hideDirectoryControls,
|
||||||
showRecentControls,
|
showRecentControls,
|
||||||
handleOpenDirectoryDialog,
|
handleOpenDirectoryDialog,
|
||||||
openNewSessionDraft,
|
onOpenScheduled,
|
||||||
|
onOpenMultiRun,
|
||||||
canOpenMultiRun,
|
canOpenMultiRun,
|
||||||
openMultiRunLauncher,
|
onOpenArchive,
|
||||||
headerActionIconClass,
|
headerActionIconClass,
|
||||||
headerActionButtonClass,
|
headerActionButtonClass,
|
||||||
isSessionSearchOpen,
|
isSessionSearchOpen,
|
||||||
@@ -57,21 +58,18 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
|||||||
searchMatchCount,
|
searchMatchCount,
|
||||||
collapseAllProjects,
|
collapseAllProjects,
|
||||||
expandAllProjects,
|
expandAllProjects,
|
||||||
openScheduledTasksDialog,
|
|
||||||
selectionModeEnabled,
|
selectionModeEnabled,
|
||||||
onToggleSelectionMode,
|
onToggleSelectionMode,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
|
||||||
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
|
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 toggleRecentSection = useSessionDisplayStore((state) => state.toggleRecentSection);
|
||||||
const toggleArchivedSessions = useSessionDisplayStore((state) => state.toggleArchivedSessions);
|
|
||||||
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
|
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
|
||||||
const setProjectSortOrder = useSessionDisplayStore((state) => state.setProjectSortOrder);
|
const setProjectSortOrder = useSessionDisplayStore((state) => state.setProjectSortOrder);
|
||||||
// VS Code forces the expanded layout, so the mode toggle is meaningless there.
|
const sessionGroupingMode = useSessionDisplayStore((state) => state.sessionGroupingMode);
|
||||||
const showDisplayModeToggle = !isVSCodeRuntime();
|
const setSessionGroupingMode = useSessionDisplayStore((state) => state.setSessionGroupingMode);
|
||||||
|
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||||
|
const toggleStickyZoneHeaders = useSessionDisplayStore((state) => state.toggleStickyZoneHeaders);
|
||||||
|
|
||||||
if (hideDirectoryControls) {
|
if (hideDirectoryControls) {
|
||||||
return null;
|
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="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-auto min-h-8 flex-col gap-1">
|
||||||
<div className="flex h-8 items-center justify-between gap-2">
|
<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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenDirectoryDialog}
|
onClick={handleOpenDirectoryDialog}
|
||||||
className={headerActionButtonClass}
|
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||||
aria-label={t('sessions.sidebar.header.actions.addProject')}
|
aria-label={t('sessions.sidebar.header.actions.addProject')}
|
||||||
>
|
>
|
||||||
<Icon name="folder-add" className={headerActionIconClass} />
|
<Icon name="folder-add" className={headerActionIconClass} />
|
||||||
@@ -100,22 +102,22 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openNewSessionDraft}
|
onClick={onOpenScheduled}
|
||||||
className={headerActionButtonClass}
|
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||||
aria-label={t('sessions.sidebar.header.actions.newSession')}
|
aria-label={t('sessions.sidebar.header.actions.scheduledTasks')}
|
||||||
>
|
>
|
||||||
<Icon name="chat-new" className={headerActionIconClass} />
|
<Icon name="calendar-schedule" className={headerActionIconClass} />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</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>
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openMultiRunLauncher}
|
onClick={onOpenMultiRun}
|
||||||
className={headerActionButtonClass}
|
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||||
aria-label={t('sessions.sidebar.header.actions.newMultiRun')}
|
aria-label={t('sessions.sidebar.header.actions.newMultiRun')}
|
||||||
disabled={!canOpenMultiRun}
|
disabled={!canOpenMultiRun}
|
||||||
>
|
>
|
||||||
@@ -129,14 +131,14 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openScheduledTasksDialog}
|
onClick={onOpenArchive}
|
||||||
className={headerActionButtonClass}
|
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||||
aria-label={t('sessions.sidebar.header.actions.scheduledTasks')}
|
aria-label={t('sessions.sidebar.nav.archive')}
|
||||||
>
|
>
|
||||||
<Icon name="calendar-schedule" className={headerActionIconClass} />
|
<Icon name="archive" className={headerActionIconClass} />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</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>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,7 +148,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsSessionSearchOpen((prev) => !prev)}
|
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-label={t('sessions.sidebar.header.actions.searchSessions')}
|
||||||
aria-expanded={isSessionSearchOpen}
|
aria-expanded={isSessionSearchOpen}
|
||||||
>
|
>
|
||||||
@@ -161,7 +163,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleSelectionMode}
|
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
|
aria-label={selectionModeEnabled
|
||||||
? t('sessions.sidebar.header.actions.exitSelection')
|
? t('sessions.sidebar.header.actions.exitSelection')
|
||||||
: t('sessions.sidebar.header.actions.selectSessions')}
|
: t('sessions.sidebar.header.actions.selectSessions')}
|
||||||
@@ -183,62 +185,8 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={headerActionButtonClass}
|
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||||
aria-label={t('sessions.sidebar.header.actions.sortProjects')}
|
aria-label={t('sessions.sidebar.header.displayMode.label')}
|
||||||
>
|
|
||||||
<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')}
|
|
||||||
>
|
>
|
||||||
<Icon name="equalizer-2" className={headerActionIconClass} />
|
<Icon name="equalizer-2" className={headerActionIconClass} />
|
||||||
</button>
|
</button>
|
||||||
@@ -246,44 +194,56 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.displayMode.label')}</p></TooltipContent>
|
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.displayMode.label')}</p></TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DropdownMenuContent align="end" className="min-w-[160px]">
|
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||||
{showDisplayModeToggle ? (
|
<DropdownMenuLabel>{t('sessions.sidebar.header.actions.sortProjects')}</DropdownMenuLabel>
|
||||||
<>
|
{([
|
||||||
<DropdownMenuItem
|
['manual', 'sessions.sidebar.header.projectSort.manual'],
|
||||||
onClick={() => setDisplayMode('default')}
|
['a-z', 'sessions.sidebar.header.projectSort.aToZ'],
|
||||||
className="flex items-center justify-between"
|
['z-a', 'sessions.sidebar.header.projectSort.zToA'],
|
||||||
>
|
['date-added', 'sessions.sidebar.header.projectSort.dateAdded'],
|
||||||
<span>{t('sessions.sidebar.header.displayMode.default')}</span>
|
['recent', 'sessions.sidebar.header.projectSort.recent'],
|
||||||
{displayMode === 'default' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
] as const).map(([order, labelKey]) => (
|
||||||
</DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem
|
key={order}
|
||||||
onClick={() => setDisplayMode('minimal')}
|
onClick={() => setProjectSortOrder(order)}
|
||||||
className="flex items-center justify-between"
|
className="flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<span>{t('sessions.sidebar.header.displayMode.minimal')}</span>
|
<span>{t(labelKey)}</span>
|
||||||
{displayMode === 'minimal' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
{projectSortOrder === order ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
))}
|
||||||
) : null}
|
<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 ? (
|
{showRecentControls ? (
|
||||||
<>
|
<DropdownMenuItem
|
||||||
{showDisplayModeToggle ? <DropdownMenuSeparator /> : null}
|
onClick={toggleRecentSection}
|
||||||
<DropdownMenuItem
|
className="flex items-center justify-between"
|
||||||
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}
|
||||||
<span>{t('sessions.sidebar.header.displayMode.showRecent')}</span>
|
</DropdownMenuItem>
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
) : null}
|
) : 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 />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={collapseAllProjects} className="flex items-center gap-2">
|
<DropdownMenuItem onClick={collapseAllProjects} className="flex items-center gap-2">
|
||||||
<Icon name="contract-up-down" className="h-4 w-4" />
|
<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" />
|
<Icon name="expand-up-down" className="h-4 w-4" />
|
||||||
<span>{t('sessions.sidebar.header.displayMode.expandAll')}</span>
|
<span>{t('sessions.sidebar.header.displayMode.expandAll')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</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,
|
compactBodyPadding?: boolean,
|
||||||
scrollContainerRef?: React.RefObject<HTMLElement | null>,
|
scrollContainerRef?: React.RefObject<HTMLElement | null>,
|
||||||
) => React.ReactNode;
|
) => 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;
|
homeDirectory: string | null;
|
||||||
collapsedProjects: Set<string>;
|
collapsedProjects: Set<string>;
|
||||||
hideDirectoryControls: boolean;
|
hideDirectoryControls: boolean;
|
||||||
@@ -67,13 +70,12 @@ type Props = {
|
|||||||
setSessionSwitcherOpen: (open: boolean) => void;
|
setSessionSwitcherOpen: (open: boolean) => void;
|
||||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||||
openNewWorktreeDialog: () => void;
|
openNewWorktreeDialog: () => void;
|
||||||
|
openWorktreesPage: (id: string) => void;
|
||||||
openProjectEditDialog: (id: string) => void;
|
openProjectEditDialog: (id: string) => void;
|
||||||
removeProject: (id: string) => void;
|
removeProject: (id: string) => void;
|
||||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||||
projectSortOrder: ProjectSortOrder;
|
projectSortOrder: ProjectSortOrder;
|
||||||
getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[];
|
|
||||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
|
||||||
openSidebarMenuKey: string | null;
|
openSidebarMenuKey: string | null;
|
||||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||||
isInlineEditing: boolean;
|
isInlineEditing: boolean;
|
||||||
@@ -90,20 +92,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Threaded into SessionGroupSection so the archived-bucket virtualizer
|
// Memoize getOrderedGroups per project so downstream consumers see a stable
|
||||||
// can resolve the scrolling ancestor synchronously (no getComputedStyle
|
// array reference while inputs are unchanged (avoids O(P) fresh arrays per
|
||||||
// walk) and skip the cost of a style recalc on every render.
|
// list render invalidating the memoized group subtrees).
|
||||||
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.
|
|
||||||
const orderedGroupsCacheRef = React.useRef<Map<string, { groups: SessionGroup[]; ordered: SessionGroup[] }>>(new Map());
|
const orderedGroupsCacheRef = React.useRef<Map<string, { groups: SessionGroup[]; ordered: SessionGroup[] }>>(new Map());
|
||||||
const orderedGroupsCacheGetOrderedGroupsRef = React.useRef<typeof props.getOrderedGroups>(props.getOrderedGroups);
|
const orderedGroupsCacheGetOrderedGroupsRef = React.useRef<typeof props.getOrderedGroups>(props.getOrderedGroups);
|
||||||
if (orderedGroupsCacheGetOrderedGroupsRef.current !== props.getOrderedGroups) {
|
if (orderedGroupsCacheGetOrderedGroupsRef.current !== props.getOrderedGroups) {
|
||||||
@@ -118,9 +109,6 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
}
|
}
|
||||||
const ordered = props.getOrderedGroups(projectId, groups);
|
const ordered = props.getOrderedGroups(projectId, groups);
|
||||||
cache.set(projectId, { groups, ordered });
|
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) {
|
if (cache.size > 256) {
|
||||||
const firstKey = cache.keys().next().value;
|
const firstKey = cache.keys().next().value;
|
||||||
if (firstKey !== undefined) cache.delete(firstKey);
|
if (firstKey !== undefined) cache.delete(firstKey);
|
||||||
@@ -128,6 +116,11 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
return ordered;
|
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) {
|
if (props.sharedSessionsOnly) {
|
||||||
return (
|
return (
|
||||||
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pr-2', props.mobileVariant ? '' : '')}>
|
<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
|
// button) and holds it in place, which makes newly revealed sessions look
|
||||||
// like they insert upward. With anchoring off, scrollTop stays put and new
|
// like they insert upward. With anchoring off, scrollTop stays put and new
|
||||||
// rows appear below naturally.
|
// 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.topContent}
|
||||||
{props.showOnlyMainWorkspace ? (
|
{props.showOnlyMainWorkspace ? (
|
||||||
<div className="space-y-[0.6rem] py-1">
|
<div className="space-y-[0.6rem] py-1">
|
||||||
@@ -186,85 +179,87 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<DndContext
|
||||||
<DndContext
|
sensors={projectSensors}
|
||||||
sensors={projectSensors}
|
collisionDetection={closestCenter}
|
||||||
collisionDetection={closestCenter}
|
onDragEnd={(event) => {
|
||||||
onDragEnd={(event) => {
|
if (props.isInlineEditing) return;
|
||||||
if (props.isInlineEditing) return;
|
// Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes
|
||||||
// Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes
|
if (props.projectSortOrder !== 'manual') return;
|
||||||
if (props.projectSortOrder !== 'manual') return;
|
const { active, over } = event;
|
||||||
const { active, over } = event;
|
if (!over || active.id === over.id) return;
|
||||||
if (!over || active.id === over.id) return;
|
const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
||||||
const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
||||||
const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
props.reorderProjects(oldIndex, newIndex);
|
||||||
props.reorderProjects(oldIndex, newIndex);
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<SortableContext items={props.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||||
<SortableContext items={props.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
{props.sectionsForRender.map((section) => {
|
||||||
{props.sectionsForRender.map((section) => {
|
const project = section.project;
|
||||||
const project = section.project;
|
const projectKey = project.id;
|
||||||
const projectKey = project.id;
|
const projectLabel = formatProjectLabel(
|
||||||
const projectLabel = formatProjectLabel(
|
project.label?.trim()
|
||||||
project.label?.trim()
|
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|
||||||
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|
|| project.normalizedPath,
|
||||||
|| project.normalizedPath,
|
);
|
||||||
);
|
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
const isCollapsed = props.collapsedProjects.has(projectKey);
|
||||||
const isCollapsed = props.collapsedProjects.has(projectKey);
|
const isActiveProject = projectKey === props.activeProjectId;
|
||||||
const isActiveProject = projectKey === props.activeProjectId;
|
const isRepo = props.projectRepoStatus.get(projectKey);
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableProjectItem
|
<SortableProjectItem
|
||||||
key={projectKey}
|
key={projectKey}
|
||||||
id={projectKey}
|
id={projectKey}
|
||||||
disabled={props.projectSortOrder !== 'manual'}
|
disabled={props.projectSortOrder !== 'manual'}
|
||||||
projectLabel={projectLabel}
|
projectLabel={projectLabel}
|
||||||
projectDescription={projectDescription}
|
projectDescription={projectDescription}
|
||||||
projectIcon={project.icon}
|
projectIcon={project.icon}
|
||||||
projectColor={project.color}
|
projectColor={project.color}
|
||||||
projectIconImage={project.iconImage}
|
projectIconImage={project.iconImage}
|
||||||
projectIconBackground={project.iconBackground}
|
projectIconBackground={project.iconBackground}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
isActiveProject={isActiveProject}
|
isActiveProject={isActiveProject}
|
||||||
isRepo={Boolean(isRepo)}
|
isRepo={Boolean(isRepo)}
|
||||||
isDesktopShell={props.isDesktopShellRuntime}
|
isDesktopShell={props.isDesktopShellRuntime}
|
||||||
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
||||||
hideDirectoryControls={props.hideDirectoryControls}
|
hideDirectoryControls={props.hideDirectoryControls}
|
||||||
mobileVariant={props.mobileVariant}
|
mobileVariant={props.mobileVariant}
|
||||||
alwaysShowActions={props.alwaysShowActions}
|
alwaysShowActions={props.alwaysShowActions}
|
||||||
onToggle={() => props.toggleProject(projectKey)}
|
statusIndicator={isCollapsed ? props.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
||||||
onNewSession={() => {
|
onToggle={() => props.toggleProject(projectKey)}
|
||||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
onNewSession={() => {
|
||||||
props.setActiveMainTab('chat');
|
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
props.setActiveMainTab('chat');
|
||||||
props.openNewSessionDraft({
|
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||||
selectedProjectId: projectKey,
|
props.openNewSessionDraft({
|
||||||
directoryOverride: project.normalizedPath,
|
selectedProjectId: projectKey,
|
||||||
});
|
directoryOverride: project.normalizedPath,
|
||||||
}}
|
});
|
||||||
onNewWorktreeSession={() => {
|
}}
|
||||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
onNewWorktreeSession={() => {
|
||||||
props.setActiveMainTab('chat');
|
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||||
props.openNewWorktreeDialog();
|
props.setActiveMainTab('chat');
|
||||||
}}
|
props.openNewWorktreeDialog();
|
||||||
onRenameStart={() => props.openProjectEditDialog(projectKey)}
|
}}
|
||||||
onClose={() => props.removeProject(projectKey)}
|
onManageWorktrees={() => props.openWorktreesPage(projectKey)}
|
||||||
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
onRenameStart={() => props.openProjectEditDialog(projectKey)}
|
||||||
showCreateButtons
|
onClose={() => props.removeProject(projectKey)}
|
||||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
showCreateButtons
|
||||||
>
|
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||||
{!isCollapsed ? (
|
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||||
<div className="space-y-0 pt-0 pb-0.5 pl-3">
|
>
|
||||||
{section.groups.length > 0 ? (
|
{!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
|
<DndContext
|
||||||
sensors={groupSensors}
|
sensors={groupSensors}
|
||||||
collisionDetection={closestCenter}
|
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}
|
{rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true, null, undefined, scrollContainerRef) : null}
|
||||||
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
|
||||||
{nestedGroups.map((group) => {
|
{nestedGroups.map((group) => {
|
||||||
@@ -297,18 +295,16 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
</SortableContext>
|
</SortableContext>
|
||||||
<DragOverlay dropAnimation={null} />
|
<DragOverlay dropAnimation={null} />
|
||||||
</DndContext>
|
</DndContext>
|
||||||
) : (
|
);
|
||||||
<div className="py-1 text-left typography-micro text-muted-foreground">{t('sessions.sidebar.empty.noSessions.title')}</div>
|
})()}
|
||||||
)}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
</SortableProjectItem>
|
||||||
</SortableProjectItem>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</SortableContext>
|
||||||
</SortableContext>
|
<DragOverlay dropAnimation={null} />
|
||||||
<DragOverlay dropAnimation={null} />
|
</DndContext>
|
||||||
</DndContext>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ const getSessionUpdatedAt = (session: Session): number => {
|
|||||||
|
|
||||||
// Recent contains non-archived root sessions that are active now or were
|
// Recent contains non-archived root sessions that are active now or were
|
||||||
// updated within the retention window. The caller applies shared lifecycle
|
// 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 = (
|
export const deriveRecentSessions = (
|
||||||
sessions: Session[],
|
sessions: Session[],
|
||||||
activeSessionIds: ReadonlySet<string>,
|
activeSessionIds: ReadonlySet<string>,
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ export const useGroupOrdering = (groupOrderByProject: Map<string, string[]>) =>
|
|||||||
groupById.delete(id);
|
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) => {
|
groups.forEach((group) => {
|
||||||
if (groupById.has(group.id)) {
|
if (!groupById.has(group.id)) return;
|
||||||
ordered.push(group);
|
if (group.isArchivedBucket) trailingGroups.push(group);
|
||||||
}
|
else newGroups.push(group);
|
||||||
});
|
});
|
||||||
return ordered;
|
return [...newGroups, ...ordered, ...trailingGroups];
|
||||||
},
|
},
|
||||||
[groupOrderByProject],
|
[groupOrderByProject],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { toast } from '@/components/ui';
|
|||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import type { MainTab } from '@/stores/useUIStore';
|
import type { MainTab } from '@/stores/useUIStore';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { streamPerfMark } from '@/stores/utils/streamDebug';
|
import { streamPerfMark } from '@/stores/utils/streamDebug';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
|
||||||
@@ -65,6 +66,9 @@ export const useSessionActions = (args: Args) => {
|
|||||||
const handleSessionSelect = React.useCallback(
|
const handleSessionSelect = React.useCallback(
|
||||||
(sessionId: string, sessionDirectory?: string | null) => {
|
(sessionId: string, sessionDirectory?: string | null) => {
|
||||||
streamPerfMark('navigation.session_select');
|
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 = () => {
|
const resetSessionSearch = () => {
|
||||||
if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) {
|
if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { compareSessionsByLifecycleOrder, getSessionLifecycleOrderValue } from '
|
|||||||
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||||
|
import { getWorktreeFirstSeenAt } from '../worktreeFirstSeen';
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
homeDirectory: string | null;
|
homeDirectory: string | null;
|
||||||
@@ -196,7 +197,16 @@ export const useSessionGrouping = (args: Args) => {
|
|||||||
return bInfo.lastUpdatedAt - aInfo.lastUpdatedAt;
|
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 aLabel = (a.label || a.branch || a.name || a.path || '').toLowerCase();
|
||||||
const bLabel = (b.label || b.branch || b.name || b.path || '').toLowerCase();
|
const bLabel = (b.label || b.branch || b.name || b.path || '').toLowerCase();
|
||||||
return aLabel.localeCompare(bLabel);
|
return aLabel.localeCompare(bLabel);
|
||||||
|
|||||||
@@ -201,6 +201,70 @@ export const useSessionSidebarSections = (args: Args) => {
|
|||||||
|
|
||||||
const sectionsForRender = hasSessionSearchQuery ? searchableProjectSections : visibleProjectSections;
|
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(() => {
|
const searchMatchCount = React.useMemo(() => {
|
||||||
if (!hasSessionSearchQuery) {
|
if (!hasSessionSearchQuery) {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -224,6 +288,7 @@ export const useSessionSidebarSections = (args: Args) => {
|
|||||||
groupSearchDataByGroup,
|
groupSearchDataByGroup,
|
||||||
searchableProjectSections,
|
searchableProjectSections,
|
||||||
sectionsForRender,
|
sectionsForRender,
|
||||||
|
flatSectionsForRender,
|
||||||
searchMatchCount,
|
searchMatchCount,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ type Args = {
|
|||||||
isInlineEditing: boolean;
|
isInlineEditing: boolean;
|
||||||
showDeletionDialog: boolean;
|
showDeletionDialog: boolean;
|
||||||
foldersMap: Record<string, SessionFolder[]>;
|
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;
|
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
|
||||||
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
||||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||||
@@ -39,6 +45,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
|||||||
isInlineEditing,
|
isInlineEditing,
|
||||||
showDeletionDialog,
|
showDeletionDialog,
|
||||||
foldersMap,
|
foldersMap,
|
||||||
|
folderScopesByProject,
|
||||||
addSessionsToFolder,
|
addSessionsToFolder,
|
||||||
removeSessionsFromFolders,
|
removeSessionsFromFolders,
|
||||||
createFolderAndStartRename,
|
createFolderAndStartRename,
|
||||||
@@ -89,38 +96,74 @@ export const useSidebarBulkActions = (args: Args) => {
|
|||||||
return null;
|
return null;
|
||||||
}, [hasSelection, selectedIds, selectionScopeKey]);
|
}, [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 [];
|
if (!derivedSelectionScope) return [];
|
||||||
return foldersMap[derivedSelectionScope] ?? [];
|
const projectScopes = folderScopesByProject.get(derivedSelectionScope);
|
||||||
}, [foldersMap, 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(() => {
|
const bulkCanRemoveFromFolder = React.useMemo(() => {
|
||||||
if (!derivedSelectionScope || !hasSelection) return false;
|
if (!hasSelection) return false;
|
||||||
const scopeFolders = foldersMap[derivedSelectionScope] ?? [];
|
for (const scope of selectionFolderScopes) {
|
||||||
for (const folder of scopeFolders) {
|
for (const folder of foldersMap[scope] ?? []) {
|
||||||
for (const id of folder.sessionIds) {
|
for (const id of folder.sessionIds) {
|
||||||
if (selectedIds.has(id)) return true;
|
if (selectedIds.has(id)) return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
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) => {
|
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
|
||||||
if (!derivedSelectionScope || !hasSelection) return;
|
if (!hasSelection) return;
|
||||||
addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds));
|
const targetScope = resolveFolderScope(folderId);
|
||||||
}, [addSessionsToFolder, selectedIds, derivedSelectionScope, hasSelection]);
|
if (!targetScope) return;
|
||||||
|
moveSelectionToFolder(targetScope, folderId);
|
||||||
|
}, [hasSelection, moveSelectionToFolder, resolveFolderScope]);
|
||||||
|
|
||||||
const handleBulkCreateFolderAndMove = React.useCallback(() => {
|
const handleBulkCreateFolderAndMove = React.useCallback(() => {
|
||||||
if (!derivedSelectionScope || !hasSelection) return;
|
const targetScope = selectionFolderScopes[0];
|
||||||
const newFolder = createFolderAndStartRename(derivedSelectionScope);
|
if (!targetScope || !hasSelection) return;
|
||||||
|
const newFolder = createFolderAndStartRename(targetScope);
|
||||||
if (!newFolder) return;
|
if (!newFolder) return;
|
||||||
addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds));
|
moveSelectionToFolder(targetScope, newFolder.id);
|
||||||
}, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope, hasSelection]);
|
}, [createFolderAndStartRename, hasSelection, moveSelectionToFolder, selectionFolderScopes]);
|
||||||
|
|
||||||
const handleBulkRemoveFromFolder = React.useCallback(() => {
|
const handleBulkRemoveFromFolder = React.useCallback(() => {
|
||||||
if (!derivedSelectionScope || !hasSelection) return;
|
if (!hasSelection) return;
|
||||||
removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds));
|
const ids = Array.from(selectedIds);
|
||||||
}, [removeSessionsFromFolders, selectedIds, derivedSelectionScope, hasSelection]);
|
for (const scope of selectionFolderScopes) {
|
||||||
|
removeSessionsFromFolders(scope, ids);
|
||||||
|
}
|
||||||
|
}, [removeSessionsFromFolders, selectedIds, selectionFolderScopes, hasSelection]);
|
||||||
|
|
||||||
const executeBulkDelete = React.useCallback(async () => {
|
const executeBulkDelete = React.useCallback(async () => {
|
||||||
const ids = Array.from(selectedIds);
|
const ids = Array.from(selectedIds);
|
||||||
|
|||||||
@@ -13,8 +13,14 @@ import { Icon } from "@/components/icon/Icon";
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||||
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export type SortableDragHandleProps = {
|
||||||
|
listeners: ReturnType<typeof useSortable>['listeners'];
|
||||||
|
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
|
||||||
|
};
|
||||||
|
|
||||||
export interface SortableProjectItemProps {
|
export interface SortableProjectItemProps {
|
||||||
id: string;
|
id: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -35,6 +41,7 @@ export interface SortableProjectItemProps {
|
|||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onNewSession: () => void;
|
onNewSession: () => void;
|
||||||
onNewWorktreeSession?: () => void;
|
onNewWorktreeSession?: () => void;
|
||||||
|
onManageWorktrees?: () => void;
|
||||||
onRenameStart: () => void;
|
onRenameStart: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
sentinelRef: (el: HTMLDivElement | null) => void;
|
sentinelRef: (el: HTMLDivElement | null) => void;
|
||||||
@@ -43,13 +50,10 @@ export interface SortableProjectItemProps {
|
|||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
openSidebarMenuKey: string | null;
|
openSidebarMenuKey: string | null;
|
||||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
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> = ({
|
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||||
id,
|
id,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -69,6 +73,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onNewSession,
|
onNewSession,
|
||||||
onNewWorktreeSession,
|
onNewWorktreeSession,
|
||||||
|
onManageWorktrees,
|
||||||
onRenameStart,
|
onRenameStart,
|
||||||
onClose,
|
onClose,
|
||||||
sentinelRef,
|
sentinelRef,
|
||||||
@@ -77,9 +82,11 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
openSidebarMenuKey,
|
openSidebarMenuKey,
|
||||||
setOpenSidebarMenuKey,
|
setOpenSidebarMenuKey,
|
||||||
|
statusIndicator = null,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
|
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -110,6 +117,12 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
{t('sessions.sidebar.project.actions.newSession')}
|
{t('sessions.sidebar.project.actions.newSession')}
|
||||||
</Item>
|
</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}>
|
<Item onClick={onRenameStart}>
|
||||||
<Icon name="pencil-ai" className="mr-1.5 h-4 w-4" />
|
<Icon name="pencil-ai" className="mr-1.5 h-4 w-4" />
|
||||||
{t('sessions.sidebar.project.actions.edit')}
|
{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) {
|
if (suppressNextToggleRef.current) {
|
||||||
suppressNextToggleRef.current = false;
|
suppressNextToggleRef.current = false;
|
||||||
return;
|
return;
|
||||||
@@ -167,9 +185,19 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
|
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
|
||||||
<ContextMenuTrigger
|
<ContextMenuTrigger
|
||||||
render={
|
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
|
<div
|
||||||
className={cn('w-full text-left group/project select-none')}
|
className={cn(
|
||||||
style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }}
|
'-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) => {
|
onContextMenu={(event) => {
|
||||||
// VS Code hides project actions entirely (hideDirectoryControls).
|
// VS Code hides project actions entirely (hideDirectoryControls).
|
||||||
if (hideDirectoryControls) return;
|
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>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
@@ -230,11 +268,14 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className={cn(
|
<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',
|
isActiveProject ? 'text-foreground' : 'text-foreground group-hover/project:text-foreground',
|
||||||
)}>
|
)}>
|
||||||
{projectLabel}
|
{projectLabel}
|
||||||
</span>
|
</span>
|
||||||
|
{statusIndicator ? (
|
||||||
|
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
|
||||||
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" sideOffset={8}>
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
@@ -381,3 +422,4 @@ const SortableGroupItemBase: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const SortableGroupItem = React.memo(SortableGroupItemBase);
|
export const SortableGroupItem = React.memo(SortableGroupItemBase);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export type SessionNode = {
|
|||||||
worktree: WorktreeMetadata | null;
|
worktree: WorktreeMetadata | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SessionGroupFolderScope = {
|
||||||
|
scopeKey: string;
|
||||||
|
directory: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type SessionGroup = {
|
export type SessionGroup = {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -17,6 +22,13 @@ export type SessionGroup = {
|
|||||||
worktree: WorktreeMetadata | null;
|
worktree: WorktreeMetadata | null;
|
||||||
directory: string | null;
|
directory: string | null;
|
||||||
folderScopeKey?: 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[];
|
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;
|
preventOverscroll?: boolean;
|
||||||
useScrollShadow?: boolean;
|
useScrollShadow?: boolean;
|
||||||
scrollShadowSize?: number;
|
scrollShadowSize?: number;
|
||||||
|
/** Suppress the top fade (e.g. when sticky headers sit at the top edge). */
|
||||||
|
hideTopScrollShadow?: boolean;
|
||||||
userIntentOnly?: boolean;
|
userIntentOnly?: boolean;
|
||||||
/** Forwarded to the inner element (e.g. textarea). */
|
/** Forwarded to the inner element (e.g. textarea). */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
@@ -37,6 +39,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
|||||||
preventOverscroll = false,
|
preventOverscroll = false,
|
||||||
useScrollShadow = false,
|
useScrollShadow = false,
|
||||||
scrollShadowSize,
|
scrollShadowSize,
|
||||||
|
hideTopScrollShadow = false,
|
||||||
userIntentOnly = false,
|
userIntentOnly = false,
|
||||||
...rest
|
...rest
|
||||||
}, ref) => {
|
}, ref) => {
|
||||||
@@ -57,6 +60,7 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
|
|||||||
as={Component}
|
as={Component}
|
||||||
ref={containerRef as React.Ref<HTMLElement>}
|
ref={containerRef as React.Ref<HTMLElement>}
|
||||||
size={scrollShadowSize}
|
size={scrollShadowSize}
|
||||||
|
hideTopShadow={hideTopScrollShadow}
|
||||||
className={cn(
|
className={cn(
|
||||||
"overlay-scrollbar-target overlay-scrollbar-container",
|
"overlay-scrollbar-target overlay-scrollbar-container",
|
||||||
preventOverscroll && "overscroll-none",
|
preventOverscroll && "overscroll-none",
|
||||||
|
|||||||
@@ -274,7 +274,10 @@ function TooltipContent({
|
|||||||
<BaseTooltip.Popup
|
<BaseTooltip.Popup
|
||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
style={{ ...style }}
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -680,7 +680,9 @@ html:not(.dark) .chat-scroll {
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
z-index: 10;
|
/* Above sticky zone headers (z-20) — the thumb must never dive under
|
||||||
|
a stuck sidebar header while scrolling. */
|
||||||
|
z-index: 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay-scrollbar--dense,
|
.overlay-scrollbar--dense,
|
||||||
|
|||||||
@@ -1283,7 +1283,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Wait for Worktree setup commands before creating or sending a session',
|
'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Wait for Worktree setup commands before creating or sending a session',
|
||||||
'settings.openchamber.worktrees.setup.toast.saveFailed': 'Failed to save worktree setup commands',
|
'settings.openchamber.worktrees.setup.toast.saveFailed': 'Failed to save worktree setup commands',
|
||||||
'settings.openchamber.worktrees.list.title': 'Existing worktrees',
|
'settings.openchamber.worktrees.list.title': 'Existing worktrees',
|
||||||
'settings.openchamber.worktrees.list.tooltip': 'Worktrees live outside the repo (OpenCode-managed). Deleting a worktree also removes linked sessions.',
|
'settings.openchamber.worktrees.list.tooltip': 'Deleting a worktree also removes its linked sessions.',
|
||||||
'settings.openchamber.worktrees.list.loading': 'Loading worktrees...',
|
'settings.openchamber.worktrees.list.loading': 'Loading worktrees...',
|
||||||
'settings.openchamber.worktrees.list.empty': 'No worktrees found for this project',
|
'settings.openchamber.worktrees.list.empty': 'No worktrees found for this project',
|
||||||
'settings.openchamber.worktrees.list.detachedHead': 'Detached HEAD',
|
'settings.openchamber.worktrees.list.detachedHead': 'Detached HEAD',
|
||||||
|
|||||||
@@ -420,6 +420,28 @@ export const dict = {
|
|||||||
'sessions.sidebar.empty.noMatches.title': 'No matching sessions',
|
'sessions.sidebar.empty.noMatches.title': 'No matching sessions',
|
||||||
'sessions.sidebar.empty.noMatches.description': 'Try a different title, branch, folder, or path.',
|
'sessions.sidebar.empty.noMatches.description': 'Try a different title, branch, folder, or path.',
|
||||||
'sessions.sidebar.activity.recentTitle': 'recent',
|
'sessions.sidebar.activity.recentTitle': 'recent',
|
||||||
|
'sessions.archivePage.allDirectories': 'All directories',
|
||||||
|
'sessions.sidebar.header.displayMode.stickyHeaders': 'Sticky project headers',
|
||||||
|
'sessions.sidebar.header.grouping.label': 'Group sessions',
|
||||||
|
'sessions.sidebar.header.grouping.byWorktree': 'By worktree',
|
||||||
|
'sessions.sidebar.header.grouping.flat': 'Flat list',
|
||||||
|
'sessions.sidebar.project.actions.manageWorktrees': 'Manage worktrees',
|
||||||
|
'sessions.worktreesPage.title': 'Worktrees in {project}',
|
||||||
|
'sessions.worktreesPage.description': 'Create, inspect, and remove git worktrees for this project.',
|
||||||
|
'sessions.worktreesPage.closeAria': 'Close worktrees',
|
||||||
|
'sessions.scheduledTasks.page.closeAria': 'Close scheduled tasks',
|
||||||
|
'sessions.sidebar.nav.archive': 'Archive',
|
||||||
|
'sessions.archivePage.title': 'Archive',
|
||||||
|
'sessions.archivePage.countSingle': '{count} archived session',
|
||||||
|
'sessions.archivePage.countPlural': '{count} archived sessions',
|
||||||
|
'sessions.archivePage.closeAria': 'Close archive',
|
||||||
|
'sessions.archivePage.searchPlaceholder': 'Search archived sessions',
|
||||||
|
'sessions.archivePage.empty.noArchived': 'No archived sessions',
|
||||||
|
'sessions.archivePage.empty.noMatches': 'No matching archived sessions',
|
||||||
|
'sessions.archivePage.otherProjects': 'other projects',
|
||||||
|
'sessions.archivePage.deleteProject': 'Delete all archived sessions in this project',
|
||||||
|
'sessions.archivePage.deleteProjectAria': 'Delete all archived sessions in {label}',
|
||||||
|
'sessions.archivePage.deleteSessionAria': 'Delete {title}',
|
||||||
'sessions.switcher.openAria': 'Open session switcher',
|
'sessions.switcher.openAria': 'Open session switcher',
|
||||||
'sessions.switcher.empty': 'No recent sessions',
|
'sessions.switcher.empty': 'No recent sessions',
|
||||||
'sessions.switcher.draftTitle': 'New session',
|
'sessions.switcher.draftTitle': 'New session',
|
||||||
|
|||||||
@@ -1250,7 +1250,7 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.worktrees.setup.waitForCommandsAria": "Esperar los comandos de configuración de Worktree antes de crear o enviar una sesión",
|
"settings.openchamber.worktrees.setup.waitForCommandsAria": "Esperar los comandos de configuración de Worktree antes de crear o enviar una sesión",
|
||||||
"settings.openchamber.worktrees.setup.toast.saveFailed": "Error al guardar los comandos de configuración del worktree",
|
"settings.openchamber.worktrees.setup.toast.saveFailed": "Error al guardar los comandos de configuración del worktree",
|
||||||
"settings.openchamber.worktrees.list.title": "Worktrees existentes",
|
"settings.openchamber.worktrees.list.title": "Worktrees existentes",
|
||||||
"settings.openchamber.worktrees.list.tooltip": "Los worktrees existen fuera del repositorio (gestionados por OpenCode). Eliminar un worktree también elimina las sesiones vinculadas.",
|
"settings.openchamber.worktrees.list.tooltip": "Eliminar un worktree también elimina las sesiones vinculadas.",
|
||||||
"settings.openchamber.worktrees.list.loading": "Cargando worktrees...",
|
"settings.openchamber.worktrees.list.loading": "Cargando worktrees...",
|
||||||
"settings.openchamber.worktrees.list.empty": "No se encontraron worktrees para este proyecto",
|
"settings.openchamber.worktrees.list.empty": "No se encontraron worktrees para este proyecto",
|
||||||
"settings.openchamber.worktrees.list.detachedHead": "HEAD desvinculado",
|
"settings.openchamber.worktrees.list.detachedHead": "HEAD desvinculado",
|
||||||
|
|||||||
@@ -421,6 +421,28 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.empty.noMatches.title": "No hay sesiones coincidentes",
|
"sessions.sidebar.empty.noMatches.title": "No hay sesiones coincidentes",
|
||||||
"sessions.sidebar.empty.noMatches.description": "Inténtalo con un título, rama, carpeta o ruta diferente.",
|
"sessions.sidebar.empty.noMatches.description": "Inténtalo con un título, rama, carpeta o ruta diferente.",
|
||||||
"sessions.sidebar.activity.recentTitle": "reciente",
|
"sessions.sidebar.activity.recentTitle": "reciente",
|
||||||
|
"sessions.archivePage.allDirectories": "Todos los directorios",
|
||||||
|
"sessions.sidebar.header.displayMode.stickyHeaders": "Encabezados de proyecto fijos",
|
||||||
|
"sessions.sidebar.header.grouping.label": "Agrupar sesiones",
|
||||||
|
"sessions.sidebar.header.grouping.byWorktree": "Por worktree",
|
||||||
|
"sessions.sidebar.header.grouping.flat": "Lista plana",
|
||||||
|
"sessions.sidebar.project.actions.manageWorktrees": "Gestionar worktrees",
|
||||||
|
"sessions.worktreesPage.title": "Worktrees en {project}",
|
||||||
|
"sessions.worktreesPage.description": "Crea, inspecciona y elimina los worktrees de git de este proyecto.",
|
||||||
|
"sessions.worktreesPage.closeAria": "Cerrar worktrees",
|
||||||
|
"sessions.scheduledTasks.page.closeAria": "Cerrar tareas programadas",
|
||||||
|
"sessions.sidebar.nav.archive": "Archivo",
|
||||||
|
"sessions.archivePage.title": "Archivo",
|
||||||
|
"sessions.archivePage.countSingle": "{count} sesión archivada",
|
||||||
|
"sessions.archivePage.countPlural": "{count} sesiones archivadas",
|
||||||
|
"sessions.archivePage.closeAria": "Cerrar archivo",
|
||||||
|
"sessions.archivePage.searchPlaceholder": "Buscar sesiones archivadas",
|
||||||
|
"sessions.archivePage.empty.noArchived": "No hay sesiones archivadas",
|
||||||
|
"sessions.archivePage.empty.noMatches": "No hay sesiones archivadas coincidentes",
|
||||||
|
"sessions.archivePage.otherProjects": "otros proyectos",
|
||||||
|
"sessions.archivePage.deleteProject": "Eliminar todas las sesiones archivadas de este proyecto",
|
||||||
|
"sessions.archivePage.deleteProjectAria": "Eliminar todas las sesiones archivadas de {label}",
|
||||||
|
"sessions.archivePage.deleteSessionAria": "Eliminar {title}",
|
||||||
"sessions.switcher.openAria": "Abrir selector de sesiones",
|
"sessions.switcher.openAria": "Abrir selector de sesiones",
|
||||||
"sessions.switcher.empty": "No hay sesiones recientes",
|
"sessions.switcher.empty": "No hay sesiones recientes",
|
||||||
"sessions.switcher.draftTitle": "Nueva sesión",
|
"sessions.switcher.draftTitle": "Nueva sesión",
|
||||||
|
|||||||
@@ -1171,7 +1171,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Attendre les commandes de configuration Worktree avant de créer ou d\'envoyer une session',
|
'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Attendre les commandes de configuration Worktree avant de créer ou d\'envoyer une session',
|
||||||
'settings.openchamber.worktrees.setup.toast.saveFailed': 'Échec de l\'enregistrement des commandes de configuration Worktree',
|
'settings.openchamber.worktrees.setup.toast.saveFailed': 'Échec de l\'enregistrement des commandes de configuration Worktree',
|
||||||
'settings.openchamber.worktrees.list.title': 'Worktrees existants',
|
'settings.openchamber.worktrees.list.title': 'Worktrees existants',
|
||||||
'settings.openchamber.worktrees.list.tooltip': 'Les worktrees vivent en dehors du dépôt (gérés par OpenCode). La suppression d\'un worktree supprime également les sessions liées.',
|
'settings.openchamber.worktrees.list.tooltip': 'La suppression d\'un worktree supprime également les sessions liées.',
|
||||||
'settings.openchamber.worktrees.list.loading': 'Chargement des worktrees...',
|
'settings.openchamber.worktrees.list.loading': 'Chargement des worktrees...',
|
||||||
'settings.openchamber.worktrees.list.empty': 'Aucun worktree trouvé pour ce projet',
|
'settings.openchamber.worktrees.list.empty': 'Aucun worktree trouvé pour ce projet',
|
||||||
'settings.openchamber.worktrees.list.detachedHead': 'HEAD détaché',
|
'settings.openchamber.worktrees.list.detachedHead': 'HEAD détaché',
|
||||||
|
|||||||
@@ -262,6 +262,28 @@ export const dict = {
|
|||||||
'sessions.sidebar.empty.noMatches.title': 'Aucune session correspondante',
|
'sessions.sidebar.empty.noMatches.title': 'Aucune session correspondante',
|
||||||
'sessions.sidebar.empty.noMatches.description': 'Essayez un autre titre, branche, dossier ou chemin.',
|
'sessions.sidebar.empty.noMatches.description': 'Essayez un autre titre, branche, dossier ou chemin.',
|
||||||
'sessions.sidebar.activity.recentTitle': 'récent',
|
'sessions.sidebar.activity.recentTitle': 'récent',
|
||||||
|
'sessions.archivePage.allDirectories': 'Tous les répertoires',
|
||||||
|
'sessions.sidebar.header.displayMode.stickyHeaders': 'Épingler les en-têtes de projet',
|
||||||
|
'sessions.sidebar.header.grouping.label': 'Regrouper les sessions',
|
||||||
|
'sessions.sidebar.header.grouping.byWorktree': 'Par worktree',
|
||||||
|
'sessions.sidebar.header.grouping.flat': 'Liste plate',
|
||||||
|
'sessions.sidebar.project.actions.manageWorktrees': 'Gérer les worktrees',
|
||||||
|
'sessions.worktreesPage.title': 'Worktrees dans {project}',
|
||||||
|
'sessions.worktreesPage.description': 'Créez, inspectez et supprimez les worktrees git de ce projet.',
|
||||||
|
'sessions.worktreesPage.closeAria': 'Fermer les worktrees',
|
||||||
|
'sessions.scheduledTasks.page.closeAria': 'Fermer les tâches planifiées',
|
||||||
|
'sessions.sidebar.nav.archive': 'Archives',
|
||||||
|
'sessions.archivePage.title': 'Archives',
|
||||||
|
'sessions.archivePage.countSingle': '{count} session archivée',
|
||||||
|
'sessions.archivePage.countPlural': '{count} sessions archivées',
|
||||||
|
'sessions.archivePage.closeAria': 'Fermer les archives',
|
||||||
|
'sessions.archivePage.searchPlaceholder': 'Rechercher des sessions archivées',
|
||||||
|
'sessions.archivePage.empty.noArchived': 'Aucune session archivée',
|
||||||
|
'sessions.archivePage.empty.noMatches': 'Aucune session archivée correspondante',
|
||||||
|
'sessions.archivePage.otherProjects': 'autres projets',
|
||||||
|
'sessions.archivePage.deleteProject': 'Supprimer toutes les sessions archivées de ce projet',
|
||||||
|
'sessions.archivePage.deleteProjectAria': 'Supprimer toutes les sessions archivées de {label}',
|
||||||
|
'sessions.archivePage.deleteSessionAria': 'Supprimer {title}',
|
||||||
'sessions.switcher.openAria': 'Sélecteur de session ouvert',
|
'sessions.switcher.openAria': 'Sélecteur de session ouvert',
|
||||||
'sessions.switcher.empty': 'Aucune session récente',
|
'sessions.switcher.empty': 'Aucune session récente',
|
||||||
'sessions.switcher.draftTitle': 'Nouvelle session',
|
'sessions.switcher.draftTitle': 'Nouvelle session',
|
||||||
|
|||||||
@@ -1283,7 +1283,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Worktree セットアップコマンドの完了を待ってからセッションを作成または送信する',
|
'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Worktree セットアップコマンドの完了を待ってからセッションを作成または送信する',
|
||||||
'settings.openchamber.worktrees.setup.toast.saveFailed': 'ワークツリー設定コマンドの保存に失敗しました',
|
'settings.openchamber.worktrees.setup.toast.saveFailed': 'ワークツリー設定コマンドの保存に失敗しました',
|
||||||
'settings.openchamber.worktrees.list.title': '既存の Worktree',
|
'settings.openchamber.worktrees.list.title': '既存の Worktree',
|
||||||
'settings.openchamber.worktrees.list.tooltip': 'Worktree はリポジトリの外にあり(OpenCode 管理)、削除するとリンクされた Session も削除されます。',
|
'settings.openchamber.worktrees.list.tooltip': 'Worktree を削除すると、リンクされたセッションも削除されます。',
|
||||||
'settings.openchamber.worktrees.list.loading': 'Worktree を読み込み中...',
|
'settings.openchamber.worktrees.list.loading': 'Worktree を読み込み中...',
|
||||||
'settings.openchamber.worktrees.list.empty': 'このプロジェクトに Worktree は見つかりません',
|
'settings.openchamber.worktrees.list.empty': 'このプロジェクトに Worktree は見つかりません',
|
||||||
'settings.openchamber.worktrees.list.detachedHead': '分離 HEAD',
|
'settings.openchamber.worktrees.list.detachedHead': '分離 HEAD',
|
||||||
|
|||||||
@@ -421,6 +421,28 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.empty.noMatches.title': '一致するセッションがありません',
|
'sessions.sidebar.empty.noMatches.title': '一致するセッションがありません',
|
||||||
'sessions.sidebar.empty.noMatches.description': '別のタイトル、ブランチ、フォルダ、パスをお試しください。',
|
'sessions.sidebar.empty.noMatches.description': '別のタイトル、ブランチ、フォルダ、パスをお試しください。',
|
||||||
'sessions.sidebar.activity.recentTitle': '最近',
|
'sessions.sidebar.activity.recentTitle': '最近',
|
||||||
|
'sessions.archivePage.allDirectories': 'すべてのディレクトリ',
|
||||||
|
'sessions.sidebar.header.displayMode.stickyHeaders': 'プロジェクトヘッダーを固定',
|
||||||
|
'sessions.sidebar.header.grouping.label': 'セッションのグループ化',
|
||||||
|
'sessions.sidebar.header.grouping.byWorktree': 'ワークツリー別',
|
||||||
|
'sessions.sidebar.header.grouping.flat': 'フラットリスト',
|
||||||
|
'sessions.sidebar.project.actions.manageWorktrees': 'ワークツリーを管理',
|
||||||
|
'sessions.worktreesPage.title': '{project} のワークツリー',
|
||||||
|
'sessions.worktreesPage.description': 'このプロジェクトの git ワークツリーを作成・確認・削除します。',
|
||||||
|
'sessions.worktreesPage.closeAria': 'ワークツリーを閉じる',
|
||||||
|
'sessions.scheduledTasks.page.closeAria': 'スケジュールタスクを閉じる',
|
||||||
|
'sessions.sidebar.nav.archive': 'アーカイブ',
|
||||||
|
'sessions.archivePage.title': 'アーカイブ',
|
||||||
|
'sessions.archivePage.countSingle': 'アーカイブ済みセッション {count} 件',
|
||||||
|
'sessions.archivePage.countPlural': 'アーカイブ済みセッション {count} 件',
|
||||||
|
'sessions.archivePage.closeAria': 'アーカイブを閉じる',
|
||||||
|
'sessions.archivePage.searchPlaceholder': 'アーカイブ済みセッションを検索',
|
||||||
|
'sessions.archivePage.empty.noArchived': 'アーカイブ済みセッションはありません',
|
||||||
|
'sessions.archivePage.empty.noMatches': '一致するアーカイブ済みセッションはありません',
|
||||||
|
'sessions.archivePage.otherProjects': 'その他のプロジェクト',
|
||||||
|
'sessions.archivePage.deleteProject': 'このプロジェクトのアーカイブ済みセッションをすべて削除',
|
||||||
|
'sessions.archivePage.deleteProjectAria': '{label} のアーカイブ済みセッションをすべて削除',
|
||||||
|
'sessions.archivePage.deleteSessionAria': '{title} を削除',
|
||||||
'sessions.switcher.openAria': 'セッションスイッチャーを開く',
|
'sessions.switcher.openAria': 'セッションスイッチャーを開く',
|
||||||
'sessions.switcher.empty': '最近のセッションはありません',
|
'sessions.switcher.empty': '最近のセッションはありません',
|
||||||
'sessions.switcher.draftTitle': '新しいセッション',
|
'sessions.switcher.draftTitle': '新しいセッション',
|
||||||
|
|||||||
@@ -1250,7 +1250,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.worktrees.setup.waitForCommandsAria': '세션을 만들거나 보내기 전에 Worktree 설정 명령이 끝날 때까지 기다리기',
|
'settings.openchamber.worktrees.setup.waitForCommandsAria': '세션을 만들거나 보내기 전에 Worktree 설정 명령이 끝날 때까지 기다리기',
|
||||||
'settings.openchamber.worktrees.setup.toast.saveFailed': 'Worktree 설정 명령 저장에 실패했습니다',
|
'settings.openchamber.worktrees.setup.toast.saveFailed': 'Worktree 설정 명령 저장에 실패했습니다',
|
||||||
'settings.openchamber.worktrees.list.title': '기존 worktree',
|
'settings.openchamber.worktrees.list.title': '기존 worktree',
|
||||||
'settings.openchamber.worktrees.list.tooltip': 'Worktree는 repo 외부에 있습니다(OpenCode 관리). worktree를 삭제하면 연결된 세션도 제거됩니다.',
|
'settings.openchamber.worktrees.list.tooltip': 'Worktree를 삭제하면 연결된 세션도 제거됩니다.',
|
||||||
'settings.openchamber.worktrees.list.loading': 'worktree 로딩 중...',
|
'settings.openchamber.worktrees.list.loading': 'worktree 로딩 중...',
|
||||||
'settings.openchamber.worktrees.list.empty': '이 프로젝트의 worktree를 찾을 수 없습니다',
|
'settings.openchamber.worktrees.list.empty': '이 프로젝트의 worktree를 찾을 수 없습니다',
|
||||||
'settings.openchamber.worktrees.list.detachedHead': 'Detached HEAD',
|
'settings.openchamber.worktrees.list.detachedHead': 'Detached HEAD',
|
||||||
|
|||||||
@@ -421,6 +421,28 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.empty.noMatches.title': '일치하는 세션 없음',
|
'sessions.sidebar.empty.noMatches.title': '일치하는 세션 없음',
|
||||||
'sessions.sidebar.empty.noMatches.description': '다른 제목, 브랜치, 폴더 또는 경로로 검색해 보세요.',
|
'sessions.sidebar.empty.noMatches.description': '다른 제목, 브랜치, 폴더 또는 경로로 검색해 보세요.',
|
||||||
'sessions.sidebar.activity.recentTitle': '최근',
|
'sessions.sidebar.activity.recentTitle': '최근',
|
||||||
|
'sessions.archivePage.allDirectories': '모든 디렉터리',
|
||||||
|
'sessions.sidebar.header.displayMode.stickyHeaders': '프로젝트 헤더 고정',
|
||||||
|
'sessions.sidebar.header.grouping.label': '세션 그룹화',
|
||||||
|
'sessions.sidebar.header.grouping.byWorktree': '워크트리별',
|
||||||
|
'sessions.sidebar.header.grouping.flat': '평면 목록',
|
||||||
|
'sessions.sidebar.project.actions.manageWorktrees': '워크트리 관리',
|
||||||
|
'sessions.worktreesPage.title': '{project}의 워크트리',
|
||||||
|
'sessions.worktreesPage.description': '이 프로젝트의 git 워크트리를 생성, 확인, 삭제합니다.',
|
||||||
|
'sessions.worktreesPage.closeAria': '워크트리 닫기',
|
||||||
|
'sessions.scheduledTasks.page.closeAria': '예약 작업 닫기',
|
||||||
|
'sessions.sidebar.nav.archive': '보관함',
|
||||||
|
'sessions.archivePage.title': '보관함',
|
||||||
|
'sessions.archivePage.countSingle': '보관된 세션 {count}개',
|
||||||
|
'sessions.archivePage.countPlural': '보관된 세션 {count}개',
|
||||||
|
'sessions.archivePage.closeAria': '보관함 닫기',
|
||||||
|
'sessions.archivePage.searchPlaceholder': '보관된 세션 검색',
|
||||||
|
'sessions.archivePage.empty.noArchived': '보관된 세션이 없습니다',
|
||||||
|
'sessions.archivePage.empty.noMatches': '일치하는 보관된 세션이 없습니다',
|
||||||
|
'sessions.archivePage.otherProjects': '기타 프로젝트',
|
||||||
|
'sessions.archivePage.deleteProject': '이 프로젝트의 보관된 세션 모두 삭제',
|
||||||
|
'sessions.archivePage.deleteProjectAria': '{label}의 보관된 세션 모두 삭제',
|
||||||
|
'sessions.archivePage.deleteSessionAria': '{title} 삭제',
|
||||||
'sessions.switcher.openAria': '세션 전환기 열기',
|
'sessions.switcher.openAria': '세션 전환기 열기',
|
||||||
'sessions.switcher.empty': '최근 세션 없음',
|
'sessions.switcher.empty': '최근 세션 없음',
|
||||||
'sessions.switcher.draftTitle': '새 세션',
|
'sessions.switcher.draftTitle': '새 세션',
|
||||||
|
|||||||
@@ -1187,7 +1187,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.worktrees.list.empty': 'Nie znaleziono worktree dla tego projektu',
|
'settings.openchamber.worktrees.list.empty': 'Nie znaleziono worktree dla tego projektu',
|
||||||
'settings.openchamber.worktrees.list.loading': 'Ładowanie worktree...',
|
'settings.openchamber.worktrees.list.loading': 'Ładowanie worktree...',
|
||||||
'settings.openchamber.worktrees.list.title': 'Istniejące worktree',
|
'settings.openchamber.worktrees.list.title': 'Istniejące worktree',
|
||||||
'settings.openchamber.worktrees.list.tooltip': 'Worktree znajdują się poza repozytorium i są zarządzane przez OpenCode. Usunięcie worktree usuwa także powiązane sesje.',
|
'settings.openchamber.worktrees.list.tooltip': 'Usunięcie worktree usuwa także powiązane sesje.',
|
||||||
'settings.openchamber.worktrees.setup.addCommand': 'Dodaj polecenie',
|
'settings.openchamber.worktrees.setup.addCommand': 'Dodaj polecenie',
|
||||||
'settings.openchamber.worktrees.setup.commandPlaceholder': 'np. bun install',
|
'settings.openchamber.worktrees.setup.commandPlaceholder': 'np. bun install',
|
||||||
'settings.openchamber.worktrees.setup.loading': 'Ładowanie...',
|
'settings.openchamber.worktrees.setup.loading': 'Ładowanie...',
|
||||||
|
|||||||
@@ -237,6 +237,28 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.empty.noMatches.title': 'Brak pasujących sesji',
|
'sessions.sidebar.empty.noMatches.title': 'Brak pasujących sesji',
|
||||||
'sessions.sidebar.empty.noMatches.description': 'Spróbuj innego tytułu, gałęzi, folderu lub ścieżki.',
|
'sessions.sidebar.empty.noMatches.description': 'Spróbuj innego tytułu, gałęzi, folderu lub ścieżki.',
|
||||||
'sessions.sidebar.activity.recentTitle': 'ostatnie',
|
'sessions.sidebar.activity.recentTitle': 'ostatnie',
|
||||||
|
'sessions.archivePage.allDirectories': 'Wszystkie katalogi',
|
||||||
|
'sessions.sidebar.header.displayMode.stickyHeaders': 'Przyklejone nagłówki projektów',
|
||||||
|
'sessions.sidebar.header.grouping.label': 'Grupowanie sesji',
|
||||||
|
'sessions.sidebar.header.grouping.byWorktree': 'Według worktree',
|
||||||
|
'sessions.sidebar.header.grouping.flat': 'Płaska lista',
|
||||||
|
'sessions.sidebar.project.actions.manageWorktrees': 'Zarządzaj worktree',
|
||||||
|
'sessions.worktreesPage.title': 'Worktree w {project}',
|
||||||
|
'sessions.worktreesPage.description': 'Twórz, przeglądaj i usuwaj worktree gita tego projektu.',
|
||||||
|
'sessions.worktreesPage.closeAria': 'Zamknij worktree',
|
||||||
|
'sessions.scheduledTasks.page.closeAria': 'Zamknij zaplanowane zadania',
|
||||||
|
'sessions.sidebar.nav.archive': 'Archiwum',
|
||||||
|
'sessions.archivePage.title': 'Archiwum',
|
||||||
|
'sessions.archivePage.countSingle': '{count} zarchiwizowana sesja',
|
||||||
|
'sessions.archivePage.countPlural': '{count} zarchiwizowanych sesji',
|
||||||
|
'sessions.archivePage.closeAria': 'Zamknij archiwum',
|
||||||
|
'sessions.archivePage.searchPlaceholder': 'Szukaj zarchiwizowanych sesji',
|
||||||
|
'sessions.archivePage.empty.noArchived': 'Brak zarchiwizowanych sesji',
|
||||||
|
'sessions.archivePage.empty.noMatches': 'Brak pasujących zarchiwizowanych sesji',
|
||||||
|
'sessions.archivePage.otherProjects': 'inne projekty',
|
||||||
|
'sessions.archivePage.deleteProject': 'Usuń wszystkie zarchiwizowane sesje tego projektu',
|
||||||
|
'sessions.archivePage.deleteProjectAria': 'Usuń wszystkie zarchiwizowane sesje w {label}',
|
||||||
|
'sessions.archivePage.deleteSessionAria': 'Usuń {title}',
|
||||||
'sessions.switcher.openAria': 'Otwórz przełącznik sesji',
|
'sessions.switcher.openAria': 'Otwórz przełącznik sesji',
|
||||||
'sessions.switcher.empty': 'Brak ostatnich sesji',
|
'sessions.switcher.empty': 'Brak ostatnich sesji',
|
||||||
'sessions.switcher.draftTitle': 'Nowa sesja',
|
'sessions.switcher.draftTitle': 'Nowa sesja',
|
||||||
|
|||||||
@@ -1250,7 +1250,7 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.worktrees.setup.waitForCommandsAria": "Aguardar os comandos de configuração do Worktree antes de criar ou enviar uma sessão",
|
"settings.openchamber.worktrees.setup.waitForCommandsAria": "Aguardar os comandos de configuração do Worktree antes de criar ou enviar uma sessão",
|
||||||
"settings.openchamber.worktrees.setup.toast.saveFailed": "Falha ao salvar os comandos de configuração do Worktree",
|
"settings.openchamber.worktrees.setup.toast.saveFailed": "Falha ao salvar os comandos de configuração do Worktree",
|
||||||
"settings.openchamber.worktrees.list.title": "Worktrees existentes",
|
"settings.openchamber.worktrees.list.title": "Worktrees existentes",
|
||||||
"settings.openchamber.worktrees.list.tooltip": "Os worktrees existem fuera do repositório (gerenciados por OpenCode). Excluir um worktree também remove sessões vinculadas.",
|
"settings.openchamber.worktrees.list.tooltip": "Excluir um worktree também remove as sessões vinculadas.",
|
||||||
"settings.openchamber.worktrees.list.loading": "Carregando worktrees...",
|
"settings.openchamber.worktrees.list.loading": "Carregando worktrees...",
|
||||||
"settings.openchamber.worktrees.list.empty": "Nenhum worktree encontrado para este projeto",
|
"settings.openchamber.worktrees.list.empty": "Nenhum worktree encontrado para este projeto",
|
||||||
"settings.openchamber.worktrees.list.detachedHead": "HEAD desvinculado",
|
"settings.openchamber.worktrees.list.detachedHead": "HEAD desvinculado",
|
||||||
|
|||||||
@@ -421,6 +421,28 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.empty.noMatches.title": "Não há sessões coincidentes",
|
"sessions.sidebar.empty.noMatches.title": "Não há sessões coincidentes",
|
||||||
"sessions.sidebar.empty.noMatches.description": "Tente com outro título, branch, pasta ou caminho.",
|
"sessions.sidebar.empty.noMatches.description": "Tente com outro título, branch, pasta ou caminho.",
|
||||||
"sessions.sidebar.activity.recentTitle": "recente",
|
"sessions.sidebar.activity.recentTitle": "recente",
|
||||||
|
"sessions.archivePage.allDirectories": "Todos os diretórios",
|
||||||
|
"sessions.sidebar.header.displayMode.stickyHeaders": "Cabeçalhos de projeto fixos",
|
||||||
|
"sessions.sidebar.header.grouping.label": "Agrupar sessões",
|
||||||
|
"sessions.sidebar.header.grouping.byWorktree": "Por worktree",
|
||||||
|
"sessions.sidebar.header.grouping.flat": "Lista plana",
|
||||||
|
"sessions.sidebar.project.actions.manageWorktrees": "Gerenciar worktrees",
|
||||||
|
"sessions.worktreesPage.title": "Worktrees em {project}",
|
||||||
|
"sessions.worktreesPage.description": "Crie, inspecione e remova os worktrees git deste projeto.",
|
||||||
|
"sessions.worktreesPage.closeAria": "Fechar worktrees",
|
||||||
|
"sessions.scheduledTasks.page.closeAria": "Fechar tarefas agendadas",
|
||||||
|
"sessions.sidebar.nav.archive": "Arquivo",
|
||||||
|
"sessions.archivePage.title": "Arquivo",
|
||||||
|
"sessions.archivePage.countSingle": "{count} sessão arquivada",
|
||||||
|
"sessions.archivePage.countPlural": "{count} sessões arquivadas",
|
||||||
|
"sessions.archivePage.closeAria": "Fechar arquivo",
|
||||||
|
"sessions.archivePage.searchPlaceholder": "Pesquisar sessões arquivadas",
|
||||||
|
"sessions.archivePage.empty.noArchived": "Nenhuma sessão arquivada",
|
||||||
|
"sessions.archivePage.empty.noMatches": "Nenhuma sessão arquivada correspondente",
|
||||||
|
"sessions.archivePage.otherProjects": "outros projetos",
|
||||||
|
"sessions.archivePage.deleteProject": "Excluir todas as sessões arquivadas deste projeto",
|
||||||
|
"sessions.archivePage.deleteProjectAria": "Excluir todas as sessões arquivadas de {label}",
|
||||||
|
"sessions.archivePage.deleteSessionAria": "Excluir {title}",
|
||||||
"sessions.switcher.openAria": "Abrir seletor de sessões",
|
"sessions.switcher.openAria": "Abrir seletor de sessões",
|
||||||
"sessions.switcher.empty": "Nenhuma sessão recente",
|
"sessions.switcher.empty": "Nenhuma sessão recente",
|
||||||
"sessions.switcher.draftTitle": "Nova sessão",
|
"sessions.switcher.draftTitle": "Nova sessão",
|
||||||
|
|||||||
@@ -1250,7 +1250,7 @@ export const settingsDict = {
|
|||||||
"settings.openchamber.worktrees.setup.waitForCommandsAria": "Чекати завершення команд налаштування Worktree перед створенням або надсиланням сесії",
|
"settings.openchamber.worktrees.setup.waitForCommandsAria": "Чекати завершення команд налаштування Worktree перед створенням або надсиланням сесії",
|
||||||
"settings.openchamber.worktrees.setup.toast.saveFailed": "Не вдалося зберегти команди налаштування Worktree",
|
"settings.openchamber.worktrees.setup.toast.saveFailed": "Не вдалося зберегти команди налаштування Worktree",
|
||||||
"settings.openchamber.worktrees.list.title": "Наявні worktree",
|
"settings.openchamber.worktrees.list.title": "Наявні worktree",
|
||||||
"settings.openchamber.worktrees.list.tooltip": "Worktree розташовані поза репозиторієм і керуються OpenCode. Видалення worktree також видаляє пов’язані сесії.",
|
"settings.openchamber.worktrees.list.tooltip": "Видалення worktree також видаляє пов’язані з ним сесії.",
|
||||||
"settings.openchamber.worktrees.list.loading": "Завантаження worktree...",
|
"settings.openchamber.worktrees.list.loading": "Завантаження worktree...",
|
||||||
"settings.openchamber.worktrees.list.empty": "Для цього проєкту не знайдено worktree",
|
"settings.openchamber.worktrees.list.empty": "Для цього проєкту не знайдено worktree",
|
||||||
"settings.openchamber.worktrees.list.detachedHead": "Відокремлено HEAD",
|
"settings.openchamber.worktrees.list.detachedHead": "Відокремлено HEAD",
|
||||||
|
|||||||
@@ -421,6 +421,28 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"sessions.sidebar.empty.noMatches.title": "Немає відповідних сесій",
|
"sessions.sidebar.empty.noMatches.title": "Немає відповідних сесій",
|
||||||
"sessions.sidebar.empty.noMatches.description": "Спробуйте інший заголовок, гілку, папку або шлях.",
|
"sessions.sidebar.empty.noMatches.description": "Спробуйте інший заголовок, гілку, папку або шлях.",
|
||||||
"sessions.sidebar.activity.recentTitle": "Останні",
|
"sessions.sidebar.activity.recentTitle": "Останні",
|
||||||
|
"sessions.archivePage.allDirectories": "Всі директорії",
|
||||||
|
"sessions.sidebar.header.displayMode.stickyHeaders": "Липкі заголовки проектів",
|
||||||
|
"sessions.sidebar.header.grouping.label": "Групування сесій",
|
||||||
|
"sessions.sidebar.header.grouping.byWorktree": "За worktree",
|
||||||
|
"sessions.sidebar.header.grouping.flat": "Плаский список",
|
||||||
|
"sessions.sidebar.project.actions.manageWorktrees": "Керувати worktree",
|
||||||
|
"sessions.worktreesPage.title": "Worktree у {project}",
|
||||||
|
"sessions.worktreesPage.description": "Створюйте, переглядайте та видаляйте git worktree цього проєкту.",
|
||||||
|
"sessions.worktreesPage.closeAria": "Закрити worktree",
|
||||||
|
"sessions.scheduledTasks.page.closeAria": "Закрити заплановані завдання",
|
||||||
|
"sessions.sidebar.nav.archive": "Архів",
|
||||||
|
"sessions.archivePage.title": "Архів",
|
||||||
|
"sessions.archivePage.countSingle": "{count} архівна сесія",
|
||||||
|
"sessions.archivePage.countPlural": "{count} архівних сесій",
|
||||||
|
"sessions.archivePage.closeAria": "Закрити архів",
|
||||||
|
"sessions.archivePage.searchPlaceholder": "Пошук архівних сесій",
|
||||||
|
"sessions.archivePage.empty.noArchived": "Немає архівних сесій",
|
||||||
|
"sessions.archivePage.empty.noMatches": "Немає збігів серед архівних сесій",
|
||||||
|
"sessions.archivePage.otherProjects": "інші проєкти",
|
||||||
|
"sessions.archivePage.deleteProject": "Видалити всі архівні сесії цього проєкту",
|
||||||
|
"sessions.archivePage.deleteProjectAria": "Видалити всі архівні сесії у {label}",
|
||||||
|
"sessions.archivePage.deleteSessionAria": "Видалити {title}",
|
||||||
"sessions.switcher.openAria": "Відкрити перемикач сесій",
|
"sessions.switcher.openAria": "Відкрити перемикач сесій",
|
||||||
"sessions.switcher.empty": "Немає недавніх сесій",
|
"sessions.switcher.empty": "Немає недавніх сесій",
|
||||||
"sessions.switcher.draftTitle": "Нова сесія",
|
"sessions.switcher.draftTitle": "Нова сесія",
|
||||||
|
|||||||
@@ -1250,7 +1250,7 @@ export const settingsDict = {
|
|||||||
'settings.openchamber.worktrees.setup.waitForCommandsAria': '创建或发送会话前等待 Worktree 初始化命令完成',
|
'settings.openchamber.worktrees.setup.waitForCommandsAria': '创建或发送会话前等待 Worktree 初始化命令完成',
|
||||||
'settings.openchamber.worktrees.setup.toast.saveFailed': '保存 Worktree 初始化命令失败',
|
'settings.openchamber.worktrees.setup.toast.saveFailed': '保存 Worktree 初始化命令失败',
|
||||||
'settings.openchamber.worktrees.list.title': '现有工作树',
|
'settings.openchamber.worktrees.list.title': '现有工作树',
|
||||||
'settings.openchamber.worktrees.list.tooltip': '工作树位于仓库外部(由 OpenCode 管理)。删除工作树也会删除关联会话。',
|
'settings.openchamber.worktrees.list.tooltip': '删除工作树也会删除其关联会话。',
|
||||||
'settings.openchamber.worktrees.list.loading': '正在加载工作树...',
|
'settings.openchamber.worktrees.list.loading': '正在加载工作树...',
|
||||||
'settings.openchamber.worktrees.list.empty': '该项目未找到工作树',
|
'settings.openchamber.worktrees.list.empty': '该项目未找到工作树',
|
||||||
'settings.openchamber.worktrees.list.detachedHead': '分离 HEAD',
|
'settings.openchamber.worktrees.list.detachedHead': '分离 HEAD',
|
||||||
|
|||||||
@@ -421,6 +421,28 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.empty.noMatches.title': '没有匹配的会话',
|
'sessions.sidebar.empty.noMatches.title': '没有匹配的会话',
|
||||||
'sessions.sidebar.empty.noMatches.description': '请尝试其他标题、分支、文件夹或路径。',
|
'sessions.sidebar.empty.noMatches.description': '请尝试其他标题、分支、文件夹或路径。',
|
||||||
'sessions.sidebar.activity.recentTitle': '最近',
|
'sessions.sidebar.activity.recentTitle': '最近',
|
||||||
|
'sessions.archivePage.allDirectories': '所有目录',
|
||||||
|
'sessions.sidebar.header.displayMode.stickyHeaders': '固定项目标题',
|
||||||
|
'sessions.sidebar.header.grouping.label': '会话分组',
|
||||||
|
'sessions.sidebar.header.grouping.byWorktree': '按工作树',
|
||||||
|
'sessions.sidebar.header.grouping.flat': '平铺列表',
|
||||||
|
'sessions.sidebar.project.actions.manageWorktrees': '管理工作树',
|
||||||
|
'sessions.worktreesPage.title': '{project} 的工作树',
|
||||||
|
'sessions.worktreesPage.description': '创建、查看和删除此项目的 git 工作树。',
|
||||||
|
'sessions.worktreesPage.closeAria': '关闭工作树',
|
||||||
|
'sessions.scheduledTasks.page.closeAria': '关闭计划任务',
|
||||||
|
'sessions.sidebar.nav.archive': '归档',
|
||||||
|
'sessions.archivePage.title': '归档',
|
||||||
|
'sessions.archivePage.countSingle': '{count} 个已归档会话',
|
||||||
|
'sessions.archivePage.countPlural': '{count} 个已归档会话',
|
||||||
|
'sessions.archivePage.closeAria': '关闭归档',
|
||||||
|
'sessions.archivePage.searchPlaceholder': '搜索已归档会话',
|
||||||
|
'sessions.archivePage.empty.noArchived': '没有已归档会话',
|
||||||
|
'sessions.archivePage.empty.noMatches': '没有匹配的已归档会话',
|
||||||
|
'sessions.archivePage.otherProjects': '其他项目',
|
||||||
|
'sessions.archivePage.deleteProject': '删除此项目的所有已归档会话',
|
||||||
|
'sessions.archivePage.deleteProjectAria': '删除 {label} 的所有已归档会话',
|
||||||
|
'sessions.archivePage.deleteSessionAria': '删除 {title}',
|
||||||
'sessions.switcher.openAria': '打开会话切换器',
|
'sessions.switcher.openAria': '打开会话切换器',
|
||||||
'sessions.switcher.empty': '没有最近会话',
|
'sessions.switcher.empty': '没有最近会话',
|
||||||
'sessions.switcher.draftTitle': '新会话',
|
'sessions.switcher.draftTitle': '新会话',
|
||||||
|
|||||||
@@ -1156,7 +1156,7 @@
|
|||||||
'settings.openchamber.worktrees.setup.waitForCommandsAria': '建立或傳送工作階段前等待 Worktree 設定命令完成',
|
'settings.openchamber.worktrees.setup.waitForCommandsAria': '建立或傳送工作階段前等待 Worktree 設定命令完成',
|
||||||
'settings.openchamber.worktrees.setup.toast.saveFailed': '儲存 Worktree 設定命令失敗',
|
'settings.openchamber.worktrees.setup.toast.saveFailed': '儲存 Worktree 設定命令失敗',
|
||||||
'settings.openchamber.worktrees.list.title': '現有 worktree',
|
'settings.openchamber.worktrees.list.title': '現有 worktree',
|
||||||
'settings.openchamber.worktrees.list.tooltip': 'Worktree 位於儲存庫外部(由 OpenCode 管理)。刪除 worktree 也會刪除關聯工作階段。',
|
'settings.openchamber.worktrees.list.tooltip': '刪除 worktree 也會刪除其關聯工作階段。',
|
||||||
'settings.openchamber.worktrees.list.loading': '正在載入 worktree...',
|
'settings.openchamber.worktrees.list.loading': '正在載入 worktree...',
|
||||||
'settings.openchamber.worktrees.list.empty': '該專案找不到 worktree',
|
'settings.openchamber.worktrees.list.empty': '該專案找不到 worktree',
|
||||||
'settings.openchamber.worktrees.list.detachedHead': '分離 HEAD',
|
'settings.openchamber.worktrees.list.detachedHead': '分離 HEAD',
|
||||||
|
|||||||
@@ -434,6 +434,28 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'sessions.sidebar.empty.noMatches.title': '沒有符合的會話',
|
'sessions.sidebar.empty.noMatches.title': '沒有符合的會話',
|
||||||
'sessions.sidebar.empty.noMatches.description': '請嘗試其他標題、分支、資料夾或路徑。',
|
'sessions.sidebar.empty.noMatches.description': '請嘗試其他標題、分支、資料夾或路徑。',
|
||||||
'sessions.sidebar.activity.recentTitle': '最近',
|
'sessions.sidebar.activity.recentTitle': '最近',
|
||||||
|
'sessions.archivePage.allDirectories': '所有目錄',
|
||||||
|
'sessions.sidebar.header.displayMode.stickyHeaders': '固定專案標題',
|
||||||
|
'sessions.sidebar.header.grouping.label': '工作階段分組',
|
||||||
|
'sessions.sidebar.header.grouping.byWorktree': '依工作樹',
|
||||||
|
'sessions.sidebar.header.grouping.flat': '平面清單',
|
||||||
|
'sessions.sidebar.project.actions.manageWorktrees': '管理工作樹',
|
||||||
|
'sessions.worktreesPage.title': '{project} 的工作樹',
|
||||||
|
'sessions.worktreesPage.description': '建立、檢視及移除此專案的 git 工作樹。',
|
||||||
|
'sessions.worktreesPage.closeAria': '關閉工作樹',
|
||||||
|
'sessions.scheduledTasks.page.closeAria': '關閉排程任務',
|
||||||
|
'sessions.sidebar.nav.archive': '封存',
|
||||||
|
'sessions.archivePage.title': '封存',
|
||||||
|
'sessions.archivePage.countSingle': '{count} 個已封存工作階段',
|
||||||
|
'sessions.archivePage.countPlural': '{count} 個已封存工作階段',
|
||||||
|
'sessions.archivePage.closeAria': '關閉封存',
|
||||||
|
'sessions.archivePage.searchPlaceholder': '搜尋已封存工作階段',
|
||||||
|
'sessions.archivePage.empty.noArchived': '沒有已封存的工作階段',
|
||||||
|
'sessions.archivePage.empty.noMatches': '沒有符合的已封存工作階段',
|
||||||
|
'sessions.archivePage.otherProjects': '其他專案',
|
||||||
|
'sessions.archivePage.deleteProject': '刪除此專案的所有已封存工作階段',
|
||||||
|
'sessions.archivePage.deleteProjectAria': '刪除 {label} 的所有已封存工作階段',
|
||||||
|
'sessions.archivePage.deleteSessionAria': '刪除 {title}',
|
||||||
'sessions.switcher.openAria': '開啟會話切換器',
|
'sessions.switcher.openAria': '開啟會話切換器',
|
||||||
'sessions.switcher.empty': '沒有最近會話',
|
'sessions.switcher.empty': '沒有最近會話',
|
||||||
'sessions.switcher.draftTitle': '新會話',
|
'sessions.switcher.draftTitle': '新會話',
|
||||||
|
|||||||
@@ -935,6 +935,36 @@ const summarySignature = (s: PrVisualSummary): string =>
|
|||||||
let prKeyedCacheSigs = new Map<string, string>();
|
let prKeyedCacheSigs = new Map<string, string>();
|
||||||
let prKeyedCacheResult: Map<string, PrVisualSummary> = new Map();
|
let prKeyedCacheResult: Map<string, PrVisualSummary> = new Map();
|
||||||
|
|
||||||
|
// Per-key summary cache so many independent row subscribers (one key each)
|
||||||
|
// keep referential stability without fighting over the multi-key cache above.
|
||||||
|
// Practically bounded by the number of worktree branches observed in a
|
||||||
|
// session; the explicit cap below guards long-running documents that rotate
|
||||||
|
// through many branches/runtimes (entries are tiny; insertion-order eviction
|
||||||
|
// only costs a one-frame identity change for the evicted key's subscriber).
|
||||||
|
const PR_SUMMARY_CACHE_MAX_ENTRIES = 300;
|
||||||
|
const prSummaryCacheByKey = new Map<string, { sig: string; summary: PrVisualSummary }>();
|
||||||
|
|
||||||
|
export const usePrVisualSummary = (key: string | null): PrVisualSummary | null => {
|
||||||
|
return useGitHubPrStatusStore((state) => {
|
||||||
|
if (!key) return null;
|
||||||
|
const entry = state.entries[key];
|
||||||
|
const summary = entry ? deriveSummary(entry) : null;
|
||||||
|
if (!summary) {
|
||||||
|
prSummaryCacheByKey.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sig = summarySignature(summary);
|
||||||
|
const cached = prSummaryCacheByKey.get(key);
|
||||||
|
if (cached && cached.sig === sig) return cached.summary;
|
||||||
|
if (!cached && prSummaryCacheByKey.size >= PR_SUMMARY_CACHE_MAX_ENTRIES) {
|
||||||
|
const oldestKey = prSummaryCacheByKey.keys().next().value;
|
||||||
|
if (oldestKey !== undefined) prSummaryCacheByKey.delete(oldestKey);
|
||||||
|
}
|
||||||
|
prSummaryCacheByKey.set(key, { sig, summary });
|
||||||
|
return summary;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const usePrVisualSummaryByKeys = (keys: string[]) => {
|
export const usePrVisualSummaryByKeys = (keys: string[]) => {
|
||||||
return useGitHubPrStatusStore((state) => {
|
return useGitHubPrStatusStore((state) => {
|
||||||
// Derive summaries for requested keys only
|
// Derive summaries for requested keys only
|
||||||
|
|||||||
@@ -19,4 +19,16 @@ describe('useSessionDisplayStore project sorting', () => {
|
|||||||
expect(migrated.projectSortOrder).toBe(projectSortOrder);
|
expect(migrated.projectSortOrder).toBe(projectSortOrder);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('v3→v4 drops the removed displayMode key and keeps the rest', () => {
|
||||||
|
const migrated = migrateSessionDisplayState(
|
||||||
|
{ displayMode: 'default', projectSortOrder: 'a-z', showRecentSection: false, showArchivedSessions: true },
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect('displayMode' in migrated).toBe(false);
|
||||||
|
expect(migrated.projectSortOrder).toBe('a-z');
|
||||||
|
expect(migrated.showRecentSection).toBe(false);
|
||||||
|
expect(migrated.showArchivedSessions).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist } from 'zustand/middleware';
|
import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
type SessionDisplayMode = 'default' | 'minimal';
|
|
||||||
|
|
||||||
type ProjectSortOrder = 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent';
|
type ProjectSortOrder = 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent';
|
||||||
|
|
||||||
|
// 'by-worktree' keeps per-worktree sub-headers inside each project zone
|
||||||
|
// (parallel-work overview); 'flat' merges everything into one recency list.
|
||||||
|
type SessionGroupingMode = 'by-worktree' | 'flat';
|
||||||
|
|
||||||
type SessionDisplayStore = {
|
type SessionDisplayStore = {
|
||||||
displayMode: SessionDisplayMode;
|
sessionGroupingMode: SessionGroupingMode;
|
||||||
|
setSessionGroupingMode: (mode: SessionGroupingMode) => void;
|
||||||
|
/** Project/recent zone headers stick to the top while their zone scrolls. */
|
||||||
|
stickyZoneHeaders: boolean;
|
||||||
|
toggleStickyZoneHeaders: () => void;
|
||||||
showRecentSection: boolean;
|
showRecentSection: boolean;
|
||||||
|
// VS Code only: the compact webview keeps archived buckets inline because it
|
||||||
|
// has no room for the full Archive page. Web/desktop ignore this flag and
|
||||||
|
// always route archived sessions to the Archive page instead.
|
||||||
showArchivedSessions: boolean;
|
showArchivedSessions: boolean;
|
||||||
projectSortOrder: ProjectSortOrder;
|
projectSortOrder: ProjectSortOrder;
|
||||||
setDisplayMode: (mode: SessionDisplayMode) => void;
|
|
||||||
setShowRecentSection: (show: boolean) => void;
|
setShowRecentSection: (show: boolean) => void;
|
||||||
setShowArchivedSessions: (show: boolean) => void;
|
setShowArchivedSessions: (show: boolean) => void;
|
||||||
toggleRecentSection: () => void;
|
toggleRecentSection: () => void;
|
||||||
@@ -22,15 +30,19 @@ export const migrateSessionDisplayState = (
|
|||||||
persisted: unknown,
|
persisted: unknown,
|
||||||
version: number,
|
version: number,
|
||||||
): Partial<SessionDisplayStore> => {
|
): Partial<SessionDisplayStore> => {
|
||||||
const state = (persisted ?? {}) as Partial<SessionDisplayStore>;
|
const state = (persisted ?? {}) as Partial<SessionDisplayStore> & {
|
||||||
if (version < 1) {
|
displayMode?: string;
|
||||||
return { ...state, displayMode: 'minimal', projectSortOrder: 'manual' };
|
};
|
||||||
}
|
|
||||||
if (version < 2) {
|
if (version < 2) {
|
||||||
return { ...state, projectSortOrder: 'manual' };
|
state.projectSortOrder = 'manual';
|
||||||
}
|
}
|
||||||
if (version < 3 && state.projectSortOrder === 'recent') {
|
if (version < 3 && state.projectSortOrder === 'recent') {
|
||||||
return { ...state, projectSortOrder: 'manual' };
|
state.projectSortOrder = 'manual';
|
||||||
|
}
|
||||||
|
if (version < 4) {
|
||||||
|
// v4 removes the default/minimal display mode: the sidebar now has a
|
||||||
|
// single row layout. Drop the stale key from persisted state.
|
||||||
|
delete state.displayMode;
|
||||||
}
|
}
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
@@ -38,15 +50,16 @@ export const migrateSessionDisplayState = (
|
|||||||
export const useSessionDisplayStore = create<SessionDisplayStore>()(
|
export const useSessionDisplayStore = create<SessionDisplayStore>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
displayMode: 'minimal',
|
sessionGroupingMode: 'by-worktree',
|
||||||
|
setSessionGroupingMode: (mode) => set({ sessionGroupingMode: mode }),
|
||||||
|
stickyZoneHeaders: true,
|
||||||
|
toggleStickyZoneHeaders: () => set((state) => ({ stickyZoneHeaders: !state.stickyZoneHeaders })),
|
||||||
showRecentSection: true,
|
showRecentSection: true,
|
||||||
// Default to HIDDEN so the pre-hydration state matches the quiet/safe
|
// Default to HIDDEN so the pre-hydration state matches the quiet/safe
|
||||||
// option: archived sessions must never flash visible on startup and then
|
// option: archived sessions must never flash visible on startup and then
|
||||||
// disappear once the persisted preference rehydrates. Users who opted into
|
// disappear once the persisted preference rehydrates.
|
||||||
// showing archived have `true` persisted, which is preserved on rehydrate.
|
|
||||||
showArchivedSessions: false,
|
showArchivedSessions: false,
|
||||||
projectSortOrder: 'manual',
|
projectSortOrder: 'manual',
|
||||||
setDisplayMode: (mode) => set({ displayMode: mode }),
|
|
||||||
setShowRecentSection: (show) => set({ showRecentSection: show }),
|
setShowRecentSection: (show) => set({ showRecentSection: show }),
|
||||||
setShowArchivedSessions: (show) => set({ showArchivedSessions: show }),
|
setShowArchivedSessions: (show) => set({ showArchivedSessions: show }),
|
||||||
toggleRecentSection: () => set((state) => ({ showRecentSection: !state.showRecentSection })),
|
toggleRecentSection: () => set((state) => ({ showRecentSection: !state.showRecentSection })),
|
||||||
@@ -55,15 +68,13 @@ export const useSessionDisplayStore = create<SessionDisplayStore>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'session-display-mode',
|
name: 'session-display-mode',
|
||||||
version: 3,
|
version: 4,
|
||||||
// v0 shipped 'default' as the only/initial mode, so most existing users
|
|
||||||
// have it persisted by accident rather than choice. Nudge everyone onto
|
|
||||||
// minimal once so the mode can be evaluated before removing it entirely.
|
|
||||||
// v1→v2 adds projectSortOrder using the canonical manual ordering.
|
// v1→v2 adds projectSortOrder using the canonical manual ordering.
|
||||||
// v2→v3 replaces the previously shipped recent default with manual.
|
// v2→v3 replaces the previously shipped recent default with manual.
|
||||||
|
// v3→v4 removes displayMode (single sidebar row layout).
|
||||||
migrate: migrateSessionDisplayState,
|
migrate: migrateSessionDisplayState,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export type { ProjectSortOrder };
|
export type { ProjectSortOrder, SessionGroupingMode };
|
||||||
|
|||||||
@@ -595,6 +595,8 @@ interface UIStore {
|
|||||||
openCodeStatusText: string;
|
openCodeStatusText: string;
|
||||||
isSessionCreateDialogOpen: boolean;
|
isSessionCreateDialogOpen: boolean;
|
||||||
isScheduledTasksDialogOpen: boolean;
|
isScheduledTasksDialogOpen: boolean;
|
||||||
|
isArchivePageOpen: boolean;
|
||||||
|
worktreesPageProjectId: string | null;
|
||||||
isSettingsDialogOpen: boolean;
|
isSettingsDialogOpen: boolean;
|
||||||
isNewWorktreeDialogOpen: boolean;
|
isNewWorktreeDialogOpen: boolean;
|
||||||
isModelSelectorOpen: boolean;
|
isModelSelectorOpen: boolean;
|
||||||
@@ -756,6 +758,10 @@ interface UIStore {
|
|||||||
setOpenCodeStatusText: (text: string) => void;
|
setOpenCodeStatusText: (text: string) => void;
|
||||||
setSessionCreateDialogOpen: (open: boolean) => void;
|
setSessionCreateDialogOpen: (open: boolean) => void;
|
||||||
setScheduledTasksDialogOpen: (open: boolean) => void;
|
setScheduledTasksDialogOpen: (open: boolean) => void;
|
||||||
|
setArchivePageOpen: (open: boolean) => void;
|
||||||
|
setWorktreesPageProjectId: (projectId: string | null) => void;
|
||||||
|
/** Close every full-page surface (Scheduled, Archive, Worktrees, Multi-run). */
|
||||||
|
closeMainSurfaces: () => void;
|
||||||
setSettingsDialogOpen: (open: boolean) => void;
|
setSettingsDialogOpen: (open: boolean) => void;
|
||||||
setNewWorktreeDialogOpen: (open: boolean) => void;
|
setNewWorktreeDialogOpen: (open: boolean) => void;
|
||||||
setModelSelectorOpen: (open: boolean) => void;
|
setModelSelectorOpen: (open: boolean) => void;
|
||||||
@@ -909,6 +915,8 @@ export const useUIStore = create<UIStore>()(
|
|||||||
openCodeStatusText: '',
|
openCodeStatusText: '',
|
||||||
isSessionCreateDialogOpen: false,
|
isSessionCreateDialogOpen: false,
|
||||||
isScheduledTasksDialogOpen: false,
|
isScheduledTasksDialogOpen: false,
|
||||||
|
isArchivePageOpen: false,
|
||||||
|
worktreesPageProjectId: null,
|
||||||
isSettingsDialogOpen: false,
|
isSettingsDialogOpen: false,
|
||||||
isNewWorktreeDialogOpen: false,
|
isNewWorktreeDialogOpen: false,
|
||||||
isModelSelectorOpen: false,
|
isModelSelectorOpen: false,
|
||||||
@@ -1552,7 +1560,35 @@ export const useUIStore = create<UIStore>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setScheduledTasksDialogOpen: (open) => {
|
setScheduledTasksDialogOpen: (open) => {
|
||||||
set({ isScheduledTasksDialogOpen: open });
|
set(open
|
||||||
|
? { isScheduledTasksDialogOpen: true, isArchivePageOpen: false, worktreesPageProjectId: null, isMultiRunLauncherOpen: false }
|
||||||
|
: { isScheduledTasksDialogOpen: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
setArchivePageOpen: (open) => {
|
||||||
|
set(open
|
||||||
|
? { isArchivePageOpen: true, isScheduledTasksDialogOpen: false, worktreesPageProjectId: null, isMultiRunLauncherOpen: false }
|
||||||
|
: { isArchivePageOpen: false });
|
||||||
|
},
|
||||||
|
|
||||||
|
setWorktreesPageProjectId: (projectId) => {
|
||||||
|
set(projectId
|
||||||
|
? { worktreesPageProjectId: projectId, isScheduledTasksDialogOpen: false, isArchivePageOpen: false, isMultiRunLauncherOpen: false }
|
||||||
|
: { worktreesPageProjectId: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
closeMainSurfaces: () => {
|
||||||
|
const state = get();
|
||||||
|
if (!state.isScheduledTasksDialogOpen && !state.isArchivePageOpen && !state.worktreesPageProjectId && !state.isMultiRunLauncherOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set({
|
||||||
|
isScheduledTasksDialogOpen: false,
|
||||||
|
isArchivePageOpen: false,
|
||||||
|
worktreesPageProjectId: null,
|
||||||
|
isMultiRunLauncherOpen: false,
|
||||||
|
multiRunLauncherPrefillPrompt: '',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
setSettingsDialogOpen: (open) => {
|
setSettingsDialogOpen: (open) => {
|
||||||
@@ -2016,10 +2052,13 @@ export const useUIStore = create<UIStore>()(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Multi-run is one of the mutually exclusive full-page surfaces:
|
||||||
|
// opening it closes the other surfaces and vice versa.
|
||||||
setMultiRunLauncherOpen: (open) => {
|
setMultiRunLauncherOpen: (open) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
isMultiRunLauncherOpen: open,
|
isMultiRunLauncherOpen: open,
|
||||||
multiRunLauncherPrefillPrompt: open ? state.multiRunLauncherPrefillPrompt : '',
|
multiRunLauncherPrefillPrompt: open ? state.multiRunLauncherPrefillPrompt : '',
|
||||||
|
...(open ? { isScheduledTasksDialogOpen: false, isArchivePageOpen: false, worktreesPageProjectId: null } : {}),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2028,6 +2067,9 @@ export const useUIStore = create<UIStore>()(
|
|||||||
isMultiRunLauncherOpen: true,
|
isMultiRunLauncherOpen: true,
|
||||||
multiRunLauncherPrefillPrompt: '',
|
multiRunLauncherPrefillPrompt: '',
|
||||||
isSessionSwitcherOpen: false,
|
isSessionSwitcherOpen: false,
|
||||||
|
isScheduledTasksDialogOpen: false,
|
||||||
|
isArchivePageOpen: false,
|
||||||
|
worktreesPageProjectId: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -2036,6 +2078,9 @@ export const useUIStore = create<UIStore>()(
|
|||||||
isMultiRunLauncherOpen: true,
|
isMultiRunLauncherOpen: true,
|
||||||
multiRunLauncherPrefillPrompt: prompt,
|
multiRunLauncherPrefillPrompt: prompt,
|
||||||
isSessionSwitcherOpen: false,
|
isSessionSwitcherOpen: false,
|
||||||
|
isScheduledTasksDialogOpen: false,
|
||||||
|
isArchivePageOpen: false,
|
||||||
|
worktreesPageProjectId: null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,20 @@
|
|||||||
background-color: transparent !important;
|
background-color: transparent !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sticky sidebar zone headers must fully mask the rows scrolling under
|
||||||
|
them. A frosted (translucent + backdrop-blur) backing is NOT possible
|
||||||
|
here: Electron's transparent/vibrancy windows break backdrop-filter
|
||||||
|
entirely (electron#20357 — the backdrop snapshot fails against a
|
||||||
|
transparent root), so any translucency lets row text bleed through
|
||||||
|
un-blurred. Use the opaque sidebar tone instead; the blur declaration
|
||||||
|
stays only as a progressive enhancement if Electron ever fixes it. */
|
||||||
|
:root.desktop-runtime[data-oc-vibrancy] .oc-zone-header-backing {
|
||||||
|
background: var(--sidebar-stuck-bg) !important;
|
||||||
|
background-color: var(--sidebar-stuck-bg) !important;
|
||||||
|
-webkit-backdrop-filter: blur(16px) saturate(1.2);
|
||||||
|
backdrop-filter: blur(16px) saturate(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
.font-sans {
|
.font-sans {
|
||||||
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif) !important;
|
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif) !important;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user