From 1291cde5c2d4fc014f3ed48e9d809ac420c412fd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 28 Jul 2026 12:04:39 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20sidebar=20redesign=20=E2=80=94=20pr?= =?UTF-8?q?oject=20zones,=20grouping=20modes,=20full-page=20surfaces=20(#2?= =?UTF-8?q?480)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- packages/ui/src/components/layout/Header.tsx | 44 ++ .../ui/src/components/layout/MainLayout.tsx | 69 ++- .../components/layout/ProjectEditDialog.tsx | 2 +- .../components/multirun/MultiRunLauncher.tsx | 20 +- .../openchamber/WorktreeSectionContent.tsx | 17 +- .../projects/ProjectSettingsPanel.tsx | 9 +- .../session/ScheduledTasksDialog.tsx | 105 +++-- .../components/session/SessionFolderItem.tsx | 72 ++-- .../src/components/session/SessionSidebar.tsx | 265 +++++++----- .../session/sidebar/DOCUMENTATION.md | 32 +- .../session/sidebar/SessionGroupSection.tsx | 400 +++++------------- .../session/sidebar/SessionNodeItem.tsx | 359 ++++++++++------ .../sidebar/SidebarActivitySections.tsx | 48 ++- .../session/sidebar/SidebarHeader.tsx | 205 ++++----- .../components/session/sidebar/SidebarNav.tsx | 26 ++ .../session/sidebar/SidebarProjectsList.tsx | 216 +++++----- .../session/sidebar/activitySections.ts | 3 +- .../session/sidebar/hooks/useGroupOrdering.ts | 14 +- .../sidebar/hooks/useSessionActions.ts | 4 + .../sidebar/hooks/useSessionGrouping.ts | 12 +- .../hooks/useSessionSidebarSections.ts | 65 +++ .../sidebar/hooks/useSidebarBulkActions.ts | 81 +++- .../session/sidebar/sortableItems.tsx | 62 ++- .../src/components/session/sidebar/types.ts | 12 + .../session/sidebar/worktreeFirstSeen.ts | 22 + .../src/components/ui/ScrollableOverlay.tsx | 4 + packages/ui/src/components/ui/tooltip.tsx | 5 +- .../ui/src/components/views/ArchiveView.tsx | 250 +++++++++++ .../src/components/views/MultiRunWindow.tsx | 85 ---- .../ui/src/components/views/WorktreesView.tsx | 42 ++ packages/ui/src/index.css | 4 +- .../ui/src/lib/i18n/messages/en.settings.ts | 2 +- packages/ui/src/lib/i18n/messages/en.ts | 22 + .../ui/src/lib/i18n/messages/es.settings.ts | 2 +- packages/ui/src/lib/i18n/messages/es.ts | 22 + .../ui/src/lib/i18n/messages/fr.settings.ts | 2 +- packages/ui/src/lib/i18n/messages/fr.ts | 22 + .../ui/src/lib/i18n/messages/ja.settings.ts | 2 +- packages/ui/src/lib/i18n/messages/ja.ts | 22 + .../ui/src/lib/i18n/messages/ko.settings.ts | 2 +- packages/ui/src/lib/i18n/messages/ko.ts | 22 + .../ui/src/lib/i18n/messages/pl.settings.ts | 2 +- packages/ui/src/lib/i18n/messages/pl.ts | 22 + .../src/lib/i18n/messages/pt-BR.settings.ts | 2 +- packages/ui/src/lib/i18n/messages/pt-BR.ts | 22 + .../ui/src/lib/i18n/messages/uk.settings.ts | 2 +- packages/ui/src/lib/i18n/messages/uk.ts | 22 + .../src/lib/i18n/messages/zh-CN.settings.ts | 2 +- packages/ui/src/lib/i18n/messages/zh-CN.ts | 22 + .../src/lib/i18n/messages/zh-TW.settings.ts | 2 +- packages/ui/src/lib/i18n/messages/zh-TW.ts | 22 + .../ui/src/stores/useGitHubPrStatusStore.ts | 30 ++ .../src/stores/useSessionDisplayStore.test.ts | 12 + .../ui/src/stores/useSessionDisplayStore.ts | 49 ++- packages/ui/src/stores/useUIStore.ts | 47 +- packages/ui/src/styles/design-system.css | 14 + 56 files changed, 1902 insertions(+), 1044 deletions(-) create mode 100644 packages/ui/src/components/session/sidebar/SidebarNav.tsx create mode 100644 packages/ui/src/components/session/sidebar/worktreeFirstSeen.ts create mode 100644 packages/ui/src/components/views/ArchiveView.tsx delete mode 100644 packages/ui/src/components/views/MultiRunWindow.tsx create mode 100644 packages/ui/src/components/views/WorktreesView.tsx diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 43d4e674..70596531 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -1300,6 +1300,37 @@ export const Header: React.FC = ({ return trimmedTitle && trimmedTitle.length > 0 ? trimmedTitle : 'Untitled Session'; }, [activeProjectLabel, currentSession?.title, currentSessionId]); + // Full-page surfaces (Scheduled, Archive, Worktrees, Multi-run) replace the + // chat area; while one is open the header shows the surface identity + // instead of the session switcher. + const isScheduledSurfaceOpen = useUIStore((state) => state.isScheduledTasksDialogOpen); + const isArchiveSurfaceOpen = useUIStore((state) => state.isArchivePageOpen); + const worktreesSurfaceProjectId = useUIStore((state) => state.worktreesPageProjectId); + const isMultiRunSurfaceOpen = useUIStore((state) => state.isMultiRunLauncherOpen); + const worktreesSurfaceProjectLabel = useProjectsStore((state) => { + if (!worktreesSurfaceProjectId) return null; + const project = state.projects.find((entry) => entry.id === worktreesSurfaceProjectId); + return project?.label?.trim() || project?.path?.split('/').pop() || null; + }); + const activeSurfaceHeader = React.useMemo<{ title: string; subtitle: string | null } | null>(() => { + if (isScheduledSurfaceOpen) { + return { title: t('sessions.scheduledTasks.dialog.title'), subtitle: null }; + } + if (isArchiveSurfaceOpen) { + return { title: t('sessions.archivePage.title'), subtitle: null }; + } + if (worktreesSurfaceProjectId) { + return { + title: t('sessions.worktreesPage.title', { project: worktreesSurfaceProjectLabel ?? '' }), + subtitle: null, + }; + } + if (isMultiRunSurfaceOpen) { + return { title: t('sessions.sidebar.header.actions.newMultiRun'), subtitle: null }; + } + return null; + }, [isArchiveSurfaceOpen, isMultiRunSurfaceOpen, isScheduledSurfaceOpen, t, worktreesSurfaceProjectId, worktreesSurfaceProjectLabel]); + const actionDirectory = React.useMemo(() => { return normalize(openDirectory || activeProject?.path || ''); @@ -2039,6 +2070,18 @@ export const Header: React.FC = ({ TitlebarLeftControls overlay; the header reserves matching left space via padding (see headerStyle) when the sidebar is collapsed. */}
+ {activeSurfaceHeader ? ( +
+ + {activeSurfaceHeader.title} + + {activeSurfaceHeader.subtitle ? ( + + {activeSurfaceHeader.subtitle} + + ) : null} +
+ ) : ( + )} {tabs.length > 0 && (
diff --git a/packages/ui/src/components/layout/MainLayout.tsx b/packages/ui/src/components/layout/MainLayout.tsx index fa21645b..b2f8100b 100644 --- a/packages/ui/src/components/layout/MainLayout.tsx +++ b/packages/ui/src/components/layout/MainLayout.tsx @@ -13,6 +13,9 @@ import { HelpDialog } from '../ui/HelpDialog'; import { OpenCodeStatusDialog } from '../ui/OpenCodeStatusDialog'; import { SessionSidebar } from '@/components/session/SessionSidebar'; import { SessionDialogs } from '@/components/session/SessionDialogs'; +import { ScheduledTasksDialog } from '@/components/session/ScheduledTasksDialog'; +import { ArchiveView } from '@/components/views/ArchiveView'; +import { WorktreesView } from '@/components/views/WorktreesView'; import { DiffWorkerProvider } from '@/contexts/DiffWorkerProvider'; import { MultiRunLauncher } from '@/components/multirun'; import { TerminalView } from '@/components/views/TerminalView'; @@ -37,7 +40,6 @@ import { PlanView } from '@/components/views/PlanView'; const DiagramView = lazyWithChunkRecovery(() => import('@/components/views/DiagramView').then(m => ({ default: m.DiagramView }))); const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView }))); const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/SettingsWindow').then(m => ({ default: m.SettingsWindow }))); -const MultiRunWindow = lazyWithChunkRecovery(() => import('@/components/views/MultiRunWindow').then(m => ({ default: m.MultiRunWindow }))); export const MainLayout: React.FC = () => { const isSidebarOpen = useUIStore((state) => state.isSidebarOpen); @@ -49,6 +51,32 @@ export const MainLayout: React.FC = () => { const isMultiRunLauncherOpen = useUIStore((state) => state.isMultiRunLauncherOpen); const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen); const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt); + const isScheduledTasksPageOpen = useUIStore((state) => state.isScheduledTasksDialogOpen); + const isArchivePageOpen = useUIStore((state) => state.isArchivePageOpen); + const worktreesPageProjectId = useUIStore((state) => state.worktreesPageProjectId); + // Any full-page surface replacing the chat area. While open, the chat and + // secondary views are fully hidden (not just covered) so none of their + // floating chrome bleeds through, and selecting a session / draft / main + // tab anywhere closes the surface. + const isSurfacePageOpen = isScheduledTasksPageOpen || isArchivePageOpen || Boolean(worktreesPageProjectId) || isMultiRunLauncherOpen; + + React.useEffect(() => { + const closeSurfacePages = () => useUIStore.getState().closeMainSurfaces(); + const unsubscribeSession = useSessionUIStore.subscribe((state, prev) => { + const sessionSelected = Boolean(state.currentSessionId) && state.currentSessionId !== prev.currentSessionId; + // Draft identity change covers re-opening a draft while one is + // already open (the boolean alone never transitions then). + const draftOpened = Boolean(state.newSessionDraft?.open) && state.newSessionDraft !== prev.newSessionDraft; + if (sessionSelected || draftOpened) closeSurfacePages(); + }); + const unsubscribeTab = useUIStore.subscribe((state, prev) => { + if (state.activeMainTab !== prev.activeMainTab) closeSurfacePages(); + }); + return () => { + unsubscribeSession(); + unsubscribeTab(); + }; + }, []); const { isMobile } = useDeviceInfo(); const mobilePanelsResetRef = React.useRef(false); @@ -306,11 +334,11 @@ export const MainLayout: React.FC = () => { )} >
-
- +
+
{secondaryView && ( -
+
{secondaryView}
)} @@ -325,6 +353,9 @@ export const MainLayout: React.FC = () => {
)} + + + {/* Always mount SessionSidebar on mobile to match desktop behavior. Conditional mount (mobileLeftDrawerVisible && ...) caused a data-loading cascade on every drawer open: paginated sessions @@ -395,14 +426,31 @@ export const MainLayout: React.FC = () => {
-
- +
+
{secondaryView && ( -
+
{secondaryView}
)} + {isMultiRunLauncherOpen && ( +
+ + {/* isWindowed: the app Header already shows the surface + title, so skip the launcher's own title bar. */} + setMultiRunLauncherOpen(false)} + onCancel={() => setMultiRunLauncherOpen(false)} + /> + +
+ )} + + +
@@ -422,13 +470,6 @@ export const MainLayout: React.FC = () => { onOpenChange={setSettingsDialogOpen} /> - - - )} diff --git a/packages/ui/src/components/layout/ProjectEditDialog.tsx b/packages/ui/src/components/layout/ProjectEditDialog.tsx index 3bc54862..11045734 100644 --- a/packages/ui/src/components/layout/ProjectEditDialog.tsx +++ b/packages/ui/src/components/layout/ProjectEditDialog.tsx @@ -24,7 +24,7 @@ export const ProjectEditDialog: React.FC = ({
{open && project ? ( - + ) : null}
diff --git a/packages/ui/src/components/multirun/MultiRunLauncher.tsx b/packages/ui/src/components/multirun/MultiRunLauncher.tsx index c56d6c54..54716085 100644 --- a/packages/ui/src/components/multirun/MultiRunLauncher.tsx +++ b/packages/ui/src/components/multirun/MultiRunLauncher.tsx @@ -674,14 +674,18 @@ export const MultiRunLauncher: React.FC = ({ {t('multirun.launcher.project.gitRequired')}
) : null} - + {/* On the full-page surface (isWindowed) there is nothing to + "cancel" — you leave via the sidebar like any other page. */} + {!isWindowed && onCancel ? ( + + ) : null}
)} + ) : null} = ({ {t('settings.openchamber.worktrees.list.empty')}

) : ( -
+ // The settings panel keeps its narrow control column; the full-page + // Worktrees surface lets rows use the whole content width. +
{availableWorktrees.map((worktree) => (
= ({

{worktree.label || worktree.branch || t('settings.openchamber.worktrees.list.detachedHead')}

- - OpenCode -

{formatPathForDisplay(worktree.path, homeDirectory)} diff --git a/packages/ui/src/components/sections/projects/ProjectSettingsPanel.tsx b/packages/ui/src/components/sections/projects/ProjectSettingsPanel.tsx index 3383fa13..4b9c4092 100644 --- a/packages/ui/src/components/sections/projects/ProjectSettingsPanel.tsx +++ b/packages/ui/src/components/sections/projects/ProjectSettingsPanel.tsx @@ -12,11 +12,18 @@ import type { ProjectEntry } from '@/lib/api/types'; type ProjectSettingsPanelProps = { project: ProjectEntry | null; onIdentitySave: (data: ProjectIdentitySaveData) => void | Promise; + /** + * 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 = ({ project, onIdentitySave, + showWorktrees = true, }) => { const form = useProjectIdentityForm(project); @@ -41,7 +48,7 @@ export const ProjectSettingsPanel: React.FC = ({

- + {showWorktrees ? : null}
); }; diff --git a/packages/ui/src/components/session/ScheduledTasksDialog.tsx b/packages/ui/src/components/session/ScheduledTasksDialog.tsx index 0b1a0702..c75e677e 100644 --- a/packages/ui/src/components/session/ScheduledTasksDialog.tsx +++ b/packages/ui/src/components/session/ScheduledTasksDialog.tsx @@ -1,11 +1,4 @@ import * as React from 'react'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Checkbox } from '@/components/ui/checkbox'; @@ -17,6 +10,7 @@ import { useUIStore } from '@/stores/useUIStore'; import { formatTimeForPreference } from '@/lib/timeFormat'; import type { TimeFormatPreference } from '@/stores/useUIStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { refreshGlobalSessions } from '@/stores/useGlobalSessionsStore'; import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents'; @@ -360,18 +354,25 @@ export function ScheduledTasksDialog() { } setMutatingTaskID(task.id); try { - await runScheduledTaskNow(selectedProjectID, task.id); + const { sessionId } = await runScheduledTaskNow(selectedProjectID, task.id); await Promise.all([ reloadTasks(selectedProjectID, { silent: true }), refreshGlobalSessions(), ]); toast.success(t('sessions.scheduledTasks.dialog.toast.started')); + if (sessionId) { + // Jump straight into the started session; selecting it also closes + // this surface (MainLayout closes surfaces on session selection). + const project = projects.find((entry) => entry.id === selectedProjectID); + useSessionUIStore.getState().setCurrentSession(sessionId, project?.path ?? null); + useUIStore.getState().setActiveMainTab('chat'); + } } catch (error) { toast.error(error instanceof Error ? error.message : t('sessions.scheduledTasks.dialog.toast.runFailed')); } finally { setMutatingTaskID(null); } - }, [selectedProjectID, reloadTasks, t]); + }, [selectedProjectID, projects, reloadTasks, t]); const projectSelector = (
@@ -412,19 +413,16 @@ export function ScheduledTasksDialog() { setEditorOpen(true); }; - const tasksContent = ( -
- {!isMobile ? ( -
- {projectSelector} - -
- ) : ( - projectSelector - )} + const selectProject = (nextProjectID: string) => { + setSelectedProjectID(nextProjectID); + if (nextProjectID) { + void reloadTasks(nextProjectID); + } else { + setTasks([]); + } + }; + const tasksList = (
{loading ? (
@@ -579,6 +577,12 @@ export function ScheduledTasksDialog() {
)}
+ ); + + const tasksContent = ( +
+ {projectSelector} + {tasksList}
); @@ -613,18 +617,53 @@ export function ScheduledTasksDialog() { > {tasksContent} - ) : ( - - - - {t('sessions.scheduledTasks.dialog.title')} - {t('sessions.scheduledTasks.dialog.description')} - - - {tasksContent} - - - )} + ) : open ? ( + // Full-page surface replacing the chat area (mounted inside
). + // Master-detail: a scrollable project filter panel at the left, the + // selected project's tasks at the right. The app Header shows the + // surface title, so the page itself only carries the close affordance. +
+
+
+
+ {projects.length === 0 ? ( +
+ {t('sessions.scheduledTasks.dialog.project.empty')} +
+ ) : projects.map((project) => ( + + ))} +
+
+
+ {/* Pages have no close button: you leave by picking a session, + a draft, or another surface in the sidebar. */} +
+ +
+
+
+ {tasksList} +
+
+
+
+
+ ) : null} { folder: SessionFolder; + /** + * Optional display label override. Flat folder rendering shows nested + * folders at the top level with a "Parent / Child" path instead of + * indentation. + */ + displayName?: string; sessions: TSessionNode[]; /** Sub-folders that belong directly to this folder */ subFolderItems?: React.ReactNode; @@ -46,8 +53,6 @@ interface SessionFolderItemProps { isDropTarget?: boolean; /** Create a new session scoped to this folder */ onNewSession?: () => void; - /** Create a new sub-folder inside this folder */ - onNewSubFolder?: () => void; /** Visual indent depth (0 = root folder, 1 = sub-folder) */ depth?: number; /** Hide folder action buttons (rename/delete/new) */ @@ -58,6 +63,7 @@ interface SessionFolderItemProps { const SessionFolderItemBase = ({ folder, + displayName, sessions, subFolderItems, isCollapsed, @@ -78,7 +84,6 @@ const SessionFolderItemBase = ({ droppableRef, isDropTarget = false, onNewSession, - onNewSubFolder, depth = 0, hideActions = false, archivedBucket = false, @@ -145,10 +150,10 @@ const SessionFolderItemBase = ({ }, [isRenaming]); const folderIconName = isCollapsed ? 'folder' : 'folder-open'; - const isSubFolder = depth > 0; + void depth; return ( -
+
{/* Folder header – also acts as a drop zone when droppableRef is provided */}
({ 'cursor-pointer', isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30', )} - onClick={renaming ? undefined : onToggle} + onClick={renaming ? undefined : (event) => { + (event.currentTarget as HTMLElement).blur(); + onToggle(); + }} role={renaming ? undefined : 'button'} tabIndex={renaming ? undefined : 0} onKeyDown={ @@ -178,7 +186,10 @@ const SessionFolderItemBase = ({ 'min-w-0 flex items-center gap-1.5 pl-1.5 flex-1 transition-[padding]', archivedBucket ? (alwaysShowActions ? 'pr-7' : 'group-hover/folder:pr-7 group-focus-within/folder:pr-7') - : '', + // Actions overlay on hover (new session, rename, delete = three + // 24px buttons anchored at the right edge); reserve room only + // while they are revealed, mirroring session-row behavior. + : (alwaysShowActions ? 'pr-20' : 'group-hover/folder:pr-20 group-focus-within/folder:pr-20'), )}> @@ -238,9 +249,16 @@ const SessionFolderItemBase = ({ ) : (
- - {folder.name} - + + + + {displayName ?? folder.name} + + + + {displayName ?? folder.name} + + • {sessions.length} @@ -256,14 +274,15 @@ const SessionFolderItemBase = ({ {/* Action buttons */} {!renaming && (!hideActions || archivedBucket) ? ( -
-
+
+
{!archivedBucket && onNewSession ? ( ) : null} - {/* Only allow sub-folders at depth 0 (one level deep max) */} - {!archivedBucket && onNewSubFolder && depth === 0 ? ( - - ) : null} {!archivedBucket ? ( @@ -1008,7 +919,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { @@ -1016,7 +927,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { ); - const groupBodyPaddingClass = compactBodyPadding ? 'pb-2 pl-1' : 'pb-3 pl-4'; + // Rows own their left gutter (aligned with the zone-header text), so the + // group body adds no extra indentation. + void compactBodyPadding; + // Folder nesting is legacy-only: existing sub-folders keep working (path + // labels), but the UI no longer offers creating new ones. + void createFolderAndStartRename; + const groupBodyPaddingClass = 'pb-2'; if (hideGroupLabel) { return
{body}
; @@ -1043,74 +960,16 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {

- {showInlinePrTitle && prIndicator ? ( - - - - - - - - {isCollapsed ? : } - - - {prIndicator.url ? ( - - ) : ( - #{prIndicator.number} - )} - - - -

- {(baseBranchLabel || headBranchLabel) ? ( -
- {baseBranchLabel && headBranchLabel ? ( - <> - {baseBranchLabel} - - {headBranchLabel} - - ) : ( - {baseBranchLabel ?? headBranchLabel ?? ''} - )} -
- ) : null} - {mergeStateLabel ?
{mergeStateLabel}
: null} - {(mergeabilityLabel || checksSummary) ? ( -
- {mergeabilityLabel ?? ''} - {mergeabilityLabel && checksSummary ? ' • ' : ''} - {checksSummary ?? ''} - {checksTail ? ` (${checksTail})` : ''} -
- ) : null} -
- - - {group.branch} - - ) : group.isArchivedBucket ? ( + {group.isArchivedBucket ? ( @@ -1124,11 +983,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { {renderHighlightedText(group.label, normalizedSessionSearchQuery)} ) : (!group.isMain || group.worktree) ? ( - + // Worktree sub-header in the flat visual language: slim + // folder-style row with a PR-tinted branch icon and PR badge. + : } - {renderHighlightedText(group.label, normalizedSessionSearchQuery)} + + {renderHighlightedText(group.label, normalizedSessionSearchQuery)} + + {groupPrSummary ? ( + + #{groupPrSummary.number} + + ) : null} ) : ( renderHighlightedText(group.label, normalizedSessionSearchQuery) @@ -1147,51 +1018,10 @@ function SessionGroupSectionBase(props: Props): React.ReactNode { {group.isArchivedBucket ? ( - ) : (!group.isMain || isGitProject) ? ( - showInlinePrTitle && prIndicator ? ( - - - - - - - -
- {(baseBranchLabel || headBranchLabel) ? ( -
- {baseBranchLabel && headBranchLabel ? ( - <> - {baseBranchLabel} - - {headBranchLabel} - - ) : ( - {baseBranchLabel ?? headBranchLabel ?? ''} - )} -
- ) : null} - {mergeStateLabel ?
{mergeStateLabel}
: null} - {(mergeabilityLabel || checksSummary) ? ( -
- {mergeabilityLabel ?? ''} - {mergeabilityLabel && checksSummary ? ' • ' : ''} - {checksSummary ?? ''} - {checksTail ? ` (${checksTail})` : ''} -
- ) : null} -
-
-
- ) : ( - - ) - ) : null} - + ) : ( + + )} + {statusLine.label} diff --git a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx index c6a058bb..53081f25 100644 --- a/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/SessionNodeItem.tsx @@ -13,7 +13,7 @@ import { } from '@/components/ui/dropdown-menu'; import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from '@/components/ui/dropdown-menu.styles'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { cn } from '@/lib/utils'; +import { cn, formatDirectoryName } from '@/lib/utils'; import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop'; import { toast } from '@/components/ui'; import { Button } from '@/components/ui/button'; @@ -29,8 +29,10 @@ import { DraggableSessionRow } from './sessionFolderDnd'; import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils'; import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils'; import type { SessionNode } from './types'; -import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils'; +import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils'; +import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; +import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore'; import { useSessionUnseenCount } from '@/sync/notification-store'; import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore'; import { useI18n } from '@/lib/i18n'; @@ -129,6 +131,14 @@ type Props = { childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras; }; +// Shared row geometry: the gutter edge matches the zone-header band padding +// (px-1.5 = 6px), the marker slot is icon-wide (14px) with a 6px gap, so row +// text starts exactly where the zone-header label starts. Nested children +// shift by one gutter step per depth level. +const ROW_GUTTER_LEFT_PX = 6; +const ROW_DEPTH_STEP_PX = 14; +const ROW_TEXT_LEFT_PX = ROW_GUTTER_LEFT_PX + 14 + 6; + const cancelScrollAnchorByContainer = new WeakMap void>(); const holdSessionRowPosition = (target: HTMLElement): void => { @@ -284,15 +294,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { menuOpenSessionId, childRenderExtrasFor, } = props; - const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel); - const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel); - const displayMode = useSessionDisplayStore((state) => state.displayMode); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); - // VS Code always uses the minimal (single-line) layout: sessions are grouped - // under workspace project headers, so the second metadata row (project/branch) - // is redundant. The display-mode toggle is hidden there, so force it on. - const isMinimalMode = displayMode === 'minimal' || isVSCode; const isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []); const runtimeApis = React.useContext(RuntimeAPIContext); const revealOnHoverClass = isVSCode @@ -303,25 +306,22 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { : 'group-hover:opacity-0 group-focus-within:opacity-0'; const showOpenInEditorAction = isVSCode; const showQuickArchiveAction = !archivedBucket && !mobileVariant; - const revealPaddingClass = isMinimalMode - ? (isVSCode - // VS Code minimal rows reveal up to three actions on hover - // (open-in-editor + quick-archive + menu, each h-4). The date sits in the - // row flow, so the title must shrink enough to clear the actions or they - // overlap the timestamp. Open-in-editor is always present in VS Code. - ? (showQuickArchiveAction && showOpenInEditorAction - ? 'group-hover:pr-18' - : showQuickArchiveAction || showOpenInEditorAction - ? 'group-hover:pr-14' - : 'group-hover:pr-8') - : 'group-hover:pr-2 group-focus-within:pr-2') - : (isVSCode - ? (showQuickArchiveAction && showOpenInEditorAction - ? 'group-hover:pr-18' - : showQuickArchiveAction || showOpenInEditorAction - ? 'group-hover:pr-12' - : 'group-hover:pr-5') - : (showQuickArchiveAction ? 'group-hover:pr-12 group-focus-within:pr-12' : 'group-hover:pr-5 group-focus-within:pr-5')); + const revealPaddingClass = isVSCode + // VS Code rows reveal up to three actions on hover + // (open-in-editor + quick-archive + menu, each h-4). The date sits in the + // row flow, so the title must shrink enough to clear the actions or they + // overlap the timestamp. Open-in-editor is always present in VS Code. + ? (showQuickArchiveAction && showOpenInEditorAction + ? 'group-hover:pr-18' + : showQuickArchiveAction || showOpenInEditorAction + ? 'group-hover:pr-14' + : 'group-hover:pr-8') + // Reserve just enough room for the hover-revealed actions (two 16px + // buttons + gap, anchored at the row edge past the title's own end) so + // they never overlap the title without leaving a large hole. + : (showQuickArchiveAction + ? 'group-hover:pr-7 group-focus-within:pr-7' + : 'group-hover:pr-3 group-focus-within:pr-3'); const alwaysActionPaddingClass = showQuickArchiveAction ? 'pr-13' : 'pr-7'; const suppressNextSelectRef = React.useRef(false); const [isTouchPressed, setIsTouchPressed] = React.useState(false); @@ -338,11 +338,63 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const session = node.session; const resolvedSession = session; + // Tooltip context: recent rows receive project/branch via secondaryMeta; + // project rows resolve them from the row's own props/node instead. + const projectLabelFromStore = useProjectsStore( + React.useCallback((state) => { + if (secondaryMeta?.projectLabel || !projectId) return null; + const project = state.projects.find((entry) => entry.id === projectId); + if (!project) return null; + return project.label?.trim() || formatDirectoryName(normalizePath(project.path) ?? project.path, null) || project.path; + }, [projectId, secondaryMeta?.projectLabel]), + ); + const tooltipProjectLabel = secondaryMeta?.projectLabel + ?? (projectLabelFromStore ? formatProjectLabel(projectLabelFromStore) : null); + const tooltipBranchLabel = secondaryMeta?.branchLabel ?? node.worktree?.branch ?? null; + const prLookupKey = React.useMemo(() => { + if (isVSCode) return null; + const branch = node.worktree?.branch?.trim(); + const directory = normalizePath(node.worktree?.path ?? null); + return branch && directory ? getGitHubPrStatusKey(directory, branch) : null; + }, [isVSCode, node.worktree]); + const prSummary = usePrVisualSummary(prLookupKey); + const prIconColor = prSummary ? `var(--pr-${prSummary.visualState})` : undefined; + const sessionGroupingMode = useSessionDisplayStore((state) => state.sessionGroupingMode); + // In by-worktree grouping the project tree already shows the branch on the + // group sub-header, so the per-row marker only appears in flat mode and in + // the mixed-context recent list. + const showInlineBranchMarker = Boolean(tooltipBranchLabel) + && (renderContext === 'recent' || sessionGroupingMode === 'flat'); + const prStatusLabel = React.useMemo(() => { + if (!prSummary) return null; + switch (prSummary.visualState) { + case 'merged': + return t('sessions.sidebar.group.pr.status.merged'); + case 'open': + return (prSummary.canMerge === true || prSummary.mergeableState === 'clean' || prSummary.checks?.state === 'success') + ? t('sessions.sidebar.group.pr.status.readyToMerge') + : t('sessions.sidebar.group.pr.status.open'); + case 'blocked': + return prSummary.mergeableState === 'dirty' + ? t('sessions.sidebar.group.pr.status.mergeConflicts') + : t('sessions.sidebar.group.pr.status.mergeBlocked'); + case 'draft': + return t('sessions.sidebar.group.pr.status.draft'); + case 'closed': + return t('sessions.sidebar.group.pr.status.closed'); + default: + return null; + } + }, [prSummary, t]); const isActive = useSessionUIStore((state) => state.currentSessionId === session.id); const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? normalizePath(groupDirectory ?? null); + // Multi-select scope: sessions are flat per project, so selection groups by + // project (falling back to the directory when no project is known) — a + // selection must survive mixing sessions from different worktrees. + const selectionScopeKey = projectId ?? sessionDirectory ?? null; // Directory bootstrap is scheduled once at sidebar level. A row only needs // the lightweight store reference for scoped state and export actions. const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: false }); @@ -418,8 +470,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const isSessionMenuOpen = isMenuOpen || isContextMenuOpen; const isMultiRunLikeSession = React.useMemo(() => parseMultiRunSessionTitle(resolvedSession.title) !== null, [resolvedSession.title]); const [fusionDialogOpen, setFusionDialogOpen] = React.useState(false); - const metadataSubsessionChevron = isVSCode && renderContext === 'recent' && !isMinimalMode; - const inlineSubsessionChevron = isVSCode && renderContext === 'recent' && isMinimalMode; const descendantCount = React.useMemo(() => collectNodeDescendantIds(node).length, [collectNodeDescendantIds, node]); @@ -526,7 +576,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { React.useEffect(() => { if (editingId !== session.id) return; const handleDocMouseDown = (e: MouseEvent) => { - if (formRef.current && !formRef.current.contains(e.target as Node)) { + // The same session can be rendered twice (recent + project), each with + // its own rename form. A click inside ANY rename form for this session + // must not count as "outside", or the sibling instance would save and + // exit the rename mid-edit. + const target = e.target as HTMLElement | null; + const withinRenameForm = target?.closest?.(`[data-session-rename-form="${CSS.escape(session.id)}"]`); + if (formRef.current && !withinRenameForm) { handleSaveEditRef.current(renameDraftRef.current); } }; @@ -550,11 +606,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { return (
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" >
{ event.preventDefault(); @@ -593,16 +653,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { - {!isMinimalMode ? ( -
-
- {hasChildren ? {isExpanded ? : } : null} - {sessionUpdatedLabel} - {hasSecondaryProjectLabel ? {secondaryMeta?.projectLabel} : null} - {hasSecondaryBranchLabel ? {secondaryMeta?.branchLabel} : null} -
-
- ) : null}
); @@ -615,10 +665,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const showStatusMarker = isStreaming || showUnreadStatus; const statusMarkerContent = isStreaming ? ( - ) : ( @@ -639,9 +689,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { ); const leadingIndicators = isMovingToWorktree || showStatusMarker || showPinnedMarker ? ( @@ -661,6 +711,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { tabIndex={0} onClick={(event) => { event.stopPropagation(); + // Blur mouse-click focus so the hover-only chevron/indicator swap + // resets on mouse-leave instead of sticking via :focus-within. + event.currentTarget.blur(); toggleParent(expansionKey); }} onKeyDown={(event) => { @@ -670,15 +723,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { toggleParent(expansionKey); } }} - style={{ minWidth: 14, minHeight: 14 }} + style={{ minWidth: 14, minHeight: 14, left: ROW_GUTTER_LEFT_PX + depth * ROW_DEPTH_STEP_PX }} className={cn( - 'inline-flex h-3.5 w-3.5 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity', - metadataSubsessionChevron - ? 'absolute left-1.5 bottom-1' - : inlineSubsessionChevron - ? 'relative mr-0.5 shrink-0' - : cn('absolute left-0.5', isMinimalMode ? 'top-1/2 -translate-y-1/2' : 'top-[14.5px] -translate-y-1/2'), - !metadataSubsessionChevron && !inlineSubsessionChevron && hideChevronUntilHover + 'absolute top-1/2 inline-flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity', + hideChevronUntilHover ? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto' : '', )} @@ -788,10 +836,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { const currentAnchor = useSessionMultiSelectStore.getState().anchorId; const descendantsById = new Map(); descendantsById.set(session.id, collectNodeDescendantIds(node)); - setRowRange(currentAnchor, session.id, orderedIds, sessionDirectory ?? null, descendantsById); + setRowRange(currentAnchor, session.id, orderedIds, selectionScopeKey, descendantsById); return; } - toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node)); + toggleRowSelected(session.id, selectionScopeKey, collectNodeDescendantIds(node)); return; } if (event?.currentTarget) holdSessionRowPosition(event.currentTarget); @@ -918,31 +966,72 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { ) : null} {sessionDirectory && !archivedBucket ? (() => { - const scopeFolders = getFoldersForScope(sessionDirectory); - const currentFolderId = getSessionFolderId(sessionDirectory, session.id); + // Folders are flat per project: list folders from every scope of the + // owning project (root + all worktrees) so sessions can be filed + // across worktrees. Each action targets the folder's owning scope, + // and moving between scopes clears the previous membership first. + const scopes: string[] = []; + const pushScope = (candidate: string | null | undefined) => { + const normalized = normalizePath(candidate ?? null); + if (normalized && !scopes.includes(normalized)) scopes.push(normalized); + }; + if (projectId && !isVSCode) { + const project = useProjectsStore.getState().projects.find((entry) => entry.id === projectId); + const projectRoot = normalizePath(project?.path ?? null); + pushScope(projectRoot); + if (projectRoot) { + (useSessionUIStore.getState().availableWorktreesByProject.get(projectRoot) ?? []) + .forEach((worktree) => pushScope(worktree.path)); + } + } + pushScope(sessionDirectory); + const folderEntries = scopes.flatMap((scope) => + getFoldersForScope(scope).map((folder) => ({ scope, folder }))); + const currentEntry = folderEntries.find(({ scope, folder }) => + getSessionFolderId(scope, session.id) === folder.id) ?? null; + const defaultScope = scopes[0] ?? sessionDirectory; return ( <> {t('sessions.sidebar.folders.moveToFolder')} - {scopeFolders.length === 0 ? ( + {folderEntries.length === 0 ? ( {t('sessions.sidebar.folders.none')} ) : ( - scopeFolders.map((folder) => ( - { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}> - {folder.name} - {currentFolderId === folder.id ? : null} - - )) + folderEntries.map(({ scope, folder }) => { + const isCurrent = currentEntry?.folder.id === folder.id; + return ( + { + if (isCurrent) { + removeSessionFromFolder(scope, session.id); + return; + } + if (currentEntry && currentEntry.scope !== scope) { + removeSessionFromFolder(currentEntry.scope, session.id); + } + addSessionToFolder(scope, folder.id, session.id); + }}> + {folder.name} + {isCurrent ? : null} + + ); + }) )} - { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}> + { + const newFolder = createFolderAndStartRename(defaultScope); + if (!newFolder) return; + if (currentEntry && currentEntry.scope !== defaultScope) { + removeSessionFromFolder(currentEntry.scope, session.id); + } + addSessionToFolder(defaultScope, newFolder.id, session.id); + }}> {t('sessions.sidebar.folders.newFolderEllipsis')} - {currentFolderId ? ( - { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive"> + {currentEntry ? ( + { removeSessionFromFolder(currentEntry.scope, session.id); }} className="text-destructive focus:text-destructive"> {t('sessions.sidebar.folders.removeFromFolder')} @@ -1067,17 +1156,16 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { render={
0 ? 'pl-[32px]' : 'pl-[18px]', // Active (currently open) session gets a subtle primary tint; // multi-select highlight takes precedence when both apply. isActive && !isRowSelected && 'bg-primary/10', @@ -1089,7 +1177,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { {leadingIndicators} {subsessionChevron}
- {isMinimalMode ? ( + {( )}
{streamingIndicator && !mobileVariant ? ( -
+
{streamingIndicator}
) : null} @@ -1223,8 +1300,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { event.stopPropagation()} > - + @@ -1260,7 +1337,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { type="button" className={cn( 'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity', - isMinimalMode && !alwaysShowActions + !alwaysShowActions ? (isSessionMenuOpen ? 'h-4 w-4 opacity-100' : cn('h-4 w-4 opacity-0', revealOnHoverClass)) @@ -1272,7 +1349,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode { onClick={handleMenuTriggerClick} onKeyDown={(event) => event.stopPropagation()} > - + {sessionMenuContent} diff --git a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx index 8f97bc53..6708a11e 100644 --- a/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarActivitySections.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { cn } from '@/lib/utils'; import type { SessionNode } from './types'; import { useI18n } from '@/lib/i18n'; +import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { Icon } from "@/components/icon/Icon"; import { collectSubtreeContainingId, @@ -61,6 +62,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode { batchSize = MAX_VISIBLE_RECENT_SESSIONS, } = props; const { t } = useI18n(); + const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders); const [collapsed, setCollapsed] = React.useState>(new Set()); const [visibleCountBySection, setVisibleCountBySection] = React.useState>(new Map()); const flatVariant = variant === 'flat'; @@ -132,7 +134,9 @@ export function SidebarActivitySections(props: Props): React.ReactNode { } return ( -
+ // No top padding: the recent header must start flush with the scroll + // edge, otherwise it visually "bumps" a few pixels before sticking. +
{visibleSections.map((section) => { const isCollapsed = collapsed.has(section.key); const visibleLimit = Math.max( @@ -162,7 +166,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode { @@ -172,26 +176,36 @@ export function SidebarActivitySections(props: Props): React.ReactNode { } return ( -
- +
+ {/* Zone header styled like a project header band; sticky with a + solid sidebar backing so rows never show through. */} +
+ +
{!isCollapsed ? ( -
+
{visibleItems.map(renderItem)} {remainingCount > 0 ? ( @@ -200,7 +214,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode { diff --git a/packages/ui/src/components/session/sidebar/SidebarHeader.tsx b/packages/ui/src/components/session/sidebar/SidebarHeader.tsx index e2449c7b..d42b29e6 100644 --- a/packages/ui/src/components/session/sidebar/SidebarHeader.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarHeader.tsx @@ -3,24 +3,25 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; -import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import { Icon } from "@/components/icon/Icon"; +import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; -import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; type Props = { hideDirectoryControls: boolean; showRecentControls: boolean; handleOpenDirectoryDialog: () => void; - openNewSessionDraft: () => void; + onOpenScheduled: () => void; + onOpenMultiRun: () => void; canOpenMultiRun: boolean; - openMultiRunLauncher: () => void; + onOpenArchive: () => void; headerActionIconClass: string; headerActionButtonClass: string; isSessionSearchOpen: boolean; @@ -32,7 +33,6 @@ type Props = { searchMatchCount: number; collapseAllProjects: () => void; expandAllProjects: () => void; - openScheduledTasksDialog: () => void; selectionModeEnabled: boolean; onToggleSelectionMode: () => void; }; @@ -43,9 +43,10 @@ export function SidebarHeader(props: Props): React.ReactNode { hideDirectoryControls, showRecentControls, handleOpenDirectoryDialog, - openNewSessionDraft, + onOpenScheduled, + onOpenMultiRun, canOpenMultiRun, - openMultiRunLauncher, + onOpenArchive, headerActionIconClass, headerActionButtonClass, isSessionSearchOpen, @@ -57,21 +58,18 @@ export function SidebarHeader(props: Props): React.ReactNode { searchMatchCount, collapseAllProjects, expandAllProjects, - openScheduledTasksDialog, selectionModeEnabled, onToggleSelectionMode, } = props; - const displayMode = useSessionDisplayStore((state) => state.displayMode); const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection); - const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions); - const setDisplayMode = useSessionDisplayStore((state) => state.setDisplayMode); const toggleRecentSection = useSessionDisplayStore((state) => state.toggleRecentSection); - const toggleArchivedSessions = useSessionDisplayStore((state) => state.toggleArchivedSessions); const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder); const setProjectSortOrder = useSessionDisplayStore((state) => state.setProjectSortOrder); - // VS Code forces the expanded layout, so the mode toggle is meaningless there. - const showDisplayModeToggle = !isVSCodeRuntime(); + const sessionGroupingMode = useSessionDisplayStore((state) => state.sessionGroupingMode); + const setSessionGroupingMode = useSessionDisplayStore((state) => state.setSessionGroupingMode); + const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders); + const toggleStickyZoneHeaders = useSessionDisplayStore((state) => state.toggleStickyZoneHeaders); if (hideDirectoryControls) { return null; @@ -81,13 +79,17 @@ export function SidebarHeader(props: Props): React.ReactNode {
-
+ {/* 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). */} +
-

{t('sessions.sidebar.header.actions.newSession')}

+

{t('sessions.sidebar.header.actions.scheduledTasks')}

-

{t('sessions.sidebar.header.actions.scheduledTasks')}

+

{t('sessions.sidebar.nav.archive')}

@@ -146,7 +148,7 @@ export function SidebarHeader(props: Props): React.ReactNode { - - -

{t('sessions.sidebar.header.actions.sortProjects')}

- - - setProjectSortOrder('manual')} - className="flex items-center justify-between" - > - {t('sessions.sidebar.header.projectSort.manual')} - {projectSortOrder === 'manual' ? : null} - - setProjectSortOrder('a-z')} - className="flex items-center justify-between" - > - {t('sessions.sidebar.header.projectSort.aToZ')} - {projectSortOrder === 'a-z' ? : null} - - setProjectSortOrder('z-a')} - className="flex items-center justify-between" - > - {t('sessions.sidebar.header.projectSort.zToA')} - {projectSortOrder === 'z-a' ? : null} - - setProjectSortOrder('date-added')} - className="flex items-center justify-between" - > - {t('sessions.sidebar.header.projectSort.dateAdded')} - {projectSortOrder === 'date-added' ? : null} - - setProjectSortOrder('recent')} - className="flex items-center justify-between" - > - {t('sessions.sidebar.header.projectSort.recent')} - {projectSortOrder === 'recent' ? : null} - - - - - - - - - @@ -246,44 +194,56 @@ export function SidebarHeader(props: Props): React.ReactNode {

{t('sessions.sidebar.header.displayMode.label')}

- - {showDisplayModeToggle ? ( - <> - setDisplayMode('default')} - className="flex items-center justify-between" - > - {t('sessions.sidebar.header.displayMode.default')} - {displayMode === 'default' ? : null} - - setDisplayMode('minimal')} - className="flex items-center justify-between" - > - {t('sessions.sidebar.header.displayMode.minimal')} - {displayMode === 'minimal' ? : null} - - - ) : null} + + {t('sessions.sidebar.header.actions.sortProjects')} + {([ + ['manual', 'sessions.sidebar.header.projectSort.manual'], + ['a-z', 'sessions.sidebar.header.projectSort.aToZ'], + ['z-a', 'sessions.sidebar.header.projectSort.zToA'], + ['date-added', 'sessions.sidebar.header.projectSort.dateAdded'], + ['recent', 'sessions.sidebar.header.projectSort.recent'], + ] as const).map(([order, labelKey]) => ( + setProjectSortOrder(order)} + className="flex items-center justify-between" + > + {t(labelKey)} + {projectSortOrder === order ? : null} + + ))} + + {t('sessions.sidebar.header.grouping.label')} + {([ + ['by-worktree', 'sessions.sidebar.header.grouping.byWorktree'], + ['flat', 'sessions.sidebar.header.grouping.flat'], + ] as const).map(([mode, labelKey]) => ( + setSessionGroupingMode(mode)} + className="flex items-center justify-between" + > + {t(labelKey)} + {sessionGroupingMode === mode ? : null} + + ))} + {showRecentControls ? ( - <> - {showDisplayModeToggle ? : null} - - {t('sessions.sidebar.header.displayMode.showRecent')} - {showRecentSection ? : null} - - - {t('sessions.sidebar.header.displayMode.showArchived')} - {showArchivedSessions ? : null} - - + + {t('sessions.sidebar.header.displayMode.showRecent')} + {showRecentSection ? : null} + ) : null} + + {t('sessions.sidebar.header.displayMode.stickyHeaders')} + {stickyZoneHeaders ? : null} + @@ -293,7 +253,6 @@ export function SidebarHeader(props: Props): React.ReactNode { {t('sessions.sidebar.header.displayMode.expandAll')} -
diff --git a/packages/ui/src/components/session/sidebar/SidebarNav.tsx b/packages/ui/src/components/session/sidebar/SidebarNav.tsx new file mode 100644 index 00000000..5f38b83d --- /dev/null +++ b/packages/ui/src/components/session/sidebar/SidebarNav.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx index 7498cec4..9d9ec257 100644 --- a/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx +++ b/packages/ui/src/components/session/sidebar/SidebarProjectsList.tsx @@ -53,6 +53,9 @@ type Props = { compactBodyPadding?: boolean, scrollContainerRef?: React.RefObject, ) => React.ReactNode; + getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[]; + setGroupOrderByProject: React.Dispatch>>; + renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode; homeDirectory: string | null; collapsedProjects: Set; hideDirectoryControls: boolean; @@ -67,13 +70,12 @@ type Props = { setSessionSwitcherOpen: (open: boolean) => void; openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void; openNewWorktreeDialog: () => void; + openWorktreesPage: (id: string) => void; openProjectEditDialog: (id: string) => void; removeProject: (id: string) => void; projectHeaderSentinelRefs: React.MutableRefObject>; reorderProjects: (fromIndex: number, toIndex: number) => void; projectSortOrder: ProjectSortOrder; - getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[]; - setGroupOrderByProject: React.Dispatch>>; openSidebarMenuKey: string | null; setOpenSidebarMenuKey: (key: string | null) => void; isInlineEditing: boolean; @@ -90,20 +92,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode { useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), ); - // Threaded into SessionGroupSection so the archived-bucket virtualizer - // can resolve the scrolling ancestor synchronously (no getComputedStyle - // walk) and skip the cost of a style recalc on every render. - const scrollContainerRef = React.useRef(null); - - // Memoize the result of getOrderedGroups. The callback is stable - // (deps: [groupOrderByProject]) and `section.groups` is a stable - // reference from useSessionSidebarSections, but the caller discards - // the result on every render and the callback allocates a new array - // each time. With many projects and many sidebar re-renders this - // builds O(P) arrays per render. The cache returns the same array - // reference when the inputs haven't changed, so the downstream - // orderedGroups.filter/find work and any consumer-memoization see a - // stable reference. + // Memoize getOrderedGroups per project so downstream consumers see a stable + // array reference while inputs are unchanged (avoids O(P) fresh arrays per + // list render invalidating the memoized group subtrees). const orderedGroupsCacheRef = React.useRef>(new Map()); const orderedGroupsCacheGetOrderedGroupsRef = React.useRef(props.getOrderedGroups); if (orderedGroupsCacheGetOrderedGroupsRef.current !== props.getOrderedGroups) { @@ -118,9 +109,6 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode { } const ordered = props.getOrderedGroups(projectId, groups); cache.set(projectId, { groups, ordered }); - // Bound the cache so re-ordering projects (which replaces the - // projects list and invalidates every projectId) doesn't grow - // unboundedly. if (cache.size > 256) { const firstKey = cache.keys().next().value; if (firstKey !== undefined) cache.delete(firstKey); @@ -128,6 +116,11 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode { return ordered; }; + // Threaded into SessionGroupSection so the archived-bucket virtualizer + // can resolve the scrolling ancestor synchronously (no getComputedStyle + // walk) and skip the cost of a style recalc on every render. + const scrollContainerRef = React.useRef(null); + if (props.sharedSessionsOnly) { return ( @@ -151,7 +144,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode { // button) and holds it in place, which makes newly revealed sessions look // like they insert upward. With anchoring off, scrollTop stays put and new // rows appear below naturally. - + {props.topContent} {props.showOnlyMainWorkspace ? (
@@ -186,85 +179,87 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode { })()}
) : ( - <> - { - if (props.isInlineEditing) return; - // Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes - if (props.projectSortOrder !== 'manual') return; - const { active, over } = event; - if (!over || active.id === over.id) return; - const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id); - const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id); - if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; - props.reorderProjects(oldIndex, newIndex); - }} - > - section.project.id)} strategy={verticalListSortingStrategy}> - {props.sectionsForRender.map((section) => { - const project = section.project; - const projectKey = project.id; - const projectLabel = formatProjectLabel( - project.label?.trim() - || formatDirectoryName(project.normalizedPath, props.homeDirectory) - || project.normalizedPath, - ); - const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory); - const isCollapsed = props.collapsedProjects.has(projectKey); - const isActiveProject = projectKey === props.activeProjectId; - const isRepo = props.projectRepoStatus.get(projectKey); - const orderedGroups = cachedGetOrderedGroups(projectKey, section.groups); - const rootGroup = orderedGroups.find((group) => group.isMain) ?? null; - const nestedGroups = rootGroup - ? orderedGroups.filter((group) => group.id !== rootGroup.id) - : orderedGroups; + { + if (props.isInlineEditing) return; + // Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes + if (props.projectSortOrder !== 'manual') return; + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id); + const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id); + if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return; + props.reorderProjects(oldIndex, newIndex); + }} + > + section.project.id)} strategy={verticalListSortingStrategy}> + {props.sectionsForRender.map((section) => { + const project = section.project; + const projectKey = project.id; + const projectLabel = formatProjectLabel( + project.label?.trim() + || formatDirectoryName(project.normalizedPath, props.homeDirectory) + || project.normalizedPath, + ); + const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory); + const isCollapsed = props.collapsedProjects.has(projectKey); + const isActiveProject = projectKey === props.activeProjectId; + const isRepo = props.projectRepoStatus.get(projectKey); - return ( - props.toggleProject(projectKey)} - onNewSession={() => { - if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey); - props.setActiveMainTab('chat'); - if (props.mobileVariant) props.setSessionSwitcherOpen(false); - props.openNewSessionDraft({ - selectedProjectId: projectKey, - directoryOverride: project.normalizedPath, - }); - }} - onNewWorktreeSession={() => { - if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey); - props.setActiveMainTab('chat'); - props.openNewWorktreeDialog(); - }} - onRenameStart={() => props.openProjectEditDialog(projectKey)} - onClose={() => props.removeProject(projectKey)} - sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }} - showCreateButtons - openSidebarMenuKey={props.openSidebarMenuKey} - setOpenSidebarMenuKey={props.setOpenSidebarMenuKey} - > - {!isCollapsed ? ( -
- {section.groups.length > 0 ? ( + return ( + props.toggleProject(projectKey)} + onNewSession={() => { + if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey); + props.setActiveMainTab('chat'); + if (props.mobileVariant) props.setSessionSwitcherOpen(false); + props.openNewSessionDraft({ + selectedProjectId: projectKey, + directoryOverride: project.normalizedPath, + }); + }} + onNewWorktreeSession={() => { + if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey); + props.setActiveMainTab('chat'); + props.openNewWorktreeDialog(); + }} + onManageWorktrees={() => props.openWorktreesPage(projectKey)} + onRenameStart={() => props.openProjectEditDialog(projectKey)} + onClose={() => props.removeProject(projectKey)} + sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }} + showCreateButtons + openSidebarMenuKey={props.openSidebarMenuKey} + setOpenSidebarMenuKey={props.setOpenSidebarMenuKey} + > + {!isCollapsed ? ( +
+ {(() => { + 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 ( + {/* 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} group.id)} strategy={verticalListSortingStrategy}> {nestedGroups.map((group) => { @@ -297,18 +295,16 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode { - ) : ( -
{t('sessions.sidebar.empty.noSessions.title')}
- )} -
- ) : null} -
- ); - })} - - - - + ); + })()} +
+ ) : null} +
+ ); + })} +
+ +
)}
); diff --git a/packages/ui/src/components/session/sidebar/activitySections.ts b/packages/ui/src/components/session/sidebar/activitySections.ts index 87050296..5c689d7d 100644 --- a/packages/ui/src/components/session/sidebar/activitySections.ts +++ b/packages/ui/src/components/session/sidebar/activitySections.ts @@ -24,7 +24,8 @@ const getSessionUpdatedAt = (session: Session): number => { // Recent contains non-archived root sessions that are active now or were // updated within the retention window. The caller applies shared lifecycle -// ordering after this membership filter. +// ordering after this membership filter; batching ("Show more") handles long +// windows in the UI. export const deriveRecentSessions = ( sessions: Session[], activeSessionIds: ReadonlySet, diff --git a/packages/ui/src/components/session/sidebar/hooks/useGroupOrdering.ts b/packages/ui/src/components/session/sidebar/hooks/useGroupOrdering.ts index fb66bb4f..59b1e93f 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useGroupOrdering.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useGroupOrdering.ts @@ -17,12 +17,18 @@ export const useGroupOrdering = (groupOrderByProject: Map) => groupById.delete(id); } }); + // Groups unknown to the saved order are NEW worktrees — surface them at + // the top of the worktree list (the root/main group is positioned by + // the renderer regardless of this ordering). Archived buckets keep + // appending at the end. + const newGroups: SessionGroup[] = []; + const trailingGroups: SessionGroup[] = []; groups.forEach((group) => { - if (groupById.has(group.id)) { - ordered.push(group); - } + if (!groupById.has(group.id)) return; + if (group.isArchivedBucket) trailingGroups.push(group); + else newGroups.push(group); }); - return ordered; + return [...newGroups, ...ordered, ...trailingGroups]; }, [groupOrderByProject], ); diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts index d3ab4123..bdc91a5e 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionActions.ts @@ -4,6 +4,7 @@ import { toast } from '@/components/ui'; import { copyTextToClipboard } from '@/lib/clipboard'; import { useI18n } from '@/lib/i18n'; import type { MainTab } from '@/stores/useUIStore'; +import { useUIStore } from '@/stores/useUIStore'; import { streamPerfMark } from '@/stores/utils/streamDebug'; import { useSessionUIStore } from '@/sync/session-ui-store'; @@ -65,6 +66,9 @@ export const useSessionActions = (args: Args) => { const handleSessionSelect = React.useCallback( (sessionId: string, sessionDirectory?: string | null) => { streamPerfMark('navigation.session_select'); + // Selecting a session always leaves any full-page surface, even when + // the session is already the current one (no store transition fires). + useUIStore.getState().closeMainSurfaces(); const resetSessionSearch = () => { if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) { return; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts index 788a9684..c81e23e9 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionGrouping.ts @@ -12,6 +12,7 @@ import { compareSessionsByLifecycleOrder, getSessionLifecycleOrderValue } from ' import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore'; +import { getWorktreeFirstSeenAt } from '../worktreeFirstSeen'; type Args = { homeDirectory: string | null; @@ -196,7 +197,16 @@ export const useSessionGrouping = (args: Args) => { return bInfo.lastUpdatedAt - aInfo.lastUpdatedAt; } - // Third priority: for inactive worktrees, sort by label (asc) + // Third priority: for inactive worktrees, most recently discovered + // first (a worktree created mid-session surfaces at the top of the + // list; startup discovery ties and falls through to labels). + const aSeen = getWorktreeFirstSeenAt(a.path); + const bSeen = getWorktreeFirstSeenAt(b.path); + if (aSeen !== bSeen) { + return bSeen - aSeen; + } + + // Fourth priority: sort by label (asc) const aLabel = (a.label || a.branch || a.name || a.path || '').toLowerCase(); const bLabel = (b.label || b.branch || b.name || b.path || '').toLowerCase(); return aLabel.localeCompare(bLabel); diff --git a/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts b/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts index c1a02d58..ec53e8f5 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSessionSidebarSections.ts @@ -201,6 +201,70 @@ export const useSessionSidebarSections = (args: Args) => { const sectionsForRender = hasSessionSearchQuery ? searchableProjectSections : visibleProjectSections; + // Flat display sections: one merged group per project containing every + // non-archived session from the project root and all of its worktrees. + // Worktree grouping stays available in `projectSections` for data consumers + // (bootstrap demand planning, ownership); rendering is flat. + // The per-section cache keeps merged group references stable so the + // memoized SessionGroupSection subtree skips unrelated update waves. + const flatSectionCacheRef = React.useRef>(new WeakMap()); + const flatSectionsForRender = React.useMemo(() => { + const cache = flatSectionCacheRef.current; + return sectionsForRender.map((section) => { + const cached = cache.get(section); + if (cached && cached.query === normalizedSessionSearchQuery) { + return cached.section; + } + + const nonArchivedGroups = section.groups.filter((group) => !group.isArchivedBucket); + const archivedGroups = section.groups.filter((group) => group.isArchivedBucket); + const sessions = nonArchivedGroups.flatMap((group) => hasSessionSearchQuery + ? (groupSearchDataByGroup.get(group)?.filteredNodes ?? []) + : group.sessions); + const folderScopes = nonArchivedGroups + .map((group) => ({ + scopeKey: group.folderScopeKey ?? normalizePath(group.directory ?? null), + directory: group.directory ?? null, + })) + .filter((scope): scope is { scopeKey: string; directory: string | null } => Boolean(scope.scopeKey)); + const rootGroup = nonArchivedGroups.find((group) => group.isMain) ?? null; + + const flatGroup: SessionGroup = { + id: 'flat', + label: rootGroup?.label ?? '', + branch: rootGroup?.branch ?? null, + description: rootGroup?.description ?? null, + isMain: true, + isArchivedBucket: false, + worktree: null, + directory: rootGroup?.directory ?? section.project.normalizedPath, + folderScopeKey: rootGroup?.folderScopeKey ?? section.project.normalizedPath, + folderScopes, + sessions, + }; + + if (hasSessionSearchQuery) { + const merged = nonArchivedGroups + .map((group) => groupSearchDataByGroup.get(group)) + .filter((data): data is GroupSearchData => Boolean(data)); + groupSearchDataByGroup.set(flatGroup, { + filteredNodes: sessions, + matchedSessionCount: merged.reduce((total, data) => total + data.matchedSessionCount, 0), + folderNameMatchCount: merged.reduce((total, data) => total + data.folderNameMatchCount, 0), + groupMatches: merged.some((data) => data.groupMatches), + hasMatch: merged.some((data) => data.hasMatch), + }); + } + + const flatSection: ProjectSection = { + project: section.project, + groups: [flatGroup, ...archivedGroups], + }; + cache.set(section, { query: normalizedSessionSearchQuery, section: flatSection }); + return flatSection; + }); + }, [groupSearchDataByGroup, hasSessionSearchQuery, normalizedSessionSearchQuery, sectionsForRender]); + const searchMatchCount = React.useMemo(() => { if (!hasSessionSearchQuery) { return 0; @@ -224,6 +288,7 @@ export const useSessionSidebarSections = (args: Args) => { groupSearchDataByGroup, searchableProjectSections, sectionsForRender, + flatSectionsForRender, searchMatchCount, }; }; diff --git a/packages/ui/src/components/session/sidebar/hooks/useSidebarBulkActions.ts b/packages/ui/src/components/session/sidebar/hooks/useSidebarBulkActions.ts index a8301a4a..90a8abf7 100644 --- a/packages/ui/src/components/session/sidebar/hooks/useSidebarBulkActions.ts +++ b/packages/ui/src/components/session/sidebar/hooks/useSidebarBulkActions.ts @@ -8,6 +8,12 @@ type Args = { isInlineEditing: boolean; showDeletionDialog: boolean; foldersMap: Record; + /** + * 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>; addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void; removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void; createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null; @@ -39,6 +45,7 @@ export const useSidebarBulkActions = (args: Args) => { isInlineEditing, showDeletionDialog, foldersMap, + folderScopesByProject, addSessionsToFolder, removeSessionsFromFolders, createFolderAndStartRename, @@ -89,38 +96,74 @@ export const useSidebarBulkActions = (args: Args) => { return null; }, [hasSelection, selectedIds, selectionScopeKey]); - const bulkScopeFolders = React.useMemo(() => { + // The selection scope is a project id; folders live per directory scope + // (project root + each worktree). Resolve all of them, in project order. + const selectionFolderScopes = React.useMemo(() => { if (!derivedSelectionScope) return []; - return foldersMap[derivedSelectionScope] ?? []; - }, [foldersMap, derivedSelectionScope]); + const projectScopes = folderScopesByProject.get(derivedSelectionScope); + if (projectScopes && projectScopes.length > 0) { + return projectScopes.map((scope) => scope.scopeKey); + } + // Fallback: the scope is already a directory (e.g. VS Code workspaces). + return [derivedSelectionScope]; + }, [derivedSelectionScope, folderScopesByProject]); + + const bulkScopeFolders = React.useMemo(() => { + return selectionFolderScopes.flatMap((scope) => foldersMap[scope] ?? []); + }, [foldersMap, selectionFolderScopes]); + + const resolveFolderScope = React.useCallback((folderId: string): string | null => { + for (const scope of selectionFolderScopes) { + if ((foldersMap[scope] ?? []).some((folder) => folder.id === folderId)) return scope; + } + return null; + }, [foldersMap, selectionFolderScopes]); const bulkCanRemoveFromFolder = React.useMemo(() => { - if (!derivedSelectionScope || !hasSelection) return false; - const scopeFolders = foldersMap[derivedSelectionScope] ?? []; - for (const folder of scopeFolders) { - for (const id of folder.sessionIds) { - if (selectedIds.has(id)) return true; + if (!hasSelection) return false; + for (const scope of selectionFolderScopes) { + for (const folder of foldersMap[scope] ?? []) { + for (const id of folder.sessionIds) { + if (selectedIds.has(id)) return true; + } } } return false; - }, [foldersMap, derivedSelectionScope, hasSelection, selectedIds]); + }, [foldersMap, selectionFolderScopes, hasSelection, selectedIds]); + + const moveSelectionToFolder = React.useCallback((targetScope: string, folderId: string) => { + const ids = Array.from(selectedIds); + // Clear memberships in every other scope first — the store only dedupes + // within one scope, and a session must live in a single folder. + for (const scope of selectionFolderScopes) { + if (scope === targetScope) continue; + removeSessionsFromFolders(scope, ids); + } + addSessionsToFolder(targetScope, folderId, ids); + }, [addSessionsToFolder, removeSessionsFromFolders, selectedIds, selectionFolderScopes]); const handleBulkMoveToFolder = React.useCallback((folderId: string) => { - if (!derivedSelectionScope || !hasSelection) return; - addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds)); - }, [addSessionsToFolder, selectedIds, derivedSelectionScope, hasSelection]); + if (!hasSelection) return; + const targetScope = resolveFolderScope(folderId); + if (!targetScope) return; + moveSelectionToFolder(targetScope, folderId); + }, [hasSelection, moveSelectionToFolder, resolveFolderScope]); const handleBulkCreateFolderAndMove = React.useCallback(() => { - if (!derivedSelectionScope || !hasSelection) return; - const newFolder = createFolderAndStartRename(derivedSelectionScope); + const targetScope = selectionFolderScopes[0]; + if (!targetScope || !hasSelection) return; + const newFolder = createFolderAndStartRename(targetScope); if (!newFolder) return; - addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds)); - }, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope, hasSelection]); + moveSelectionToFolder(targetScope, newFolder.id); + }, [createFolderAndStartRename, hasSelection, moveSelectionToFolder, selectionFolderScopes]); const handleBulkRemoveFromFolder = React.useCallback(() => { - if (!derivedSelectionScope || !hasSelection) return; - removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds)); - }, [removeSessionsFromFolders, selectedIds, derivedSelectionScope, hasSelection]); + if (!hasSelection) return; + const ids = Array.from(selectedIds); + for (const scope of selectionFolderScopes) { + removeSessionsFromFolders(scope, ids); + } + }, [removeSessionsFromFolders, selectedIds, selectionFolderScopes, hasSelection]); const executeBulkDelete = React.useCallback(async () => { const ids = Array.from(selectedIds); diff --git a/packages/ui/src/components/session/sidebar/sortableItems.tsx b/packages/ui/src/components/session/sidebar/sortableItems.tsx index 9e1d2594..bb51e3a9 100644 --- a/packages/ui/src/components/session/sidebar/sortableItems.tsx +++ b/packages/ui/src/components/session/sidebar/sortableItems.tsx @@ -13,8 +13,14 @@ import { Icon } from "@/components/icon/Icon"; import { cn } from '@/lib/utils'; import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta'; import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { useI18n } from '@/lib/i18n'; +export type SortableDragHandleProps = { + listeners: ReturnType['listeners']; + setActivatorNodeRef: ReturnType['setActivatorNodeRef']; +}; + export interface SortableProjectItemProps { id: string; disabled?: boolean; @@ -35,6 +41,7 @@ export interface SortableProjectItemProps { onToggle: () => void; onNewSession: () => void; onNewWorktreeSession?: () => void; + onManageWorktrees?: () => void; onRenameStart: () => void; onClose: () => void; sentinelRef: (el: HTMLDivElement | null) => void; @@ -43,13 +50,10 @@ export interface SortableProjectItemProps { hideHeader?: boolean; openSidebarMenuKey: string | null; setOpenSidebarMenuKey: (key: string | null) => void; + /** Aggregated activity/attention indicator shown while the project is collapsed. */ + statusIndicator?: React.ReactNode; } -export type SortableDragHandleProps = { - listeners: ReturnType['listeners']; - setActivatorNodeRef: ReturnType['setActivatorNodeRef']; -}; - export const SortableProjectItem: React.FC = ({ id, disabled = false, @@ -69,6 +73,7 @@ export const SortableProjectItem: React.FC = ({ onToggle, onNewSession, onNewWorktreeSession, + onManageWorktrees, onRenameStart, onClose, sentinelRef, @@ -77,9 +82,11 @@ export const SortableProjectItem: React.FC = ({ hideHeader = false, openSidebarMenuKey, setOpenSidebarMenuKey, + statusIndicator = null, }) => { const { t } = useI18n(); const { currentTheme } = useThemeSystem(); + const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders); const { attributes, listeners, @@ -110,6 +117,12 @@ export const SortableProjectItem: React.FC = ({ {t('sessions.sidebar.project.actions.newSession')} )} + {isRepo && !hideDirectoryControls && onManageWorktrees && ( + + + {t('sessions.sidebar.project.actions.manageWorktrees')} + + )} {t('sessions.sidebar.project.actions.edit')} @@ -139,7 +152,12 @@ export const SortableProjectItem: React.FC = ({ } }, []); - const handleToggleClick = React.useCallback(() => { + const handleToggleClick = React.useCallback((event: React.MouseEvent) => { + // Drop mouse-click focus so hover-revealed chrome (chevron, actions) + // hides again on mouse-leave instead of sticking via :focus-within. + // Keyboard users keep their focus-visible ring (blur only fires here + // for pointer interactions that produced a click). + event.currentTarget.blur(); if (suppressNextToggleRef.current) { suppressNextToggleRef.current = false; return; @@ -167,9 +185,19 @@ export const SortableProjectItem: React.FC = ({ { // VS Code hides project actions entirely (hideDirectoryControls). if (hideDirectoryControls) return; @@ -179,7 +207,17 @@ export const SortableProjectItem: React.FC = ({ /> } > -
+
@@ -381,3 +422,4 @@ const SortableGroupItemBase: React.FC<{ }; export const SortableGroupItem = React.memo(SortableGroupItemBase); + diff --git a/packages/ui/src/components/session/sidebar/types.ts b/packages/ui/src/components/session/sidebar/types.ts index ba137fa0..fd99a666 100644 --- a/packages/ui/src/components/session/sidebar/types.ts +++ b/packages/ui/src/components/session/sidebar/types.ts @@ -7,6 +7,11 @@ export type SessionNode = { worktree: WorktreeMetadata | null; }; +export type SessionGroupFolderScope = { + scopeKey: string; + directory: string | null; +}; + export type SessionGroup = { id: string; label: string; @@ -17,6 +22,13 @@ export type SessionGroup = { worktree: WorktreeMetadata | null; directory: string | null; folderScopeKey?: string | null; + /** + * Flat display groups merge sessions from the project root and every + * worktree; their folders come from all of these scopes. When present, the + * group section gathers folders across every listed scope (in order) + * instead of reading the single folderScopeKey. + */ + folderScopes?: SessionGroupFolderScope[]; sessions: SessionNode[]; }; diff --git a/packages/ui/src/components/session/sidebar/worktreeFirstSeen.ts b/packages/ui/src/components/session/sidebar/worktreeFirstSeen.ts new file mode 100644 index 00000000..ede3b8ad --- /dev/null +++ b/packages/ui/src/components/session/sidebar/worktreeFirstSeen.ts @@ -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(); + +export const recordWorktreesSeen = (paths: Iterable, 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; +}; diff --git a/packages/ui/src/components/ui/ScrollableOverlay.tsx b/packages/ui/src/components/ui/ScrollableOverlay.tsx index 8c1a1198..15f43f2f 100644 --- a/packages/ui/src/components/ui/ScrollableOverlay.tsx +++ b/packages/ui/src/components/ui/ScrollableOverlay.tsx @@ -16,6 +16,8 @@ type ScrollableOverlayProps = React.HTMLAttributes & { preventOverscroll?: boolean; useScrollShadow?: boolean; scrollShadowSize?: number; + /** Suppress the top fade (e.g. when sticky headers sit at the top edge). */ + hideTopScrollShadow?: boolean; userIntentOnly?: boolean; /** Forwarded to the inner element (e.g. textarea). */ disabled?: boolean; @@ -37,6 +39,7 @@ export const ScrollableOverlay = React.forwardRef { @@ -57,6 +60,7 @@ export const ScrollableOverlay = React.forwardRef} size={scrollShadowSize} + hideTopShadow={hideTopScrollShadow} className={cn( "overlay-scrollbar-target overlay-scrollbar-container", preventOverscroll && "overscroll-none", diff --git a/packages/ui/src/components/ui/tooltip.tsx b/packages/ui/src/components/ui/tooltip.tsx index b33ada92..7b1c3976 100644 --- a/packages/ui/src/components/ui/tooltip.tsx +++ b/packages/ui/src/components/ui/tooltip.tsx @@ -274,7 +274,10 @@ function TooltipContent({ 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(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(() => { + const byDirectory = new Map(); + 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[], + ) => ( +
+ + {sessionsForDelete ? ( + + + + + {t('sessions.archivePage.deleteProject')} + + ) : null} +
+ ); + + return ( +
+
+ {/* Directory filter panel */} +
+
+ {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, + ))} +
+
+ + {/* Session list */} +
+
+
+ + { + 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" + /> +
+ {/* Pages have no close button: you leave via the sidebar. */} + + {filteredSessions.length === 1 + ? t('sessions.archivePage.countSingle', { count: filteredSessions.length }) + : t('sessions.archivePage.countPlural', { count: filteredSessions.length })} + +
+ +
+
+ {visibleSessions.length === 0 ? ( +
+

+ {normalizedQuery ? t('sessions.archivePage.empty.noMatches') : t('sessions.archivePage.empty.noArchived')} +

+
+ ) : visibleSessions.map((session) => { + const sessionDirectory = normalizePath(resolveGlobalSessionDirectory(session)) ?? ''; + const directoryLabel = sessionDirectory + ? (formatDirectoryName(sessionDirectory, homeDirectory) || sessionDirectory) + : null; + return ( +
openSession(session)} + role="button" + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openSession(session); + } + }} + > + + {session.title || t('sessions.sidebar.session.untitled')} + + {normalizedQuery && directoryLabel ? ( + + {directoryLabel} + + ) : null} + + {formatSessionDateLabel(session.time?.archived ?? session.time?.updated ?? session.time?.created ?? Date.now())} + + +
+ ); + })} + {remainingCount > 0 ? ( + + ) : null} +
+
+
+
+
+ ); +} diff --git a/packages/ui/src/components/views/MultiRunWindow.tsx b/packages/ui/src/components/views/MultiRunWindow.tsx deleted file mode 100644 index 47042cd7..00000000 --- a/packages/ui/src/components/views/MultiRunWindow.tsx +++ /dev/null @@ -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 = ({ - 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 ( - { - if (!next && hasOpenFloatingMenu()) return; - onOpenChange(next); - }} - > - - -
- -
- -
- - {t('multiRun.window.description')} - - onOpenChange(false)} - onCancel={() => onOpenChange(false)} - isWindowed - /> -
-
-
-
- ); -}; diff --git a/packages/ui/src/components/views/WorktreesView.tsx b/packages/ui/src/components/views/WorktreesView.tsx new file mode 100644 index 00000000..1861faf5 --- /dev/null +++ b/packages/ui/src/components/views/WorktreesView.tsx @@ -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 ( +
+
+
+
+ +
+ +
+
+
+ ); +} diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index afcc7d05..ceb0b975 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -680,7 +680,9 @@ html:not(.dark) .chat-scroll { inset: 0; pointer-events: none; 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, diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 98db7ec4..e689657e 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -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.toast.saveFailed': 'Failed to save worktree setup commands', '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.empty': 'No worktrees found for this project', 'settings.openchamber.worktrees.list.detachedHead': 'Detached HEAD', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index e522ed7a..23f5011c 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -420,6 +420,28 @@ export const dict = { 'sessions.sidebar.empty.noMatches.title': 'No matching sessions', 'sessions.sidebar.empty.noMatches.description': 'Try a different title, branch, folder, or path.', '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.empty': 'No recent sessions', 'sessions.switcher.draftTitle': 'New session', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 26967069..b20fbfcb 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -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.toast.saveFailed": "Error al guardar los comandos de configuración del worktree", "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.empty": "No se encontraron worktrees para este proyecto", "settings.openchamber.worktrees.list.detachedHead": "HEAD desvinculado", diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index c5e81de9..e5f46751 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -421,6 +421,28 @@ export const dict: Record = { "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.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.empty": "No hay sesiones recientes", "sessions.switcher.draftTitle": "Nueva sesión", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 035f9abc..4b9d8102 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -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.toast.saveFailed': 'Échec de l\'enregistrement des commandes de configuration Worktree', '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.empty': 'Aucun worktree trouvé pour ce projet', 'settings.openchamber.worktrees.list.detachedHead': 'HEAD détaché', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index aa14466a..60ba4fc0 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -262,6 +262,28 @@ export const dict = { 'sessions.sidebar.empty.noMatches.title': 'Aucune session correspondante', 'sessions.sidebar.empty.noMatches.description': 'Essayez un autre titre, branche, dossier ou chemin.', '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.empty': 'Aucune session récente', 'sessions.switcher.draftTitle': 'Nouvelle session', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 9b150a65..cb8b4f66 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1283,7 +1283,7 @@ export const settingsDict = { 'settings.openchamber.worktrees.setup.waitForCommandsAria': 'Worktree セットアップコマンドの完了を待ってからセッションを作成または送信する', 'settings.openchamber.worktrees.setup.toast.saveFailed': 'ワークツリー設定コマンドの保存に失敗しました', '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.empty': 'このプロジェクトに Worktree は見つかりません', 'settings.openchamber.worktrees.list.detachedHead': '分離 HEAD', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index b8c01087..ef32a963 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -421,6 +421,28 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '一致するセッションがありません', 'sessions.sidebar.empty.noMatches.description': '別のタイトル、ブランチ、フォルダ、パスをお試しください。', '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.empty': '最近のセッションはありません', 'sessions.switcher.draftTitle': '新しいセッション', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 456f84ab..266b0a35 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1250,7 +1250,7 @@ export const settingsDict = { 'settings.openchamber.worktrees.setup.waitForCommandsAria': '세션을 만들거나 보내기 전에 Worktree 설정 명령이 끝날 때까지 기다리기', 'settings.openchamber.worktrees.setup.toast.saveFailed': '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.empty': '이 프로젝트의 worktree를 찾을 수 없습니다', 'settings.openchamber.worktrees.list.detachedHead': 'Detached HEAD', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index c89e482b..607b1dbf 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -421,6 +421,28 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '일치하는 세션 없음', 'sessions.sidebar.empty.noMatches.description': '다른 제목, 브랜치, 폴더 또는 경로로 검색해 보세요.', '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.empty': '최근 세션 없음', 'sessions.switcher.draftTitle': '새 세션', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index ca771ff2..403d2868 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1187,7 +1187,7 @@ export const settingsDict = { 'settings.openchamber.worktrees.list.empty': 'Nie znaleziono worktree dla tego projektu', 'settings.openchamber.worktrees.list.loading': 'Ładowanie 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.commandPlaceholder': 'np. bun install', 'settings.openchamber.worktrees.setup.loading': 'Ładowanie...', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 7848f788..ad56768c 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -237,6 +237,28 @@ export const dict: Record = { '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.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.empty': 'Brak ostatnich sesji', 'sessions.switcher.draftTitle': 'Nowa sesja', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index e446508f..62404469 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -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.toast.saveFailed": "Falha ao salvar os comandos de configuração do Worktree", "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.empty": "Nenhum worktree encontrado para este projeto", "settings.openchamber.worktrees.list.detachedHead": "HEAD desvinculado", diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 9709cb70..4c1496dd 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -421,6 +421,28 @@ export const dict: Record = { "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.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.empty": "Nenhuma sessão recente", "sessions.switcher.draftTitle": "Nova sessão", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index d217b8de..a73d271a 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1250,7 +1250,7 @@ export const settingsDict = { "settings.openchamber.worktrees.setup.waitForCommandsAria": "Чекати завершення команд налаштування Worktree перед створенням або надсиланням сесії", "settings.openchamber.worktrees.setup.toast.saveFailed": "Не вдалося зберегти команди налаштування 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.empty": "Для цього проєкту не знайдено worktree", "settings.openchamber.worktrees.list.detachedHead": "Відокремлено HEAD", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 1a545841..cc76d4d2 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -421,6 +421,28 @@ export const dict: Record = { "sessions.sidebar.empty.noMatches.title": "Немає відповідних сесій", "sessions.sidebar.empty.noMatches.description": "Спробуйте інший заголовок, гілку, папку або шлях.", "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.empty": "Немає недавніх сесій", "sessions.switcher.draftTitle": "Нова сесія", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 884a30ff..0ce5ffb7 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1250,7 +1250,7 @@ export const settingsDict = { 'settings.openchamber.worktrees.setup.waitForCommandsAria': '创建或发送会话前等待 Worktree 初始化命令完成', 'settings.openchamber.worktrees.setup.toast.saveFailed': '保存 Worktree 初始化命令失败', '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.empty': '该项目未找到工作树', 'settings.openchamber.worktrees.list.detachedHead': '分离 HEAD', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index fcaa38fe..d9c335c3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -421,6 +421,28 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '没有匹配的会话', 'sessions.sidebar.empty.noMatches.description': '请尝试其他标题、分支、文件夹或路径。', '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.empty': '没有最近会话', 'sessions.switcher.draftTitle': '新会话', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 3ca45850..59d59654 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1156,7 +1156,7 @@ 'settings.openchamber.worktrees.setup.waitForCommandsAria': '建立或傳送工作階段前等待 Worktree 設定命令完成', 'settings.openchamber.worktrees.setup.toast.saveFailed': '儲存 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.empty': '該專案找不到 worktree', 'settings.openchamber.worktrees.list.detachedHead': '分離 HEAD', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 9ca613f4..79d88b7b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -434,6 +434,28 @@ export const dict: Record = { 'sessions.sidebar.empty.noMatches.title': '沒有符合的會話', 'sessions.sidebar.empty.noMatches.description': '請嘗試其他標題、分支、資料夾或路徑。', '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.empty': '沒有最近會話', 'sessions.switcher.draftTitle': '新會話', diff --git a/packages/ui/src/stores/useGitHubPrStatusStore.ts b/packages/ui/src/stores/useGitHubPrStatusStore.ts index e7767375..b9dc9e5c 100644 --- a/packages/ui/src/stores/useGitHubPrStatusStore.ts +++ b/packages/ui/src/stores/useGitHubPrStatusStore.ts @@ -935,6 +935,36 @@ const summarySignature = (s: PrVisualSummary): string => let prKeyedCacheSigs = new Map(); let prKeyedCacheResult: Map = 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(); + +export const usePrVisualSummary = (key: string | null): PrVisualSummary | null => { + return useGitHubPrStatusStore((state) => { + if (!key) return null; + const entry = state.entries[key]; + const summary = entry ? deriveSummary(entry) : null; + if (!summary) { + prSummaryCacheByKey.delete(key); + return null; + } + const sig = summarySignature(summary); + const cached = prSummaryCacheByKey.get(key); + if (cached && cached.sig === sig) return cached.summary; + if (!cached && prSummaryCacheByKey.size >= PR_SUMMARY_CACHE_MAX_ENTRIES) { + const oldestKey = prSummaryCacheByKey.keys().next().value; + if (oldestKey !== undefined) prSummaryCacheByKey.delete(oldestKey); + } + prSummaryCacheByKey.set(key, { sig, summary }); + return summary; + }); +}; + export const usePrVisualSummaryByKeys = (keys: string[]) => { return useGitHubPrStatusStore((state) => { // Derive summaries for requested keys only diff --git a/packages/ui/src/stores/useSessionDisplayStore.test.ts b/packages/ui/src/stores/useSessionDisplayStore.test.ts index 71a58199..6c5c0201 100644 --- a/packages/ui/src/stores/useSessionDisplayStore.test.ts +++ b/packages/ui/src/stores/useSessionDisplayStore.test.ts @@ -19,4 +19,16 @@ describe('useSessionDisplayStore project sorting', () => { expect(migrated.projectSortOrder).toBe(projectSortOrder); }); } + + test('v3→v4 drops the removed displayMode key and keeps the rest', () => { + const migrated = migrateSessionDisplayState( + { displayMode: 'default', projectSortOrder: 'a-z', showRecentSection: false, showArchivedSessions: true }, + 3, + ); + + expect('displayMode' in migrated).toBe(false); + expect(migrated.projectSortOrder).toBe('a-z'); + expect(migrated.showRecentSection).toBe(false); + expect(migrated.showArchivedSessions).toBe(true); + }); }); diff --git a/packages/ui/src/stores/useSessionDisplayStore.ts b/packages/ui/src/stores/useSessionDisplayStore.ts index b3ab9876..0a2578bb 100644 --- a/packages/ui/src/stores/useSessionDisplayStore.ts +++ b/packages/ui/src/stores/useSessionDisplayStore.ts @@ -1,16 +1,24 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -type SessionDisplayMode = 'default' | 'minimal'; - type ProjectSortOrder = 'manual' | 'a-z' | 'z-a' | 'date-added' | 'recent'; +// 'by-worktree' keeps per-worktree sub-headers inside each project zone +// (parallel-work overview); 'flat' merges everything into one recency list. +type SessionGroupingMode = 'by-worktree' | 'flat'; + type SessionDisplayStore = { - displayMode: SessionDisplayMode; + sessionGroupingMode: SessionGroupingMode; + setSessionGroupingMode: (mode: SessionGroupingMode) => void; + /** Project/recent zone headers stick to the top while their zone scrolls. */ + stickyZoneHeaders: boolean; + toggleStickyZoneHeaders: () => void; showRecentSection: boolean; + // VS Code only: the compact webview keeps archived buckets inline because it + // has no room for the full Archive page. Web/desktop ignore this flag and + // always route archived sessions to the Archive page instead. showArchivedSessions: boolean; projectSortOrder: ProjectSortOrder; - setDisplayMode: (mode: SessionDisplayMode) => void; setShowRecentSection: (show: boolean) => void; setShowArchivedSessions: (show: boolean) => void; toggleRecentSection: () => void; @@ -22,15 +30,19 @@ export const migrateSessionDisplayState = ( persisted: unknown, version: number, ): Partial => { - const state = (persisted ?? {}) as Partial; - if (version < 1) { - return { ...state, displayMode: 'minimal', projectSortOrder: 'manual' }; - } + const state = (persisted ?? {}) as Partial & { + displayMode?: string; + }; if (version < 2) { - return { ...state, projectSortOrder: 'manual' }; + state.projectSortOrder = 'manual'; } if (version < 3 && state.projectSortOrder === 'recent') { - return { ...state, projectSortOrder: 'manual' }; + state.projectSortOrder = 'manual'; + } + if (version < 4) { + // v4 removes the default/minimal display mode: the sidebar now has a + // single row layout. Drop the stale key from persisted state. + delete state.displayMode; } return state; }; @@ -38,15 +50,16 @@ export const migrateSessionDisplayState = ( export const useSessionDisplayStore = create()( persist( (set) => ({ - displayMode: 'minimal', + sessionGroupingMode: 'by-worktree', + setSessionGroupingMode: (mode) => set({ sessionGroupingMode: mode }), + stickyZoneHeaders: true, + toggleStickyZoneHeaders: () => set((state) => ({ stickyZoneHeaders: !state.stickyZoneHeaders })), showRecentSection: true, // Default to HIDDEN so the pre-hydration state matches the quiet/safe // option: archived sessions must never flash visible on startup and then - // disappear once the persisted preference rehydrates. Users who opted into - // showing archived have `true` persisted, which is preserved on rehydrate. + // disappear once the persisted preference rehydrates. showArchivedSessions: false, projectSortOrder: 'manual', - setDisplayMode: (mode) => set({ displayMode: mode }), setShowRecentSection: (show) => set({ showRecentSection: show }), setShowArchivedSessions: (show) => set({ showArchivedSessions: show }), toggleRecentSection: () => set((state) => ({ showRecentSection: !state.showRecentSection })), @@ -55,15 +68,13 @@ export const useSessionDisplayStore = create()( }), { name: 'session-display-mode', - version: 3, - // v0 shipped 'default' as the only/initial mode, so most existing users - // have it persisted by accident rather than choice. Nudge everyone onto - // minimal once so the mode can be evaluated before removing it entirely. + version: 4, // v1→v2 adds projectSortOrder using the canonical manual ordering. // v2→v3 replaces the previously shipped recent default with manual. + // v3→v4 removes displayMode (single sidebar row layout). migrate: migrateSessionDisplayState, }, ), ); -export type { ProjectSortOrder }; +export type { ProjectSortOrder, SessionGroupingMode }; diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index e7108338..817ec27f 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -595,6 +595,8 @@ interface UIStore { openCodeStatusText: string; isSessionCreateDialogOpen: boolean; isScheduledTasksDialogOpen: boolean; + isArchivePageOpen: boolean; + worktreesPageProjectId: string | null; isSettingsDialogOpen: boolean; isNewWorktreeDialogOpen: boolean; isModelSelectorOpen: boolean; @@ -756,6 +758,10 @@ interface UIStore { setOpenCodeStatusText: (text: string) => void; setSessionCreateDialogOpen: (open: boolean) => void; setScheduledTasksDialogOpen: (open: boolean) => void; + setArchivePageOpen: (open: boolean) => void; + setWorktreesPageProjectId: (projectId: string | null) => void; + /** Close every full-page surface (Scheduled, Archive, Worktrees, Multi-run). */ + closeMainSurfaces: () => void; setSettingsDialogOpen: (open: boolean) => void; setNewWorktreeDialogOpen: (open: boolean) => void; setModelSelectorOpen: (open: boolean) => void; @@ -909,6 +915,8 @@ export const useUIStore = create()( openCodeStatusText: '', isSessionCreateDialogOpen: false, isScheduledTasksDialogOpen: false, + isArchivePageOpen: false, + worktreesPageProjectId: null, isSettingsDialogOpen: false, isNewWorktreeDialogOpen: false, isModelSelectorOpen: false, @@ -1552,7 +1560,35 @@ export const useUIStore = create()( }, setScheduledTasksDialogOpen: (open) => { - set({ isScheduledTasksDialogOpen: open }); + set(open + ? { isScheduledTasksDialogOpen: true, isArchivePageOpen: false, worktreesPageProjectId: null, isMultiRunLauncherOpen: false } + : { isScheduledTasksDialogOpen: false }); + }, + + setArchivePageOpen: (open) => { + set(open + ? { isArchivePageOpen: true, isScheduledTasksDialogOpen: false, worktreesPageProjectId: null, isMultiRunLauncherOpen: false } + : { isArchivePageOpen: false }); + }, + + setWorktreesPageProjectId: (projectId) => { + set(projectId + ? { worktreesPageProjectId: projectId, isScheduledTasksDialogOpen: false, isArchivePageOpen: false, isMultiRunLauncherOpen: false } + : { worktreesPageProjectId: null }); + }, + + closeMainSurfaces: () => { + const state = get(); + if (!state.isScheduledTasksDialogOpen && !state.isArchivePageOpen && !state.worktreesPageProjectId && !state.isMultiRunLauncherOpen) { + return; + } + set({ + isScheduledTasksDialogOpen: false, + isArchivePageOpen: false, + worktreesPageProjectId: null, + isMultiRunLauncherOpen: false, + multiRunLauncherPrefillPrompt: '', + }); }, setSettingsDialogOpen: (open) => { @@ -2016,10 +2052,13 @@ export const useUIStore = create()( } }, + // Multi-run is one of the mutually exclusive full-page surfaces: + // opening it closes the other surfaces and vice versa. setMultiRunLauncherOpen: (open) => { set((state) => ({ isMultiRunLauncherOpen: open, multiRunLauncherPrefillPrompt: open ? state.multiRunLauncherPrefillPrompt : '', + ...(open ? { isScheduledTasksDialogOpen: false, isArchivePageOpen: false, worktreesPageProjectId: null } : {}), })); }, @@ -2028,6 +2067,9 @@ export const useUIStore = create()( isMultiRunLauncherOpen: true, multiRunLauncherPrefillPrompt: '', isSessionSwitcherOpen: false, + isScheduledTasksDialogOpen: false, + isArchivePageOpen: false, + worktreesPageProjectId: null, }); }, @@ -2036,6 +2078,9 @@ export const useUIStore = create()( isMultiRunLauncherOpen: true, multiRunLauncherPrefillPrompt: prompt, isSessionSwitcherOpen: false, + isScheduledTasksDialogOpen: false, + isArchivePageOpen: false, + worktreesPageProjectId: null, }); }, diff --git a/packages/ui/src/styles/design-system.css b/packages/ui/src/styles/design-system.css index 80b815f5..023f3cc7 100644 --- a/packages/ui/src/styles/design-system.css +++ b/packages/ui/src/styles/design-system.css @@ -150,6 +150,20 @@ 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-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif) !important; }