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