feat(ui): sidebar redesign — project zones, grouping modes, full-page surfaces (#2480)
* checkpoint: flatten sidebar core (flat sessions, zones, single mode, folders flat) * checkpoint: sidebar nav + scheduled/archive full-page surfaces, recent zone header * checkpoint: worktrees management surface via project menu * checkpoint: docs, i18n, validation for sidebar redesign * checkpoint: unified row/zone geometry, recent backfill, tooltips everywhere, primary spinner * checkpoint: branch icon marker, date moved to rich tooltip, recent back to pure time window * checkpoint: PR state on branch markers + tooltip, no reserved right space, aligned show-more, instant tooltips * checkpoint: reserve hover-action space so title text is not overlapped * checkpoint: tighten hover-action reserve * checkpoint: color-only unread emphasis to avoid title reflow * checkpoint: drop new-subfolder action, folder actions overlay on hover * checkpoint: fix sticky project headers (sticky on trigger div), stuck elevation * checkpoint: full-bleed semibold zone headers, no top scroll fade * checkpoint: headers without background tint (typography-only emphasis) * checkpoint: recent header flush with scroll top (no pre-stick bump) * checkpoint: shared tooltip provider with grouping (instant handoff between rows) * checkpoint: blur pointer-click focus so hover chrome hides on mouse-leave * checkpoint: tooltip closeDelay bridges inter-row gap * checkpoint: nav above controls, merged view dropdown, project-scoped bulk selection, cross-worktree folders * checkpoint: folder header tooltip with full path name * checkpoint: true page surfaces (hidden chat, header title, close-on-select), multirun page, run-now jump, folders on top, controls row polish * checkpoint: rename mode — dual-instance outside-click fix, no vertical shift * checkpoint: session grouping mode toggle (by-worktree default, flat option) * checkpoint: worktree header — hover padding reserve + delete worktree action * checkpoint: frosted sticky header backing under desktop vibrancy * checkpoint: align worktree sub-header with project header icon column * checkpoint: restore worktree group DnD reorder; dense vibrancy header tint (Chromium mask+backdrop-filter) * checkpoint: vibrancy — drop scroller mask so backdrop-filter samples rows (Chromium backdrop-root limitation) * checkpoint: vibrancy headers use opaque sidebar tone (Electron transparent-window backdrop-filter bug) * checkpoint: nav collapsed to one row (New session + surface icons), mirrors Add project row * checkpoint: raise sticky zone headers above row action layers (z-20) * checkpoint: New session as full-width CTA + single quiet toolbar row * checkpoint: New session row back to quiet text form above the toolbar * checkpoint: align toolbar left icon with New session icon column * checkpoint: hoverless flat header controls (color-only hover states) * checkpoint: sticky project headers toggle in view dropdown (default on) * checkpoint: full-page surfaces adapted — archive directory filter panel, scheduled master-detail, multirun without duplicate title bar * checkpoint: multirun joins mutually exclusive surface set * checkpoint: surfaces leave via navigation only (no close/cancel buttons), robust close-on-new-session, drop dead MultiRunWindow * checkpoint: no scheduled header description, aligned empty-worktree note, collapse/expand covers worktree groups * checkpoint: overlay scrollbar above sticky zone headers * checkpoint: archive delete icons reveal on hover with padding shift * checkpoint: new worktrees surface at top of the worktree list * checkpoint: no grab cursor on worktree headers * checkpoint: worktrees page list-only with inline action, no worktrees in edit dialog, drop menu ellipsis * checkpoint: worktrees page uses full content width * checkpoint: worktrees header — 'in' instead of em dash, no description * checkpoint: drop legacy OpenCode badge from worktree list, tooltip without OpenCode mention * review: bound pr summary cache
This commit is contained in:
committed by
GitHub
parent
5787ea5d49
commit
1291cde5c2
@@ -3,18 +3,16 @@
|
||||
## Refactor result
|
||||
|
||||
- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components.
|
||||
- Sidebar is now a single multi-project tree: `recent` top section, then projects, then worktrees/archived groups, then sessions.
|
||||
- `NavRail` is no longer part of sidebar/navigation flow.
|
||||
- Project headers now own root sessions directly; there is no separate rendered `project root` subgroup.
|
||||
- Active/hover row styling is text-first; selected sessions use primary text instead of background fills.
|
||||
- Archived groups are collapsed by default and support bulk deletion at group/folder level.
|
||||
- Session rows support compact inline dates in minimal mode and simplified metadata in default mode.
|
||||
- Layout (web/desktop): top navigation (`SidebarNav`: New session, Scheduled, Multi-run, Archive), then the `recent` zone, then one zone per project with a **flat** session list. There is no rendered worktree grouping level.
|
||||
- **Two grouping display modes** (`useSessionDisplayStore.sessionGroupingMode`, toggled in the view dropdown): `'by-worktree'` (default) renders the worktree-grouped `sectionsForRender` with slim PR-aware branch sub-headers inside each project zone; `'flat'` renders `flatSectionsForRender` — one merged non-archived group per project (`id: 'flat'`, `folderScopes` listing every contributing scope) with per-row branch markers. Both derive from the same `projectSections` data layer, which alone feeds bootstrap demand planning and PR polling.
|
||||
- Project headers are sticky background "zone" bands (`SortableProjectItem`); the `recent` section header uses the same band styling. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
|
||||
- Session rows have a single layout (former `minimal`); the `default`/`minimal` display mode was removed (`session-display-mode` store v4 migration drops the key). Rows show an inline branch label (from `node.worktree` or recent's `secondaryMeta`) when the session lives outside the project root, and bold titles while unread.
|
||||
- Folders render **flat** after the loose sessions: nested folders keep `parentId` in the data model but display at one level with a "Parent / Child" path label (`SessionFolderItem.displayName`); collapsing a folder hides its whole subtree. Folder actions resolve their owning scope per folder entry (folders from multiple worktree scopes can coexist under one project).
|
||||
- Archived sessions are not shown in the web/desktop sidebar; the Archive page (`ArchiveView`, `useUIStore.isArchivePageOpen`) replaces the old toggle. VS Code keeps inline archived buckets behind `showArchivedSessions` (compact webview has no page surfaces). Unarchive is not possible through the upstream OpenCode HTTP API (`session.update` can only set a finite `time.archived`).
|
||||
- Scheduled tasks (`ScheduledTasksDialog`, now a full-page surface on web/desktop) and per-project worktree management (`WorktreesView`, opened from the project menu) render as overlays inside `<main>` in `MainLayout`; the sidebar no longer mounts them.
|
||||
- Group-level PR-status polling/indicators and worktree-group drag-to-reorder were removed together with the worktree grouping level; `oc.sessions.groupOrder` is no longer read or written. Worktree PR/branch context lives in the Worktrees surface.
|
||||
- Root session menus can quickly create a worktree from the session directory's current branch and move the full session subtree there while idle.
|
||||
- Directory loading is demand-driven: the sidebar publishes one complete priority plan for all known project/worktree directories, while the sync layer owns bounded execution.
|
||||
- New extractions in latest pass reduced local effect/callback bulk further:
|
||||
- project session list builders
|
||||
- authoritative deletion cleanup
|
||||
- sticky project header observer
|
||||
|
||||
## VS Code grouping
|
||||
|
||||
@@ -26,14 +24,15 @@
|
||||
|
||||
### Components
|
||||
|
||||
- `SidebarHeader.tsx`: Top header UI for add-project, session search, and display mode.
|
||||
- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only.
|
||||
- `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all).
|
||||
- `SidebarNav.tsx`: Text navigation rows above the tree (New session, Scheduled, Multi-run, Archive); hidden in VS Code.
|
||||
- `SidebarActivitySections.tsx`: Global top section renderer; currently used for the `recent` section only, styled as a zone header.
|
||||
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
|
||||
- `SidebarProjectsList.tsx`: Main scrollable tree renderer for projects, root sessions, worktrees/groups, and empty/search states.
|
||||
- `SessionGroupSection.tsx`: Renders a single worktree/archived group, collapse/expand, folder subtree, group-level controls, and explicit loading/error/retry state for empty groups.
|
||||
- `SessionNodeItem.tsx`: Renders one session row/tree node with inline metadata, menu actions, minimal/default variants, and nested children. Rows do not initiate directory bootstrap on mount.
|
||||
- `SidebarProjectsList.tsx`: Main scrollable renderer for project zones and their flat/archived groups plus empty/search states; owns project drag-to-reorder.
|
||||
- `SessionGroupSection.tsx`: Renders one flat (or archived) group: sessions first, then flat folder entries with path labels, show-more batching, and explicit loading/error/retry state for empty groups. Archived buckets (VS Code) virtualize past 50 rows.
|
||||
- `SessionNodeItem.tsx`: Renders one session row/tree node with a single-line layout, inline branch label, indicators, menu actions, and nested children. Rows do not initiate directory bootstrap on mount.
|
||||
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
|
||||
- `sortableItems.tsx`: DnD sortable wrappers for project and group ordering plus project-row action affordances.
|
||||
- `sortableItems.tsx`: DnD sortable wrapper for project ordering plus the sticky zone-band project header and its action affordances.
|
||||
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
|
||||
- `sessionOwnership.ts`: Resolves session directories once into shared project/worktree ownership and folder-scope indexes.
|
||||
|
||||
@@ -45,7 +44,6 @@
|
||||
- `hooks/useSessionGrouping.ts`: Builds grouped session structures and search text/filter helpers.
|
||||
- `hooks/useSessionSidebarSections.ts`: Composes final per-project sections and group search metadata for rendering.
|
||||
- `hooks/useProjectSessionSelection.ts`: Resolves active/current project-session selection logic and session-directory context.
|
||||
- `hooks/useGroupOrdering.ts`: Applies persisted/custom group order with stable fallback ordering; archived groups are reorderable.
|
||||
- `hooks/useArchivedAutoFolders.ts`: Maintains archived auto-folder structure and assignment behavior.
|
||||
- `hooks/useSidebarPersistence.ts`: Persists sidebar UI state (expanded/collapsed/pinned/group order/active session) to storage + desktop settings.
|
||||
- `hooks/useProjectRepoStatus.ts`: Tracks per-project git-repo state and root branch metadata.
|
||||
|
||||
@@ -14,8 +14,8 @@ import { cn } from '@/lib/utils';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { SessionFolderItem } from '../SessionFolderItem';
|
||||
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
|
||||
import type { SortableDragHandleProps } from './sortableItems';
|
||||
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
|
||||
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
|
||||
import { isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import { compareSessionsByLifecycleOrder, EMPTY_SESSION_ORDER_RANKS } from '@/sync/session-ordering';
|
||||
@@ -28,11 +28,8 @@ import {
|
||||
selectFolderRootNodes,
|
||||
} from './sessionNodeItemUtils';
|
||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { openExternalUrl } from '@/lib/url';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useChildStoreManager } from '@/sync/sync-context';
|
||||
|
||||
@@ -71,8 +68,6 @@ type Props = {
|
||||
renderContext?: 'project' | 'recent',
|
||||
renderExtras?: SessionNodeRenderExtras,
|
||||
) => React.ReactNode;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
lastRepoStatus: boolean;
|
||||
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number) => void;
|
||||
resetGroupSessionLimit: (groupKey: string) => void;
|
||||
mobileVariant: boolean;
|
||||
@@ -94,29 +89,6 @@ type Props = {
|
||||
editingId: string | null;
|
||||
editTitle: string;
|
||||
openSidebarMenuKey: string | null;
|
||||
prVisualStateByDirectoryBranch: Map<string, {
|
||||
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
|
||||
number: number;
|
||||
url: string | null;
|
||||
state: 'open' | 'closed' | 'merged';
|
||||
draft: boolean;
|
||||
title: string | null;
|
||||
base: string | null;
|
||||
head: string | null;
|
||||
checks: {
|
||||
state: 'success' | 'failure' | 'pending' | 'unknown';
|
||||
total: number;
|
||||
success: number;
|
||||
failure: number;
|
||||
pending: number;
|
||||
} | null;
|
||||
canMerge: boolean | null;
|
||||
mergeableState: string | null;
|
||||
repo: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
} | null;
|
||||
}>;
|
||||
onToggleCollapsedGroup: (groupKey: string) => void;
|
||||
dragHandleProps?: SortableDragHandleProps | null;
|
||||
compactBodyPadding?: boolean;
|
||||
@@ -176,13 +148,6 @@ const groupHasExpansionMembershipChange = (
|
||||
return group.sessions.some(visit);
|
||||
};
|
||||
|
||||
const getProjectRepoStatusValue = (props: Props): boolean | null | undefined => {
|
||||
if (!props.projectId) return undefined;
|
||||
return props.projectRepoStatus.has(props.projectId)
|
||||
? props.projectRepoStatus.get(props.projectId)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
// Bail on Object.is for the props that drive the most work: the group
|
||||
// itself, its key, and the group-level chrome. These change rarely and
|
||||
@@ -200,11 +165,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.projectRepoStatus !== next.projectRepoStatus
|
||||
&& getProjectRepoStatusValue(prev) !== getProjectRepoStatusValue(next)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (prev.pinnedSessionIds !== next.pinnedSessionIds
|
||||
&& groupHasPinnedMembershipChange(next.group, prev.pinnedSessionIds, next.pinnedSessionIds)) {
|
||||
return false;
|
||||
@@ -236,20 +196,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
if (prevMenuSessionId || nextMenuSessionId) return false;
|
||||
}
|
||||
|
||||
// Per-row / per-state props. The PR-visual-state map flips frequently
|
||||
// during bootstrap but a single group's value is usually stable, so we
|
||||
// compare only the value this group actually consumes instead of the
|
||||
// whole map reference.
|
||||
if (prev.prVisualStateByDirectoryBranch !== next.prVisualStateByDirectoryBranch) {
|
||||
const prevVal = prev.group?.directory && prev.group?.branch
|
||||
? prev.prVisualStateByDirectoryBranch.get(`${prev.group.directory}::${prev.group.branch.trim()}`)
|
||||
: undefined;
|
||||
const nextVal = next.group?.directory && next.group?.branch
|
||||
? next.prVisualStateByDirectoryBranch.get(`${next.group.directory}::${next.group.branch.trim()}`)
|
||||
: undefined;
|
||||
if (!Object.is(prevVal, nextVal)) return false;
|
||||
}
|
||||
|
||||
// Other props are typically stable references from the parent. Default
|
||||
// to reference equality (the cheap path) and only re-render when the
|
||||
// parent actually swapped something.
|
||||
@@ -264,7 +210,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
||||
&& prev.showDeletionDialog === next.showDeletionDialog
|
||||
&& prev.setDeleteFolderConfirm === next.setDeleteFolderConfirm
|
||||
&& prev.renderSessionNode === next.renderSessionNode
|
||||
&& prev.lastRepoStatus === next.lastRepoStatus
|
||||
&& prev.showMoreGroupSessions === next.showMoreGroupSessions
|
||||
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit
|
||||
&& prev.mobileVariant === next.mobileVariant
|
||||
@@ -306,8 +251,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
showDeletionDialog,
|
||||
setDeleteFolderConfirm,
|
||||
renderSessionNode,
|
||||
projectRepoStatus,
|
||||
lastRepoStatus,
|
||||
showMoreGroupSessions,
|
||||
resetGroupSessionLimit,
|
||||
mobileVariant,
|
||||
@@ -328,7 +271,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
sessionOrderIndex,
|
||||
editingId,
|
||||
openSidebarMenuKey,
|
||||
prVisualStateByDirectoryBranch,
|
||||
onToggleCollapsedGroup,
|
||||
dragHandleProps,
|
||||
compactBodyPadding = false,
|
||||
@@ -347,11 +289,17 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}, [pinnedSessionIds, sessionOrderIndex]);
|
||||
|
||||
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
||||
// VS Code always uses the expanded layout (see SessionNodeItem).
|
||||
const isMinimalMode = displayMode === 'minimal' && !isVSCodeRuntime();
|
||||
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
|
||||
// PR state for the worktree sub-header (grouped display mode).
|
||||
const groupPrKey = React.useMemo(() => {
|
||||
if (group.isMain || group.isArchivedBucket || hideGroupLabel) return null;
|
||||
const directory = normalizePath(group.directory ?? null);
|
||||
const branch = group.branch?.trim();
|
||||
return directory && branch ? getGitHubPrStatusKey(directory, branch) : null;
|
||||
}, [group.branch, group.directory, group.isArchivedBucket, group.isMain, hideGroupLabel]);
|
||||
const groupPrSummary = usePrVisualSummary(groupPrKey);
|
||||
const groupPrColor = groupPrSummary ? `var(--pr-${groupPrSummary.visualState})` : undefined;
|
||||
const childStores = useChildStoreManager();
|
||||
const bootstrapDirectory = normalizePath(group.directory ?? null);
|
||||
const bootstrapState = React.useSyncExternalStore(
|
||||
@@ -375,9 +323,16 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
[compareSessionNodes, group.sessions, searchData?.filteredNodes, shouldFilterGroupContents],
|
||||
);
|
||||
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
||||
// Merged flat groups list every contributing scope; single-scope groups
|
||||
// (archived buckets, VS Code workspaces) fall back to folderScopeKey.
|
||||
const folderScopes = React.useMemo<Array<{ scopeKey: string; directory: string | null }>>(() => {
|
||||
if (group.folderScopes && group.folderScopes.length > 0) return group.folderScopes;
|
||||
return folderScopeKey ? [{ scopeKey: folderScopeKey, directory: group.directory ?? null }] : [];
|
||||
}, [folderScopeKey, group.directory, group.folderScopes]);
|
||||
const scopeFolders = React.useMemo(
|
||||
() => folderScopeKey ? (foldersMap[folderScopeKey] ?? []) : [],
|
||||
[folderScopeKey, foldersMap]
|
||||
() => folderScopes.flatMap(({ scopeKey, directory }) =>
|
||||
(foldersMap[scopeKey] ?? []).map((folder) => ({ folder, scopeKey, scopeDirectory: directory }))),
|
||||
[folderScopes, foldersMap]
|
||||
);
|
||||
|
||||
const nodeBySessionId = React.useMemo(() => {
|
||||
@@ -394,9 +349,9 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
return map;
|
||||
}, [sourceGroupNodes]);
|
||||
|
||||
const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map((folder) => {
|
||||
const allFoldersForGroupBase = React.useMemo(() => scopeFolders.map(({ folder, scopeKey, scopeDirectory }) => {
|
||||
const nodes = selectFolderRootNodes(folder.sessionIds, nodeBySessionId).sort(compareSessionNodes);
|
||||
return { folder, nodes };
|
||||
return { folder, scopeKey, scopeDirectory, nodes };
|
||||
}), [scopeFolders, nodeBySessionId, compareSessionNodes]);
|
||||
|
||||
const allFoldersForGroup = React.useMemo(() => {
|
||||
@@ -707,87 +662,15 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isGitProject = projectId && projectRepoStatus.has(projectId)
|
||||
? Boolean(projectRepoStatus.get(projectId))
|
||||
: lastRepoStatus;
|
||||
const groupDirectoryKey = normalizePath(group.directory ?? null);
|
||||
const groupBranchKey = group.branch?.trim() ?? null;
|
||||
const prIndicator = groupDirectoryKey && groupBranchKey
|
||||
? (prVisualStateByDirectoryBranch.get(`${groupDirectoryKey}::${groupBranchKey}`) ?? null)
|
||||
const showBranchSubtitle = !group.isMain && Boolean(group.branch);
|
||||
const statusLine = group.branch && isBranchDifferentFromLabel(group.branch, group.label)
|
||||
? { label: group.branch, color: null as string | null }
|
||||
: null;
|
||||
const showInlinePrTitle = Boolean(prIndicator && group.branch);
|
||||
const showBranchSubtitle = !prIndicator && !group.isMain && Boolean(group.branch);
|
||||
const prVisualState = prIndicator?.visualState ?? null;
|
||||
const checksSummary = prIndicator && prIndicator.state === 'open' && prIndicator.checks
|
||||
? t('sessions.sidebar.group.pr.checksPassed', {
|
||||
success: prIndicator.checks.success,
|
||||
total: prIndicator.checks.total,
|
||||
})
|
||||
: null;
|
||||
const checksTail = prIndicator && prIndicator.state === 'open' && prIndicator.checks
|
||||
? [
|
||||
prIndicator.checks.failure > 0
|
||||
? t('sessions.sidebar.group.pr.failingCount', { count: prIndicator.checks.failure })
|
||||
: null,
|
||||
prIndicator.checks.pending > 0
|
||||
? t('sessions.sidebar.group.pr.pendingCount', { count: prIndicator.checks.pending })
|
||||
: null,
|
||||
].filter((item): item is string => Boolean(item)).join(', ')
|
||||
: null;
|
||||
const mergeabilityLabel = prIndicator && prIndicator.state === 'open'
|
||||
? (prIndicator.mergeableState === 'blocked' || prIndicator.mergeableState === 'dirty'
|
||||
? t('sessions.sidebar.group.pr.conflictsOrBlocked')
|
||||
: (prIndicator.mergeableState === 'clean' || prIndicator.canMerge === true ? t('sessions.sidebar.group.pr.mergeable') : null))
|
||||
: null;
|
||||
const mergeStateLabel = prIndicator && prIndicator.state === 'open' && prIndicator.mergeableState
|
||||
? t('sessions.sidebar.group.pr.mergeState', { state: prIndicator.mergeableState })
|
||||
: null;
|
||||
const baseBranchLabel = prIndicator?.base ?? null;
|
||||
const headBranchLabel = prIndicator?.head ?? null;
|
||||
const statusLine = (() => {
|
||||
if (!prIndicator) {
|
||||
return group.branch && isBranchDifferentFromLabel(group.branch, group.label)
|
||||
? { label: group.branch, color: null as string | null }
|
||||
: null;
|
||||
}
|
||||
switch (prIndicator.visualState) {
|
||||
case 'merged':
|
||||
return { label: t('sessions.sidebar.group.pr.status.merged'), color: 'var(--pr-merged)' };
|
||||
case 'open':
|
||||
return (prIndicator.canMerge === true || prIndicator.mergeableState === 'clean' || prIndicator.checks?.state === 'success')
|
||||
? { label: t('sessions.sidebar.group.pr.status.readyToMerge'), color: 'var(--pr-open)' }
|
||||
: { label: t('sessions.sidebar.group.pr.status.open'), color: 'var(--pr-open)' };
|
||||
case 'blocked':
|
||||
return {
|
||||
label: prIndicator.mergeableState === 'dirty'
|
||||
? t('sessions.sidebar.group.pr.status.mergeConflicts')
|
||||
: t('sessions.sidebar.group.pr.status.mergeBlocked'),
|
||||
color: 'var(--pr-blocked)',
|
||||
};
|
||||
case 'draft':
|
||||
return { label: t('sessions.sidebar.group.pr.status.draft'), color: 'var(--pr-draft)' };
|
||||
case 'closed':
|
||||
return { label: t('sessions.sidebar.group.pr.status.closed'), color: 'var(--pr-closed)' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const branchIconColor = statusLine?.color ?? (prVisualState ? `var(--pr-${prVisualState})` : undefined);
|
||||
const handlePrLinkClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const url = prIndicator?.url;
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
void openExternalUrl(url);
|
||||
};
|
||||
|
||||
const renderOneFolderItem = (folder: SessionFolder, nodes: SessionNode[], depth: number): React.ReactNode => {
|
||||
const directSubFolders = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id);
|
||||
const subFolderItems = directSubFolders.length > 0
|
||||
? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}</>
|
||||
: undefined;
|
||||
type FolderEntry = (typeof allFoldersForGroup)[number];
|
||||
|
||||
const renderOneFolderItem = (entry: FolderEntry, displayName: string): React.ReactNode => {
|
||||
const { folder, scopeKey, scopeDirectory, nodes } = entry;
|
||||
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
|
||||
|
||||
return (
|
||||
@@ -795,12 +678,12 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
{(droppableRef, isDropTarget) => (
|
||||
<SessionFolderItem
|
||||
folder={folder}
|
||||
displayName={displayName}
|
||||
sessions={nodes}
|
||||
subFolderItems={subFolderItems}
|
||||
isCollapsed={hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id)}
|
||||
onToggle={() => toggleFolderCollapse(folder.id)}
|
||||
onRename={(name) => {
|
||||
if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name);
|
||||
renameFolder(scopeKey, folder.id, name);
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (group.isArchivedBucket) {
|
||||
@@ -812,15 +695,14 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!folderScopeKey) return;
|
||||
if (!showDeletionDialog) {
|
||||
deleteFolder(folderScopeKey, folder.id);
|
||||
deleteFolder(scopeKey, folder.id);
|
||||
return;
|
||||
}
|
||||
const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length;
|
||||
const sessionCount = nodes.length;
|
||||
setDeleteFolderConfirm({
|
||||
scopeKey: folderScopeKey,
|
||||
scopeKey,
|
||||
folderId: folder.id,
|
||||
folderName: folder.name,
|
||||
subFolderCount,
|
||||
@@ -836,7 +718,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
childRenderExtrasFor,
|
||||
})
|
||||
: undefined}
|
||||
groupDirectory={group.directory}
|
||||
groupDirectory={scopeDirectory ?? group.directory}
|
||||
projectId={projectId}
|
||||
mobileVariant={mobileVariant}
|
||||
alwaysShowActions={alwaysShowActions}
|
||||
@@ -845,8 +727,8 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
onRenameDraftChange={(value) => setRenameFolderDraft(value)}
|
||||
onRenameSave={() => {
|
||||
const trimmed = renameFolderDraft.trim();
|
||||
if (trimmed && folderScopeKey) {
|
||||
renameFolder(folderScopeKey, folder.id, trimmed);
|
||||
if (trimmed) {
|
||||
renameFolder(scopeKey, folder.id, trimmed);
|
||||
}
|
||||
setRenamingFolderId(null);
|
||||
setRenameFolderDraft('');
|
||||
@@ -857,17 +739,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}}
|
||||
droppableRef={droppableRef}
|
||||
isDropTarget={isDropTarget}
|
||||
depth={depth}
|
||||
depth={0}
|
||||
onNewSession={() => {
|
||||
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) setSessionSwitcherOpen(false);
|
||||
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: group.directory, targetFolderId: folder.id });
|
||||
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: scopeDirectory ?? group.directory, targetFolderId: folder.id });
|
||||
}}
|
||||
onNewSubFolder={depth === 0 ? () => {
|
||||
if (!folderScopeKey) return;
|
||||
createFolderAndStartRename(folderScopeKey, folder.id);
|
||||
} : undefined}
|
||||
hideActions={false}
|
||||
archivedBucket={group.isArchivedBucket === true}
|
||||
/>
|
||||
@@ -876,24 +754,55 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
);
|
||||
};
|
||||
|
||||
const renderFolderItems = () => rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0));
|
||||
// Folders render flat: nested folders keep their data-model parent link but
|
||||
// display at the same level with a "Parent / Child" path label, so sessions
|
||||
// never gain extra indentation. Collapsing a folder hides its whole subtree.
|
||||
const renderFolderItems = () => {
|
||||
const childEntriesByParentId = new Map<string, FolderEntry[]>();
|
||||
for (const entry of allFoldersForGroup) {
|
||||
const parentId = entry.folder.parentId;
|
||||
if (!parentId) continue;
|
||||
const existing = childEntriesByParentId.get(parentId);
|
||||
if (existing) existing.push(entry);
|
||||
else childEntriesByParentId.set(parentId, [entry]);
|
||||
}
|
||||
const out: React.ReactNode[] = [];
|
||||
const visit = (entry: FolderEntry, parentPath: string) => {
|
||||
const displayName = parentPath ? `${parentPath} / ${entry.folder.name}` : entry.folder.name;
|
||||
out.push(renderOneFolderItem(entry, displayName));
|
||||
const isFolderCollapsed = !hasSessionSearchQuery && collapsedFolderIds.has(entry.folder.id);
|
||||
if (isFolderCollapsed) return;
|
||||
(childEntriesByParentId.get(entry.folder.id) ?? []).forEach((child) => visit(child, displayName));
|
||||
};
|
||||
rootFolders.forEach((entry) => visit(entry, ''));
|
||||
return out;
|
||||
};
|
||||
// Reserve room for the hover-revealed header actions (new draft + delete
|
||||
// worktree) so they never overlap the label / PR badge.
|
||||
const hasWorktreeDeleteAction = Boolean(!group.isMain && group.worktree);
|
||||
const groupHeaderRightPadding = alwaysShowActions
|
||||
? (hasWorktreeDeleteAction ? 'pr-14' : 'pr-7')
|
||||
: isMinimalMode
|
||||
? (hasWorktreeDeleteAction
|
||||
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
||||
: 'pr-2')
|
||||
: (hasWorktreeDeleteAction
|
||||
? 'pr-5 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
||||
: 'pr-5');
|
||||
: (hasWorktreeDeleteAction
|
||||
? 'pr-2 group-hover/gh:pr-14 group-focus-within/gh:pr-14'
|
||||
: 'pr-2 group-hover/gh:pr-7 group-focus-within/gh:pr-7');
|
||||
|
||||
const body = (
|
||||
<SessionFolderDndScope
|
||||
scopeKey={folderScopeKey}
|
||||
scopeKey={folderScopes[0]?.scopeKey ?? folderScopeKey}
|
||||
hasFolders={allFoldersForGroup.length > 0}
|
||||
onSessionDroppedOnFolder={(sessionId, folderId) => {
|
||||
if (folderScopeKey) addSessionToFolder(folderScopeKey, folderId, sessionId);
|
||||
const targetEntry = allFoldersForGroup.find(({ folder }) => folder.id === folderId);
|
||||
if (!targetEntry) return;
|
||||
// Clear membership in other scopes first — the store only dedupes
|
||||
// within one scope, and a session must live in a single folder.
|
||||
const foldersStore = useSessionFoldersStore.getState();
|
||||
for (const { scopeKey } of folderScopes) {
|
||||
if (scopeKey === targetEntry.scopeKey) continue;
|
||||
if (foldersStore.getSessionFolderId(scopeKey, sessionId)) {
|
||||
foldersStore.removeSessionFromFolder(scopeKey, sessionId);
|
||||
}
|
||||
}
|
||||
addSessionToFolder(targetEntry.scopeKey, folderId, sessionId);
|
||||
}}
|
||||
>
|
||||
{renderFolderItems()}
|
||||
@@ -964,7 +873,9 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
}))
|
||||
)}
|
||||
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
|
||||
<div className="py-1 text-left typography-micro text-muted-foreground">
|
||||
// pl-[26px] lines the text up with the worktree sub-header label
|
||||
// (gutter + icon + gap).
|
||||
<div className="py-1 pl-[26px] text-left typography-micro text-muted-foreground">
|
||||
{group.isArchivedBucket
|
||||
? t('sessions.sidebar.group.empty.noArchivedSessions')
|
||||
: bootstrapState === 'queued' || bootstrapState === 'running'
|
||||
@@ -999,7 +910,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showMoreGroupSessions(groupKey, visibleSessions.length)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{t('sessions.sidebar.group.showMore')}
|
||||
</button>
|
||||
@@ -1008,7 +919,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetGroupSessionLimit(groupKey)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{t('sessions.sidebar.group.showFewer')}
|
||||
</button>
|
||||
@@ -1016,7 +927,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
</SessionFolderDndScope>
|
||||
);
|
||||
|
||||
const groupBodyPaddingClass = compactBodyPadding ? 'pb-2 pl-1' : 'pb-3 pl-4';
|
||||
// Rows own their left gutter (aligned with the zone-header text), so the
|
||||
// group body adds no extra indentation.
|
||||
void compactBodyPadding;
|
||||
// Folder nesting is legacy-only: existing sub-folders keep working (path
|
||||
// labels), but the UI no longer offers creating new ones.
|
||||
void createFolderAndStartRename;
|
||||
const groupBodyPaddingClass = 'pb-2';
|
||||
|
||||
if (hideGroupLabel) {
|
||||
return <div className="oc-group"><div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div></div>;
|
||||
@@ -1043,74 +960,16 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
<div
|
||||
ref={dragHandleProps?.setActivatorNodeRef}
|
||||
className={cn(
|
||||
'min-w-0 flex flex-1 items-start gap-1 overflow-hidden pl-0.5 transition-[padding] cursor-grab active:cursor-grabbing',
|
||||
// pl-1.5 lines the branch icon up with the project-zone header
|
||||
// icon (container pl-2.5 + 6px = band pl-4 past its -ml-2.5).
|
||||
'min-w-0 flex flex-1 items-start gap-1 overflow-hidden pl-1.5 transition-[padding]',
|
||||
groupHeaderRightPadding,
|
||||
)}
|
||||
{...(dragHandleProps?.listeners ?? {})}
|
||||
>
|
||||
<div className="min-w-0 flex flex-1 flex-col justify-center gap-0.5 overflow-hidden">
|
||||
<p className="text-[14px] font-normal truncate text-foreground/92">
|
||||
{showInlinePrTitle && prIndicator ? (
|
||||
<span className="inline-flex min-w-0 max-w-full items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0 items-center gap-1 leading-none align-middle">
|
||||
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<Icon name="git-branch"
|
||||
className={cn('h-3.5 w-3.5 shrink-0', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')}
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-muted-foreground h-3.5 w-3.5 items-center justify-center',
|
||||
alwaysShowActions ? 'inline-flex' : 'hidden group-hover/gh:inline-flex',
|
||||
)}>
|
||||
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
</span>
|
||||
{prIndicator.url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center leading-none"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={handlePrLinkClick}
|
||||
>
|
||||
#{prIndicator.number}
|
||||
</button>
|
||||
) : (
|
||||
<span className="inline-flex shrink-0 items-center leading-none">#{prIndicator.number}</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
|
||||
<div className="space-y-1 text-xs">
|
||||
{(baseBranchLabel || headBranchLabel) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{baseBranchLabel && headBranchLabel ? (
|
||||
<>
|
||||
<span>{baseBranchLabel}</span>
|
||||
<Icon name="arrow-left-long" className="mx-0.5 inline h-3 w-3 align-[-2px]" />
|
||||
<span>{headBranchLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
|
||||
{(mergeabilityLabel || checksSummary) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{mergeabilityLabel ?? ''}
|
||||
{mergeabilityLabel && checksSummary ? ' • ' : ''}
|
||||
{checksSummary ?? ''}
|
||||
{checksTail ? ` (${checksTail})` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="ml-1 min-w-0 flex-1 truncate leading-none align-middle">{group.branch}</span>
|
||||
</span>
|
||||
) : group.isArchivedBucket ? (
|
||||
{group.isArchivedBucket ? (
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
|
||||
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<Icon name="archive" className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')} />
|
||||
@@ -1124,11 +983,13 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
|
||||
</span>
|
||||
) : (!group.isMain || group.worktree) ? (
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1">
|
||||
// Worktree sub-header in the flat visual language: slim
|
||||
// folder-style row with a PR-tinted branch icon and PR badge.
|
||||
<span className="inline-flex min-w-0 max-w-full items-center gap-1.5">
|
||||
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
<Icon name="git-branch"
|
||||
className={cn('h-3.5 w-3.5 shrink-0 text-muted-foreground', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')}
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}
|
||||
className={cn('h-3.5 w-3.5 shrink-0', !groupPrColor && 'text-muted-foreground', alwaysShowActions ? 'hidden' : 'group-hover/gh:hidden')}
|
||||
style={groupPrColor ? { color: groupPrColor } : undefined}
|
||||
/>
|
||||
<span className={cn(
|
||||
'text-muted-foreground h-3.5 w-3.5 items-center justify-center',
|
||||
@@ -1137,7 +998,17 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{renderHighlightedText(group.label, normalizedSessionSearchQuery)}</span>
|
||||
<span className="min-w-0 truncate typography-ui-label font-semibold text-muted-foreground">
|
||||
{renderHighlightedText(group.label, normalizedSessionSearchQuery)}
|
||||
</span>
|
||||
{groupPrSummary ? (
|
||||
<span
|
||||
className="flex-shrink-0 text-[0.72rem] font-medium leading-none"
|
||||
style={groupPrColor ? { color: groupPrColor } : undefined}
|
||||
>
|
||||
#{groupPrSummary.number}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
renderHighlightedText(group.label, normalizedSessionSearchQuery)
|
||||
@@ -1147,51 +1018,10 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 leading-tight">
|
||||
{group.isArchivedBucket ? (
|
||||
<Icon name="archive" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (!group.isMain || isGitProject) ? (
|
||||
showInlinePrTitle && prIndicator ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
<Icon name="git-branch" className="h-3.5 w-3.5 text-muted-foreground"
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
|
||||
<div className="space-y-1 text-xs">
|
||||
{(baseBranchLabel || headBranchLabel) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{baseBranchLabel && headBranchLabel ? (
|
||||
<>
|
||||
<span>{baseBranchLabel}</span>
|
||||
<Icon name="arrow-left-long" className="mx-0.5 inline h-3 w-3 align-[-2px]" />
|
||||
<span>{headBranchLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
|
||||
{(mergeabilityLabel || checksSummary) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{mergeabilityLabel ?? ''}
|
||||
{mergeabilityLabel && checksSummary ? ' • ' : ''}
|
||||
{checksSummary ?? ''}
|
||||
{checksTail ? ` (${checksTail})` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Icon name="git-branch" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground"
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}/>
|
||||
)
|
||||
) : null}
|
||||
<span
|
||||
className={cn('min-w-0 truncate text-[11px] font-medium', !statusLine.color && 'text-muted-foreground/80')}
|
||||
style={statusLine.color ? { color: statusLine.color } : undefined}
|
||||
>
|
||||
) : (
|
||||
<Icon name="git-branch" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate text-[11px] font-medium text-muted-foreground/80">
|
||||
{statusLine.label}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { dropdownMenuItemClass, dropdownMenuPopupClass, dropdownMenuSeparatorClass, dropdownMenuSubTriggerClass } from '@/components/ui/dropdown-menu.styles';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, formatDirectoryName } from '@/lib/utils';
|
||||
import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -29,8 +29,10 @@ import { DraggableSessionRow } from './sessionFolderDnd';
|
||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange } from './sessionNodeItemUtils';
|
||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||
import type { SessionNode } from './types';
|
||||
import { formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
@@ -129,6 +131,14 @@ type Props = {
|
||||
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
|
||||
};
|
||||
|
||||
// Shared row geometry: the gutter edge matches the zone-header band padding
|
||||
// (px-1.5 = 6px), the marker slot is icon-wide (14px) with a 6px gap, so row
|
||||
// text starts exactly where the zone-header label starts. Nested children
|
||||
// shift by one gutter step per depth level.
|
||||
const ROW_GUTTER_LEFT_PX = 6;
|
||||
const ROW_DEPTH_STEP_PX = 14;
|
||||
const ROW_TEXT_LEFT_PX = ROW_GUTTER_LEFT_PX + 14 + 6;
|
||||
|
||||
const cancelScrollAnchorByContainer = new WeakMap<HTMLElement, () => void>();
|
||||
|
||||
const holdSessionRowPosition = (target: HTMLElement): void => {
|
||||
@@ -284,15 +294,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
menuOpenSessionId,
|
||||
childRenderExtrasFor,
|
||||
} = props;
|
||||
const hasSecondaryProjectLabel = Boolean(secondaryMeta?.projectLabel);
|
||||
const hasSecondaryBranchLabel = Boolean(secondaryMeta?.branchLabel);
|
||||
|
||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
// VS Code always uses the minimal (single-line) layout: sessions are grouped
|
||||
// under workspace project headers, so the second metadata row (project/branch)
|
||||
// is redundant. The display-mode toggle is hidden there, so force it on.
|
||||
const isMinimalMode = displayMode === 'minimal' || isVSCode;
|
||||
const isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []);
|
||||
const runtimeApis = React.useContext(RuntimeAPIContext);
|
||||
const revealOnHoverClass = isVSCode
|
||||
@@ -303,25 +306,22 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
: 'group-hover:opacity-0 group-focus-within:opacity-0';
|
||||
const showOpenInEditorAction = isVSCode;
|
||||
const showQuickArchiveAction = !archivedBucket && !mobileVariant;
|
||||
const revealPaddingClass = isMinimalMode
|
||||
? (isVSCode
|
||||
// VS Code minimal rows reveal up to three actions on hover
|
||||
// (open-in-editor + quick-archive + menu, each h-4). The date sits in the
|
||||
// row flow, so the title must shrink enough to clear the actions or they
|
||||
// overlap the timestamp. Open-in-editor is always present in VS Code.
|
||||
? (showQuickArchiveAction && showOpenInEditorAction
|
||||
? 'group-hover:pr-18'
|
||||
: showQuickArchiveAction || showOpenInEditorAction
|
||||
? 'group-hover:pr-14'
|
||||
: 'group-hover:pr-8')
|
||||
: 'group-hover:pr-2 group-focus-within:pr-2')
|
||||
: (isVSCode
|
||||
? (showQuickArchiveAction && showOpenInEditorAction
|
||||
? 'group-hover:pr-18'
|
||||
: showQuickArchiveAction || showOpenInEditorAction
|
||||
? 'group-hover:pr-12'
|
||||
: 'group-hover:pr-5')
|
||||
: (showQuickArchiveAction ? 'group-hover:pr-12 group-focus-within:pr-12' : 'group-hover:pr-5 group-focus-within:pr-5'));
|
||||
const revealPaddingClass = isVSCode
|
||||
// VS Code rows reveal up to three actions on hover
|
||||
// (open-in-editor + quick-archive + menu, each h-4). The date sits in the
|
||||
// row flow, so the title must shrink enough to clear the actions or they
|
||||
// overlap the timestamp. Open-in-editor is always present in VS Code.
|
||||
? (showQuickArchiveAction && showOpenInEditorAction
|
||||
? 'group-hover:pr-18'
|
||||
: showQuickArchiveAction || showOpenInEditorAction
|
||||
? 'group-hover:pr-14'
|
||||
: 'group-hover:pr-8')
|
||||
// Reserve just enough room for the hover-revealed actions (two 16px
|
||||
// buttons + gap, anchored at the row edge past the title's own end) so
|
||||
// they never overlap the title without leaving a large hole.
|
||||
: (showQuickArchiveAction
|
||||
? 'group-hover:pr-7 group-focus-within:pr-7'
|
||||
: 'group-hover:pr-3 group-focus-within:pr-3');
|
||||
const alwaysActionPaddingClass = showQuickArchiveAction ? 'pr-13' : 'pr-7';
|
||||
const suppressNextSelectRef = React.useRef(false);
|
||||
const [isTouchPressed, setIsTouchPressed] = React.useState(false);
|
||||
@@ -338,11 +338,63 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
|
||||
const session = node.session;
|
||||
const resolvedSession = session;
|
||||
// Tooltip context: recent rows receive project/branch via secondaryMeta;
|
||||
// project rows resolve them from the row's own props/node instead.
|
||||
const projectLabelFromStore = useProjectsStore(
|
||||
React.useCallback((state) => {
|
||||
if (secondaryMeta?.projectLabel || !projectId) return null;
|
||||
const project = state.projects.find((entry) => entry.id === projectId);
|
||||
if (!project) return null;
|
||||
return project.label?.trim() || formatDirectoryName(normalizePath(project.path) ?? project.path, null) || project.path;
|
||||
}, [projectId, secondaryMeta?.projectLabel]),
|
||||
);
|
||||
const tooltipProjectLabel = secondaryMeta?.projectLabel
|
||||
?? (projectLabelFromStore ? formatProjectLabel(projectLabelFromStore) : null);
|
||||
const tooltipBranchLabel = secondaryMeta?.branchLabel ?? node.worktree?.branch ?? null;
|
||||
const prLookupKey = React.useMemo(() => {
|
||||
if (isVSCode) return null;
|
||||
const branch = node.worktree?.branch?.trim();
|
||||
const directory = normalizePath(node.worktree?.path ?? null);
|
||||
return branch && directory ? getGitHubPrStatusKey(directory, branch) : null;
|
||||
}, [isVSCode, node.worktree]);
|
||||
const prSummary = usePrVisualSummary(prLookupKey);
|
||||
const prIconColor = prSummary ? `var(--pr-${prSummary.visualState})` : undefined;
|
||||
const sessionGroupingMode = useSessionDisplayStore((state) => state.sessionGroupingMode);
|
||||
// In by-worktree grouping the project tree already shows the branch on the
|
||||
// group sub-header, so the per-row marker only appears in flat mode and in
|
||||
// the mixed-context recent list.
|
||||
const showInlineBranchMarker = Boolean(tooltipBranchLabel)
|
||||
&& (renderContext === 'recent' || sessionGroupingMode === 'flat');
|
||||
const prStatusLabel = React.useMemo(() => {
|
||||
if (!prSummary) return null;
|
||||
switch (prSummary.visualState) {
|
||||
case 'merged':
|
||||
return t('sessions.sidebar.group.pr.status.merged');
|
||||
case 'open':
|
||||
return (prSummary.canMerge === true || prSummary.mergeableState === 'clean' || prSummary.checks?.state === 'success')
|
||||
? t('sessions.sidebar.group.pr.status.readyToMerge')
|
||||
: t('sessions.sidebar.group.pr.status.open');
|
||||
case 'blocked':
|
||||
return prSummary.mergeableState === 'dirty'
|
||||
? t('sessions.sidebar.group.pr.status.mergeConflicts')
|
||||
: t('sessions.sidebar.group.pr.status.mergeBlocked');
|
||||
case 'draft':
|
||||
return t('sessions.sidebar.group.pr.status.draft');
|
||||
case 'closed':
|
||||
return t('sessions.sidebar.group.pr.status.closed');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [prSummary, t]);
|
||||
const isActive = useSessionUIStore((state) => state.currentSessionId === session.id);
|
||||
|
||||
const sessionDirectory =
|
||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(groupDirectory ?? null);
|
||||
// Multi-select scope: sessions are flat per project, so selection groups by
|
||||
// project (falling back to the directory when no project is known) — a
|
||||
// selection must survive mixing sessions from different worktrees.
|
||||
const selectionScopeKey = projectId ?? sessionDirectory ?? null;
|
||||
// Directory bootstrap is scheduled once at sidebar level. A row only needs
|
||||
// the lightweight store reference for scoped state and export actions.
|
||||
const directoryStore = useDirectoryStore(sessionDirectory ?? undefined, { bootstrap: false });
|
||||
@@ -418,8 +470,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const isSessionMenuOpen = isMenuOpen || isContextMenuOpen;
|
||||
const isMultiRunLikeSession = React.useMemo(() => parseMultiRunSessionTitle(resolvedSession.title) !== null, [resolvedSession.title]);
|
||||
const [fusionDialogOpen, setFusionDialogOpen] = React.useState(false);
|
||||
const metadataSubsessionChevron = isVSCode && renderContext === 'recent' && !isMinimalMode;
|
||||
const inlineSubsessionChevron = isVSCode && renderContext === 'recent' && isMinimalMode;
|
||||
|
||||
const descendantCount = React.useMemo(() => collectNodeDescendantIds(node).length, [collectNodeDescendantIds, node]);
|
||||
|
||||
@@ -526,7 +576,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
React.useEffect(() => {
|
||||
if (editingId !== session.id) return;
|
||||
const handleDocMouseDown = (e: MouseEvent) => {
|
||||
if (formRef.current && !formRef.current.contains(e.target as Node)) {
|
||||
// The same session can be rendered twice (recent + project), each with
|
||||
// its own rename form. A click inside ANY rename form for this session
|
||||
// must not count as "outside", or the sibling instance would save and
|
||||
// exit the rename mid-edit.
|
||||
const target = e.target as HTMLElement | null;
|
||||
const withinRenameForm = target?.closest?.(`[data-session-rename-form="${CSS.escape(session.id)}"]`);
|
||||
if (formRef.current && !withinRenameForm) {
|
||||
handleSaveEditRef.current(renameDraftRef.current);
|
||||
}
|
||||
};
|
||||
@@ -550,11 +606,15 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn('group relative flex items-center rounded-sm px-1.5 py-1', depth > 0 && 'pl-[20px]')}
|
||||
style={{ paddingLeft: ROW_TEXT_LEFT_PX + depth * ROW_DEPTH_STEP_PX }}
|
||||
// my-0.5 matches the normal row box so entering rename mode does not
|
||||
// shift the row vertically.
|
||||
className="group relative my-0.5 flex items-center rounded-sm py-1 pr-1.5"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0">
|
||||
<form
|
||||
ref={formRef}
|
||||
data-session-rename-form={session.id}
|
||||
className="flex w-full items-center gap-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
@@ -593,16 +653,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<Icon name="close" className="size-4" />
|
||||
</button>
|
||||
</form>
|
||||
{!isMinimalMode ? (
|
||||
<div className="flex items-center justify-between gap-3 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
|
||||
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
{hasChildren ? <span className="inline-flex items-center justify-center flex-shrink-0">{isExpanded ? <Icon name="arrow-down-s" className="h-3 w-3" /> : <Icon name="arrow-right-s" className="h-3 w-3" />}</span> : null}
|
||||
<span className="flex-shrink-0">{sessionUpdatedLabel}</span>
|
||||
{hasSecondaryProjectLabel ? <span className="truncate">{secondaryMeta?.projectLabel}</span> : null}
|
||||
{hasSecondaryBranchLabel ? <span className="inline-flex min-w-0 items-center gap-0.5"><Icon name="git-branch" className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" /><span className="truncate">{secondaryMeta?.branchLabel}</span></span> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -615,10 +665,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const showStatusMarker = isStreaming || showUnreadStatus;
|
||||
const statusMarkerContent = isStreaming
|
||||
? (
|
||||
<span
|
||||
className="h-1.5 w-1.5 rounded-full bg-primary animate-busy-pulse"
|
||||
<Icon
|
||||
name="loader-4"
|
||||
className="h-3 w-3 animate-spin text-primary"
|
||||
aria-label={t('sessions.sidebar.session.status.active')}
|
||||
title={t('sessions.sidebar.session.status.active')}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
@@ -639,9 +689,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
);
|
||||
const leadingIndicators = isMovingToWorktree || showStatusMarker || showPinnedMarker ? (
|
||||
<span
|
||||
style={{ left: ROW_GUTTER_LEFT_PX + depth * ROW_DEPTH_STEP_PX }}
|
||||
className={cn(
|
||||
'pointer-events-none absolute left-0.5 inline-flex h-3.5 w-3.5 items-center justify-center transition-opacity',
|
||||
isMinimalMode ? 'top-1/2 -translate-y-1/2' : 'top-[14.5px] -translate-y-1/2',
|
||||
'pointer-events-none absolute top-1/2 inline-flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center transition-opacity',
|
||||
hideLeadingIndicatorOnHover ? 'opacity-100 group-hover:opacity-0 group-focus-within:opacity-0' : '',
|
||||
)}
|
||||
>
|
||||
@@ -661,6 +711,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
tabIndex={0}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
// Blur mouse-click focus so the hover-only chevron/indicator swap
|
||||
// resets on mouse-leave instead of sticking via :focus-within.
|
||||
event.currentTarget.blur();
|
||||
toggleParent(expansionKey);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
@@ -670,15 +723,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
toggleParent(expansionKey);
|
||||
}
|
||||
}}
|
||||
style={{ minWidth: 14, minHeight: 14 }}
|
||||
style={{ minWidth: 14, minHeight: 14, left: ROW_GUTTER_LEFT_PX + depth * ROW_DEPTH_STEP_PX }}
|
||||
className={cn(
|
||||
'inline-flex h-3.5 w-3.5 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||
metadataSubsessionChevron
|
||||
? 'absolute left-1.5 bottom-1'
|
||||
: inlineSubsessionChevron
|
||||
? 'relative mr-0.5 shrink-0'
|
||||
: cn('absolute left-0.5', isMinimalMode ? 'top-1/2 -translate-y-1/2' : 'top-[14.5px] -translate-y-1/2'),
|
||||
!metadataSubsessionChevron && !inlineSubsessionChevron && hideChevronUntilHover
|
||||
'absolute top-1/2 inline-flex h-3.5 w-3.5 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||
hideChevronUntilHover
|
||||
? 'opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto'
|
||||
: '',
|
||||
)}
|
||||
@@ -788,10 +836,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
const currentAnchor = useSessionMultiSelectStore.getState().anchorId;
|
||||
const descendantsById = new Map<string, string[]>();
|
||||
descendantsById.set(session.id, collectNodeDescendantIds(node));
|
||||
setRowRange(currentAnchor, session.id, orderedIds, sessionDirectory ?? null, descendantsById);
|
||||
setRowRange(currentAnchor, session.id, orderedIds, selectionScopeKey, descendantsById);
|
||||
return;
|
||||
}
|
||||
toggleRowSelected(session.id, sessionDirectory ?? null, collectNodeDescendantIds(node));
|
||||
toggleRowSelected(session.id, selectionScopeKey, collectNodeDescendantIds(node));
|
||||
return;
|
||||
}
|
||||
if (event?.currentTarget) holdSessionRowPosition(event.currentTarget);
|
||||
@@ -918,31 +966,72 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
) : null}
|
||||
|
||||
{sessionDirectory && !archivedBucket ? (() => {
|
||||
const scopeFolders = getFoldersForScope(sessionDirectory);
|
||||
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
|
||||
// Folders are flat per project: list folders from every scope of the
|
||||
// owning project (root + all worktrees) so sessions can be filed
|
||||
// across worktrees. Each action targets the folder's owning scope,
|
||||
// and moving between scopes clears the previous membership first.
|
||||
const scopes: string[] = [];
|
||||
const pushScope = (candidate: string | null | undefined) => {
|
||||
const normalized = normalizePath(candidate ?? null);
|
||||
if (normalized && !scopes.includes(normalized)) scopes.push(normalized);
|
||||
};
|
||||
if (projectId && !isVSCode) {
|
||||
const project = useProjectsStore.getState().projects.find((entry) => entry.id === projectId);
|
||||
const projectRoot = normalizePath(project?.path ?? null);
|
||||
pushScope(projectRoot);
|
||||
if (projectRoot) {
|
||||
(useSessionUIStore.getState().availableWorktreesByProject.get(projectRoot) ?? [])
|
||||
.forEach((worktree) => pushScope(worktree.path));
|
||||
}
|
||||
}
|
||||
pushScope(sessionDirectory);
|
||||
const folderEntries = scopes.flatMap((scope) =>
|
||||
getFoldersForScope(scope).map((folder) => ({ scope, folder })));
|
||||
const currentEntry = folderEntries.find(({ scope, folder }) =>
|
||||
getSessionFolderId(scope, session.id) === folder.id) ?? null;
|
||||
const defaultScope = scopes[0] ?? sessionDirectory;
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<Sub>
|
||||
<SubTrigger className="[&>svg]:mr-1"><Icon name="folder" className="h-4 w-4" />{t('sessions.sidebar.folders.moveToFolder')}</SubTrigger>
|
||||
<SubContent className="min-w-[180px]">
|
||||
{scopeFolders.length === 0 ? (
|
||||
{folderEntries.length === 0 ? (
|
||||
<Item disabled className="text-muted-foreground">{t('sessions.sidebar.folders.none')}</Item>
|
||||
) : (
|
||||
scopeFolders.map((folder) => (
|
||||
<Item key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
{currentFolderId === folder.id ? <Icon name="check" className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
|
||||
</Item>
|
||||
))
|
||||
folderEntries.map(({ scope, folder }) => {
|
||||
const isCurrent = currentEntry?.folder.id === folder.id;
|
||||
return (
|
||||
<Item key={folder.id} onClick={() => {
|
||||
if (isCurrent) {
|
||||
removeSessionFromFolder(scope, session.id);
|
||||
return;
|
||||
}
|
||||
if (currentEntry && currentEntry.scope !== scope) {
|
||||
removeSessionFromFolder(currentEntry.scope, session.id);
|
||||
}
|
||||
addSessionToFolder(scope, folder.id, session.id);
|
||||
}}>
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
{isCurrent ? <Icon name="check" className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
|
||||
</Item>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<Separator />
|
||||
<Item onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
|
||||
<Item onClick={() => {
|
||||
const newFolder = createFolderAndStartRename(defaultScope);
|
||||
if (!newFolder) return;
|
||||
if (currentEntry && currentEntry.scope !== defaultScope) {
|
||||
removeSessionFromFolder(currentEntry.scope, session.id);
|
||||
}
|
||||
addSessionToFolder(defaultScope, newFolder.id, session.id);
|
||||
}}>
|
||||
<Icon name="add" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.folders.newFolderEllipsis')}
|
||||
</Item>
|
||||
{currentFolderId ? (
|
||||
<Item onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
|
||||
{currentEntry ? (
|
||||
<Item onClick={() => { removeSessionFromFolder(currentEntry.scope, session.id); }} className="text-destructive focus:text-destructive">
|
||||
<Icon name="close" className="mr-1 h-4 w-4" />
|
||||
{t('sessions.sidebar.folders.removeFromFolder')}
|
||||
</Item>
|
||||
@@ -1067,17 +1156,16 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
render={
|
||||
<div
|
||||
data-session-row={session.id}
|
||||
data-session-scope={sessionDirectory ?? ''}
|
||||
data-session-scope={selectionScopeKey ?? ''}
|
||||
data-session-archived={archivedBucket ? '1' : '0'}
|
||||
onClick={handleRowBackgroundClick}
|
||||
// Row geometry mirrors the zone-header band: full container
|
||||
// width, px-1.5 inner edge, a 14px icon-wide gutter (status
|
||||
// marker / chevron) plus a 6px gap, so the title starts at the
|
||||
// same x as the header text. Children indent one gutter step.
|
||||
style={{ paddingLeft: ROW_TEXT_LEFT_PX + depth * ROW_DEPTH_STEP_PX }}
|
||||
className={cn(
|
||||
'group relative my-0.5 flex cursor-pointer items-center rounded-md py-1 pr-1.5',
|
||||
// Pull the row box left into the container gutter so the
|
||||
// selection highlight covers the chevron/status markers
|
||||
// (which sit in that gutter), then re-pad so the title text
|
||||
// stays put.
|
||||
'-ml-3',
|
||||
depth > 0 ? 'pl-[32px]' : 'pl-[18px]',
|
||||
// Active (currently open) session gets a subtle primary tint;
|
||||
// multi-select highlight takes precedence when both apply.
|
||||
isActive && !isRowSelected && 'bg-primary/10',
|
||||
@@ -1089,7 +1177,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
{leadingIndicators}
|
||||
{subsessionChevron}
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
{isMinimalMode ? (
|
||||
{(
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -1111,24 +1199,41 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
: revealPaddingClass,
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
|
||||
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||
<div className="flex w-full items-center min-w-0 flex-1 gap-1 overflow-hidden">
|
||||
{/* Unread emphasis is color-only: a font-weight change
|
||||
would reflow the truncated title and cause a micro
|
||||
horizontal shift when the status flips. */}
|
||||
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : needsAttention ? 'text-foreground' : 'text-foreground/80')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||
{alwaysShowActions ? (
|
||||
// Touch runtimes have no hover tooltip, so the compact
|
||||
// date stays inline there.
|
||||
<span className="ml-2 inline-flex flex-shrink-0 items-center gap-1 text-[0.72rem] text-muted-foreground/75">
|
||||
{sessionGoalGlyph}
|
||||
{showInlineBranchMarker ? (
|
||||
<Icon
|
||||
name="git-branch"
|
||||
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
|
||||
style={prIconColor ? { color: prIconColor } : undefined}
|
||||
/>
|
||||
) : null}
|
||||
{sessionCompactUpdatedLabel}
|
||||
</span>
|
||||
) : null}
|
||||
{!alwaysShowActions ? (
|
||||
<div className="relative ml-1 flex h-4 min-w-4 flex-shrink-0 items-center justify-end">
|
||||
) : (sessionGoalGlyph || showInlineBranchMarker) ? (
|
||||
<div className="relative ml-1 flex h-4 flex-shrink-0 items-center justify-end">
|
||||
<span className={cn(
|
||||
'inline-flex items-center gap-1 whitespace-nowrap text-right text-[0.72rem] text-muted-foreground/75 transition-opacity duration-150',
|
||||
'inline-flex items-center gap-1 whitespace-nowrap text-right transition-opacity duration-150',
|
||||
isSessionMenuOpen
|
||||
? 'opacity-0'
|
||||
: hideOnHoverClass,
|
||||
)}>
|
||||
{sessionGoalGlyph}
|
||||
{sessionCompactUpdatedLabel}
|
||||
{showInlineBranchMarker ? (
|
||||
<Icon
|
||||
name="git-branch"
|
||||
className={cn('h-3 w-3', !prIconColor && 'text-muted-foreground/60')}
|
||||
style={prIconColor ? { color: prIconColor } : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1145,68 +1250,40 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
the per-row metadata tooltip is redundant noise there. */}
|
||||
{!isVSCode ? (
|
||||
<TooltipContent side="right" sideOffset={8} className="max-w-xs text-left">
|
||||
<div className="flex flex-col gap-1 text-left text-xs">
|
||||
<div className={cn('flex items-center gap-3 text-left text-muted-foreground', secondaryMeta?.projectLabel ? 'justify-between' : 'justify-start')}>
|
||||
{secondaryMeta?.projectLabel ? <div className="min-w-0 truncate">{secondaryMeta.projectLabel}</div> : null}
|
||||
<div className="flex-shrink-0">{sessionUpdatedLabel}</div>
|
||||
<div className="flex min-w-44 flex-col gap-1.5 text-left text-xs">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0 truncate font-medium text-foreground">{sessionTitle}</span>
|
||||
<span className="flex-shrink-0 text-muted-foreground" title={sessionUpdatedLabel}>{sessionCompactUpdatedLabel}</span>
|
||||
</div>
|
||||
{secondaryMeta?.branchLabel ? (
|
||||
<div className="flex items-center gap-3 text-left text-muted-foreground justify-start">
|
||||
<div className="flex min-w-0 items-center gap-1.5 overflow-hidden">
|
||||
<span className="inline-flex min-w-0 items-center gap-0.5"><Icon name="git-branch" className="h-3 w-3 flex-shrink-0" /><span className="truncate">{secondaryMeta.branchLabel}</span></span>
|
||||
</div>
|
||||
{tooltipProjectLabel ? (
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
|
||||
<Icon name="folder" className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="min-w-0 truncate">{tooltipProjectLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{tooltipBranchLabel ? (
|
||||
<div className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
|
||||
<Icon name="git-branch" className="h-3 w-3 flex-shrink-0" style={prIconColor ? { color: prIconColor } : undefined} />
|
||||
<span className="min-w-0 truncate">{tooltipBranchLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{prSummary && prStatusLabel ? (
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Icon name="git-pull-request" className="h-3 w-3 flex-shrink-0" style={prIconColor ? { color: prIconColor } : undefined} />
|
||||
<span className="min-w-0 truncate" style={prIconColor ? { color: prIconColor } : undefined}>
|
||||
#{prSummary.number} · {prStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={handleRowPointerDown}
|
||||
onPointerUp={handleRowPointerEnd}
|
||||
onPointerCancel={handleRowPointerEnd}
|
||||
onMouseDown={handleRowMouseDown}
|
||||
onClick={(event) => handleRowSelect(event)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSessionDoubleClick(session.id, sessionTitle);
|
||||
}}
|
||||
className={cn(
|
||||
'flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none transition-[padding]',
|
||||
isTouchPressed && 'bg-interactive-hover/70',
|
||||
alwaysShowActions
|
||||
? (isVSCode ? revealPaddingClass : alwaysActionPaddingClass)
|
||||
: revealPaddingClass
|
||||
)}
|
||||
>
|
||||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-1')}>
|
||||
<div className={cn('block min-w-0 flex-1 truncate typography-ui-label font-normal', isActive ? 'text-primary' : 'text-foreground')}>{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title={t('sessions.sidebar.session.status.permissionRequired')} aria-label={t('sessions.sidebar.session.status.permissionRequired')}>
|
||||
<Icon name="shield" className="h-3 w-3" />
|
||||
<span className="leading-none">{pendingPermissionCount}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isMinimalMode ? (
|
||||
<div className="flex items-center justify-between gap-3 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
|
||||
<div className={cn('flex min-w-0 items-center gap-1.5 overflow-hidden', metadataSubsessionChevron && hasChildren ? 'pl-4' : '')}>
|
||||
{sessionGoalGlyph}
|
||||
<span className="flex-shrink-0">{sessionUpdatedLabel}</span>
|
||||
{hasSecondaryProjectLabel ? <span className="truncate">{secondaryMeta?.projectLabel}</span> : null}
|
||||
{hasSecondaryBranchLabel ? <span className="inline-flex min-w-0 items-center gap-0.5"><Icon name="git-branch" className="h-3 w-3 flex-shrink-0 text-muted-foreground/70" /><span className="truncate">{secondaryMeta?.branchLabel}</span></span> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{streamingIndicator && !mobileVariant ? (
|
||||
<div className={cn('absolute top-1/2 -translate-y-1/2 z-10', isMinimalMode ? 'right-0' : 'right-[30px]')}>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 z-10">
|
||||
{streamingIndicator}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1223,8 +1300,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
<QuickSessionAction
|
||||
archiveLabel={t('sessions.sidebar.bulkActions.archive')}
|
||||
deleteLabel={t('sessions.sidebar.bulkActions.delete')}
|
||||
buttonSizeClass={isMinimalMode && !alwaysShowActions ? 'h-4 w-4' : 'h-6 w-6'}
|
||||
iconSizeClass={isMinimalMode && !alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5'}
|
||||
buttonSizeClass={!alwaysShowActions ? 'h-4 w-4' : 'h-6 w-6'}
|
||||
iconSizeClass={!alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5'}
|
||||
onPointerDown={handleQuickArchivePointerDown}
|
||||
onMouseDown={handleQuickArchiveMouseDown}
|
||||
onArchive={handleQuickArchiveClick}
|
||||
@@ -1238,7 +1315,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||
isMinimalMode && !alwaysShowActions ? 'h-4 w-4' : 'h-6 w-6',
|
||||
!alwaysShowActions ? 'h-4 w-4' : 'h-6 w-6',
|
||||
)}
|
||||
aria-label={t('sessions.sidebar.session.actions.openInEditor')}
|
||||
onPointerDown={handleOpenInEditorPointerDown}
|
||||
@@ -1246,7 +1323,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
onClick={handleOpenInEditorClick}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Icon name="external-link" className={cn(isMinimalMode && !alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
||||
<Icon name="external-link" className={cn(!alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" sideOffset={8}>
|
||||
@@ -1260,7 +1337,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 transition-opacity',
|
||||
isMinimalMode && !alwaysShowActions
|
||||
!alwaysShowActions
|
||||
? (isSessionMenuOpen
|
||||
? 'h-4 w-4 opacity-100'
|
||||
: cn('h-4 w-4 opacity-0', revealOnHoverClass))
|
||||
@@ -1272,7 +1349,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
||||
onClick={handleMenuTriggerClick}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Icon name="more-2" className={cn(isMinimalMode && !alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
||||
<Icon name="more-2" className={cn(!alwaysShowActions ? 'h-2.5 w-2.5' : 'h-3.5 w-3.5')} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
{sessionMenuContent}
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SessionNode } from './types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import {
|
||||
collectSubtreeContainingId,
|
||||
@@ -61,6 +62,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
||||
const flatVariant = variant === 'flat';
|
||||
@@ -132,7 +134,9 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(flatVariant ? 'space-y-0.5 pb-2' : 'space-y-2 pb-2 pt-1')}>
|
||||
// No top padding: the recent header must start flush with the scroll
|
||||
// edge, otherwise it visually "bumps" a few pixels before sticking.
|
||||
<div className={cn(flatVariant ? 'space-y-0.5 pb-2' : 'space-y-2 pb-2')}>
|
||||
{visibleSections.map((section) => {
|
||||
const isCollapsed = collapsed.has(section.key);
|
||||
const visibleLimit = Math.max(
|
||||
@@ -162,7 +166,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{t('sessions.sidebar.group.showMore')}
|
||||
</button>
|
||||
@@ -172,26 +176,36 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={section.key} className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection(section.key)}
|
||||
className="group flex w-full items-center gap-1 rounded-md px-0.5 py-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<span className="inline-flex h-4 w-4 items-center justify-center text-muted-foreground">
|
||||
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
<span className="text-[14px] font-normal text-foreground/95">{section.title}</span>
|
||||
</button>
|
||||
<div key={section.key} className="relative space-y-1">
|
||||
{/* Zone header styled like a project header band; sticky with a
|
||||
solid sidebar backing so rows never show through. */}
|
||||
<div className={cn(
|
||||
'-ml-2.5 -mr-2',
|
||||
stickyZoneHeaders && 'oc-zone-header-backing sticky top-0 z-20 bg-sidebar',
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSection(section.key)}
|
||||
className="group flex w-full items-center gap-1.5 py-1 pl-4 pr-3.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-expanded={!isCollapsed}
|
||||
>
|
||||
<span className="inline-flex h-3.5 w-3.5 items-center justify-center">
|
||||
<Icon name="history" className={cn('h-3.5 w-3.5 text-muted-foreground/80', 'group-hover:hidden')} />
|
||||
<span className="hidden h-3.5 w-3.5 items-center justify-center text-muted-foreground group-hover:inline-flex">
|
||||
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-[14px] font-semibold lowercase text-foreground">{section.title}</span>
|
||||
</button>
|
||||
</div>
|
||||
{!isCollapsed ? (
|
||||
<div className={cn('space-y-0.5 pl-7')}>
|
||||
<div className={cn('space-y-0.5')}>
|
||||
{visibleItems.map(renderItem)}
|
||||
{remainingCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{t('sessions.sidebar.group.showMore')}
|
||||
</button>
|
||||
@@ -200,7 +214,7 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => resetSectionLimit(section.key)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
className="mt-0.5 flex items-center justify-start rounded-md pl-[26px] pr-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
{t('sessions.sidebar.group.showFewer')}
|
||||
</button>
|
||||
|
||||
@@ -3,24 +3,25 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
type Props = {
|
||||
hideDirectoryControls: boolean;
|
||||
showRecentControls: boolean;
|
||||
handleOpenDirectoryDialog: () => void;
|
||||
openNewSessionDraft: () => void;
|
||||
onOpenScheduled: () => void;
|
||||
onOpenMultiRun: () => void;
|
||||
canOpenMultiRun: boolean;
|
||||
openMultiRunLauncher: () => void;
|
||||
onOpenArchive: () => void;
|
||||
headerActionIconClass: string;
|
||||
headerActionButtonClass: string;
|
||||
isSessionSearchOpen: boolean;
|
||||
@@ -32,7 +33,6 @@ type Props = {
|
||||
searchMatchCount: number;
|
||||
collapseAllProjects: () => void;
|
||||
expandAllProjects: () => void;
|
||||
openScheduledTasksDialog: () => void;
|
||||
selectionModeEnabled: boolean;
|
||||
onToggleSelectionMode: () => void;
|
||||
};
|
||||
@@ -43,9 +43,10 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
hideDirectoryControls,
|
||||
showRecentControls,
|
||||
handleOpenDirectoryDialog,
|
||||
openNewSessionDraft,
|
||||
onOpenScheduled,
|
||||
onOpenMultiRun,
|
||||
canOpenMultiRun,
|
||||
openMultiRunLauncher,
|
||||
onOpenArchive,
|
||||
headerActionIconClass,
|
||||
headerActionButtonClass,
|
||||
isSessionSearchOpen,
|
||||
@@ -57,21 +58,18 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
searchMatchCount,
|
||||
collapseAllProjects,
|
||||
expandAllProjects,
|
||||
openScheduledTasksDialog,
|
||||
selectionModeEnabled,
|
||||
onToggleSelectionMode,
|
||||
} = props;
|
||||
|
||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
|
||||
const showArchivedSessions = useSessionDisplayStore((state) => state.showArchivedSessions);
|
||||
const setDisplayMode = useSessionDisplayStore((state) => state.setDisplayMode);
|
||||
const toggleRecentSection = useSessionDisplayStore((state) => state.toggleRecentSection);
|
||||
const toggleArchivedSessions = useSessionDisplayStore((state) => state.toggleArchivedSessions);
|
||||
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
|
||||
const setProjectSortOrder = useSessionDisplayStore((state) => state.setProjectSortOrder);
|
||||
// VS Code forces the expanded layout, so the mode toggle is meaningless there.
|
||||
const showDisplayModeToggle = !isVSCodeRuntime();
|
||||
const sessionGroupingMode = useSessionDisplayStore((state) => state.sessionGroupingMode);
|
||||
const setSessionGroupingMode = useSessionDisplayStore((state) => state.setSessionGroupingMode);
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
const toggleStickyZoneHeaders = useSessionDisplayStore((state) => state.toggleStickyZoneHeaders);
|
||||
|
||||
if (hideDirectoryControls) {
|
||||
return null;
|
||||
@@ -81,13 +79,17 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<div className="select-none flex-shrink-0 px-2.5 py-1">
|
||||
<div className="flex h-auto min-h-8 flex-col gap-1">
|
||||
<div className="flex h-8 items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* Quiet toolbar under the New-session CTA: project/surface entry
|
||||
points at left, list controls at right. ml-[3px] compensates the
|
||||
icon inset inside the 24px buttons so the first glyph lines up
|
||||
with the New-session icon above (16px from the sidebar edge). */}
|
||||
<div className="ml-[3px] flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenDirectoryDialog}
|
||||
className={headerActionButtonClass}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||
aria-label={t('sessions.sidebar.header.actions.addProject')}
|
||||
>
|
||||
<Icon name="folder-add" className={headerActionIconClass} />
|
||||
@@ -100,22 +102,22 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewSessionDraft}
|
||||
className={headerActionButtonClass}
|
||||
aria-label={t('sessions.sidebar.header.actions.newSession')}
|
||||
onClick={onOpenScheduled}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||
aria-label={t('sessions.sidebar.header.actions.scheduledTasks')}
|
||||
>
|
||||
<Icon name="chat-new" className={headerActionIconClass} />
|
||||
<Icon name="calendar-schedule" className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.newSession')}</p></TooltipContent>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.scheduledTasks')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openMultiRunLauncher}
|
||||
className={headerActionButtonClass}
|
||||
onClick={onOpenMultiRun}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||
aria-label={t('sessions.sidebar.header.actions.newMultiRun')}
|
||||
disabled={!canOpenMultiRun}
|
||||
>
|
||||
@@ -129,14 +131,14 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openScheduledTasksDialog}
|
||||
className={headerActionButtonClass}
|
||||
aria-label={t('sessions.sidebar.header.actions.scheduledTasks')}
|
||||
onClick={onOpenArchive}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||
aria-label={t('sessions.sidebar.nav.archive')}
|
||||
>
|
||||
<Icon name="calendar-schedule" className={headerActionIconClass} />
|
||||
<Icon name="archive" className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.scheduledTasks')}</p></TooltipContent>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.nav.archive')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -146,7 +148,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSessionSearchOpen((prev) => !prev)}
|
||||
className={headerActionButtonClass}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||
aria-label={t('sessions.sidebar.header.actions.searchSessions')}
|
||||
aria-expanded={isSessionSearchOpen}
|
||||
>
|
||||
@@ -161,7 +163,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleSelectionMode}
|
||||
className={cn(headerActionButtonClass, selectionModeEnabled && 'bg-interactive-hover text-primary')}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent', selectionModeEnabled && 'bg-interactive-hover text-primary')}
|
||||
aria-label={selectionModeEnabled
|
||||
? t('sessions.sidebar.header.actions.exitSelection')
|
||||
: t('sessions.sidebar.header.actions.selectSessions')}
|
||||
@@ -183,62 +185,8 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={headerActionButtonClass}
|
||||
aria-label={t('sessions.sidebar.header.actions.sortProjects')}
|
||||
>
|
||||
<Icon name="sort-desc" className={headerActionIconClass} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.actions.sortProjects')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[160px]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setProjectSortOrder('manual')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.projectSort.manual')}</span>
|
||||
{projectSortOrder === 'manual' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setProjectSortOrder('a-z')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.projectSort.aToZ')}</span>
|
||||
{projectSortOrder === 'a-z' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setProjectSortOrder('z-a')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.projectSort.zToA')}</span>
|
||||
{projectSortOrder === 'z-a' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setProjectSortOrder('date-added')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.projectSort.dateAdded')}</span>
|
||||
{projectSortOrder === 'date-added' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setProjectSortOrder('recent')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.projectSort.recent')}</span>
|
||||
{projectSortOrder === 'recent' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={headerActionButtonClass}
|
||||
aria-label={t('sessions.sidebar.header.actions.sessionDisplayMode')}
|
||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent')}
|
||||
aria-label={t('sessions.sidebar.header.displayMode.label')}
|
||||
>
|
||||
<Icon name="equalizer-2" className={headerActionIconClass} />
|
||||
</button>
|
||||
@@ -246,44 +194,56 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>{t('sessions.sidebar.header.displayMode.label')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[160px]">
|
||||
{showDisplayModeToggle ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('default')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.displayMode.default')}</span>
|
||||
{displayMode === 'default' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('minimal')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.displayMode.minimal')}</span>
|
||||
{displayMode === 'minimal' ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
<DropdownMenuLabel>{t('sessions.sidebar.header.actions.sortProjects')}</DropdownMenuLabel>
|
||||
{([
|
||||
['manual', 'sessions.sidebar.header.projectSort.manual'],
|
||||
['a-z', 'sessions.sidebar.header.projectSort.aToZ'],
|
||||
['z-a', 'sessions.sidebar.header.projectSort.zToA'],
|
||||
['date-added', 'sessions.sidebar.header.projectSort.dateAdded'],
|
||||
['recent', 'sessions.sidebar.header.projectSort.recent'],
|
||||
] as const).map(([order, labelKey]) => (
|
||||
<DropdownMenuItem
|
||||
key={order}
|
||||
onClick={() => setProjectSortOrder(order)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t(labelKey)}</span>
|
||||
{projectSortOrder === order ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>{t('sessions.sidebar.header.grouping.label')}</DropdownMenuLabel>
|
||||
{([
|
||||
['by-worktree', 'sessions.sidebar.header.grouping.byWorktree'],
|
||||
['flat', 'sessions.sidebar.header.grouping.flat'],
|
||||
] as const).map(([mode, labelKey]) => (
|
||||
<DropdownMenuItem
|
||||
key={mode}
|
||||
onClick={() => setSessionGroupingMode(mode)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t(labelKey)}</span>
|
||||
{sessionGroupingMode === mode ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
{showRecentControls ? (
|
||||
<>
|
||||
{showDisplayModeToggle ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem
|
||||
onClick={toggleRecentSection}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.displayMode.showRecent')}</span>
|
||||
{showRecentSection ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={toggleArchivedSessions}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.displayMode.showArchived')}</span>
|
||||
{showArchivedSessions ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuItem
|
||||
onClick={toggleRecentSection}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.displayMode.showRecent')}</span>
|
||||
{showRecentSection ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuItem
|
||||
onClick={toggleStickyZoneHeaders}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>{t('sessions.sidebar.header.displayMode.stickyHeaders')}</span>
|
||||
{stickyZoneHeaders ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={collapseAllProjects} className="flex items-center gap-2">
|
||||
<Icon name="contract-up-down" className="h-4 w-4" />
|
||||
@@ -293,7 +253,6 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
||||
<Icon name="expand-up-down" className="h-4 w-4" />
|
||||
<span>{t('sessions.sidebar.header.displayMode.expandAll')}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
// Primary sidebar action: starting a session is the one control worth its own
|
||||
// row; it keeps the quiet text-row form so the top reads as content, while
|
||||
// every other control lives in the icon toolbar below.
|
||||
type Props = {
|
||||
onNewSession: () => void;
|
||||
};
|
||||
|
||||
export function SidebarNav(props: Props): React.ReactNode {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="select-none flex-shrink-0 px-2.5 pt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onNewSession}
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-left typography-ui-label font-normal text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
<Icon name="chat-new" className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{t('sessions.sidebar.header.actions.newSession')}</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -53,6 +53,9 @@ type Props = {
|
||||
compactBodyPadding?: boolean,
|
||||
scrollContainerRef?: React.RefObject<HTMLElement | null>,
|
||||
) => React.ReactNode;
|
||||
getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[];
|
||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||
homeDirectory: string | null;
|
||||
collapsedProjects: Set<string>;
|
||||
hideDirectoryControls: boolean;
|
||||
@@ -67,13 +70,12 @@ type Props = {
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null }) => void;
|
||||
openNewWorktreeDialog: () => void;
|
||||
openWorktreesPage: (id: string) => void;
|
||||
openProjectEditDialog: (id: string) => void;
|
||||
removeProject: (id: string) => void;
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||
projectSortOrder: ProjectSortOrder;
|
||||
getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[];
|
||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
isInlineEditing: boolean;
|
||||
@@ -90,20 +92,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
|
||||
// Threaded into SessionGroupSection so the archived-bucket virtualizer
|
||||
// can resolve the scrolling ancestor synchronously (no getComputedStyle
|
||||
// walk) and skip the cost of a style recalc on every render.
|
||||
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
// Memoize the result of getOrderedGroups. The callback is stable
|
||||
// (deps: [groupOrderByProject]) and `section.groups` is a stable
|
||||
// reference from useSessionSidebarSections, but the caller discards
|
||||
// the result on every render and the callback allocates a new array
|
||||
// each time. With many projects and many sidebar re-renders this
|
||||
// builds O(P) arrays per render. The cache returns the same array
|
||||
// reference when the inputs haven't changed, so the downstream
|
||||
// orderedGroups.filter/find work and any consumer-memoization see a
|
||||
// stable reference.
|
||||
// Memoize getOrderedGroups per project so downstream consumers see a stable
|
||||
// array reference while inputs are unchanged (avoids O(P) fresh arrays per
|
||||
// list render invalidating the memoized group subtrees).
|
||||
const orderedGroupsCacheRef = React.useRef<Map<string, { groups: SessionGroup[]; ordered: SessionGroup[] }>>(new Map());
|
||||
const orderedGroupsCacheGetOrderedGroupsRef = React.useRef<typeof props.getOrderedGroups>(props.getOrderedGroups);
|
||||
if (orderedGroupsCacheGetOrderedGroupsRef.current !== props.getOrderedGroups) {
|
||||
@@ -118,9 +109,6 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
}
|
||||
const ordered = props.getOrderedGroups(projectId, groups);
|
||||
cache.set(projectId, { groups, ordered });
|
||||
// Bound the cache so re-ordering projects (which replaces the
|
||||
// projects list and invalidates every projectId) doesn't grow
|
||||
// unboundedly.
|
||||
if (cache.size > 256) {
|
||||
const firstKey = cache.keys().next().value;
|
||||
if (firstKey !== undefined) cache.delete(firstKey);
|
||||
@@ -128,6 +116,11 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
return ordered;
|
||||
};
|
||||
|
||||
// Threaded into SessionGroupSection so the archived-bucket virtualizer
|
||||
// can resolve the scrolling ancestor synchronously (no getComputedStyle
|
||||
// walk) and skip the cost of a style recalc on every render.
|
||||
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
if (props.sharedSessionsOnly) {
|
||||
return (
|
||||
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pr-2', props.mobileVariant ? '' : '')}>
|
||||
@@ -151,7 +144,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
// button) and holds it in place, which makes newly revealed sessions look
|
||||
// like they insert upward. With anchoring off, scrollTop stays put and new
|
||||
// rows appear below naturally.
|
||||
<ScrollableOverlay ref={scrollContainerRef} useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', props.mobileVariant ? '' : '')}>
|
||||
<ScrollableOverlay ref={scrollContainerRef} useScrollShadow hideTopScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('oc-sidebar-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', props.mobileVariant ? '' : '')}>
|
||||
{props.topContent}
|
||||
{props.showOnlyMainWorkspace ? (
|
||||
<div className="space-y-[0.6rem] py-1">
|
||||
@@ -186,85 +179,87 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<DndContext
|
||||
sensors={projectSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
if (props.isInlineEditing) return;
|
||||
// Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes
|
||||
if (props.projectSortOrder !== 'manual') return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
||||
const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
props.reorderProjects(oldIndex, newIndex);
|
||||
}}
|
||||
>
|
||||
<SortableContext items={props.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{props.sectionsForRender.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = formatProjectLabel(
|
||||
project.label?.trim()
|
||||
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|
||||
|| project.normalizedPath,
|
||||
);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||||
const isCollapsed = props.collapsedProjects.has(projectKey);
|
||||
const isActiveProject = projectKey === props.activeProjectId;
|
||||
const isRepo = props.projectRepoStatus.get(projectKey);
|
||||
const orderedGroups = cachedGetOrderedGroups(projectKey, section.groups);
|
||||
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
|
||||
const nestedGroups = rootGroup
|
||||
? orderedGroups.filter((group) => group.id !== rootGroup.id)
|
||||
: orderedGroups;
|
||||
<DndContext
|
||||
sensors={projectSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
if (props.isInlineEditing) return;
|
||||
// Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes
|
||||
if (props.projectSortOrder !== 'manual') return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
||||
const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
props.reorderProjects(oldIndex, newIndex);
|
||||
}}
|
||||
>
|
||||
<SortableContext items={props.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||
{props.sectionsForRender.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = formatProjectLabel(
|
||||
project.label?.trim()
|
||||
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|
||||
|| project.normalizedPath,
|
||||
);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||||
const isCollapsed = props.collapsedProjects.has(projectKey);
|
||||
const isActiveProject = projectKey === props.activeProjectId;
|
||||
const isRepo = props.projectRepoStatus.get(projectKey);
|
||||
|
||||
return (
|
||||
<SortableProjectItem
|
||||
key={projectKey}
|
||||
id={projectKey}
|
||||
disabled={props.projectSortOrder !== 'manual'}
|
||||
projectLabel={projectLabel}
|
||||
projectDescription={projectDescription}
|
||||
projectIcon={project.icon}
|
||||
projectColor={project.color}
|
||||
projectIconImage={project.iconImage}
|
||||
projectIconBackground={project.iconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
isActiveProject={isActiveProject}
|
||||
isRepo={Boolean(isRepo)}
|
||||
isDesktopShell={props.isDesktopShellRuntime}
|
||||
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
||||
hideDirectoryControls={props.hideDirectoryControls}
|
||||
mobileVariant={props.mobileVariant}
|
||||
alwaysShowActions={props.alwaysShowActions}
|
||||
onToggle={() => props.toggleProject(projectKey)}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||
props.openNewSessionDraft({
|
||||
selectedProjectId: projectKey,
|
||||
directoryOverride: project.normalizedPath,
|
||||
});
|
||||
}}
|
||||
onNewWorktreeSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
props.openNewWorktreeDialog();
|
||||
}}
|
||||
onRenameStart={() => props.openProjectEditDialog(projectKey)}
|
||||
onClose={() => props.removeProject(projectKey)}
|
||||
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
showCreateButtons
|
||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||
>
|
||||
{!isCollapsed ? (
|
||||
<div className="space-y-0 pt-0 pb-0.5 pl-3">
|
||||
{section.groups.length > 0 ? (
|
||||
return (
|
||||
<SortableProjectItem
|
||||
key={projectKey}
|
||||
id={projectKey}
|
||||
disabled={props.projectSortOrder !== 'manual'}
|
||||
projectLabel={projectLabel}
|
||||
projectDescription={projectDescription}
|
||||
projectIcon={project.icon}
|
||||
projectColor={project.color}
|
||||
projectIconImage={project.iconImage}
|
||||
projectIconBackground={project.iconBackground}
|
||||
isCollapsed={isCollapsed}
|
||||
isActiveProject={isActiveProject}
|
||||
isRepo={Boolean(isRepo)}
|
||||
isDesktopShell={props.isDesktopShellRuntime}
|
||||
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
||||
hideDirectoryControls={props.hideDirectoryControls}
|
||||
mobileVariant={props.mobileVariant}
|
||||
alwaysShowActions={props.alwaysShowActions}
|
||||
statusIndicator={isCollapsed ? props.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
||||
onToggle={() => props.toggleProject(projectKey)}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||
props.openNewSessionDraft({
|
||||
selectedProjectId: projectKey,
|
||||
directoryOverride: project.normalizedPath,
|
||||
});
|
||||
}}
|
||||
onNewWorktreeSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
props.openNewWorktreeDialog();
|
||||
}}
|
||||
onManageWorktrees={() => props.openWorktreesPage(projectKey)}
|
||||
onRenameStart={() => props.openProjectEditDialog(projectKey)}
|
||||
onClose={() => props.removeProject(projectKey)}
|
||||
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
showCreateButtons
|
||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||
>
|
||||
{!isCollapsed ? (
|
||||
<div className="space-y-0 pt-0.5 pb-0.5">
|
||||
{(() => {
|
||||
const orderedGroups = cachedGetOrderedGroups(projectKey, section.groups);
|
||||
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
|
||||
const nestedGroups = rootGroup
|
||||
? orderedGroups.filter((group) => group.id !== rootGroup.id)
|
||||
: orderedGroups;
|
||||
return (
|
||||
<DndContext
|
||||
sensors={groupSensors}
|
||||
collisionDetection={closestCenter}
|
||||
@@ -284,6 +279,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
});
|
||||
}}
|
||||
>
|
||||
{/* Root/flat sessions render directly under the
|
||||
project zone header; worktree and archived
|
||||
groups keep their own slim sortable sub-header. */}
|
||||
{rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true, null, undefined, scrollContainerRef) : null}
|
||||
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
|
||||
{nestedGroups.map((group) => {
|
||||
@@ -297,18 +295,16 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="py-1 text-left typography-micro text-muted-foreground">{t('sessions.sidebar.empty.noSessions.title')}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</SortableProjectItem>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : null}
|
||||
</SortableProjectItem>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,8 @@ const getSessionUpdatedAt = (session: Session): number => {
|
||||
|
||||
// Recent contains non-archived root sessions that are active now or were
|
||||
// updated within the retention window. The caller applies shared lifecycle
|
||||
// ordering after this membership filter.
|
||||
// ordering after this membership filter; batching ("Show more") handles long
|
||||
// windows in the UI.
|
||||
export const deriveRecentSessions = (
|
||||
sessions: Session[],
|
||||
activeSessionIds: ReadonlySet<string>,
|
||||
|
||||
@@ -17,12 +17,18 @@ export const useGroupOrdering = (groupOrderByProject: Map<string, string[]>) =>
|
||||
groupById.delete(id);
|
||||
}
|
||||
});
|
||||
// Groups unknown to the saved order are NEW worktrees — surface them at
|
||||
// the top of the worktree list (the root/main group is positioned by
|
||||
// the renderer regardless of this ordering). Archived buckets keep
|
||||
// appending at the end.
|
||||
const newGroups: SessionGroup[] = [];
|
||||
const trailingGroups: SessionGroup[] = [];
|
||||
groups.forEach((group) => {
|
||||
if (groupById.has(group.id)) {
|
||||
ordered.push(group);
|
||||
}
|
||||
if (!groupById.has(group.id)) return;
|
||||
if (group.isArchivedBucket) trailingGroups.push(group);
|
||||
else newGroups.push(group);
|
||||
});
|
||||
return ordered;
|
||||
return [...newGroups, ...ordered, ...trailingGroups];
|
||||
},
|
||||
[groupOrderByProject],
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { streamPerfMark } from '@/stores/utils/streamDebug';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
@@ -65,6 +66,9 @@ export const useSessionActions = (args: Args) => {
|
||||
const handleSessionSelect = React.useCallback(
|
||||
(sessionId: string, sessionDirectory?: string | null) => {
|
||||
streamPerfMark('navigation.session_select');
|
||||
// Selecting a session always leaves any full-page surface, even when
|
||||
// the session is already the current one (no store transition fires).
|
||||
useUIStore.getState().closeMainSurfaces();
|
||||
const resetSessionSearch = () => {
|
||||
if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) {
|
||||
return;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { compareSessionsByLifecycleOrder, getSessionLifecycleOrderValue } from '
|
||||
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { getWorktreeFirstSeenAt } from '../worktreeFirstSeen';
|
||||
|
||||
type Args = {
|
||||
homeDirectory: string | null;
|
||||
@@ -196,7 +197,16 @@ export const useSessionGrouping = (args: Args) => {
|
||||
return bInfo.lastUpdatedAt - aInfo.lastUpdatedAt;
|
||||
}
|
||||
|
||||
// Third priority: for inactive worktrees, sort by label (asc)
|
||||
// Third priority: for inactive worktrees, most recently discovered
|
||||
// first (a worktree created mid-session surfaces at the top of the
|
||||
// list; startup discovery ties and falls through to labels).
|
||||
const aSeen = getWorktreeFirstSeenAt(a.path);
|
||||
const bSeen = getWorktreeFirstSeenAt(b.path);
|
||||
if (aSeen !== bSeen) {
|
||||
return bSeen - aSeen;
|
||||
}
|
||||
|
||||
// Fourth priority: sort by label (asc)
|
||||
const aLabel = (a.label || a.branch || a.name || a.path || '').toLowerCase();
|
||||
const bLabel = (b.label || b.branch || b.name || b.path || '').toLowerCase();
|
||||
return aLabel.localeCompare(bLabel);
|
||||
|
||||
@@ -201,6 +201,70 @@ export const useSessionSidebarSections = (args: Args) => {
|
||||
|
||||
const sectionsForRender = hasSessionSearchQuery ? searchableProjectSections : visibleProjectSections;
|
||||
|
||||
// Flat display sections: one merged group per project containing every
|
||||
// non-archived session from the project root and all of its worktrees.
|
||||
// Worktree grouping stays available in `projectSections` for data consumers
|
||||
// (bootstrap demand planning, ownership); rendering is flat.
|
||||
// The per-section cache keeps merged group references stable so the
|
||||
// memoized SessionGroupSection subtree skips unrelated update waves.
|
||||
const flatSectionCacheRef = React.useRef<WeakMap<ProjectSection, { query: string; section: ProjectSection }>>(new WeakMap());
|
||||
const flatSectionsForRender = React.useMemo<ProjectSection[]>(() => {
|
||||
const cache = flatSectionCacheRef.current;
|
||||
return sectionsForRender.map((section) => {
|
||||
const cached = cache.get(section);
|
||||
if (cached && cached.query === normalizedSessionSearchQuery) {
|
||||
return cached.section;
|
||||
}
|
||||
|
||||
const nonArchivedGroups = section.groups.filter((group) => !group.isArchivedBucket);
|
||||
const archivedGroups = section.groups.filter((group) => group.isArchivedBucket);
|
||||
const sessions = nonArchivedGroups.flatMap((group) => hasSessionSearchQuery
|
||||
? (groupSearchDataByGroup.get(group)?.filteredNodes ?? [])
|
||||
: group.sessions);
|
||||
const folderScopes = nonArchivedGroups
|
||||
.map((group) => ({
|
||||
scopeKey: group.folderScopeKey ?? normalizePath(group.directory ?? null),
|
||||
directory: group.directory ?? null,
|
||||
}))
|
||||
.filter((scope): scope is { scopeKey: string; directory: string | null } => Boolean(scope.scopeKey));
|
||||
const rootGroup = nonArchivedGroups.find((group) => group.isMain) ?? null;
|
||||
|
||||
const flatGroup: SessionGroup = {
|
||||
id: 'flat',
|
||||
label: rootGroup?.label ?? '',
|
||||
branch: rootGroup?.branch ?? null,
|
||||
description: rootGroup?.description ?? null,
|
||||
isMain: true,
|
||||
isArchivedBucket: false,
|
||||
worktree: null,
|
||||
directory: rootGroup?.directory ?? section.project.normalizedPath,
|
||||
folderScopeKey: rootGroup?.folderScopeKey ?? section.project.normalizedPath,
|
||||
folderScopes,
|
||||
sessions,
|
||||
};
|
||||
|
||||
if (hasSessionSearchQuery) {
|
||||
const merged = nonArchivedGroups
|
||||
.map((group) => groupSearchDataByGroup.get(group))
|
||||
.filter((data): data is GroupSearchData => Boolean(data));
|
||||
groupSearchDataByGroup.set(flatGroup, {
|
||||
filteredNodes: sessions,
|
||||
matchedSessionCount: merged.reduce((total, data) => total + data.matchedSessionCount, 0),
|
||||
folderNameMatchCount: merged.reduce((total, data) => total + data.folderNameMatchCount, 0),
|
||||
groupMatches: merged.some((data) => data.groupMatches),
|
||||
hasMatch: merged.some((data) => data.hasMatch),
|
||||
});
|
||||
}
|
||||
|
||||
const flatSection: ProjectSection = {
|
||||
project: section.project,
|
||||
groups: [flatGroup, ...archivedGroups],
|
||||
};
|
||||
cache.set(section, { query: normalizedSessionSearchQuery, section: flatSection });
|
||||
return flatSection;
|
||||
});
|
||||
}, [groupSearchDataByGroup, hasSessionSearchQuery, normalizedSessionSearchQuery, sectionsForRender]);
|
||||
|
||||
const searchMatchCount = React.useMemo(() => {
|
||||
if (!hasSessionSearchQuery) {
|
||||
return 0;
|
||||
@@ -224,6 +288,7 @@ export const useSessionSidebarSections = (args: Args) => {
|
||||
groupSearchDataByGroup,
|
||||
searchableProjectSections,
|
||||
sectionsForRender,
|
||||
flatSectionsForRender,
|
||||
searchMatchCount,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,6 +8,12 @@ type Args = {
|
||||
isInlineEditing: boolean;
|
||||
showDeletionDialog: boolean;
|
||||
foldersMap: Record<string, SessionFolder[]>;
|
||||
/**
|
||||
* Selection scope is the project id (flat per-project session list); this
|
||||
* map resolves it to the project's folder scopes (root + worktrees). When
|
||||
* the scope is missing here it is treated as a plain directory scope.
|
||||
*/
|
||||
folderScopesByProject: Map<string, Array<{ scopeKey: string; directory: string | null }>>;
|
||||
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
|
||||
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||
@@ -39,6 +45,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
isInlineEditing,
|
||||
showDeletionDialog,
|
||||
foldersMap,
|
||||
folderScopesByProject,
|
||||
addSessionsToFolder,
|
||||
removeSessionsFromFolders,
|
||||
createFolderAndStartRename,
|
||||
@@ -89,38 +96,74 @@ export const useSidebarBulkActions = (args: Args) => {
|
||||
return null;
|
||||
}, [hasSelection, selectedIds, selectionScopeKey]);
|
||||
|
||||
const bulkScopeFolders = React.useMemo(() => {
|
||||
// The selection scope is a project id; folders live per directory scope
|
||||
// (project root + each worktree). Resolve all of them, in project order.
|
||||
const selectionFolderScopes = React.useMemo<string[]>(() => {
|
||||
if (!derivedSelectionScope) return [];
|
||||
return foldersMap[derivedSelectionScope] ?? [];
|
||||
}, [foldersMap, derivedSelectionScope]);
|
||||
const projectScopes = folderScopesByProject.get(derivedSelectionScope);
|
||||
if (projectScopes && projectScopes.length > 0) {
|
||||
return projectScopes.map((scope) => scope.scopeKey);
|
||||
}
|
||||
// Fallback: the scope is already a directory (e.g. VS Code workspaces).
|
||||
return [derivedSelectionScope];
|
||||
}, [derivedSelectionScope, folderScopesByProject]);
|
||||
|
||||
const bulkScopeFolders = React.useMemo(() => {
|
||||
return selectionFolderScopes.flatMap((scope) => foldersMap[scope] ?? []);
|
||||
}, [foldersMap, selectionFolderScopes]);
|
||||
|
||||
const resolveFolderScope = React.useCallback((folderId: string): string | null => {
|
||||
for (const scope of selectionFolderScopes) {
|
||||
if ((foldersMap[scope] ?? []).some((folder) => folder.id === folderId)) return scope;
|
||||
}
|
||||
return null;
|
||||
}, [foldersMap, selectionFolderScopes]);
|
||||
|
||||
const bulkCanRemoveFromFolder = React.useMemo(() => {
|
||||
if (!derivedSelectionScope || !hasSelection) return false;
|
||||
const scopeFolders = foldersMap[derivedSelectionScope] ?? [];
|
||||
for (const folder of scopeFolders) {
|
||||
for (const id of folder.sessionIds) {
|
||||
if (selectedIds.has(id)) return true;
|
||||
if (!hasSelection) return false;
|
||||
for (const scope of selectionFolderScopes) {
|
||||
for (const folder of foldersMap[scope] ?? []) {
|
||||
for (const id of folder.sessionIds) {
|
||||
if (selectedIds.has(id)) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [foldersMap, derivedSelectionScope, hasSelection, selectedIds]);
|
||||
}, [foldersMap, selectionFolderScopes, hasSelection, selectedIds]);
|
||||
|
||||
const moveSelectionToFolder = React.useCallback((targetScope: string, folderId: string) => {
|
||||
const ids = Array.from(selectedIds);
|
||||
// Clear memberships in every other scope first — the store only dedupes
|
||||
// within one scope, and a session must live in a single folder.
|
||||
for (const scope of selectionFolderScopes) {
|
||||
if (scope === targetScope) continue;
|
||||
removeSessionsFromFolders(scope, ids);
|
||||
}
|
||||
addSessionsToFolder(targetScope, folderId, ids);
|
||||
}, [addSessionsToFolder, removeSessionsFromFolders, selectedIds, selectionFolderScopes]);
|
||||
|
||||
const handleBulkMoveToFolder = React.useCallback((folderId: string) => {
|
||||
if (!derivedSelectionScope || !hasSelection) return;
|
||||
addSessionsToFolder(derivedSelectionScope, folderId, Array.from(selectedIds));
|
||||
}, [addSessionsToFolder, selectedIds, derivedSelectionScope, hasSelection]);
|
||||
if (!hasSelection) return;
|
||||
const targetScope = resolveFolderScope(folderId);
|
||||
if (!targetScope) return;
|
||||
moveSelectionToFolder(targetScope, folderId);
|
||||
}, [hasSelection, moveSelectionToFolder, resolveFolderScope]);
|
||||
|
||||
const handleBulkCreateFolderAndMove = React.useCallback(() => {
|
||||
if (!derivedSelectionScope || !hasSelection) return;
|
||||
const newFolder = createFolderAndStartRename(derivedSelectionScope);
|
||||
const targetScope = selectionFolderScopes[0];
|
||||
if (!targetScope || !hasSelection) return;
|
||||
const newFolder = createFolderAndStartRename(targetScope);
|
||||
if (!newFolder) return;
|
||||
addSessionsToFolder(derivedSelectionScope, newFolder.id, Array.from(selectedIds));
|
||||
}, [addSessionsToFolder, createFolderAndStartRename, selectedIds, derivedSelectionScope, hasSelection]);
|
||||
moveSelectionToFolder(targetScope, newFolder.id);
|
||||
}, [createFolderAndStartRename, hasSelection, moveSelectionToFolder, selectionFolderScopes]);
|
||||
|
||||
const handleBulkRemoveFromFolder = React.useCallback(() => {
|
||||
if (!derivedSelectionScope || !hasSelection) return;
|
||||
removeSessionsFromFolders(derivedSelectionScope, Array.from(selectedIds));
|
||||
}, [removeSessionsFromFolders, selectedIds, derivedSelectionScope, hasSelection]);
|
||||
if (!hasSelection) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
for (const scope of selectionFolderScopes) {
|
||||
removeSessionsFromFolders(scope, ids);
|
||||
}
|
||||
}, [removeSessionsFromFolders, selectedIds, selectionFolderScopes, hasSelection]);
|
||||
|
||||
const executeBulkDelete = React.useCallback(async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
|
||||
@@ -13,8 +13,14 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PROJECT_COLOR_MAP, PROJECT_ICON_MAP, ProjectIconImage } from '@/lib/projectMeta';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export type SortableDragHandleProps = {
|
||||
listeners: ReturnType<typeof useSortable>['listeners'];
|
||||
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
|
||||
};
|
||||
|
||||
export interface SortableProjectItemProps {
|
||||
id: string;
|
||||
disabled?: boolean;
|
||||
@@ -35,6 +41,7 @@ export interface SortableProjectItemProps {
|
||||
onToggle: () => void;
|
||||
onNewSession: () => void;
|
||||
onNewWorktreeSession?: () => void;
|
||||
onManageWorktrees?: () => void;
|
||||
onRenameStart: () => void;
|
||||
onClose: () => void;
|
||||
sentinelRef: (el: HTMLDivElement | null) => void;
|
||||
@@ -43,13 +50,10 @@ export interface SortableProjectItemProps {
|
||||
hideHeader?: boolean;
|
||||
openSidebarMenuKey: string | null;
|
||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||
/** Aggregated activity/attention indicator shown while the project is collapsed. */
|
||||
statusIndicator?: React.ReactNode;
|
||||
}
|
||||
|
||||
export type SortableDragHandleProps = {
|
||||
listeners: ReturnType<typeof useSortable>['listeners'];
|
||||
setActivatorNodeRef: ReturnType<typeof useSortable>['setActivatorNodeRef'];
|
||||
};
|
||||
|
||||
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
id,
|
||||
disabled = false,
|
||||
@@ -69,6 +73,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
onToggle,
|
||||
onNewSession,
|
||||
onNewWorktreeSession,
|
||||
onManageWorktrees,
|
||||
onRenameStart,
|
||||
onClose,
|
||||
sentinelRef,
|
||||
@@ -77,9 +82,11 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
hideHeader = false,
|
||||
openSidebarMenuKey,
|
||||
setOpenSidebarMenuKey,
|
||||
statusIndicator = null,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -110,6 +117,12 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
{t('sessions.sidebar.project.actions.newSession')}
|
||||
</Item>
|
||||
)}
|
||||
{isRepo && !hideDirectoryControls && onManageWorktrees && (
|
||||
<Item onClick={onManageWorktrees}>
|
||||
<Icon name="node-tree" className="mr-1.5 h-4 w-4" />
|
||||
{t('sessions.sidebar.project.actions.manageWorktrees')}
|
||||
</Item>
|
||||
)}
|
||||
<Item onClick={onRenameStart}>
|
||||
<Icon name="pencil-ai" className="mr-1.5 h-4 w-4" />
|
||||
{t('sessions.sidebar.project.actions.edit')}
|
||||
@@ -139,7 +152,12 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleClick = React.useCallback(() => {
|
||||
const handleToggleClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
// Drop mouse-click focus so hover-revealed chrome (chevron, actions)
|
||||
// hides again on mouse-leave instead of sticking via :focus-within.
|
||||
// Keyboard users keep their focus-visible ring (blur only fires here
|
||||
// for pointer interactions that produced a click).
|
||||
event.currentTarget.blur();
|
||||
if (suppressNextToggleRef.current) {
|
||||
suppressNextToggleRef.current = false;
|
||||
return;
|
||||
@@ -167,9 +185,19 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
<ContextMenu open={isContextMenuOpen} onOpenChange={setIsContextMenuOpen}>
|
||||
<ContextMenuTrigger
|
||||
render={
|
||||
// Sticky zone header: this trigger div is a direct child of
|
||||
// the project wrapper (which spans header + sessions), so it
|
||||
// can stick for the whole zone. The solid sidebar backing
|
||||
// keeps scrolled session rows from showing through the
|
||||
// translucent band.
|
||||
// Full-bleed band: pull past the list container's padding so
|
||||
// the section band spans the entire sidebar width (ref: edge-
|
||||
// to-edge section headers, not rounded pills).
|
||||
<div
|
||||
className={cn('w-full text-left group/project select-none')}
|
||||
style={{ backgroundColor: isDesktopShell && isStuck ? 'transparent' : undefined }}
|
||||
className={cn(
|
||||
'-ml-2.5 -mr-2 text-left group/project select-none',
|
||||
stickyZoneHeaders && 'oc-zone-header-backing sticky top-0 z-20 bg-sidebar',
|
||||
)}
|
||||
onContextMenu={(event) => {
|
||||
// VS Code hides project actions entirely (hideDirectoryControls).
|
||||
if (hideDirectoryControls) return;
|
||||
@@ -179,7 +207,17 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="relative flex items-center gap-1 px-0.5 py-0.5" {...attributes}>
|
||||
<div
|
||||
className={cn(
|
||||
// pl-4 keeps the icon/text aligned with the padded rows below
|
||||
// (container pl-2.5 + band px-1.5 it replaces).
|
||||
'relative flex items-center gap-1 py-1 pl-4 pr-3.5',
|
||||
// Desktop shell reports when the header is actually stuck;
|
||||
// a subtle elevation makes the pinned state readable.
|
||||
isStuck && 'shadow-md',
|
||||
)}
|
||||
{...attributes}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -230,11 +268,14 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
)}
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-[14px] font-normal truncate lowercase',
|
||||
'text-[14px] font-semibold truncate lowercase',
|
||||
isActiveProject ? 'text-foreground' : 'text-foreground group-hover/project:text-foreground',
|
||||
)}>
|
||||
{projectLabel}
|
||||
</span>
|
||||
{statusIndicator ? (
|
||||
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
@@ -381,3 +422,4 @@ const SortableGroupItemBase: React.FC<{
|
||||
};
|
||||
|
||||
export const SortableGroupItem = React.memo(SortableGroupItemBase);
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@ export type SessionNode = {
|
||||
worktree: WorktreeMetadata | null;
|
||||
};
|
||||
|
||||
export type SessionGroupFolderScope = {
|
||||
scopeKey: string;
|
||||
directory: string | null;
|
||||
};
|
||||
|
||||
export type SessionGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -17,6 +22,13 @@ export type SessionGroup = {
|
||||
worktree: WorktreeMetadata | null;
|
||||
directory: string | null;
|
||||
folderScopeKey?: string | null;
|
||||
/**
|
||||
* Flat display groups merge sessions from the project root and every
|
||||
* worktree; their folders come from all of these scopes. When present, the
|
||||
* group section gathers folders across every listed scope (in order)
|
||||
* instead of reading the single folderScopeKey.
|
||||
*/
|
||||
folderScopes?: SessionGroupFolderScope[];
|
||||
sessions: SessionNode[];
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { normalizePath } from './utils';
|
||||
|
||||
// In-memory first-seen tracker for worktree directories. Worktree metadata
|
||||
// carries no creation time, so we record when a path first appears during
|
||||
// this app run: a worktree created mid-session sorts to the top of its
|
||||
// project's empty-worktree tail, while everything discovered at startup ties
|
||||
// (same tick) and falls back to alphabetical order.
|
||||
const firstSeenAtByPath = new Map<string, number>();
|
||||
|
||||
export const recordWorktreesSeen = (paths: Iterable<string | null | undefined>, seenAt: number): void => {
|
||||
for (const path of paths) {
|
||||
const normalized = normalizePath(path ?? null);
|
||||
if (normalized && !firstSeenAtByPath.has(normalized)) {
|
||||
firstSeenAtByPath.set(normalized, seenAt);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getWorktreeFirstSeenAt = (path: string | null | undefined): number => {
|
||||
const normalized = normalizePath(path ?? null);
|
||||
return normalized ? (firstSeenAtByPath.get(normalized) ?? 0) : 0;
|
||||
};
|
||||
Reference in New Issue
Block a user