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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user