perf(ui): streamline session sidebar state
This commit is contained in:
@@ -3,7 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
|||||||
|
|
||||||
import { SessionActivityDuration } from '@/components/session/SessionActivityDuration';
|
import { SessionActivityDuration } from '@/components/session/SessionActivityDuration';
|
||||||
import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
|
import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
|
||||||
import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems';
|
import { useSwitcherItems } from '@/components/session/sidebar/shell/useSwitcherItems';
|
||||||
import { useTabletLayout } from '@/lib/device';
|
import { useTabletLayout } from '@/lib/device';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { useUpdatePolling } from '@/hooks/useUpdatePolling';
|
|||||||
import { useDeviceInfo } from '@/lib/device';
|
import { useDeviceInfo } from '@/lib/device';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||||
|
import { useSessionListSync } from '@/components/session/sidebar/list/useSessionListSync';
|
||||||
|
|
||||||
import { ChatView } from '@/components/views/ChatView';
|
import { ChatView } from '@/components/views/ChatView';
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/Se
|
|||||||
* crossing the threshold reloads into it (see watchHostedSurfaceViewport).
|
* crossing the threshold reloads into it (see watchHostedSurfaceViewport).
|
||||||
*/
|
*/
|
||||||
export const MainLayout: React.FC = () => {
|
export const MainLayout: React.FC = () => {
|
||||||
|
useSessionListSync({ isVSCode: false });
|
||||||
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
|
||||||
const setIsMobile = useUIStore((state) => state.setIsMobile);
|
const setIsMobile = useUIStore((state) => state.setIsMobile);
|
||||||
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
|
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
|||||||
import type { UsageWindow } from '@/types';
|
import type { UsageWindow } from '@/types';
|
||||||
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
||||||
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
||||||
|
import { useSessionListSync } from '@/components/session/sidebar/list/useSessionListSync';
|
||||||
|
|
||||||
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
const SettingsView = lazyWithChunkRecovery(() => import('@/components/views/SettingsView').then(m => ({ default: m.SettingsView })));
|
||||||
|
|
||||||
@@ -526,8 +527,11 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [usesExpandedLayout, currentView, viewMode]);
|
}, [usesExpandedLayout, currentView, viewMode]);
|
||||||
|
|
||||||
|
useSessionListSync({ isVSCode: true });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="h-full w-full bg-background text-foreground flex flex-col">
|
<>
|
||||||
|
<div ref={containerRef} className="h-full w-full bg-background text-foreground flex flex-col">
|
||||||
{viewMode === 'editor' ? (
|
{viewMode === 'editor' ? (
|
||||||
// Editor mode: just chat, no sidebar
|
// Editor mode: just chat, no sidebar
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -639,7 +643,8 @@ export const VSCodeLayout: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<SessionDialogs />
|
<SessionDialogs />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
|||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sidebar/sessionNodeItemUtils';
|
import { CollapsedActivityIndicator, type CollapsedActivityState } from './sidebar/sessions/collapsedActivityIndicator';
|
||||||
import { CollapsedActivityIndicator } from './sidebar/collapsedActivityIndicator';
|
|
||||||
import type { CollapsedActivityState } from './sidebar/collapsedActivityState';
|
|
||||||
|
|
||||||
interface SessionFolderItemProps<TSessionNode> {
|
interface SessionFolderItemProps<TSessionNode> {
|
||||||
folder: SessionFolder;
|
folder: SessionFolder;
|
||||||
@@ -24,23 +22,7 @@ interface SessionFolderItemProps<TSessionNode> {
|
|||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onRename: (name: string) => void;
|
onRename: (name: string) => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
renderSessionNode: (
|
children?: React.ReactNode;
|
||||||
node: TSessionNode,
|
|
||||||
depth?: number,
|
|
||||||
groupDir?: string | null,
|
|
||||||
projectId?: string | null,
|
|
||||||
archivedBucket?: boolean,
|
|
||||||
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
|
|
||||||
renderContext?: 'project' | 'recent',
|
|
||||||
renderExtras?: SessionNodeChildRenderExtras,
|
|
||||||
) => React.ReactNode;
|
|
||||||
/**
|
|
||||||
* Returns the precomputed per-row render extras for a given node. The
|
|
||||||
* group precomputes subtree-contains lookups once, then resolves a
|
|
||||||
* per-node structure key here so SessionNodeItem's React.memo comparator
|
|
||||||
* can answer with a single string compare instead of a recursive walk.
|
|
||||||
*/
|
|
||||||
getRenderExtras?: (node: TSessionNode) => SessionNodeRenderExtras<TSessionNode> | undefined;
|
|
||||||
groupDirectory?: string | null;
|
groupDirectory?: string | null;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
mobileVariant?: boolean;
|
mobileVariant?: boolean;
|
||||||
@@ -74,8 +56,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
onToggle,
|
onToggle,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
renderSessionNode,
|
children,
|
||||||
getRenderExtras,
|
|
||||||
groupDirectory,
|
groupDirectory,
|
||||||
projectId,
|
projectId,
|
||||||
mobileVariant = false,
|
mobileVariant = false,
|
||||||
@@ -97,6 +78,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
const [localDraft, setLocalDraft] = React.useState('');
|
const [localDraft, setLocalDraft] = React.useState('');
|
||||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
|
||||||
const renaming = isRenaming || localRenaming;
|
const renaming = isRenaming || localRenaming;
|
||||||
const draft = isRenaming ? renameDraft : localDraft;
|
const draft = isRenaming ? renameDraft : localDraft;
|
||||||
|
|
||||||
@@ -167,6 +149,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30',
|
isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30',
|
||||||
)}
|
)}
|
||||||
onClick={renaming ? undefined : (event) => {
|
onClick={renaming ? undefined : (event) => {
|
||||||
|
// SAFETY: this handler is attached to the div rendered directly above.
|
||||||
(event.currentTarget as HTMLElement).blur();
|
(event.currentTarget as HTMLElement).blur();
|
||||||
onToggle();
|
onToggle();
|
||||||
}}
|
}}
|
||||||
@@ -346,11 +329,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
{subFolderItems}
|
{subFolderItems}
|
||||||
{/* Then sessions */}
|
{/* Then sessions */}
|
||||||
{sessions.length > 0 ? (
|
{sessions.length > 0 ? (
|
||||||
<div className="pl-3">
|
children
|
||||||
{sessions.map((node) =>
|
|
||||||
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket, undefined, 'project', getRenderExtras?.(node)),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : !subFolderItems ? (
|
) : !subFolderItems ? (
|
||||||
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
|
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
|
||||||
{t('sessions.sidebar.folderItem.emptyFolder')}
|
{t('sessions.sidebar.folderItem.emptyFolder')}
|
||||||
@@ -362,6 +341,9 @@ const SessionFolderItemBase = <TSessionNode,>({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SessionFolderItem = React.memo(SessionFolderItemBase) as <TSessionNode>(
|
export const SessionFolderItem = (
|
||||||
|
/* SAFETY: React.memo preserves the generic component's props and return type. */
|
||||||
|
React.memo(SessionFolderItemBase) as <TSessionNode>(
|
||||||
props: SessionFolderItemProps<TSessionNode>,
|
props: SessionFolderItemProps<TSessionNode>,
|
||||||
) => React.ReactElement;
|
) => React.ReactElement
|
||||||
|
);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import { Icon } from '@/components/icon/Icon';
|
|||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
||||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||||
import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/hooks/useSwitcherItems';
|
import { useSwitcherItems, type SwitcherItem } from '@/components/session/sidebar/shell/useSwitcherItems';
|
||||||
import { useUIStore } from '@/stores/useUIStore';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||||
import { formatSessionCompactDateLabel } from './sidebar/utils';
|
import { formatSessionCompactDateLabel } from './sidebar/utils';
|
||||||
|
|||||||
@@ -1,89 +1,37 @@
|
|||||||
# Session Sidebar Documentation
|
# Session Sidebar
|
||||||
|
|
||||||
## Refactor result
|
Sidebar code is organized by the business object it owns. Shared contracts are
|
||||||
|
kept at this root in `types.ts` and `utils.tsx`.
|
||||||
|
|
||||||
- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components.
|
- `shell/` owns sidebar chrome, navigation, search, confirmations, and switcher effects.
|
||||||
- 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.
|
- `list/` owns global-first session collection, directory bootstrap demand,
|
||||||
- **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.
|
layout-owned synchronization, authoritative cleanup, and nearby-session prefetch.
|
||||||
- **Project display is independent from grouping.** `'all'` keeps every project zone; `'single'` is web/desktop/PWA-only and renders one selected project under the always-present Chats section. Its project header is a non-collapsible picker ordered by the current project sort. Recent and collapse/expand-all controls are hidden without changing their persisted preferences. Opening a materialized project session updates the picker from the session's confirmed directory; changing only a draft target does not. In `'single'` + `'flat'`, active sessions reveal in batches of 20. `'single'` + `'by-worktree'` retains the ordinary per-group limits. Project display mode, session grouping, project sort, and the Recent preference are server-backed shared settings with the hydrated browser store as the migration/failure cache. The selected single project and sticky-header preference remain device-local.
|
- `projects/` owns project zones, grouping, ordering, scroller behavior, project
|
||||||
- When sticky zone headers are enabled, project headers are sticky "zone" bands (`SortableProjectItem`); on a vibrant desktop the scrolling content fades behind an unmasked, non-interactive copy of the stuck icon/title without painting a background. The transparent fade zone blocks interaction with obscured rows. The `recent` section uses the same overlay while it is the leading sticky header. Collapsed projects show an aggregated busy/unseen indicator (`ProjectAggregateStatusIndicator`), derived from the live status index and notification store scoped to the project's directories.
|
view state, repository state, and worktree presentation.
|
||||||
- **Activity is a dot plus a counter, never a spinner.** The row's left gutter shows a static dot — primary while the session runs (`busy`/`retry`), info while it is unread — and the metadata slot on the right swaps the goal/branch/date group for the elapsed time of the turn (`SessionActivityDuration`, ticking once per second). The readout takes the dot's color in each state — primary while running, info once it is waiting to be read — so the pair reads as one indicator. A running spinner repainted a composited layer per row every frame for the whole turn; the counter conveys the same "something is happening" at 1 fps. The counter follows the unread marker's lifetime exactly: it survives the turn ending, disappears when the session is read, and never lingers on the session being watched (which is marked read as it goes idle). Aggregate indicators for collapsed groups, folders, and projects show the dot only — a group may hold several running turns, so a single counter would have nothing to count. The same treatment applies to the mobile sessions sheet and session switcher rows. The worktree-move indicator stays a spinner: it marks a short user-initiated operation, not a session state.
|
- `sessions/` owns session rows, row actions, expansion, ownership, and activity indicators.
|
||||||
- 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.
|
- `recent/` owns Recent and managed Chats activity projections.
|
||||||
- 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).
|
- `folders/` owns folder DnD, bulk actions, archived folders, and folder UI.
|
||||||
- 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). Restore (unarchive) is available per session (row context menu, Archive page row) and in bulk (selection bar) and writes `time.archived = 0` — the server cannot clear the field over HTTP, so the global session cache splits active/archived client-side (see "Restore (unarchive) contract" in `sync/DOCUMENTATION.md`).
|
|
||||||
- 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.
|
|
||||||
- Managed Chats never offer the worktree-move action in either the sidebar row menu or the active-session header menu because their directories are not project repositories.
|
|
||||||
- Managed Chats use the shared Chats root as their folder scope. Their activity section renders the normal folder tree, and sessions created from a Chats folder are assigned back to that root-scoped folder after their date/session directory materializes. Per-session folder scopes created by older builds remain visible for compatibility.
|
|
||||||
- An empty Chats section says that there are no chats yet; it never reuses the project/workspace empty message.
|
|
||||||
- The New session keyboard command inherits the active materialized session directory. Explicit sidebar entry points, including the top New session row and the Chats `+`, open a fresh managed Chat draft instead.
|
|
||||||
- The new-worktree keyboard command is a silent no-op while a managed Chat draft is open. It must not retarget that draft to the active project or show a Git/worktree error because Chats never participate in worktrees.
|
|
||||||
- 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.
|
|
||||||
- When multiple configured projects are checkouts of the same Git repository, exactly one project owns the shared worktree topology: the configured canonical primary root when present, otherwise the first configured source for that repository. Any worktree path that is also a configured project is omitted from subordinate worktree groups, so every directory has one sidebar location while remaining part of bootstrap demand.
|
|
||||||
|
|
||||||
## VS Code grouping
|
`MainLayout` and `VSCodeLayout` call `useSessionListSync({ isVSCode })`
|
||||||
|
unconditionally. The hook publishes complete directory bootstrap demand,
|
||||||
|
refreshes newly added topology, coalesces control events, and performs
|
||||||
|
authoritative cleanup. Root-level `useGlobalSessionsPolling` remains the only
|
||||||
|
initial and 45-second global poller. `useSessionListSync` must not create a
|
||||||
|
second global polling lifecycle.
|
||||||
|
|
||||||
- VS Code uses the **same grouped project tree** as web/desktop (project headers + folders + pinned-first ordering), not a separate flat list. Each open VS Code workspace folder is a project header.
|
The global sessions cache is the complete source for active and archived
|
||||||
- VS Code groups strictly **by open workspace**: `useSessionGrouping` funnels every non-archived session into the project's root group and emits **no per-worktree subgroups** (worktrees aren't registered in VS Code). `getSessionsForProject` buckets sessions to a workspace by exact directory match, so only sessions whose directory is an open workspace folder appear.
|
coverage. Initialized directory stores only supply sessions missing from that
|
||||||
- VS Code passes `hideDirectoryControls` (clean workspace headers, no worktree/close chrome) and no longer passes `showOnlyMainWorkspace`/`sharedSessionsOnly`. Folders and pinning therefore work natively, scoped to the workspace root.
|
cache. Live busy and retry state comes from `global-session-status`, never from
|
||||||
|
the global cache or persisted history. A failed global or directory fetch keeps
|
||||||
|
existing data; it is never treated as an authoritative empty list.
|
||||||
|
|
||||||
## File summaries
|
Web and desktop show managed Chats before optional Recent activity. Chats use
|
||||||
|
their shared managed root for folders and never expose worktree actions. Project
|
||||||
|
display can be all projects or one selected project. VS Code excludes worktrees
|
||||||
|
and managed Chats, while retaining its workspace-scoped grouped list and inline
|
||||||
|
archived buckets.
|
||||||
|
|
||||||
### Components
|
Directory demand always includes known project roots and worktrees. Visibility
|
||||||
|
only changes priority. Row mounts must not start bootstrap work. Selection and
|
||||||
- `SidebarHeader.tsx`: Top header UI for add-project, session search, selection mode, project sort, and the display menu (recent toggle, collapse/expand all).
|
activity subscriptions stay session-scoped so a structural list update does not
|
||||||
- A successful add/create/clone from the project-directory dialog transitions to a new-session draft targeted at that project, matching the project's `+` action; changing project metadata alone must not leave the visible session or draft on a different directory.
|
make every row observe unrelated streaming updates.
|
||||||
- `SidebarNav.tsx`: Text navigation rows above the tree (New session, Scheduled, Multi-run, Archive); hidden in VS Code.
|
|
||||||
- `SidebarActivitySections.tsx`: Global top section renderer for OpenChamber-managed `chats` followed by optional project-only `recent` sessions, styled as zone headers. The desktop sticky identity overlay follows the activity header whose sentinel has crossed the scroller edge, so a small scroll cannot relabel Chats as Recent.
|
|
||||||
- `SidebarFooter.tsx`: Static footer with icon-only settings, shortcuts, and about actions.
|
|
||||||
- `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. Pending-question counts stay per-session while expanded and roll up hidden descendants from their owning directory stores while collapsed. Rows do not initiate directory bootstrap on mount.
|
|
||||||
- `collapsedActivityIndicator.tsx`: Aggregate busy/unseen dot for collapsed groups and folders.
|
|
||||||
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
|
|
||||||
- `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.
|
|
||||||
|
|
||||||
### Hooks
|
|
||||||
|
|
||||||
- `hooks/useSessionActions.ts`: Centralizes session row actions (select/open, rename, share/unshare, archive/delete, confirmations).
|
|
||||||
- `hooks/useSessionSearchEffects.ts`: Handles search open/close UX and input focus behavior.
|
|
||||||
- `hooks/useSessionPrefetch.ts`: Publishes directory-aware nearby/active session prefetch demand to the shared message loader. Recent may prefetch across projects without substituting the current directory.
|
|
||||||
- `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/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.
|
|
||||||
- `hooks/useProjectSessionLists.ts`: Reads live and archived project buckets from the shared ownership index.
|
|
||||||
- `hooks/useAuthoritativeSessionCleanup.ts`: Establishes the first complete active+archived list as a non-destructive baseline, then cleans persisted state only for sessions omitted by a later authoritative snapshot.
|
|
||||||
- `hooks/useStickyProjectHeaders.ts`: Tracks which project headers are sticky/stuck via `IntersectionObserver`.
|
|
||||||
|
|
||||||
### Types and utilities
|
|
||||||
|
|
||||||
- `types.ts`: Shared sidebar types (`SessionNode`, `SessionGroup`, summary/search metadata).
|
|
||||||
- `activitySections.ts`: Persisted top-section storage/helpers for the current `recent` session list.
|
|
||||||
- `sessionBootstrapDemands.ts`: Builds the deduplicated directory demand plan. Selected directories rank above active projects, expanded groups, visible collapsed groups, and background/collapsed projects.
|
|
||||||
- `utils.tsx`: Shared sidebar utilities (path normalization, dedupe, archived scope keys, project relation checks, text highlight, labels, compact/default date formatting). Shared session ranking lives in `sync/session-ordering.ts`.
|
|
||||||
|
|
||||||
## Loading rules
|
|
||||||
|
|
||||||
- Always publish every known project root and worktree directory. Collapse/visibility changes priority only; they do not opt a directory out of authoritative refresh.
|
|
||||||
- Current directory and selected-session directory are `selected` demand and therefore run first.
|
|
||||||
- Expanded projects/worktrees outrank merely visible and background groups.
|
|
||||||
- The sync scheduler deduplicates, promotes, retries, and limits work. Sidebar components must not reproduce that lifecycle with mount effects.
|
|
||||||
- Hide speculative work when the sidebar/chat surface is hidden: message prefetch, Git/PR enrichment and subscriptions, search listeners, sticky-header observation, and archived-folder derivation stop. The session row tree unmounts so row-owned status, permission, unseen, and viewport subscriptions do no background work. The outer sidebar remains mounted, preserving UI state and authoritative directory refresh for an immediate reopen; deferred derived work reruns from current state when visibility returns.
|
|
||||||
- The sidebar does not subscribe its whole tree to the cross-directory live-session aggregate. Global create/structural/lifecycle snapshots drive rendered session metadata; the cached sync index only fills sessions not yet present globally and provides refresh fallback data. Row activity continues to come from the session-keyed live status index.
|
|
||||||
- Session selection does not invalidate the sidebar orchestration component. Each mounted row selects only whether its own session ID is active, while parent expansion, project selection memory, and neighbor prefetch run in small effect-only subscribers.
|
|
||||||
- Parent expansion is exclusively manual. Selecting or navigating to a subsession never expands its parent automatically. Project/worktree and `recent` trees use independent persisted context keys and receive separate stable projections, so expansion changes in one context neither invalidate nor change the other. The persisted storage key remains `v3`; older state mixed contexts and is not migrated into this contract.
|
|
||||||
- Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics.
|
|
||||||
- Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling.
|
|
||||||
- Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes do not trigger a rebuild. The separate lifecycle rank invalidates ordering only on `settled ↔ active` transitions, with root sessions ranked among roots and child sessions only among siblings of the same parent.
|
|
||||||
- CLI/server-created sessions use the low-frequency OpenChamber control event stream to refresh only the created session directory. The same event retriggers bounded worktree discovery so a newly created external worktree gains ownership without a view reload; it does not re-enable broad session or streaming subscriptions.
|
|
||||||
- Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders.
|
|
||||||
- Structural updates rebuild grouped nodes only for projects whose local sessions, worktrees, repository state, or branch changed; unchanged project sections preserve references so memoized group/session descendants skip the update wave.
|
|
||||||
- Empty successful lists, unresolved loads, and failed loads are separate UI states. Failed groups expose Retry and retain prior data.
|
|
||||||
- Directory permission failures remain visible even when stale sessions are retained. Flat groups inspect every represented root/worktree directory; local Desktop may open the native picker for the exact failed directory, while other runtimes keep the ordinary Retry action.
|
|
||||||
- Pins and folder assignments are not pruned from the first startup snapshot or from optimistic mutations. Confirmed local deletion and routed external deletion clean immediately; a later authoritative omission after an established baseline covers missed external delete events.
|
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
|
||||||
|
|
||||||
const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
|
||||||
|
|
||||||
const isSubtaskSession = (session: Session): boolean => {
|
|
||||||
return Boolean((session as Session & { parentID?: string | null }).parentID);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isArchivedSession = (session: Session): boolean => {
|
|
||||||
return Boolean(session.time?.archived);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSessionUpdatedAt = (session: Session): number => {
|
|
||||||
const updated = session.time?.updated;
|
|
||||||
const created = session.time?.created;
|
|
||||||
if (typeof updated === 'number' && Number.isFinite(updated)) {
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
if (typeof created === 'number' && Number.isFinite(created)) {
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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; batching ("Show more") handles long
|
|
||||||
// windows in the UI.
|
|
||||||
export const deriveRecentSessions = (
|
|
||||||
sessions: Session[],
|
|
||||||
activeSessionIds: ReadonlySet<string>,
|
|
||||||
now = Date.now(),
|
|
||||||
): Session[] => {
|
|
||||||
const minUpdatedAt = now - RECENT_SESSION_MAX_AGE_MS;
|
|
||||||
return sessions.filter((session) => {
|
|
||||||
if (isArchivedSession(session) || isSubtaskSession(session)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return activeSessionIds.has(session.id) || getSessionUpdatedAt(session) >= minUpdatedAt;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { CollapsedActivityState } from './collapsedActivityState';
|
|
||||||
|
|
||||||
export function CollapsedActivityIndicator({
|
|
||||||
state,
|
|
||||||
activeLabel,
|
|
||||||
unreadLabel,
|
|
||||||
className,
|
|
||||||
}: {
|
|
||||||
state: Exclude<CollapsedActivityState, null>;
|
|
||||||
activeLabel: string;
|
|
||||||
unreadLabel: string;
|
|
||||||
className?: string;
|
|
||||||
}): React.ReactNode {
|
|
||||||
const label = state === 'active' ? activeLabel : unreadLabel;
|
|
||||||
// Aggregate rows carry the dot only; the elapsed counter is per session and
|
|
||||||
// has no meaning for a collapsed group that may hold several running turns.
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'h-1.5 w-1.5 shrink-0 rounded-full',
|
|
||||||
state === 'active' ? 'bg-primary' : 'bg-[var(--status-info)]',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
aria-label={label}
|
|
||||||
title={label}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
|
||||||
import type { SessionNode } from './types';
|
|
||||||
|
|
||||||
export type CollapsedActivityState = 'active' | 'unread' | null;
|
|
||||||
|
|
||||||
export const mergeCollapsedActivityStates = (
|
|
||||||
current: CollapsedActivityState,
|
|
||||||
next: CollapsedActivityState,
|
|
||||||
): CollapsedActivityState => {
|
|
||||||
if (current === 'active' || next === 'active') return 'active';
|
|
||||||
if (current === 'unread' || next === 'unread') return 'unread';
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSessionNodeActivityState = (
|
|
||||||
node: SessionNode,
|
|
||||||
activeSessionIds: Set<string>,
|
|
||||||
unreadSessionIds: Set<string>,
|
|
||||||
includeUnreadSubtasks: boolean,
|
|
||||||
): CollapsedActivityState => {
|
|
||||||
if (activeSessionIds.has(node.session.id)) {
|
|
||||||
return 'active';
|
|
||||||
}
|
|
||||||
|
|
||||||
let state: CollapsedActivityState = null;
|
|
||||||
const isSubtask = Boolean((node.session as Session & { parentID?: string | null }).parentID);
|
|
||||||
if (unreadSessionIds.has(node.session.id) && (includeUnreadSubtasks || !isSubtask)) {
|
|
||||||
state = 'unread';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const child of node.children) {
|
|
||||||
state = mergeCollapsedActivityStates(
|
|
||||||
state,
|
|
||||||
getSessionNodeActivityState(child, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
|
||||||
);
|
|
||||||
if (state === 'active') return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
return state;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getSessionNodesActivityState = (
|
|
||||||
nodes: SessionNode[],
|
|
||||||
activeSessionIds: Set<string>,
|
|
||||||
unreadSessionIds: Set<string>,
|
|
||||||
includeUnreadSubtasks: boolean,
|
|
||||||
): CollapsedActivityState => {
|
|
||||||
let state: CollapsedActivityState = null;
|
|
||||||
for (const node of nodes) {
|
|
||||||
state = mergeCollapsedActivityStates(
|
|
||||||
state,
|
|
||||||
getSessionNodeActivityState(node, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
|
||||||
);
|
|
||||||
if (state === 'active') return state;
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
};
|
|
||||||
+79
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { I18nProvider } from '@/lib/i18n';
|
||||||
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
|
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||||||
|
import { installHookTestDom } from '../test-utils/testDom';
|
||||||
|
|
||||||
|
type BulkActionCapture = {
|
||||||
|
onCreateFolderAndMove: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let bulkActionCapture: BulkActionCapture | null = null;
|
||||||
|
|
||||||
|
mock.module('./BulkActionBar', () => ({
|
||||||
|
BulkActionBar: (props: BulkActionCapture) => {
|
||||||
|
bulkActionCapture = props;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('./ConfirmDialogs', () => ({
|
||||||
|
BulkSessionDeleteConfirmDialog: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { SessionBulkActions } = await import('./SessionBulkActions');
|
||||||
|
|
||||||
|
describe('SessionBulkActions public behavior', () => {
|
||||||
|
test('moves the selected sessions into a newly created folder while a row edit is active', async () => {
|
||||||
|
const dom = installHookTestDom();
|
||||||
|
const root = createRoot(dom.container);
|
||||||
|
const originalFolders = useSessionFoldersStore.getState();
|
||||||
|
const originalSelection = useSessionMultiSelectStore.getState();
|
||||||
|
const cssDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'CSS');
|
||||||
|
const renameRequests: Array<{ scopeKey: string; folder: { id: string; name: string } }> = [];
|
||||||
|
const moved: Array<{ scopeKey: string; folderId: string; ids: string[] }> = [];
|
||||||
|
useSessionFoldersStore.setState({
|
||||||
|
foldersMap: {},
|
||||||
|
addSessionsToFolder: (scopeKey, folderId, ids) => moved.push({ scopeKey, folderId, ids }),
|
||||||
|
});
|
||||||
|
useSessionMultiSelectStore.setState({
|
||||||
|
enabled: true,
|
||||||
|
selectedIds: new Set(['session-a']),
|
||||||
|
scopeKey: 'project-a',
|
||||||
|
anchorId: 'session-a',
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'CSS', {
|
||||||
|
configurable: true,
|
||||||
|
value: { escape: (value: string) => value },
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(
|
||||||
|
<I18nProvider>
|
||||||
|
<SessionBulkActions
|
||||||
|
getFolderScopesForProject={() => [{ scopeKey: '/workspace', directory: '/workspace' }]}
|
||||||
|
isInlineEditing
|
||||||
|
startFolderRename={(scopeKey, folder) => renameRequests.push({ scopeKey, folder })}
|
||||||
|
/>
|
||||||
|
</I18nProvider>,
|
||||||
|
));
|
||||||
|
expect(bulkActionCapture).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => bulkActionCapture?.onCreateFolderAndMove());
|
||||||
|
const createdFolder = useSessionFoldersStore.getState().foldersMap['/workspace']?.[0];
|
||||||
|
expect(createdFolder?.name).toBe('New folder');
|
||||||
|
expect(renameRequests).toEqual([{ scopeKey: '/workspace', folder: createdFolder }]);
|
||||||
|
expect(moved).toEqual([{ scopeKey: '/workspace', folderId: createdFolder?.id ?? '', ids: ['session-a'] }]);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
useSessionFoldersStore.setState(originalFolders, true);
|
||||||
|
useSessionMultiSelectStore.setState(originalSelection, true);
|
||||||
|
if (cssDescriptor) Object.defineProperty(globalThis, 'CSS', cssDescriptor);
|
||||||
|
else Reflect.deleteProperty(globalThis, 'CSS');
|
||||||
|
bulkActionCapture = null;
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
|
import { BulkActionBar } from './BulkActionBar';
|
||||||
|
import { BulkSessionDeleteConfirmDialog, type BulkDeleteSessionsConfirmState } from '../shell/ConfirmDialogs';
|
||||||
|
import { useSidebarBulkActions } from './useSidebarBulkActions';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
getFolderScopesForProject: (projectId: string) => readonly { scopeKey: string; directory: string | null }[];
|
||||||
|
isInlineEditing: boolean;
|
||||||
|
startFolderRename: (scopeKey: string, folder: { id: string; name: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Owns the sidebar selection projection and its destructive confirmation. */
|
||||||
|
export function SessionBulkActions({ getFolderScopesForProject, isInlineEditing, startFolderRename }: Props): React.ReactNode {
|
||||||
|
const [bulkDeleteConfirm, setBulkDeleteConfirm] = React.useState<BulkDeleteSessionsConfirmState>(null);
|
||||||
|
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||||
|
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
||||||
|
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
||||||
|
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
||||||
|
const addSessionsToFolder = useSessionFoldersStore((state) => state.addSessionsToFolder);
|
||||||
|
const removeSessionsFromFolders = useSessionFoldersStore((state) => state.removeSessionsFromFolders);
|
||||||
|
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
|
||||||
|
const unarchiveSessions = useSessionUIStore((state) => state.unarchiveSessions);
|
||||||
|
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
|
||||||
|
const bulk = useSidebarBulkActions({
|
||||||
|
isInlineEditing,
|
||||||
|
showDeletionDialog,
|
||||||
|
foldersMap,
|
||||||
|
getFolderScopesForProject,
|
||||||
|
addSessionsToFolder,
|
||||||
|
removeSessionsFromFolders,
|
||||||
|
createFolderAndStartRename: (scopeKey) => {
|
||||||
|
const folder = createFolder(scopeKey, 'New folder');
|
||||||
|
startFolderRename(scopeKey, folder);
|
||||||
|
return folder;
|
||||||
|
},
|
||||||
|
archiveSessions,
|
||||||
|
unarchiveSessions,
|
||||||
|
deleteSessions,
|
||||||
|
setBulkDeleteConfirm,
|
||||||
|
});
|
||||||
|
|
||||||
|
return <>
|
||||||
|
{bulk.selectionModeEnabled && bulk.hasSelection ? <BulkActionBar
|
||||||
|
selectedCount={bulk.selectedIdsSize}
|
||||||
|
scopeKey={bulk.derivedSelectionScope}
|
||||||
|
scopeFolders={bulk.bulkScopeFolders}
|
||||||
|
archivedBucket={bulk.bulkScopeIsArchived}
|
||||||
|
onMoveToFolder={bulk.handleBulkMoveToFolder}
|
||||||
|
onCreateFolderAndMove={bulk.handleBulkCreateFolderAndMove}
|
||||||
|
onRemoveFromFolder={bulk.handleBulkRemoveFromFolder}
|
||||||
|
canRemoveFromFolder={bulk.bulkCanRemoveFromFolder}
|
||||||
|
onRestore={bulk.handleBulkRestore}
|
||||||
|
onDelete={bulk.handleBulkDelete}
|
||||||
|
onDone={bulk.handleExitSelectionMode}
|
||||||
|
/> : null}
|
||||||
|
<BulkSessionDeleteConfirmDialog
|
||||||
|
value={bulkDeleteConfirm}
|
||||||
|
setValue={setBulkDeleteConfirm}
|
||||||
|
showDeletionDialog={showDeletionDialog}
|
||||||
|
setShowDeletionDialog={setShowDeletionDialog}
|
||||||
|
onConfirm={bulk.confirmBulkDelete}
|
||||||
|
/>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { installHookTestDom } from '../test-utils/testDom';
|
||||||
|
|
||||||
|
type DragEnd = (event: {
|
||||||
|
active: { data: { current: { type: string; sessionId: string } } };
|
||||||
|
over: { data: { current: { type: string; folderId: string } } } | null;
|
||||||
|
}) => void;
|
||||||
|
|
||||||
|
let handleDragEnd: DragEnd | null = null;
|
||||||
|
|
||||||
|
mock.module('@dnd-kit/core', () => ({
|
||||||
|
DndContext: ({ children, onDragEnd }: { children: React.ReactNode; onDragEnd: DragEnd }) => {
|
||||||
|
handleDragEnd = onDragEnd;
|
||||||
|
return <>{children}</>;
|
||||||
|
},
|
||||||
|
DragOverlay: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
PointerSensor: class {},
|
||||||
|
closestCenter: () => null,
|
||||||
|
useSensor: () => null,
|
||||||
|
useSensors: () => [],
|
||||||
|
useDraggable: () => ({ attributes: {}, listeners: {}, setNodeRef: () => undefined, isDragging: false }),
|
||||||
|
useDroppable: () => ({ setNodeRef: () => undefined, isOver: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { SessionFolderDndScope } = await import('./sessionFolderDnd');
|
||||||
|
|
||||||
|
describe('SessionFolderDndScope public behavior', () => {
|
||||||
|
test('routes a session-folder drop without depending on row edit or menu state', async () => {
|
||||||
|
const dom = installHookTestDom();
|
||||||
|
const root = createRoot(dom.container);
|
||||||
|
const drops: Array<{ sessionId: string; folderId: string }> = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(
|
||||||
|
<SessionFolderDndScope
|
||||||
|
scopeKey="/workspace"
|
||||||
|
hasFolders
|
||||||
|
onSessionDroppedOnFolder={(sessionId, folderId) => drops.push({ sessionId, folderId })}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</SessionFolderDndScope>,
|
||||||
|
));
|
||||||
|
expect(handleDragEnd).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => handleDragEnd?.({
|
||||||
|
active: { data: { current: { type: 'session', sessionId: 'session-a' } } },
|
||||||
|
over: { data: { current: { type: 'folder', folderId: 'folder-a' } } },
|
||||||
|
}));
|
||||||
|
expect(drops).toEqual([{ sessionId: 'session-a', folderId: 'folder-a' }]);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
handleDragEnd = null;
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -3,7 +3,7 @@ import {
|
|||||||
getArchivedScopeKey,
|
getArchivedScopeKey,
|
||||||
resolveArchivedFolderName,
|
resolveArchivedFolderName,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import type { SessionOwnershipIndex } from '../sessionOwnership';
|
import type { SessionOwnershipIndex } from '../sessions/sessionOwnership';
|
||||||
|
|
||||||
type ProjectForArchivedFolders = {
|
type ProjectForArchivedFolders = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { resolveSelectionFolderScopes } from './useSidebarBulkActions';
|
||||||
|
|
||||||
|
describe('sidebar bulk project scopes', () => {
|
||||||
|
test('uses every root and worktree scope owned by the selected project', () => {
|
||||||
|
const scopes = resolveSelectionFolderScopes('project-a', (projectId) => projectId === 'project-a'
|
||||||
|
? [
|
||||||
|
{ scopeKey: '/workspace/project-a', directory: '/workspace/project-a' },
|
||||||
|
{ scopeKey: '/workspace/project-a-worktree', directory: '/workspace/project-a-worktree' },
|
||||||
|
]
|
||||||
|
: []);
|
||||||
|
|
||||||
|
expect(scopes).toEqual(['/workspace/project-a', '/workspace/project-a-worktree']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a directory scope when no project scope owns it', () => {
|
||||||
|
expect(resolveSelectionFolderScopes('/workspace/vscode', () => [])).toEqual(['/workspace/vscode']);
|
||||||
|
});
|
||||||
|
});
|
||||||
+15
-10
@@ -13,7 +13,7 @@ type Args = {
|
|||||||
* map resolves it to the project's folder scopes (root + worktrees). When
|
* 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.
|
* the scope is missing here it is treated as a plain directory scope.
|
||||||
*/
|
*/
|
||||||
folderScopesByProject: Map<string, Array<{ scopeKey: string; directory: string | null }>>;
|
getFolderScopesForProject: (projectId: string) => readonly { scopeKey: string; directory: string | null }[];
|
||||||
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
|
addSessionsToFolder: (scopeKey: string, folderId: string, sessionIds: string[]) => void;
|
||||||
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
removeSessionsFromFolders: (scopeKey: string, sessionIds: string[]) => void;
|
||||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||||
@@ -26,6 +26,17 @@ type Args = {
|
|||||||
} | null>>;
|
} | null>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resolveSelectionFolderScopes = (
|
||||||
|
selectionScope: string | null,
|
||||||
|
getFolderScopesForProject: Args['getFolderScopesForProject'],
|
||||||
|
): string[] => {
|
||||||
|
if (!selectionScope) return [];
|
||||||
|
const projectScopes = getFolderScopesForProject(selectionScope);
|
||||||
|
return projectScopes.length > 0
|
||||||
|
? projectScopes.map((scope) => scope.scopeKey)
|
||||||
|
: [selectionScope];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk-action logic for the sidebar. The hot-path concern is that this
|
* Bulk-action logic for the sidebar. The hot-path concern is that this
|
||||||
* hook subscribes to `useSessionMultiSelectStore` — which can fire on
|
* hook subscribes to `useSessionMultiSelectStore` — which can fire on
|
||||||
@@ -46,7 +57,7 @@ export const useSidebarBulkActions = (args: Args) => {
|
|||||||
isInlineEditing,
|
isInlineEditing,
|
||||||
showDeletionDialog,
|
showDeletionDialog,
|
||||||
foldersMap,
|
foldersMap,
|
||||||
folderScopesByProject,
|
getFolderScopesForProject,
|
||||||
addSessionsToFolder,
|
addSessionsToFolder,
|
||||||
removeSessionsFromFolders,
|
removeSessionsFromFolders,
|
||||||
createFolderAndStartRename,
|
createFolderAndStartRename,
|
||||||
@@ -101,14 +112,8 @@ export const useSidebarBulkActions = (args: Args) => {
|
|||||||
// The selection scope is a project id; folders live per directory scope
|
// The selection scope is a project id; folders live per directory scope
|
||||||
// (project root + each worktree). Resolve all of them, in project order.
|
// (project root + each worktree). Resolve all of them, in project order.
|
||||||
const selectionFolderScopes = React.useMemo<string[]>(() => {
|
const selectionFolderScopes = React.useMemo<string[]>(() => {
|
||||||
if (!derivedSelectionScope) return [];
|
return resolveSelectionFolderScopes(derivedSelectionScope, getFolderScopesForProject);
|
||||||
const projectScopes = folderScopesByProject.get(derivedSelectionScope);
|
}, [derivedSelectionScope, getFolderScopesForProject]);
|
||||||
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(() => {
|
const bulkScopeFolders = React.useMemo(() => {
|
||||||
return selectionFolderScopes.flatMap((scope) => foldersMap[scope] ?? []);
|
return selectionFolderScopes.flatMap((scope) => foldersMap[scope] ?? []);
|
||||||
-44
@@ -1,44 +0,0 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
|
||||||
import {
|
|
||||||
buildAuthoritativeSessionIdentityMap,
|
|
||||||
findRemovedAuthoritativeSessions,
|
|
||||||
} from '../authoritativeSessionCleanup';
|
|
||||||
|
|
||||||
const session = (id: string, directory = '/repo'): Session => ({ id, directory }) as Session;
|
|
||||||
|
|
||||||
describe('authoritative session cleanup', () => {
|
|
||||||
test('does not infer deletion from the first authoritative startup snapshot', () => {
|
|
||||||
const current = buildAuthoritativeSessionIdentityMap([]);
|
|
||||||
|
|
||||||
expect(findRemovedAuthoritativeSessions(null, current)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('finds sessions omitted after an established authoritative baseline', () => {
|
|
||||||
const previous = buildAuthoritativeSessionIdentityMap([
|
|
||||||
session('deleted'),
|
|
||||||
session('retained'),
|
|
||||||
]);
|
|
||||||
const current = buildAuthoritativeSessionIdentityMap([session('retained')]);
|
|
||||||
|
|
||||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([
|
|
||||||
{ directory: '/repo', sessionId: 'deleted' },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('treats archive membership as retained authority', () => {
|
|
||||||
const previous = buildAuthoritativeSessionIdentityMap([session('archived')]);
|
|
||||||
const current = buildAuthoritativeSessionIdentityMap([
|
|
||||||
{ ...session('archived'), time: { archived: 10 } } as Session,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not treat a directory move as session deletion', () => {
|
|
||||||
const previous = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-a')]);
|
|
||||||
const current = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-b')]);
|
|
||||||
|
|
||||||
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
|
||||||
|
|
||||||
type SafeStorageLike = {
|
|
||||||
getItem: (key: string) => string | null;
|
|
||||||
setItem: (key: string, value: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Keys = {
|
|
||||||
sessionExpanded: string;
|
|
||||||
projectCollapse: string;
|
|
||||||
groupOrder: string;
|
|
||||||
groupCollapse: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Args = {
|
|
||||||
isVSCode: boolean;
|
|
||||||
safeStorage: SafeStorageLike;
|
|
||||||
keys: Keys;
|
|
||||||
groupOrderByProject: Map<string, string[]>;
|
|
||||||
collapsedGroups: Set<string>;
|
|
||||||
setExpandedParents: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
||||||
setCollapsedProjects: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSidebarPersistence = (args: Args) => {
|
|
||||||
const {
|
|
||||||
isVSCode,
|
|
||||||
safeStorage,
|
|
||||||
keys,
|
|
||||||
groupOrderByProject,
|
|
||||||
collapsedGroups,
|
|
||||||
setExpandedParents,
|
|
||||||
setCollapsedProjects,
|
|
||||||
} = args;
|
|
||||||
|
|
||||||
const persistCollapsedProjectsTimer = React.useRef<number | null>(null);
|
|
||||||
const pendingCollapsedProjects = React.useRef<Set<string> | null>(null);
|
|
||||||
|
|
||||||
const flushCollapsedProjectsPersist = React.useCallback(() => {
|
|
||||||
if (isVSCode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const collapsed = pendingCollapsedProjects.current;
|
|
||||||
pendingCollapsedProjects.current = null;
|
|
||||||
persistCollapsedProjectsTimer.current = null;
|
|
||||||
if (!collapsed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { projects } = useProjectsStore.getState();
|
|
||||||
const updatedProjects = projects.map((project) => ({
|
|
||||||
...project,
|
|
||||||
sidebarCollapsed: collapsed.has(project.id),
|
|
||||||
}));
|
|
||||||
void updateDesktopSettings({ projects: updatedProjects }).catch(() => {});
|
|
||||||
}, [isVSCode]);
|
|
||||||
|
|
||||||
const scheduleCollapsedProjectsPersist = React.useCallback((collapsed: Set<string>) => {
|
|
||||||
if (typeof window === 'undefined' || isVSCode) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingCollapsedProjects.current = collapsed;
|
|
||||||
if (persistCollapsedProjectsTimer.current !== null) {
|
|
||||||
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
|
||||||
}
|
|
||||||
persistCollapsedProjectsTimer.current = window.setTimeout(() => {
|
|
||||||
flushCollapsedProjectsPersist();
|
|
||||||
}, 700);
|
|
||||||
}, [isVSCode, flushCollapsedProjectsPersist]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
if (typeof window !== 'undefined' && persistCollapsedProjectsTimer.current !== null) {
|
|
||||||
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
|
||||||
}
|
|
||||||
persistCollapsedProjectsTimer.current = null;
|
|
||||||
pendingCollapsedProjects.current = null;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
try {
|
|
||||||
const storedParents = safeStorage.getItem(keys.sessionExpanded);
|
|
||||||
if (storedParents) {
|
|
||||||
const parsed = JSON.parse(storedParents);
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
setExpandedParents(new Set(parsed.filter((item) => typeof item === 'string')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const storedProjects = safeStorage.getItem(keys.projectCollapse);
|
|
||||||
if (storedProjects) {
|
|
||||||
const parsed = JSON.parse(storedProjects);
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
setCollapsedProjects(new Set(parsed.filter((item) => typeof item === 'string')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}, [keys.projectCollapse, keys.sessionExpanded, safeStorage, setCollapsedProjects, setExpandedParents]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
try {
|
|
||||||
const serialized = Object.fromEntries(groupOrderByProject.entries());
|
|
||||||
safeStorage.setItem(keys.groupOrder, JSON.stringify(serialized));
|
|
||||||
} catch {
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}, [groupOrderByProject, keys.groupOrder, safeStorage]);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
try {
|
|
||||||
safeStorage.setItem(keys.groupCollapse, JSON.stringify(Array.from(collapsedGroups)));
|
|
||||||
} catch {
|
|
||||||
// ignored
|
|
||||||
}
|
|
||||||
}, [collapsedGroups, keys.groupCollapse, safeStorage]);
|
|
||||||
|
|
||||||
return { scheduleCollapsedProjectsPersist };
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { buildSessionBootstrapDemands } from './sessionBootstrapDemands';
|
||||||
|
|
||||||
|
describe('SessionProjectCollection', () => {
|
||||||
|
test('preserves authoritative background demand when its visible rows are absent', () => {
|
||||||
|
const demands = buildSessionBootstrapDemands({
|
||||||
|
knownDirectories: ['/project', '/project/worktree'],
|
||||||
|
activeProjectDirectory: '/project',
|
||||||
|
activeProjectId: 'project',
|
||||||
|
collapsedProjects: new Set(),
|
||||||
|
collapsedGroups: new Set(),
|
||||||
|
currentDirectory: null,
|
||||||
|
currentSessionDirectory: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(demands.map((demand) => demand.directory)).toEqual(['/project', '/project/worktree']);
|
||||||
|
expect(demands[0]?.priority).toBe('active-project');
|
||||||
|
expect(demands[1]?.priority).toBe('background');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSync } from '@/sync/use-sync';
|
||||||
|
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||||
|
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||||
|
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||||
|
import { useArchivedAutoFolders } from '../folders/useArchivedAutoFolders';
|
||||||
|
import { ProjectSessionSelectionEffect } from '../projects/useProjectSessionSelection';
|
||||||
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
|
import { useRecentSessionCollection, useSessionProjectCollection } from './sessionCollection';
|
||||||
|
import { createSessionOwnershipIndex } from '../sessions/sessionOwnership';
|
||||||
|
import { useProjectSessionLists } from '../projects/useProjectSessionLists';
|
||||||
|
import { useSessionSidebarSections } from '../projects/useSessionSidebarSections';
|
||||||
|
import { SessionPrefetchEffect } from './useSessionPrefetch';
|
||||||
|
import { normalizePath } from '../utils';
|
||||||
|
import type { SessionGroup } from '../types';
|
||||||
|
import { SessionProjectScroller } from '../projects/SessionProjectScroller';
|
||||||
|
import { useSessionGrouping } from '../projects/useSessionGrouping';
|
||||||
|
import { useStickyProjectHeaders } from '../projects/useStickyProjectHeaders';
|
||||||
|
import { SessionBulkActions } from '../folders/SessionBulkActions';
|
||||||
|
import { RecentSessionSection } from '../recent/RecentSessionSection';
|
||||||
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
|
import type { useSessionProjectViewState } from '../projects/useSessionProjectViewState';
|
||||||
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
|
import type { DeleteSessionConfirmState } from '../sessions/useSessionActions';
|
||||||
|
import { useExpandedParents } from '../sessions/useExpandedParents';
|
||||||
|
|
||||||
|
const PR_NO_PR_RETRY_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
type Project = {
|
||||||
|
id: string;
|
||||||
|
path: string;
|
||||||
|
label?: string;
|
||||||
|
normalizedPath: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||||
|
iconBackground?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionProjectCollectionProps = {
|
||||||
|
topology: {
|
||||||
|
projects: Project[];
|
||||||
|
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||||
|
knownDirectories: Set<string>;
|
||||||
|
isVSCode: boolean;
|
||||||
|
worktreeMetadata: Map<string, WorktreeMetadata>;
|
||||||
|
gitBranches: Map<string, string | null>;
|
||||||
|
projectRepoStatus: Map<string, boolean | null>;
|
||||||
|
projectRootBranches: Map<string, string | null>;
|
||||||
|
lastRepoStatus: boolean;
|
||||||
|
};
|
||||||
|
view: {
|
||||||
|
isVisible: boolean;
|
||||||
|
hasSessionSearchQuery: boolean;
|
||||||
|
normalizedSessionSearchQuery: string;
|
||||||
|
activeProjectId: string | null;
|
||||||
|
showInlineArchived: boolean;
|
||||||
|
useGroupedSections: boolean;
|
||||||
|
homeDirectory: string | null;
|
||||||
|
mobileVariant: boolean;
|
||||||
|
hideDirectoryControls: boolean;
|
||||||
|
showOnlyMainWorkspace: boolean;
|
||||||
|
isDesktopShellRuntime: boolean;
|
||||||
|
stickyZoneHeaders: boolean;
|
||||||
|
projectSortOrder: import('@/stores/useSessionDisplayStore').ProjectSortOrder;
|
||||||
|
emptyState: React.ReactNode;
|
||||||
|
searchEmptyState: React.ReactNode;
|
||||||
|
isSessionsLoading: boolean;
|
||||||
|
isWorktreeTopologyLoading: boolean;
|
||||||
|
unresolvedWorktreeProjectPaths: ReadonlySet<string>;
|
||||||
|
projectView: ReturnType<typeof useSessionProjectViewState>['state'];
|
||||||
|
};
|
||||||
|
actions: {
|
||||||
|
rowActions: {
|
||||||
|
allowReselect: boolean;
|
||||||
|
onSessionSelected?: (sessionId: string) => void;
|
||||||
|
isSessionSearchOpen: boolean;
|
||||||
|
sessionSearchQuery: string;
|
||||||
|
setSessionSearchQuery: (value: string) => void;
|
||||||
|
setIsSessionSearchOpen: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
alwaysShowActions: boolean;
|
||||||
|
notifyOnSubtasks: boolean;
|
||||||
|
setActiveProjectIdOnly: (id: string) => void;
|
||||||
|
setActiveMainTab: (tab: import('@/stores/useUIStore').MainTab) => void;
|
||||||
|
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;
|
||||||
|
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||||
|
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||||
|
initialActiveSessionByProject: Map<string, string>;
|
||||||
|
persistActiveSessionByProject: (value: Map<string, string>) => void;
|
||||||
|
projectViewActions: Pick<
|
||||||
|
ReturnType<typeof useSessionProjectViewState>['actions'],
|
||||||
|
'getOrderedGroups' | 'setGroupOrderByProject' | 'toggleGroup' | 'toggleProject'
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const VisibleSessionProjects: React.FC<SessionProjectCollectionProps> = ({ topology, view, actions }) => {
|
||||||
|
const { alwaysShowActions, notifyOnSubtasks, projectViewActions, rowActions, ...scrollerActions } = actions;
|
||||||
|
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
||||||
|
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
||||||
|
const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder);
|
||||||
|
const projectView = view.projectView;
|
||||||
|
const { getOrderedGroups, setGroupOrderByProject, toggleGroup, toggleProject } = projectViewActions;
|
||||||
|
const collection = useSessionProjectCollection({ knownDirectories: topology.knownDirectories, isVSCode: topology.isVSCode, isVisible: true });
|
||||||
|
const [visibleSessionCountByGroup, setVisibleSessionCountByGroup] = React.useState<Map<string, number>>(new Map());
|
||||||
|
const showMoreGroupSessions = React.useCallback((groupId: string, currentVisibleCount: number) => {
|
||||||
|
setVisibleSessionCountByGroup((current) => new Map(current).set(groupId, currentVisibleCount + 7));
|
||||||
|
}, []);
|
||||||
|
const resetGroupSessionLimit = React.useCallback((groupId: string) => {
|
||||||
|
setVisibleSessionCountByGroup((current) => {
|
||||||
|
if (!current.has(groupId)) return current;
|
||||||
|
const next = new Map(current);
|
||||||
|
next.delete(groupId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
|
||||||
|
const recentSessions = useRecentSessionCollection({
|
||||||
|
enabled: showRecentSection,
|
||||||
|
isVSCode: topology.isVSCode,
|
||||||
|
pinnedSessionIds: collection.pinnedSessionIds,
|
||||||
|
sessionOrderRanks: collection.sessionOrderRanks,
|
||||||
|
sessions: collection.sessions,
|
||||||
|
});
|
||||||
|
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||||
|
const [editTitle, setEditTitle] = React.useState('');
|
||||||
|
const [openSidebarMenuKey, setOpenSidebarMenuKey] = React.useState<string | null>(null);
|
||||||
|
const [deleteSessionConfirm, setDeleteSessionConfirm] = React.useState<DeleteSessionConfirmState>(null);
|
||||||
|
const [copiedSessionId, setCopiedSessionId] = React.useState<string | null>(null);
|
||||||
|
const [folderRename, setFolderRename] = React.useState<{ scopeKey: string; folderId: string; draft: string } | null>(null);
|
||||||
|
const startFolderRename = React.useCallback((scopeKey: string, folder: { id: string; name: string }) => {
|
||||||
|
setFolderRename({ scopeKey, folderId: folder.id, draft: folder.name });
|
||||||
|
}, []);
|
||||||
|
const setFolderRenameDraft = React.useCallback((draft: string) => {
|
||||||
|
setFolderRename((current) => current ? { ...current, draft } : null);
|
||||||
|
}, []);
|
||||||
|
const clearFolderRename = React.useCallback(() => setFolderRename(null), []);
|
||||||
|
const { expandedParents, toggleParent } = useExpandedParents();
|
||||||
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||||
|
const selectSessionForProject = React.useCallback((sessionId: string, sessionDirectory: string | null) => {
|
||||||
|
if (sessionId === useSessionUIStore.getState().currentSessionId) return;
|
||||||
|
setCurrentSession(sessionId, sessionDirectory);
|
||||||
|
}, [setCurrentSession]);
|
||||||
|
const sync = useSync();
|
||||||
|
const { buildGroupedSessions, filterSessionNodesForSearch, buildGroupSearchText } = useSessionGrouping({
|
||||||
|
homeDirectory: view.homeDirectory,
|
||||||
|
worktreeMetadata: topology.worktreeMetadata,
|
||||||
|
pinnedSessionIds: collection.pinnedSessionIds,
|
||||||
|
sessionOrderRanks: collection.sessionOrderRanks,
|
||||||
|
gitBranches: topology.gitBranches,
|
||||||
|
isVSCode: topology.isVSCode,
|
||||||
|
});
|
||||||
|
const ownership = React.useMemo(
|
||||||
|
() => createSessionOwnershipIndex(collection.sessions, topology.projects, topology.availableWorktreesByProject, topology.isVSCode, collection.archivedSessions),
|
||||||
|
[collection.archivedSessions, collection.sessions, topology.availableWorktreesByProject, topology.isVSCode, topology.projects],
|
||||||
|
);
|
||||||
|
const { getSessionsForProject, getArchivedSessionsForProject } = useProjectSessionLists({ ownership });
|
||||||
|
const { projectSections, groupSearchDataByGroup, sectionsForRender, flatSectionsForRender } = useSessionSidebarSections({
|
||||||
|
normalizedProjects: topology.projects,
|
||||||
|
getSessionsForProject,
|
||||||
|
getArchivedSessionsForProject,
|
||||||
|
availableWorktreesByProject: topology.availableWorktreesByProject,
|
||||||
|
projectRepoStatus: topology.projectRepoStatus,
|
||||||
|
projectRootBranches: topology.projectRootBranches,
|
||||||
|
lastRepoStatus: topology.lastRepoStatus,
|
||||||
|
buildGroupedSessions,
|
||||||
|
hasSessionSearchQuery: view.hasSessionSearchQuery,
|
||||||
|
normalizedSessionSearchQuery: view.normalizedSessionSearchQuery,
|
||||||
|
filterSessionNodesForSearch,
|
||||||
|
buildGroupSearchText,
|
||||||
|
foldersMap,
|
||||||
|
});
|
||||||
|
const source = view.useGroupedSections ? sectionsForRender : flatSectionsForRender;
|
||||||
|
const sectionsForSidebarRender = React.useMemo(() => view.showInlineArchived ? source : source.map((section) => (
|
||||||
|
section.groups.some((group) => group.isArchivedBucket)
|
||||||
|
? { ...section, groups: section.groups.filter((group) => !group.isArchivedBucket) }
|
||||||
|
: section
|
||||||
|
)), [source, view.showInlineArchived]);
|
||||||
|
const getFolderScopesForProject = React.useCallback((projectId: string) => {
|
||||||
|
const section = flatSectionsForRender.find((entry) => entry.project.id === projectId);
|
||||||
|
return section?.groups.find((group) => !group.isArchivedBucket)?.folderScopes ?? [];
|
||||||
|
}, [flatSectionsForRender]);
|
||||||
|
const projectHeaderSentinelRefs = React.useRef<Map<string, HTMLDivElement | null>>(new Map());
|
||||||
|
const stuckProjectHeaders = useStickyProjectHeaders({
|
||||||
|
enabled: view.stickyZoneHeaders,
|
||||||
|
isDesktopShellRuntime: view.isDesktopShellRuntime,
|
||||||
|
projectSections,
|
||||||
|
projectHeaderSentinelRefs,
|
||||||
|
});
|
||||||
|
useArchivedAutoFolders({
|
||||||
|
enabled: true,
|
||||||
|
normalizedProjects: topology.projects,
|
||||||
|
ownership,
|
||||||
|
isSessionsLoading: view.isSessionsLoading,
|
||||||
|
hasAuthoritativeGlobalSessions: collection.hasAuthoritativeGlobalSessions,
|
||||||
|
isWorktreeTopologyLoading: view.isWorktreeTopologyLoading,
|
||||||
|
unresolvedWorktreeProjectPaths: view.unresolvedWorktreeProjectPaths,
|
||||||
|
foldersMap,
|
||||||
|
createFolder,
|
||||||
|
addSessionToFolder,
|
||||||
|
});
|
||||||
|
const { github } = useRuntimeAPIs();
|
||||||
|
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||||
|
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||||
|
const ensureEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
|
||||||
|
const setParams = useGitHubPrStatusStore((state) => state.setParams);
|
||||||
|
const refreshTargets = useGitHubPrStatusStore((state) => state.refreshTargets);
|
||||||
|
const retriedRef = React.useRef(new Set<string>());
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!github || !githubAuthChecked || !githubAuthStatus?.connected) return;
|
||||||
|
const targets = new Map<string, { directory: string; branch: string }>();
|
||||||
|
const now = Date.now();
|
||||||
|
projectSections.forEach((section) => {
|
||||||
|
if (projectView.collapsedProjects.has(section.project.id)) return;
|
||||||
|
section.groups.forEach((group) => {
|
||||||
|
if (group.isArchivedBucket || group.isMain) return;
|
||||||
|
const directory = normalizePath(group.directory ?? null);
|
||||||
|
const branch = group.branch?.trim() || topology.gitBranches.get(directory || '')?.trim();
|
||||||
|
if (!directory || !branch) return;
|
||||||
|
const key = getGitHubPrStatusKey(directory, branch);
|
||||||
|
const entry = useGitHubPrStatusStore.getState().entries[key];
|
||||||
|
const terminal = entry?.status?.pr?.state === 'closed' || entry?.status?.pr?.state === 'merged';
|
||||||
|
const retryKey = `${directory}::${branch}`;
|
||||||
|
const lastChecked = Math.max(entry?.lastRefreshAt ?? 0, entry?.lastDiscoveryPollAt ?? 0);
|
||||||
|
const retry = Boolean(entry?.isInitialStatusResolved && (!entry.status?.pr || terminal) && (!retriedRef.current.has(retryKey) || now - lastChecked >= PR_NO_PR_RETRY_MS));
|
||||||
|
if (!entry || !entry.isInitialStatusResolved || retry) {
|
||||||
|
if (retry) retriedRef.current.add(retryKey);
|
||||||
|
targets.set(key, { directory, branch });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
targets.forEach((target, key) => {
|
||||||
|
ensureEntry(key);
|
||||||
|
setParams(key, { ...target, remoteName: null, canShow: true, github, githubAuthChecked, githubConnected: githubAuthStatus.connected });
|
||||||
|
});
|
||||||
|
if (targets.size) void refreshTargets([...targets.values()], { silent: true, markInitialResolved: true });
|
||||||
|
}, [ensureEntry, github, githubAuthChecked, githubAuthStatus?.connected, projectSections, projectView.collapsedProjects, refreshTargets, setParams, topology.gitBranches]);
|
||||||
|
const sessionOrderIndex = React.useMemo(
|
||||||
|
() => new Map(collection.orderedSessions.map((session, index) => [session.id, index])),
|
||||||
|
[collection.orderedSessions],
|
||||||
|
);
|
||||||
|
const orderedSectionsForRender = React.useMemo(
|
||||||
|
() => sectionsForSidebarRender.map((section) => {
|
||||||
|
const groups = getOrderedGroups(section.project.id, section.groups);
|
||||||
|
return groups === section.groups ? section : { ...section, groups };
|
||||||
|
}),
|
||||||
|
[getOrderedGroups, sectionsForSidebarRender],
|
||||||
|
);
|
||||||
|
const groupProps = React.useMemo(() => ({
|
||||||
|
hasSessionSearchQuery: view.hasSessionSearchQuery,
|
||||||
|
normalizedSessionSearchQuery: view.normalizedSessionSearchQuery,
|
||||||
|
groupSearchDataByGroup,
|
||||||
|
collapsedGroups: projectView.collapsedGroups,
|
||||||
|
hideDirectoryControls: view.hideDirectoryControls,
|
||||||
|
mobileVariant: view.mobileVariant,
|
||||||
|
alwaysShowActions,
|
||||||
|
activeProjectId: view.activeProjectId,
|
||||||
|
notifyOnSubtasks,
|
||||||
|
pinnedSessionIds: collection.pinnedSessionIds,
|
||||||
|
sessionOrderIndex,
|
||||||
|
expandedParents,
|
||||||
|
editingId,
|
||||||
|
editTitle,
|
||||||
|
copiedSessionId,
|
||||||
|
setEditingId,
|
||||||
|
setEditTitle,
|
||||||
|
toggleParent,
|
||||||
|
allowReselect: rowActions.allowReselect,
|
||||||
|
onSessionSelected: rowActions.onSessionSelected,
|
||||||
|
isSessionSearchOpen: rowActions.isSessionSearchOpen,
|
||||||
|
sessionSearchQuery: rowActions.sessionSearchQuery,
|
||||||
|
setSessionSearchQuery: rowActions.setSessionSearchQuery,
|
||||||
|
setIsSessionSearchOpen: rowActions.setIsSessionSearchOpen,
|
||||||
|
deleteSessionConfirm,
|
||||||
|
setDeleteSessionConfirm,
|
||||||
|
startFolderRename,
|
||||||
|
setCopiedSessionId,
|
||||||
|
folderRename,
|
||||||
|
setFolderRenameDraft,
|
||||||
|
clearFolderRename,
|
||||||
|
}), [
|
||||||
|
collection.pinnedSessionIds,
|
||||||
|
alwaysShowActions,
|
||||||
|
notifyOnSubtasks,
|
||||||
|
projectView.collapsedGroups,
|
||||||
|
groupSearchDataByGroup,
|
||||||
|
sessionOrderIndex,
|
||||||
|
editTitle,
|
||||||
|
editingId,
|
||||||
|
expandedParents,
|
||||||
|
folderRename,
|
||||||
|
setFolderRenameDraft,
|
||||||
|
clearFolderRename,
|
||||||
|
startFolderRename,
|
||||||
|
deleteSessionConfirm,
|
||||||
|
copiedSessionId,
|
||||||
|
setCopiedSessionId,
|
||||||
|
rowActions,
|
||||||
|
toggleParent,
|
||||||
|
view.activeProjectId,
|
||||||
|
view.hideDirectoryControls,
|
||||||
|
view.hasSessionSearchQuery,
|
||||||
|
view.mobileVariant,
|
||||||
|
view.normalizedSessionSearchQuery,
|
||||||
|
]);
|
||||||
|
const groupActions = React.useMemo(() => ({
|
||||||
|
showMoreGroupSessions,
|
||||||
|
resetGroupSessionLimit,
|
||||||
|
setActiveProjectIdOnly: scrollerActions.setActiveProjectIdOnly,
|
||||||
|
setActiveMainTab: scrollerActions.setActiveMainTab,
|
||||||
|
setSessionSwitcherOpen: scrollerActions.setSessionSwitcherOpen,
|
||||||
|
openNewSessionDraft: scrollerActions.openNewSessionDraft,
|
||||||
|
onToggleCollapsedGroup: toggleGroup,
|
||||||
|
}), [
|
||||||
|
resetGroupSessionLimit,
|
||||||
|
showMoreGroupSessions,
|
||||||
|
toggleGroup,
|
||||||
|
scrollerActions.openNewSessionDraft,
|
||||||
|
scrollerActions.setActiveMainTab,
|
||||||
|
scrollerActions.setActiveProjectIdOnly,
|
||||||
|
scrollerActions.setSessionSwitcherOpen,
|
||||||
|
]);
|
||||||
|
const recentSection = React.useMemo(() => (
|
||||||
|
!topology.isVSCode && showRecentSection ? <RecentSessionSection
|
||||||
|
projects={topology.projects}
|
||||||
|
availableWorktreesByProject={topology.availableWorktreesByProject}
|
||||||
|
gitBranches={topology.gitBranches}
|
||||||
|
homeDirectory={view.homeDirectory}
|
||||||
|
hasSessionSearchQuery={view.hasSessionSearchQuery}
|
||||||
|
normalizedSessionSearchQuery={view.normalizedSessionSearchQuery}
|
||||||
|
isDesktopShellRuntime={view.isDesktopShellRuntime}
|
||||||
|
sessions={collection.sessions}
|
||||||
|
childrenMap={collection.childrenMap}
|
||||||
|
pinnedSessionIds={collection.pinnedSessionIds}
|
||||||
|
recentSessions={recentSessions}
|
||||||
|
expandedParents={expandedParents}
|
||||||
|
notifyOnSubtasks={notifyOnSubtasks}
|
||||||
|
editingId={editingId}
|
||||||
|
editTitle={editTitle}
|
||||||
|
copiedSessionId={copiedSessionId}
|
||||||
|
openSidebarMenuKey={openSidebarMenuKey}
|
||||||
|
mobileVariant={view.mobileVariant}
|
||||||
|
alwaysShowActions={alwaysShowActions}
|
||||||
|
setEditingId={setEditingId}
|
||||||
|
setEditTitle={setEditTitle}
|
||||||
|
toggleParent={toggleParent}
|
||||||
|
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||||
|
allowReselect={rowActions.allowReselect}
|
||||||
|
onSessionSelected={rowActions.onSessionSelected}
|
||||||
|
isSessionSearchOpen={rowActions.isSessionSearchOpen}
|
||||||
|
sessionSearchQuery={rowActions.sessionSearchQuery}
|
||||||
|
setSessionSearchQuery={rowActions.setSessionSearchQuery}
|
||||||
|
setIsSessionSearchOpen={rowActions.setIsSessionSearchOpen}
|
||||||
|
deleteSessionConfirm={deleteSessionConfirm}
|
||||||
|
setDeleteSessionConfirm={setDeleteSessionConfirm}
|
||||||
|
startFolderRename={startFolderRename}
|
||||||
|
setCopiedSessionId={setCopiedSessionId}
|
||||||
|
/> : null
|
||||||
|
), [
|
||||||
|
alwaysShowActions,
|
||||||
|
collection.childrenMap,
|
||||||
|
collection.pinnedSessionIds,
|
||||||
|
collection.sessions,
|
||||||
|
copiedSessionId,
|
||||||
|
deleteSessionConfirm,
|
||||||
|
editTitle,
|
||||||
|
editingId,
|
||||||
|
expandedParents,
|
||||||
|
notifyOnSubtasks,
|
||||||
|
openSidebarMenuKey,
|
||||||
|
recentSessions,
|
||||||
|
rowActions,
|
||||||
|
showRecentSection,
|
||||||
|
startFolderRename,
|
||||||
|
toggleParent,
|
||||||
|
topology.availableWorktreesByProject,
|
||||||
|
topology.gitBranches,
|
||||||
|
topology.isVSCode,
|
||||||
|
topology.projects,
|
||||||
|
view.hasSessionSearchQuery,
|
||||||
|
view.homeDirectory,
|
||||||
|
view.isDesktopShellRuntime,
|
||||||
|
view.mobileVariant,
|
||||||
|
view.normalizedSessionSearchQuery,
|
||||||
|
]);
|
||||||
|
const scrollerModel = React.useMemo(() => ({
|
||||||
|
topContent: recentSection,
|
||||||
|
hasSharedSessions: Boolean(recentSection),
|
||||||
|
sectionsForRender: orderedSectionsForRender,
|
||||||
|
projectSections,
|
||||||
|
activeProjectId: view.activeProjectId,
|
||||||
|
emptyState: view.emptyState,
|
||||||
|
searchEmptyState: view.searchEmptyState,
|
||||||
|
projectRepoStatus: topology.projectRepoStatus,
|
||||||
|
stuckProjectHeaders,
|
||||||
|
projectHeaderSentinelRefs,
|
||||||
|
state: { editingId, openSidebarMenuKey, setOpenSidebarMenuKey, visibleSessionCountByGroup },
|
||||||
|
groupProps,
|
||||||
|
}), [
|
||||||
|
groupProps,
|
||||||
|
editingId,
|
||||||
|
openSidebarMenuKey,
|
||||||
|
projectSections,
|
||||||
|
orderedSectionsForRender,
|
||||||
|
stuckProjectHeaders,
|
||||||
|
topology.projectRepoStatus,
|
||||||
|
view.activeProjectId,
|
||||||
|
view.emptyState,
|
||||||
|
view.searchEmptyState,
|
||||||
|
visibleSessionCountByGroup,
|
||||||
|
recentSection,
|
||||||
|
]);
|
||||||
|
const scrollerView = React.useMemo(() => ({
|
||||||
|
homeDirectory: view.homeDirectory,
|
||||||
|
collapsedProjects: projectView.collapsedProjects,
|
||||||
|
showOnlyMainWorkspace: view.showOnlyMainWorkspace,
|
||||||
|
hasSessionSearchQuery: view.hasSessionSearchQuery,
|
||||||
|
normalizedSessionSearchQuery: view.normalizedSessionSearchQuery,
|
||||||
|
hideDirectoryControls: view.hideDirectoryControls,
|
||||||
|
isDesktopShellRuntime: view.isDesktopShellRuntime,
|
||||||
|
stickyZoneHeaders: view.stickyZoneHeaders,
|
||||||
|
mobileVariant: view.mobileVariant,
|
||||||
|
alwaysShowActions,
|
||||||
|
projectSortOrder: view.projectSortOrder,
|
||||||
|
}), [
|
||||||
|
projectView.collapsedProjects,
|
||||||
|
view.homeDirectory,
|
||||||
|
view.hasSessionSearchQuery,
|
||||||
|
view.hideDirectoryControls,
|
||||||
|
view.isDesktopShellRuntime,
|
||||||
|
view.mobileVariant,
|
||||||
|
alwaysShowActions,
|
||||||
|
view.normalizedSessionSearchQuery,
|
||||||
|
view.projectSortOrder,
|
||||||
|
view.showOnlyMainWorkspace,
|
||||||
|
view.stickyZoneHeaders,
|
||||||
|
]);
|
||||||
|
const scrollerActionSet = React.useMemo(() => ({
|
||||||
|
group: groupActions,
|
||||||
|
toggleProject,
|
||||||
|
setActiveProjectIdOnly: scrollerActions.setActiveProjectIdOnly,
|
||||||
|
setActiveMainTab: scrollerActions.setActiveMainTab,
|
||||||
|
setSessionSwitcherOpen: scrollerActions.setSessionSwitcherOpen,
|
||||||
|
openNewSessionDraft: scrollerActions.openNewSessionDraft,
|
||||||
|
openNewWorktreeDialog: scrollerActions.openNewWorktreeDialog,
|
||||||
|
openWorktreesPage: scrollerActions.openWorktreesPage,
|
||||||
|
openProjectEditDialog: scrollerActions.openProjectEditDialog,
|
||||||
|
removeProject: scrollerActions.removeProject,
|
||||||
|
reorderProjects: scrollerActions.reorderProjects,
|
||||||
|
setGroupOrderByProject,
|
||||||
|
renderProjectStatusIndicator: scrollerActions.renderProjectStatusIndicator,
|
||||||
|
}), [
|
||||||
|
groupActions,
|
||||||
|
scrollerActions.openNewSessionDraft,
|
||||||
|
scrollerActions.openNewWorktreeDialog,
|
||||||
|
scrollerActions.openProjectEditDialog,
|
||||||
|
scrollerActions.openWorktreesPage,
|
||||||
|
scrollerActions.removeProject,
|
||||||
|
scrollerActions.reorderProjects,
|
||||||
|
scrollerActions.setActiveMainTab,
|
||||||
|
scrollerActions.setActiveProjectIdOnly,
|
||||||
|
scrollerActions.setSessionSwitcherOpen,
|
||||||
|
setGroupOrderByProject,
|
||||||
|
toggleProject,
|
||||||
|
scrollerActions.renderProjectStatusIndicator,
|
||||||
|
]);
|
||||||
|
return <>
|
||||||
|
<ProjectSessionSelectionEffect
|
||||||
|
projectSections={projectSections}
|
||||||
|
activeProjectId={view.activeProjectId}
|
||||||
|
initialActiveSessionByProject={actions.initialActiveSessionByProject}
|
||||||
|
persistActiveSessionByProject={actions.persistActiveSessionByProject}
|
||||||
|
mobileVariant={view.mobileVariant}
|
||||||
|
openNewSessionDraft={actions.openNewSessionDraft}
|
||||||
|
setActiveMainTab={actions.setActiveMainTab}
|
||||||
|
setSessionSwitcherOpen={actions.setSessionSwitcherOpen}
|
||||||
|
sessionOwnerBySessionId={ownership.bySessionId}
|
||||||
|
handleSessionSelect={selectSessionForProject}
|
||||||
|
/>
|
||||||
|
<SessionPrefetchEffect
|
||||||
|
sortedSessions={collection.orderedSessions}
|
||||||
|
recentSessions={recentSessions}
|
||||||
|
prefetchSession={sync.prefetchSession}
|
||||||
|
/>
|
||||||
|
<SessionProjectScroller model={scrollerModel} view={scrollerView} actions={scrollerActionSet} />
|
||||||
|
<SessionBulkActions
|
||||||
|
getFolderScopesForProject={getFolderScopesForProject}
|
||||||
|
isInlineEditing={editingId !== null}
|
||||||
|
startFolderRename={startFolderRename}
|
||||||
|
/>
|
||||||
|
</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SessionProjectCollection: React.FC<SessionProjectCollectionProps> = (props) => props.view.isVisible ? <VisibleSessionProjects {...props} /> : null;
|
||||||
+18
@@ -44,4 +44,22 @@ describe("buildSessionBootstrapDemands", () => {
|
|||||||
expect(byDirectory.get("/repo/wt-a")?.priority).toBe("expanded")
|
expect(byDirectory.get("/repo/wt-a")?.priority).toBe("expanded")
|
||||||
expect(byDirectory.get("/repo/wt-b")?.priority).toBe("selected")
|
expect(byDirectory.get("/repo/wt-b")?.priority).toBe("selected")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps the complete known topology demanded without a visible section projection", () => {
|
||||||
|
const demands = buildSessionBootstrapDemands({
|
||||||
|
knownDirectories: ["/repo", "/repo/wt-a", "/repo/wt-b"],
|
||||||
|
activeProjectDirectory: "/repo",
|
||||||
|
activeProjectId: "project-a",
|
||||||
|
collapsedProjects: new Set(),
|
||||||
|
collapsedGroups: new Set(),
|
||||||
|
currentDirectory: null,
|
||||||
|
currentSessionDirectory: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(demands.map(({ directory, priority }) => [directory, priority])).toEqual([
|
||||||
|
["/repo", "active-project"],
|
||||||
|
["/repo/wt-a", "background"],
|
||||||
|
["/repo/wt-b", "background"],
|
||||||
|
])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
+12
-5
@@ -1,5 +1,5 @@
|
|||||||
import type { DirectoryBootstrapDemand, DirectoryBootstrapPriority } from "@/sync/child-store"
|
import type { DirectoryBootstrapDemand, DirectoryBootstrapPriority } from "@/sync/child-store"
|
||||||
import { normalizePath } from "./utils"
|
import { normalizePath } from "../utils"
|
||||||
|
|
||||||
type BootstrapProjectSection = {
|
type BootstrapProjectSection = {
|
||||||
project: { id: string; normalizedPath: string }
|
project: { id: string; normalizedPath: string }
|
||||||
@@ -11,16 +11,18 @@ type BootstrapProjectSection = {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
const PRIORITY_RANK: Record<DirectoryBootstrapPriority, number> = {
|
const PRIORITY_RANK = {
|
||||||
selected: 0,
|
selected: 0,
|
||||||
"active-project": 1,
|
"active-project": 1,
|
||||||
expanded: 2,
|
expanded: 2,
|
||||||
visible: 3,
|
visible: 3,
|
||||||
background: 4,
|
background: 4,
|
||||||
}
|
} satisfies Record<DirectoryBootstrapPriority, number>
|
||||||
|
|
||||||
export function buildSessionBootstrapDemands(input: {
|
export function buildSessionBootstrapDemands(input: {
|
||||||
projectSections: BootstrapProjectSection[]
|
projectSections?: BootstrapProjectSection[]
|
||||||
|
knownDirectories?: Iterable<string>
|
||||||
|
activeProjectDirectory?: string | null
|
||||||
activeProjectId: string | null
|
activeProjectId: string | null
|
||||||
collapsedProjects: ReadonlySet<string>
|
collapsedProjects: ReadonlySet<string>
|
||||||
collapsedGroups: ReadonlySet<string>
|
collapsedGroups: ReadonlySet<string>
|
||||||
@@ -40,7 +42,12 @@ export function buildSessionBootstrapDemands(input: {
|
|||||||
byDirectory.set(normalizedDirectory, { directory: normalizedDirectory, priority, reason })
|
byDirectory.set(normalizedDirectory, { directory: normalizedDirectory, priority, reason })
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const section of input.projectSections) {
|
for (const directory of input.knownDirectories ?? []) {
|
||||||
|
add(directory, "background", "known-project")
|
||||||
|
}
|
||||||
|
add(input.activeProjectDirectory, "active-project", "project-expanded")
|
||||||
|
|
||||||
|
for (const section of input.projectSections ?? []) {
|
||||||
const projectExpanded = !input.collapsedProjects.has(section.project.id)
|
const projectExpanded = !input.collapsedProjects.has(section.project.id)
|
||||||
let projectPriority: DirectoryBootstrapPriority = "background"
|
let projectPriority: DirectoryBootstrapPriority = "background"
|
||||||
if (section.project.id === input.activeProjectId) {
|
if (section.project.id === input.activeProjectId) {
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import type { Event } from '@opencode-ai/sdk/v2/client';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { deriveRecentSessions } from '../recent/activitySections';
|
||||||
|
import { applyGlobalSessionStatusEvent, useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||||
|
import { getDescendantIds, projectSidebarActiveSessions, projectSidebarCollection, useRecentSessionCollection } from './sessionCollection';
|
||||||
|
|
||||||
|
const installMinimalDom = () => {
|
||||||
|
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||||
|
const setGlobal = (name: string, value: unknown) => {
|
||||||
|
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||||
|
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||||
|
};
|
||||||
|
class ElementStub {}
|
||||||
|
const documentStub: Record<string, unknown> = {
|
||||||
|
nodeType: 9, defaultView: globalThis, activeElement: null,
|
||||||
|
addEventListener: () => undefined, removeEventListener: () => undefined,
|
||||||
|
};
|
||||||
|
const container = {
|
||||||
|
nodeType: 1, tagName: 'DIV', nodeName: 'DIV', namespaceURI: 'http://www.w3.org/1999/xhtml', ownerDocument: documentStub,
|
||||||
|
addEventListener: () => undefined, removeEventListener: () => undefined,
|
||||||
|
};
|
||||||
|
documentStub.documentElement = container;
|
||||||
|
documentStub.body = container;
|
||||||
|
setGlobal('document', documentStub);
|
||||||
|
setGlobal('window', globalThis);
|
||||||
|
setGlobal('Element', ElementStub);
|
||||||
|
setGlobal('HTMLElement', ElementStub);
|
||||||
|
setGlobal('HTMLIFrameElement', ElementStub);
|
||||||
|
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
return {
|
||||||
|
container: container as unknown as Element,
|
||||||
|
restore: () => {
|
||||||
|
for (const [name, descriptor] of descriptors) {
|
||||||
|
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||||
|
else Reflect.deleteProperty(globalThis, name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const session = (id: string, directory: string | null): Session => {
|
||||||
|
// SAFETY: Sidebar projection reads only id, directory, and time from session fixtures.
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
directory,
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
} as Session;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('projectSidebarActiveSessions', () => {
|
||||||
|
test('keeps global precedence and order, then appends missing live sessions', () => {
|
||||||
|
const global = [session('global-b', '/workspace/b'), session('global-a', '/workspace/a')];
|
||||||
|
const live = [session('global-a', '/workspace/a'), session('live-c', '/workspace/c')];
|
||||||
|
|
||||||
|
expect(projectSidebarActiveSessions({
|
||||||
|
globalActiveSessions: global,
|
||||||
|
liveSessions: live,
|
||||||
|
knownDirectories: new Set(['/workspace/a', '/workspace/b', '/workspace/c']),
|
||||||
|
isVSCode: false,
|
||||||
|
}).map((entry) => entry.id)).toEqual(['global-b', 'global-a', 'live-c']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters unknown VS Code directories', () => {
|
||||||
|
const sessions = [session('known', '/workspace/known'), session('unknown', '/workspace/unknown')];
|
||||||
|
|
||||||
|
expect(projectSidebarActiveSessions({
|
||||||
|
globalActiveSessions: sessions,
|
||||||
|
liveSessions: [],
|
||||||
|
knownDirectories: new Set(['/workspace/known']),
|
||||||
|
isVSCode: true,
|
||||||
|
}).map((entry) => entry.id)).toEqual(['known']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows missing or unknown directories for web when no directories are known', () => {
|
||||||
|
const sessions = [session('unknown', '/workspace/unknown'), session('empty', null)];
|
||||||
|
|
||||||
|
expect(projectSidebarActiveSessions({
|
||||||
|
globalActiveSessions: sessions,
|
||||||
|
liveSessions: [],
|
||||||
|
knownDirectories: new Set(),
|
||||||
|
isVSCode: false,
|
||||||
|
}).map((entry) => entry.id)).toEqual(['unknown', 'empty']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps archived sessions despite directory filtering', () => {
|
||||||
|
const archived = session('archived', '/workspace/unknown');
|
||||||
|
archived.time.archived = 1;
|
||||||
|
|
||||||
|
expect(projectSidebarActiveSessions({
|
||||||
|
globalActiveSessions: [archived],
|
||||||
|
liveSessions: [],
|
||||||
|
knownDirectories: new Set(['/workspace/known']),
|
||||||
|
isVSCode: true,
|
||||||
|
}).map((entry) => entry.id)).toEqual(['archived']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not replace a filtered global record with a live duplicate', () => {
|
||||||
|
expect(projectSidebarActiveSessions({
|
||||||
|
globalActiveSessions: [session('same', '/workspace/unknown')],
|
||||||
|
liveSessions: [session('same', '/workspace/known')],
|
||||||
|
knownDirectories: new Set(['/workspace/known']),
|
||||||
|
isVSCode: true,
|
||||||
|
})).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('projectSidebarCollection', () => {
|
||||||
|
test('returns the same structural projection for unchanged inputs without module caching', () => {
|
||||||
|
const globalActiveSessions = [session('a', '/workspace/a'), session('b', '/workspace/b')];
|
||||||
|
const input = {
|
||||||
|
globalActiveSessions,
|
||||||
|
liveSessions: [],
|
||||||
|
knownDirectories: new Set(['/workspace/a', '/workspace/b']),
|
||||||
|
isVSCode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeSelection = projectSidebarCollection(input);
|
||||||
|
const afterSelection = projectSidebarCollection(input);
|
||||||
|
|
||||||
|
expect(afterSelection).toEqual(beforeSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rebuilds when a structural session collection input changes', () => {
|
||||||
|
const input = {
|
||||||
|
globalActiveSessions: [session('a', '/workspace/a')],
|
||||||
|
liveSessions: [],
|
||||||
|
knownDirectories: new Set(['/workspace/a']),
|
||||||
|
isVSCode: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const before = projectSidebarCollection(input);
|
||||||
|
const after = projectSidebarCollection({
|
||||||
|
...input,
|
||||||
|
globalActiveSessions: [session('a', '/workspace/a'), session('b', '/workspace/a')],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(after).not.toBe(before);
|
||||||
|
expect(after.map((entry) => entry.id)).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps project membership independent from Recent active membership', () => {
|
||||||
|
const input = {
|
||||||
|
globalActiveSessions: [session('old-root', '/workspace/a')],
|
||||||
|
liveSessions: [],
|
||||||
|
knownDirectories: new Set(['/workspace/a']),
|
||||||
|
isVSCode: false,
|
||||||
|
};
|
||||||
|
const projectBefore = projectSidebarCollection(input);
|
||||||
|
const recentBefore = deriveRecentSessions(projectBefore, new Set(), 200_000_000);
|
||||||
|
const projectAfter = projectSidebarCollection(input);
|
||||||
|
const recentAfter = deriveRecentSessions(projectAfter, new Set(['old-root']), 200_000_000);
|
||||||
|
|
||||||
|
expect(projectAfter).toEqual(projectBefore);
|
||||||
|
expect(recentBefore).toEqual([]);
|
||||||
|
expect(recentAfter.map((entry) => entry.id)).toEqual(['old-root']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useRecentSessionCollection', () => {
|
||||||
|
test('updates mounted Recent membership when global active status changes', async () => {
|
||||||
|
const dom = installMinimalDom();
|
||||||
|
const root: Root = createRoot(dom.container);
|
||||||
|
const oldSession = { ...session('old-root', '/workspace/a'), time: { created: 1, updated: 1 } };
|
||||||
|
let renderedIds: string[] = [];
|
||||||
|
let renderCount = 0;
|
||||||
|
let timeReadCount = 0;
|
||||||
|
Object.defineProperty(oldSession, 'time', {
|
||||||
|
get: () => {
|
||||||
|
timeReadCount += 1;
|
||||||
|
return { created: 1, updated: 1 };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
timeReadCount = 0;
|
||||||
|
const Harness = () => {
|
||||||
|
renderCount += 1;
|
||||||
|
const recent = useRecentSessionCollection({
|
||||||
|
enabled: true,
|
||||||
|
isVSCode: false,
|
||||||
|
pinnedSessionIds: new Set(),
|
||||||
|
sessionOrderRanks: new Map(),
|
||||||
|
sessions: [oldSession],
|
||||||
|
});
|
||||||
|
renderedIds = recent.map((entry) => entry.id);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
useGlobalSessionStatusStore.setState({ statusById: new Map() });
|
||||||
|
await act(async () => root.render(React.createElement(Harness)));
|
||||||
|
expect(renderedIds).toEqual([]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
// SAFETY: This fixture matches the SDK event shape consumed by the status event reducer.
|
||||||
|
applyGlobalSessionStatusEvent('/workspace/a', {
|
||||||
|
type: 'session.status',
|
||||||
|
properties: { sessionID: 'old-root', status: { type: 'busy' } },
|
||||||
|
} as Event);
|
||||||
|
});
|
||||||
|
expect(renderedIds).toEqual(['old-root']);
|
||||||
|
const activeRenderCount = renderCount;
|
||||||
|
const activeDeriveOperationCount = timeReadCount;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
// SAFETY: This fixture matches the SDK event shape consumed by the status event reducer.
|
||||||
|
applyGlobalSessionStatusEvent('/other-workspace', {
|
||||||
|
type: 'session.status',
|
||||||
|
properties: { sessionID: 'old-root', status: { type: 'retry', attempt: 2, message: 'waiting' } },
|
||||||
|
} as Event);
|
||||||
|
});
|
||||||
|
expect(renderCount).toBe(activeRenderCount);
|
||||||
|
expect(timeReadCount).toBe(activeDeriveOperationCount);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
useGlobalSessionStatusStore.setState({ statusById: new Map() });
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getDescendantIds', () => {
|
||||||
|
test('returns a depth-first subtree without exposing session entities', () => {
|
||||||
|
const childA = session('child-a', '/workspace/a');
|
||||||
|
const grandchild = session('grandchild', '/workspace/a');
|
||||||
|
const childB = session('child-b', '/workspace/a');
|
||||||
|
const childrenMap = new Map([
|
||||||
|
['root', [childA, childB]],
|
||||||
|
['child-a', [grandchild]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getDescendantIds(childrenMap, 'root'))
|
||||||
|
.toEqual(['child-a', 'grandchild', 'child-b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cuts a parent cycle with deterministic unique descendants and excludes the root', () => {
|
||||||
|
const childA = session('a', '/workspace/a');
|
||||||
|
const childB = session('b', '/workspace/a');
|
||||||
|
const childC = session('c', '/workspace/a');
|
||||||
|
const childrenMap = new Map([
|
||||||
|
['root', [childA]],
|
||||||
|
['a', [childB, childC]],
|
||||||
|
['b', [childA]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getDescendantIds(childrenMap, 'root')).toEqual(['a', 'b', 'c']);
|
||||||
|
expect(new Set(getDescendantIds(childrenMap, 'root')).size).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import { useAllLiveSessions } from '@/sync/sync-context';
|
||||||
|
import {
|
||||||
|
compareSessionsByLifecycleOrder,
|
||||||
|
EMPTY_SESSION_ORDER_RANKS,
|
||||||
|
orderSessionsByLifecycleScopes,
|
||||||
|
useSessionOrderingStore,
|
||||||
|
} from '@/sync/session-ordering';
|
||||||
|
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||||
|
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||||
|
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||||
|
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||||
|
import { deriveRecentSessions } from '../recent/activitySections';
|
||||||
|
import { normalizePath } from '../utils';
|
||||||
|
|
||||||
|
type ProjectSidebarActiveSessionsArgs = {
|
||||||
|
globalActiveSessions: Session[];
|
||||||
|
liveSessions: Session[];
|
||||||
|
knownDirectories: Set<string>;
|
||||||
|
isVSCode: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_ACTIVE_SESSION_IDS: ReadonlySet<string> = new Set();
|
||||||
|
|
||||||
|
const isKnownActiveSessionDirectory = (
|
||||||
|
session: Session,
|
||||||
|
knownDirectories: Set<string>,
|
||||||
|
isVSCode: boolean,
|
||||||
|
): boolean => {
|
||||||
|
if (session.time?.archived) return true;
|
||||||
|
const directory = normalizePath(resolveGlobalSessionDirectory(session))?.toLowerCase();
|
||||||
|
if (!directory) return !isVSCode;
|
||||||
|
if (knownDirectories.size === 0) return !isVSCode;
|
||||||
|
return knownDirectories.has(directory);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Global sessions provide complete sidebar coverage; initialized directory
|
||||||
|
// stores only fill gaps until the global cache catches up.
|
||||||
|
export const projectSidebarActiveSessions = ({
|
||||||
|
globalActiveSessions,
|
||||||
|
liveSessions,
|
||||||
|
knownDirectories,
|
||||||
|
isVSCode,
|
||||||
|
}: ProjectSidebarActiveSessionsArgs): Session[] => {
|
||||||
|
const sessions = [...globalActiveSessions];
|
||||||
|
const knownIds = new Set(globalActiveSessions.map((session) => session.id));
|
||||||
|
|
||||||
|
for (const session of liveSessions) {
|
||||||
|
if (knownIds.has(session.id)) continue;
|
||||||
|
sessions.push(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions.filter((session) => isKnownActiveSessionDirectory(session, knownDirectories, isVSCode));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const projectSidebarCollection = (args: ProjectSidebarActiveSessionsArgs): Session[] => {
|
||||||
|
return projectSidebarActiveSessions(args);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The collection owns hierarchy membership. Consumers receive this narrow
|
||||||
|
// resolver instead of retaining the collection's mutable indexing detail.
|
||||||
|
export const getDescendantIds = (
|
||||||
|
childrenMap: ReadonlyMap<string, readonly Session[]>,
|
||||||
|
sessionId: string,
|
||||||
|
): string[] => {
|
||||||
|
const descendants: string[] = [];
|
||||||
|
const visited = new Set<string>([sessionId]);
|
||||||
|
const visit = (parentId: string): void => {
|
||||||
|
for (const child of childrenMap.get(parentId) ?? []) {
|
||||||
|
if (visited.has(child.id)) continue;
|
||||||
|
visited.add(child.id);
|
||||||
|
descendants.push(child.id);
|
||||||
|
visit(child.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(sessionId);
|
||||||
|
return descendants;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseSessionProjectCollectionArgs = {
|
||||||
|
knownDirectories: Set<string>;
|
||||||
|
isVSCode: boolean;
|
||||||
|
isVisible: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The collection owns the global-first/live-gap merge and lifecycle ordering.
|
||||||
|
// Selection state intentionally never enters this boundary: rows subscribe to
|
||||||
|
// active state themselves, leaving this projection referentially stable.
|
||||||
|
export const useSessionProjectCollection = ({
|
||||||
|
knownDirectories,
|
||||||
|
isVSCode,
|
||||||
|
isVisible,
|
||||||
|
}: UseSessionProjectCollectionArgs) => {
|
||||||
|
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||||
|
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||||
|
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
|
||||||
|
const liveSessions = useAllLiveSessions();
|
||||||
|
const pinnedSessionIds = useSessionPinnedStore((state) => state.ids);
|
||||||
|
const sessionOrderRanks = useSessionOrderingStore(React.useCallback(
|
||||||
|
(state) => isVisible ? state.rankById : EMPTY_SESSION_ORDER_RANKS,
|
||||||
|
[isVisible],
|
||||||
|
));
|
||||||
|
const sessions = React.useMemo(() => projectSidebarCollection({
|
||||||
|
globalActiveSessions,
|
||||||
|
liveSessions,
|
||||||
|
knownDirectories,
|
||||||
|
isVSCode,
|
||||||
|
}), [globalActiveSessions, isVSCode, knownDirectories, liveSessions]);
|
||||||
|
const orderedSessions = React.useMemo(
|
||||||
|
() => orderSessionsByLifecycleScopes(sessions, pinnedSessionIds, sessionOrderRanks),
|
||||||
|
[pinnedSessionIds, sessionOrderRanks, sessions],
|
||||||
|
);
|
||||||
|
const sessionById = React.useMemo(() => new Map(
|
||||||
|
[...orderedSessions, ...archivedSessions].map((session) => [session.id, session]),
|
||||||
|
), [archivedSessions, orderedSessions]);
|
||||||
|
const childrenMap = React.useMemo(() => {
|
||||||
|
const children = new Map<string, Session[]>();
|
||||||
|
for (const session of sessionById.values()) {
|
||||||
|
// SAFETY: OpenCode's session records carry parentID for sub-session
|
||||||
|
// hierarchy; the SDK's base Session type does not currently expose it.
|
||||||
|
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||||
|
if (!parentID) continue;
|
||||||
|
const siblings = children.get(parentID) ?? [];
|
||||||
|
siblings.push(session);
|
||||||
|
children.set(parentID, siblings);
|
||||||
|
}
|
||||||
|
return children;
|
||||||
|
}, [sessionById]);
|
||||||
|
const getDescendantIdsForAction = React.useCallback(
|
||||||
|
(sessionId: string, options: { includeArchived: boolean }) => getDescendantIds(childrenMap, sessionId)
|
||||||
|
.filter((id) => options.includeArchived || !Boolean(sessionById.get(id)?.time?.archived)),
|
||||||
|
[childrenMap, sessionById],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
archivedSessions,
|
||||||
|
childrenMap,
|
||||||
|
getDescendantIds: getDescendantIdsForAction,
|
||||||
|
globalActiveSessions,
|
||||||
|
hasAuthoritativeGlobalSessions,
|
||||||
|
liveSessions,
|
||||||
|
orderedSessions,
|
||||||
|
pinnedSessionIds,
|
||||||
|
sessionOrderRanks,
|
||||||
|
sessions,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseRecentSessionCollectionArgs = {
|
||||||
|
enabled: boolean;
|
||||||
|
isVSCode: boolean;
|
||||||
|
pinnedSessionIds: Set<string>;
|
||||||
|
sessionOrderRanks: ReadonlyMap<string, number>;
|
||||||
|
sessions: Session[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recent is a separate high-frequency collection view. Its active membership
|
||||||
|
// never participates in project ownership or project section projection.
|
||||||
|
export const useRecentSessionCollection = ({
|
||||||
|
enabled,
|
||||||
|
isVSCode,
|
||||||
|
pinnedSessionIds,
|
||||||
|
sessionOrderRanks,
|
||||||
|
sessions,
|
||||||
|
}: UseRecentSessionCollectionArgs): Session[] => {
|
||||||
|
const activeSessionIdSet = useGlobalSessionStatusStore(
|
||||||
|
React.useCallback(
|
||||||
|
(state) => enabled && !isVSCode ? state.activeSessionIds : EMPTY_ACTIVE_SESSION_IDS,
|
||||||
|
[enabled, isVSCode],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return React.useMemo(() => {
|
||||||
|
if (!enabled || isVSCode) return [];
|
||||||
|
return deriveRecentSessions(sessions, activeSessionIdSet)
|
||||||
|
.sort((left, right) => compareSessionsByLifecycleOrder(left, right, pinnedSessionIds, sessionOrderRanks));
|
||||||
|
}, [activeSessionIdSet, enabled, isVSCode, pinnedSessionIds, sessionOrderRanks, sessions]);
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { buildKnownSessionDirectories } from './sessionListDirectories';
|
||||||
|
|
||||||
|
describe('buildKnownSessionDirectories', () => {
|
||||||
|
test('normalizes project roots and optionally includes worktrees', () => {
|
||||||
|
const worktrees = new Map([
|
||||||
|
['/repo', [{ path: '/repo/worktree', projectDirectory: '/repo', branch: 'worktree', label: 'worktree' }]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect([...buildKnownSessionDirectories([{ path: '/Repo' }], worktrees)]).toEqual([
|
||||||
|
'/repo',
|
||||||
|
'/repo/worktree',
|
||||||
|
]);
|
||||||
|
expect([...buildKnownSessionDirectories([{ path: '/Repo' }], worktrees, { includeWorktrees: false })]).toEqual([
|
||||||
|
'/repo',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
|
import { normalizePath } from '../utils';
|
||||||
|
|
||||||
|
export const buildKnownSessionDirectories = (
|
||||||
|
projects: Array<{ path: string }>,
|
||||||
|
availableWorktreesByProject: Map<string, WorktreeMetadata[]>,
|
||||||
|
options?: { includeWorktrees?: boolean },
|
||||||
|
): Set<string> => {
|
||||||
|
const directories = new Set<string>();
|
||||||
|
for (const project of projects) {
|
||||||
|
const normalized = normalizePath(project.path)?.toLowerCase();
|
||||||
|
if (normalized) directories.add(normalized);
|
||||||
|
}
|
||||||
|
if (options?.includeWorktrees === false) {
|
||||||
|
return directories;
|
||||||
|
}
|
||||||
|
for (const worktrees of availableWorktreesByProject.values()) {
|
||||||
|
for (const worktree of worktrees) {
|
||||||
|
const normalized = normalizePath(worktree.path)?.toLowerCase();
|
||||||
|
if (normalized) directories.add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return directories;
|
||||||
|
};
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import { installHookTestDom } from '../test-utils/testDom';
|
||||||
|
import {
|
||||||
|
buildAuthoritativeSessionIdentityMap,
|
||||||
|
findRemovedAuthoritativeSessions,
|
||||||
|
} from './authoritativeSessionCleanup';
|
||||||
|
|
||||||
|
// SAFETY: cleanup identity tests only consume the SDK session ID and directory fields.
|
||||||
|
const session = (id: string, directory = '/repo'): Session => ({ id, directory }) as Session;
|
||||||
|
|
||||||
|
const cleanups: Array<{ runtimeKey: string; directory: string; sessionId: string }> = [];
|
||||||
|
mock.module('@/lib/runtime-switch', () => ({ getRuntimeKey: () => 'runtime' }));
|
||||||
|
mock.module('@/sync/session-deletion-cleanup', () => ({
|
||||||
|
cleanupPersistedSessionState: (identity: { runtimeKey: string; directory: string; sessionId: string }) => cleanups.push(identity),
|
||||||
|
}));
|
||||||
|
const { useAuthoritativeSessionCleanup } = await import('./useAuthoritativeSessionCleanup');
|
||||||
|
|
||||||
|
const CleanupProbe: React.FC<{ sessions: Session[]; revision: number }> = ({ sessions, revision }) => {
|
||||||
|
useAuthoritativeSessionCleanup({ enabled: true, hasAuthoritativeGlobalSessions: true, sessions });
|
||||||
|
return React.createElement('span', null, revision);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('authoritative session cleanup', () => {
|
||||||
|
let root: Root;
|
||||||
|
let dom: ReturnType<typeof installHookTestDom>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cleanups.length = 0;
|
||||||
|
dom = installHookTestDom();
|
||||||
|
root = createRoot(dom.container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
dom.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not infer deletion from the first authoritative startup snapshot', () => {
|
||||||
|
const current = buildAuthoritativeSessionIdentityMap([]);
|
||||||
|
|
||||||
|
expect(findRemovedAuthoritativeSessions(null, current)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finds sessions omitted after an established authoritative baseline', () => {
|
||||||
|
const previous = buildAuthoritativeSessionIdentityMap([
|
||||||
|
session('deleted'),
|
||||||
|
session('retained'),
|
||||||
|
]);
|
||||||
|
const current = buildAuthoritativeSessionIdentityMap([session('retained')]);
|
||||||
|
|
||||||
|
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([
|
||||||
|
{ directory: '/repo', sessionId: 'deleted' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('treats archive membership as retained authority', () => {
|
||||||
|
const previous = buildAuthoritativeSessionIdentityMap([session('archived')]);
|
||||||
|
const current = buildAuthoritativeSessionIdentityMap([
|
||||||
|
// SAFETY: cleanup identity tests only consume the SDK session ID and directory fields.
|
||||||
|
{ ...session('archived'), time: { archived: 10 } } as Session,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not treat a directory move as session deletion', () => {
|
||||||
|
const previous = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-a')]);
|
||||||
|
const current = buildAuthoritativeSessionIdentityMap([session('moved', '/repo-b')]);
|
||||||
|
|
||||||
|
expect(findRemovedAuthoritativeSessions(previous, current)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the first mounted complete snapshot as a baseline, then cleans an omission once', () => {
|
||||||
|
const baseline = [session('deleted'), session('retained')];
|
||||||
|
act(() => root.render(React.createElement(CleanupProbe, { sessions: baseline, revision: 0 })));
|
||||||
|
expect(cleanups).toEqual([]);
|
||||||
|
|
||||||
|
act(() => root.render(React.createElement(CleanupProbe, { sessions: [session('retained')], revision: 1 })));
|
||||||
|
expect(cleanups).toEqual([{ runtimeKey: 'runtime', directory: '/repo', sessionId: 'deleted' }]);
|
||||||
|
|
||||||
|
act(() => root.render(React.createElement(CleanupProbe, { sessions: [session('retained')], revision: 2 })));
|
||||||
|
expect(cleanups).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retains archive and move identities, preserves the same-array baseline on unrelated rerender, and resets on remount', () => {
|
||||||
|
const baseline = [session('session', '/repo-a')];
|
||||||
|
act(() => root.render(React.createElement(CleanupProbe, { sessions: baseline, revision: 0 })));
|
||||||
|
act(() => root.render(React.createElement(CleanupProbe, { sessions: baseline, revision: 1 })));
|
||||||
|
act(() => root.render(React.createElement(CleanupProbe, { sessions: [{ ...session('session', '/repo-a'), time: { created: 0, updated: 0, archived: 1 } }], revision: 2 })));
|
||||||
|
act(() => root.render(React.createElement(CleanupProbe, { sessions: [session('session', '/repo-b')], revision: 3 })));
|
||||||
|
expect(cleanups).toEqual([]);
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
root = createRoot(dom.container);
|
||||||
|
act(() => root.render(React.createElement(CleanupProbe, { sessions: [], revision: 4 })));
|
||||||
|
expect(cleanups).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import { cleanupPersistedSessionState } from '@/sync/session-deletion-cleanup';
|
|||||||
import {
|
import {
|
||||||
buildAuthoritativeSessionIdentityMap,
|
buildAuthoritativeSessionIdentityMap,
|
||||||
findRemovedAuthoritativeSessions,
|
findRemovedAuthoritativeSessions,
|
||||||
} from '../authoritativeSessionCleanup';
|
} from './authoritativeSessionCleanup';
|
||||||
|
|
||||||
export const useAuthoritativeSessionCleanup = (args: {
|
export const useAuthoritativeSessionCleanup = (args: {
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||||
|
import React from 'react';
|
||||||
|
import { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { installHookTestDom } from '../test-utils/testDom';
|
||||||
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
|
|
||||||
|
type Event =
|
||||||
|
| { type: 'scheduled-task-ran' }
|
||||||
|
| { type: 'session-created'; directory: string };
|
||||||
|
|
||||||
|
type LifecycleState = {
|
||||||
|
demands: Array<{ owner: string; directories: string[] }>;
|
||||||
|
clearedOwners: string[];
|
||||||
|
globalRefreshes: number;
|
||||||
|
directoryRefreshes: string[][];
|
||||||
|
cleanupInputs: Array<{ enabled: boolean; hasAuthoritativeGlobalSessions: boolean; sessionCount: number; sessions: unknown[] }>;
|
||||||
|
listener: ((event: Event) => void) | null;
|
||||||
|
subscriptions: number;
|
||||||
|
unsubscriptions: number;
|
||||||
|
};
|
||||||
|
const state: LifecycleState = {
|
||||||
|
demands: [],
|
||||||
|
clearedOwners: [],
|
||||||
|
globalRefreshes: 0,
|
||||||
|
directoryRefreshes: [],
|
||||||
|
cleanupInputs: [],
|
||||||
|
listener: null,
|
||||||
|
subscriptions: 0,
|
||||||
|
unsubscriptions: 0,
|
||||||
|
};
|
||||||
|
const childStores = {
|
||||||
|
setBootstrapDemand: (owner: string, demands: Array<{ directory: string }>) => {
|
||||||
|
state.demands.push({ owner, directories: demands.map((demand) => demand.directory) });
|
||||||
|
},
|
||||||
|
clearBootstrapDemand: (owner: string) => state.clearedOwners.push(owner),
|
||||||
|
};
|
||||||
|
type GlobalSessionsState = { activeSessions: never[]; archivedSessions: never[]; status: 'ready' };
|
||||||
|
const globalSessions: GlobalSessionsState = { activeSessions: [], archivedSessions: [], status: 'ready' };
|
||||||
|
|
||||||
|
mock.module('@/sync/sync-context', () => ({
|
||||||
|
useChildStoreManager: () => childStores,
|
||||||
|
}));
|
||||||
|
mock.module('@/sync/sync-refs', () => ({ getAllSyncSessions: () => [] }));
|
||||||
|
mock.module('@/stores/useGlobalSessionsStore', () => ({
|
||||||
|
useGlobalSessionsStore: <T,>(selector: (value: GlobalSessionsState) => T): T => selector(globalSessions),
|
||||||
|
refreshGlobalSessions: () => { state.globalRefreshes += 1; },
|
||||||
|
refreshGlobalSessionsForDirectories: (directories: string[]) => { state.directoryRefreshes.push(directories); },
|
||||||
|
}));
|
||||||
|
mock.module('@/lib/openchamberEvents', () => ({
|
||||||
|
subscribeOpenchamberEvents: (listener: (event: Event) => void) => {
|
||||||
|
state.subscriptions += 1;
|
||||||
|
state.listener = listener;
|
||||||
|
return () => {
|
||||||
|
state.unsubscriptions += 1;
|
||||||
|
state.listener = null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
mock.module('./useAuthoritativeSessionCleanup', () => ({
|
||||||
|
useAuthoritativeSessionCleanup: (input: { enabled: boolean; hasAuthoritativeGlobalSessions: boolean; sessions: unknown[] }) => {
|
||||||
|
state.cleanupInputs.push({
|
||||||
|
enabled: input.enabled,
|
||||||
|
hasAuthoritativeGlobalSessions: input.hasAuthoritativeGlobalSessions,
|
||||||
|
sessionCount: input.sessions.length,
|
||||||
|
sessions: input.sessions,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { useSessionListSync } = await import('./useSessionListSync');
|
||||||
|
|
||||||
|
const projects = [{ id: 'project', path: '/project' }];
|
||||||
|
const projectDirectories = new Set(['/project']);
|
||||||
|
const worktree: WorktreeMetadata = { path: '/worktree', projectDirectory: '/project', branch: 'feature', label: 'feature' };
|
||||||
|
|
||||||
|
const LifecycleProbe: React.FC<{ isVSCode: boolean }> = ({ isVSCode }) => {
|
||||||
|
useSessionListSync({ isVSCode });
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LifecycleHarness: React.FC<{ isVSCode: boolean; branch: 'hidden' | 'visible' | 'compact-sessions' | 'compact-chat' | 'expanded' }> = ({ isVSCode, branch }) => <>
|
||||||
|
<LifecycleProbe isVSCode={isVSCode} />
|
||||||
|
<span>{branch}</span>
|
||||||
|
</>;
|
||||||
|
|
||||||
|
describe('useSessionListSync', () => {
|
||||||
|
let root: Root;
|
||||||
|
let dom: ReturnType<typeof installHookTestDom>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state.demands = [];
|
||||||
|
state.clearedOwners = [];
|
||||||
|
state.globalRefreshes = 0;
|
||||||
|
state.directoryRefreshes = [];
|
||||||
|
state.cleanupInputs = [];
|
||||||
|
state.listener = null;
|
||||||
|
state.subscriptions = 0;
|
||||||
|
state.unsubscriptions = 0;
|
||||||
|
dom = installHookTestDom();
|
||||||
|
root = createRoot(dom.container);
|
||||||
|
useProjectsStore.setState({ projects, activeProjectId: 'project' });
|
||||||
|
useDirectoryStore.setState({ currentDirectory: '/project' });
|
||||||
|
useSessionUIStore.setState({ currentSessionDirectory: null, availableWorktreesByProject: new Map() });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => root.unmount());
|
||||||
|
dom.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves initial global refresh to the root poller while publishing complete demand', () => {
|
||||||
|
act(() => useSessionUIStore.setState({ availableWorktreesByProject: new Map([['/project', [worktree]]]) }));
|
||||||
|
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||||
|
|
||||||
|
expect(state.globalRefreshes).toBe(0);
|
||||||
|
expect(state.demands).toHaveLength(1);
|
||||||
|
expect(state.demands[0]?.directories).toEqual(['/project', '/worktree']);
|
||||||
|
expect(state.directoryRefreshes).toEqual([]);
|
||||||
|
expect(state.subscriptions).toBe(1);
|
||||||
|
expect(state.cleanupInputs.at(-1)).toEqual({ enabled: true, hasAuthoritativeGlobalSessions: true, sessionCount: 0, sessions: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshes every VS Code directory on first mount and only topology additions afterward', () => {
|
||||||
|
act(() => root.render(<LifecycleProbe isVSCode />));
|
||||||
|
act(() => useProjectsStore.setState({ projects: [...projects, { id: 'added', path: '/added' }] }));
|
||||||
|
|
||||||
|
expect(state.directoryRefreshes).toEqual([['/project'], ['/added']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('coalesces control events and clears the listener, timeout, and demand on unmount', async () => {
|
||||||
|
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||||
|
state.listener?.({ type: 'session-created', directory: '/created-a' });
|
||||||
|
state.listener?.({ type: 'session-created', directory: '/created-b' });
|
||||||
|
state.listener?.({ type: 'scheduled-task-ran' });
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||||
|
expect(state.globalRefreshes).toBe(1);
|
||||||
|
expect(state.directoryRefreshes).toEqual([]);
|
||||||
|
|
||||||
|
const owner = state.demands[0]?.owner;
|
||||||
|
act(() => root.unmount());
|
||||||
|
expect(state.unsubscriptions).toBe(1);
|
||||||
|
expect(state.clearedOwners).toEqual([owner]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not duplicate lifecycle ownership when a hidden MainLayout or compact VS Code view rerenders', () => {
|
||||||
|
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||||
|
const cleanupSessions = state.cleanupInputs.at(-1)?.sessions;
|
||||||
|
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||||
|
expect(state.globalRefreshes).toBe(0);
|
||||||
|
expect(state.subscriptions).toBe(1);
|
||||||
|
expect(state.demands).toHaveLength(1);
|
||||||
|
expect(state.cleanupInputs.at(-1)?.sessions).toBe(cleanupSessions);
|
||||||
|
|
||||||
|
act(() => root.unmount());
|
||||||
|
root = createRoot(dom.container);
|
||||||
|
act(() => root.render(<LifecycleProbe isVSCode />));
|
||||||
|
expect(state.globalRefreshes).toBe(0);
|
||||||
|
expect(state.subscriptions).toBe(2);
|
||||||
|
expect(state.unsubscriptions).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancels a pending control-event refresh before a layout remount', async () => {
|
||||||
|
act(() => root.render(<LifecycleProbe isVSCode={false} />));
|
||||||
|
state.listener?.({ type: 'session-created', directory: '/created' });
|
||||||
|
act(() => root.unmount());
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 550));
|
||||||
|
expect(state.directoryRefreshes).toEqual([]);
|
||||||
|
expect(state.unsubscriptions).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('binds MainLayout ownership to real Store worktrees without duplicating lifecycle work across branches', () => {
|
||||||
|
useProjectsStore.setState({
|
||||||
|
projects: [{ id: 'project', path: '/project' }],
|
||||||
|
activeProjectId: 'project',
|
||||||
|
});
|
||||||
|
useDirectoryStore.setState({ currentDirectory: '/project' });
|
||||||
|
useSessionUIStore.setState({
|
||||||
|
currentSessionDirectory: '/worktree',
|
||||||
|
availableWorktreesByProject: new Map([['/project', [worktree]]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => root.render(<LifecycleHarness isVSCode={false} branch="hidden" />));
|
||||||
|
act(() => root.render(<LifecycleHarness isVSCode={false} branch="visible" />));
|
||||||
|
act(() => root.render(<LifecycleHarness isVSCode={false} branch="expanded" />));
|
||||||
|
|
||||||
|
expect(state.demands).toHaveLength(1);
|
||||||
|
expect(state.demands[0]?.directories).toEqual(['/project', '/worktree']);
|
||||||
|
expect(state.globalRefreshes).toBe(0);
|
||||||
|
expect(state.subscriptions).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('binds VS Code ownership to Store projects without worktrees and refreshes its first directories once', () => {
|
||||||
|
useProjectsStore.setState({
|
||||||
|
projects: [{ id: 'project', path: '/project' }],
|
||||||
|
activeProjectId: 'project',
|
||||||
|
});
|
||||||
|
useDirectoryStore.setState({ currentDirectory: '/project' });
|
||||||
|
useSessionUIStore.setState({
|
||||||
|
currentSessionDirectory: '/project',
|
||||||
|
availableWorktreesByProject: new Map([['/project', [worktree]]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => root.render(<LifecycleHarness isVSCode branch="compact-sessions" />));
|
||||||
|
act(() => root.render(<LifecycleHarness isVSCode branch="compact-chat" />));
|
||||||
|
act(() => root.unmount());
|
||||||
|
root = createRoot(dom.container);
|
||||||
|
act(() => root.render(<LifecycleHarness isVSCode branch="compact-sessions" />));
|
||||||
|
|
||||||
|
expect(state.demands.map((demand) => demand.directories)).toEqual([['/project'], ['/project']]);
|
||||||
|
expect(state.directoryRefreshes).toEqual([['/project'], ['/project']]);
|
||||||
|
expect(state.globalRefreshes).toBe(0);
|
||||||
|
expect(state.subscriptions).toBe(2);
|
||||||
|
expect(state.unsubscriptions).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not rerender VS Code lifecycle ownership for worktree-map-only changes', () => {
|
||||||
|
useProjectsStore.setState({
|
||||||
|
projects: [{ id: 'project', path: '/project' }],
|
||||||
|
activeProjectId: 'project',
|
||||||
|
});
|
||||||
|
useDirectoryStore.setState({ currentDirectory: '/project' });
|
||||||
|
useSessionUIStore.setState({
|
||||||
|
currentSessionDirectory: '/project',
|
||||||
|
availableWorktreesByProject: new Map([['/project', [worktree]]]),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => root.render(<LifecycleHarness isVSCode branch="compact-sessions" />));
|
||||||
|
const cleanupInputCount = state.cleanupInputs.length;
|
||||||
|
const demandCount = state.demands.length;
|
||||||
|
const directoryRefreshCount = state.directoryRefreshes.length;
|
||||||
|
const subscriptionCount = state.subscriptions;
|
||||||
|
|
||||||
|
act(() => useSessionUIStore.setState({
|
||||||
|
availableWorktreesByProject: new Map([['/project', [{ ...worktree, path: '/other-worktree' }]]]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(state.cleanupInputs).toHaveLength(cleanupInputCount);
|
||||||
|
expect(state.demands).toHaveLength(demandCount);
|
||||||
|
expect(state.directoryRefreshes).toHaveLength(directoryRefreshCount);
|
||||||
|
expect(state.subscriptions).toBe(subscriptionCount);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { subscribeOpenchamberEvents } from '@/lib/openchamberEvents';
|
||||||
|
import { refreshGlobalSessions, refreshGlobalSessionsForDirectories, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||||
|
import { useChildStoreManager } from '@/sync/sync-context';
|
||||||
|
import { getAllSyncSessions } from '@/sync/sync-refs';
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { buildSessionBootstrapDemands } from './sessionBootstrapDemands';
|
||||||
|
import { buildKnownSessionDirectories } from './sessionListDirectories';
|
||||||
|
import { useAuthoritativeSessionCleanup } from './useAuthoritativeSessionCleanup';
|
||||||
|
import { normalizePath } from '../utils';
|
||||||
|
|
||||||
|
const EMPTY_WORKTREES_BY_PROJECT = new Map();
|
||||||
|
|
||||||
|
type UseSessionListSyncOptions = {
|
||||||
|
isVSCode: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSessionListSync = ({
|
||||||
|
isVSCode,
|
||||||
|
}: UseSessionListSyncOptions) => {
|
||||||
|
const childStores = useChildStoreManager();
|
||||||
|
const projects = useProjectsStore((state) => state.projects);
|
||||||
|
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
|
||||||
|
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||||
|
const currentSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
|
||||||
|
const availableWorktreesByProject = useSessionUIStore((state) => isVSCode ? EMPTY_WORKTREES_BY_PROJECT : state.availableWorktreesByProject);
|
||||||
|
const knownDirectories = React.useMemo(
|
||||||
|
() => buildKnownSessionDirectories(projects, availableWorktreesByProject, { includeWorktrees: !isVSCode }),
|
||||||
|
[availableWorktreesByProject, isVSCode, projects],
|
||||||
|
);
|
||||||
|
const globalActiveSessions = useGlobalSessionsStore((state) => state.activeSessions);
|
||||||
|
const archivedSessions = useGlobalSessionsStore((state) => state.archivedSessions);
|
||||||
|
const hasAuthoritativeGlobalSessions = useGlobalSessionsStore((state) => state.status === 'ready');
|
||||||
|
const bootstrapDemandOwner = `session-list-sync:${React.useId()}`;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
childStores.setBootstrapDemand(bootstrapDemandOwner, buildSessionBootstrapDemands({
|
||||||
|
knownDirectories,
|
||||||
|
activeProjectDirectory: normalizePath(projects.find((project) => project.id === activeProjectId)?.path ?? null),
|
||||||
|
activeProjectId,
|
||||||
|
collapsedProjects: new Set(),
|
||||||
|
collapsedGroups: new Set(),
|
||||||
|
currentDirectory,
|
||||||
|
currentSessionDirectory,
|
||||||
|
}));
|
||||||
|
return () => childStores.clearBootstrapDemand(bootstrapDemandOwner);
|
||||||
|
}, [activeProjectId, bootstrapDemandOwner, childStores, currentDirectory, currentSessionDirectory, knownDirectories, projects]);
|
||||||
|
|
||||||
|
const knownProjectSessionDirectoriesRef = React.useRef<Set<string> | null>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const directories = new Set(knownDirectories);
|
||||||
|
const previous = knownProjectSessionDirectoriesRef.current;
|
||||||
|
knownProjectSessionDirectoriesRef.current = directories;
|
||||||
|
const added = previous ? [...directories].filter((directory) => !previous.has(directory)) : isVSCode ? [...directories] : [];
|
||||||
|
if (added.length) void refreshGlobalSessionsForDirectories(added, getAllSyncSessions());
|
||||||
|
}, [isVSCode, knownDirectories]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let refreshAll = false;
|
||||||
|
const directories = new Set<string>();
|
||||||
|
const unsubscribe = subscribeOpenchamberEvents((event) => {
|
||||||
|
if (event.type === 'scheduled-task-ran') refreshAll = true;
|
||||||
|
else if (event.type === 'session-created') directories.add(event.directory);
|
||||||
|
else return;
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
timeout = null;
|
||||||
|
if (refreshAll) {
|
||||||
|
refreshAll = false;
|
||||||
|
directories.clear();
|
||||||
|
void refreshGlobalSessions(getAllSyncSessions());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const requested = [...directories];
|
||||||
|
directories.clear();
|
||||||
|
if (requested.length) void refreshGlobalSessionsForDirectories(requested, getAllSyncSessions());
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cleanupSessions = React.useMemo(
|
||||||
|
() => [...globalActiveSessions, ...archivedSessions],
|
||||||
|
[archivedSessions, globalActiveSessions],
|
||||||
|
);
|
||||||
|
useAuthoritativeSessionCleanup({
|
||||||
|
enabled: true,
|
||||||
|
hasAuthoritativeGlobalSessions,
|
||||||
|
sessions: cleanupSessions,
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import { useSessionPrefetch } from './useSessionPrefetch';
|
||||||
|
import { installHookTestDom } from '../test-utils/testDom';
|
||||||
|
|
||||||
|
const session = (id: string): Session => ({
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID: 'project',
|
||||||
|
title: id,
|
||||||
|
version: '1',
|
||||||
|
directory: '/workspace',
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('session prefetch demand', () => {
|
||||||
|
test('deduplicates the same nearby session from project and Recent projections', async () => {
|
||||||
|
const dom = installHookTestDom();
|
||||||
|
const root = createRoot(dom.container);
|
||||||
|
const current = session('current');
|
||||||
|
const nearby = session('nearby');
|
||||||
|
const calls: string[] = [];
|
||||||
|
const Harness = () => {
|
||||||
|
useSessionPrefetch({
|
||||||
|
enabled: true,
|
||||||
|
currentSessionId: current.id,
|
||||||
|
sortedSessions: [current, nearby],
|
||||||
|
recentSessions: [current, nearby],
|
||||||
|
prefetchSession: async (sessionId) => { calls.push(sessionId); },
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(React.createElement(Harness)));
|
||||||
|
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 850)); });
|
||||||
|
expect(calls).toEqual(['nearby']);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+6
-6
@@ -14,7 +14,7 @@ type Args = {
|
|||||||
currentSessionId: string | null;
|
currentSessionId: string | null;
|
||||||
sortedSessions: Session[];
|
sortedSessions: Session[];
|
||||||
recentSessions?: Session[];
|
recentSessions?: Session[];
|
||||||
prefetchSession: (sessionId: string, directory: string) => Promise<unknown>;
|
prefetchSession: (sessionId: string, directory: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PrefetchRequest = {
|
type PrefetchRequest = {
|
||||||
@@ -24,11 +24,11 @@ type PrefetchRequest = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const sessionDirectory = (session: Session | null | undefined): string | null => {
|
const sessionDirectory = (session: Session | null | undefined): string | null => {
|
||||||
const directory = (session as (Session & { directory?: string | null }) | null | undefined)?.directory;
|
const directory = session?.directory?.trim();
|
||||||
return typeof directory === 'string' && directory.trim() ? directory : null;
|
return directory || null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
|
export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
|
||||||
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
|
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
|
||||||
const sessionPrefetchQueueRef = React.useRef<PrefetchRequest[]>([]);
|
const sessionPrefetchQueueRef = React.useRef<PrefetchRequest[]>([]);
|
||||||
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
||||||
@@ -47,7 +47,7 @@ const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions,
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pumpSessionPrefetchQueue = React.useCallback(() => {
|
const pumpSessionPrefetchQueue = React.useCallback(() => {
|
||||||
if (!enabled || prefetchDisabled || typeof window === 'undefined') {
|
if (!enabled || prefetchDisabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions,
|
|||||||
const scheduleSessionPrefetch = React.useCallback((session: Session | null | undefined) => {
|
const scheduleSessionPrefetch = React.useCallback((session: Session | null | undefined) => {
|
||||||
const sessionId = session?.id;
|
const sessionId = session?.id;
|
||||||
const directory = sessionDirectory(session);
|
const directory = sessionDirectory(session);
|
||||||
if (!enabled || prefetchDisabled || !sessionId || !directory || sessionId === currentSessionId || typeof window === 'undefined') {
|
if (!enabled || prefetchDisabled || !sessionId || !directory || sessionId === currentSessionId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const request = { sessionId, directory, generation: generationRef.current };
|
const request = { sessionId, directory, generation: generationRef.current };
|
||||||
+227
@@ -0,0 +1,227 @@
|
|||||||
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { I18nProvider } from '@/lib/i18n';
|
||||||
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import type { SessionGroupSectionProps } from './SessionGroupSection';
|
||||||
|
import { SessionProjectScroller } from './SessionProjectScroller';
|
||||||
|
import { RecentSessionSection } from '../recent/RecentSessionSection';
|
||||||
|
import { SidebarActivitySections } from '../recent/SidebarActivitySections';
|
||||||
|
import { installHookTestDom } from '../test-utils/testDom';
|
||||||
|
|
||||||
|
type FolderCallbacks = {
|
||||||
|
onRename: (name: string) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RowPropsCapture = Pick<SessionGroupSectionProps,
|
||||||
|
| 'allowReselect'
|
||||||
|
| 'onSessionSelected'
|
||||||
|
| 'isSessionSearchOpen'
|
||||||
|
| 'sessionSearchQuery'
|
||||||
|
| 'deleteSessionConfirm'
|
||||||
|
| 'copiedSessionId'
|
||||||
|
| 'setCopiedSessionId'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type ExpectNever<T extends never> = T;
|
||||||
|
type RowDomainCallback =
|
||||||
|
| 'handleSaveEdit'
|
||||||
|
| 'handleCancelEdit'
|
||||||
|
| 'handleSessionSelect'
|
||||||
|
| 'handleSessionDoubleClick'
|
||||||
|
| 'handleShareSession'
|
||||||
|
| 'handleCopyShareUrl'
|
||||||
|
| 'handleCopySessionId'
|
||||||
|
| 'handleUnshareSession'
|
||||||
|
| 'handleDeleteSession'
|
||||||
|
| 'handleRestoreSession';
|
||||||
|
|
||||||
|
// Structural group contracts must not expose the row domain action surface.
|
||||||
|
type _SessionGroupSectionHasNoRowDomainCallbacks = ExpectNever<Extract<keyof SessionGroupSectionProps, RowDomainCallback>>;
|
||||||
|
type _SessionProjectScrollerHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof SessionProjectScroller>, RowDomainCallback>>;
|
||||||
|
type _RecentSessionSectionHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof RecentSessionSection>, RowDomainCallback>>;
|
||||||
|
type _SidebarActivitySectionsHasNoRowDomainCallbacks = ExpectNever<Extract<keyof React.ComponentProps<typeof SidebarActivitySections>, RowDomainCallback>>;
|
||||||
|
|
||||||
|
let folderCallbacks: FolderCallbacks | null = null;
|
||||||
|
let rowPropsCapture: RowPropsCapture | null = null;
|
||||||
|
|
||||||
|
mock.module('../../SessionFolderItem', () => ({
|
||||||
|
SessionFolderItem: (props: FolderCallbacks) => {
|
||||||
|
folderCallbacks = props;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../folders/sessionFolderDnd', () => ({
|
||||||
|
DroppableFolderWrapper: ({ children }: { children: (ref: () => void, isOver: boolean) => React.ReactNode }) => <>{children(() => undefined, false)}</>,
|
||||||
|
SessionFolderDndScope: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('@/sync/sync-context', () => ({
|
||||||
|
setActiveSession: () => undefined,
|
||||||
|
useChildStoreManager: () => ({
|
||||||
|
subscribeBootstrap: () => () => undefined,
|
||||||
|
getBootstrapState: () => null,
|
||||||
|
getBootstrapFailure: () => undefined,
|
||||||
|
requestBootstrap: () => undefined,
|
||||||
|
}),
|
||||||
|
useDirectoryStore: () => null,
|
||||||
|
useGlobalSessionStatus: () => null,
|
||||||
|
useSessionPermissions: () => null,
|
||||||
|
useSessionQuestionCount: () => 0,
|
||||||
|
useSyncSDK: () => null,
|
||||||
|
useSyncDirectory: () => null,
|
||||||
|
buildSessionMessageRecordsSnapshot: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../sessions/collapsedActivityIndicator', () => ({
|
||||||
|
CollapsedSessionActivityIndicator: () => null,
|
||||||
|
useCollapsedSessionActivityState: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../sessions/SessionTreeItem', () => ({
|
||||||
|
SessionTreeItem: (props: RowPropsCapture) => {
|
||||||
|
rowPropsCapture = props;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { SessionGroupSection } = await import('./SessionGroupSection');
|
||||||
|
|
||||||
|
const folder: SessionFolder = {
|
||||||
|
id: 'folder-a',
|
||||||
|
name: 'Initial folder',
|
||||||
|
parentId: null,
|
||||||
|
sessionIds: [],
|
||||||
|
createdAt: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const group: SessionGroupSectionProps['group'] = {
|
||||||
|
id: 'main',
|
||||||
|
label: 'Main',
|
||||||
|
branch: null,
|
||||||
|
description: null,
|
||||||
|
isMain: true,
|
||||||
|
worktree: null,
|
||||||
|
directory: '/workspace',
|
||||||
|
folderScopeKey: '/workspace',
|
||||||
|
sessions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupWithSession: SessionGroupSectionProps['group'] = {
|
||||||
|
...group,
|
||||||
|
// SAFETY: SessionGroupSection only reads the fixture session's id in this test.
|
||||||
|
sessions: [{ session: { id: 'session-a' } as Session, children: [], worktree: null }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const createProps = (): SessionGroupSectionProps => ({
|
||||||
|
group,
|
||||||
|
groupKey: 'project:main',
|
||||||
|
projectId: 'project',
|
||||||
|
hideGroupLabel: true,
|
||||||
|
hasSessionSearchQuery: false,
|
||||||
|
normalizedSessionSearchQuery: '',
|
||||||
|
groupSearchDataByGroup: new WeakMap(),
|
||||||
|
collapsedGroups: new Set(),
|
||||||
|
hideDirectoryControls: false,
|
||||||
|
showMoreGroupSessions: () => undefined,
|
||||||
|
resetGroupSessionLimit: () => undefined,
|
||||||
|
mobileVariant: false,
|
||||||
|
alwaysShowActions: false,
|
||||||
|
activeProjectId: 'project',
|
||||||
|
setActiveProjectIdOnly: () => undefined,
|
||||||
|
setActiveMainTab: () => undefined,
|
||||||
|
setSessionSwitcherOpen: () => undefined,
|
||||||
|
openNewSessionDraft: () => undefined,
|
||||||
|
pinnedSessionIds: new Set(),
|
||||||
|
sessionOrderIndex: new Map(),
|
||||||
|
notifyOnSubtasks: false,
|
||||||
|
expandedParents: new Set(),
|
||||||
|
editingId: null,
|
||||||
|
editTitle: '',
|
||||||
|
copiedSessionId: null,
|
||||||
|
openSidebarMenuKey: null,
|
||||||
|
setEditingId: () => undefined,
|
||||||
|
setEditTitle: () => undefined,
|
||||||
|
toggleParent: () => undefined,
|
||||||
|
setOpenSidebarMenuKey: () => undefined,
|
||||||
|
startFolderRename: () => undefined,
|
||||||
|
allowReselect: false,
|
||||||
|
isSessionSearchOpen: false,
|
||||||
|
sessionSearchQuery: '',
|
||||||
|
setSessionSearchQuery: () => undefined,
|
||||||
|
setIsSessionSearchOpen: () => undefined,
|
||||||
|
deleteSessionConfirm: null,
|
||||||
|
setDeleteSessionConfirm: () => undefined,
|
||||||
|
setCopiedSessionId: () => undefined,
|
||||||
|
onToggleCollapsedGroup: () => undefined,
|
||||||
|
folderRename: null,
|
||||||
|
setFolderRenameDraft: () => undefined,
|
||||||
|
clearFolderRename: () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SessionGroupSection public behavior', () => {
|
||||||
|
test('routes rendered folder rename and delete actions to the owning folder store', async () => {
|
||||||
|
const dom = installHookTestDom();
|
||||||
|
const root = createRoot(dom.container);
|
||||||
|
const originalFolders = useSessionFoldersStore.getState();
|
||||||
|
const originalUi = useUIStore.getState();
|
||||||
|
useSessionFoldersStore.setState({ foldersMap: { '/workspace': [folder] } });
|
||||||
|
useUIStore.setState({ showDeletionDialog: false });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(<I18nProvider><SessionGroupSection {...createProps()} /></I18nProvider>));
|
||||||
|
expect(folderCallbacks).not.toBeNull();
|
||||||
|
|
||||||
|
await act(async () => folderCallbacks?.onRename('Renamed folder'));
|
||||||
|
expect(useSessionFoldersStore.getState().foldersMap['/workspace']?.[0]?.name).toBe('Renamed folder');
|
||||||
|
|
||||||
|
await act(async () => folderCallbacks?.onDelete());
|
||||||
|
expect(useSessionFoldersStore.getState().foldersMap['/workspace']).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
useSessionFoldersStore.setState(originalFolders, true);
|
||||||
|
useUIStore.setState(originalUi, true);
|
||||||
|
folderCallbacks = null;
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('propagates confirmation, search/navigation, and copy ownership changes to rendered rows', async () => {
|
||||||
|
const dom = installHookTestDom();
|
||||||
|
const root = createRoot(dom.container);
|
||||||
|
const firstSelected = () => undefined;
|
||||||
|
const nextSelected = () => undefined;
|
||||||
|
const firstCopied = () => undefined;
|
||||||
|
const nextCopied = () => undefined;
|
||||||
|
const initialProps = createProps();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(<I18nProvider><SessionGroupSection {...initialProps} group={groupWithSession} onSessionSelected={firstSelected} setCopiedSessionId={firstCopied} /></I18nProvider>));
|
||||||
|
expect(rowPropsCapture?.onSessionSelected).toBe(firstSelected);
|
||||||
|
expect(rowPropsCapture?.sessionSearchQuery).toBe('');
|
||||||
|
expect(rowPropsCapture?.deleteSessionConfirm).toBeNull();
|
||||||
|
expect(rowPropsCapture?.copiedSessionId).toBeNull();
|
||||||
|
expect(rowPropsCapture?.setCopiedSessionId).toBe(firstCopied);
|
||||||
|
|
||||||
|
// SAFETY: the confirmation is only forwarded by identity to the row mock.
|
||||||
|
const confirmation = { session: { id: 'session-a' } as Session, descendantCount: 0, descendantIds: [], archivedBucket: false };
|
||||||
|
await act(async () => root.render(<I18nProvider><SessionGroupSection {...initialProps} group={groupWithSession} allowReselect onSessionSelected={nextSelected} isSessionSearchOpen sessionSearchQuery="search" deleteSessionConfirm={confirmation} copiedSessionId="session-a" setCopiedSessionId={nextCopied} /></I18nProvider>));
|
||||||
|
expect(rowPropsCapture?.allowReselect).toBe(true);
|
||||||
|
expect(rowPropsCapture?.onSessionSelected).toBe(nextSelected);
|
||||||
|
expect(rowPropsCapture?.isSessionSearchOpen).toBe(true);
|
||||||
|
expect(rowPropsCapture?.sessionSearchQuery).toBe('search');
|
||||||
|
expect(rowPropsCapture?.deleteSessionConfirm).toBe(confirmation);
|
||||||
|
expect(rowPropsCapture?.copiedSessionId).toBe('session-a');
|
||||||
|
expect(rowPropsCapture?.setCopiedSessionId).toBe(nextCopied);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
rowPropsCapture = null;
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||||
|
import { normalizeFolderRoots, selectFolderIdsForProjection } from '../sessions/sessionNodeItemUtils';
|
||||||
|
|
||||||
|
const folder = (id: string, parentId: string | null = null, sessionIds: string[] = []): SessionFolder => ({
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
parentId,
|
||||||
|
sessionIds,
|
||||||
|
createdAt: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeFolderRoots', () => {
|
||||||
|
test('returns cycle and orphan folders as deterministic fallback roots without duplication', () => {
|
||||||
|
const folders = [
|
||||||
|
folder('cycle-a', 'cycle-b', ['session-a']),
|
||||||
|
folder('cycle-b', 'cycle-a'),
|
||||||
|
folder('orphan', 'missing-parent'),
|
||||||
|
folder('root'),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(normalizeFolderRoots(folders).map((entry) => entry.id))
|
||||||
|
.toEqual(['orphan', 'root', 'cycle-a']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps normal nested folder root order unchanged', () => {
|
||||||
|
const folders = [folder('root-a'), folder('child-a', 'root-a'), folder('root-b')];
|
||||||
|
|
||||||
|
expect(normalizeFolderRoots(folders).map((entry) => entry.id)).toEqual(['root-a', 'root-b']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('selectFolderIdsForProjection', () => {
|
||||||
|
const malformedFolders = [
|
||||||
|
{ id: 'cycle-a', name: 'cycle-a', parentId: 'cycle-b', nodeCount: 0 },
|
||||||
|
{ id: 'cycle-b', name: 'cycle-b', parentId: 'cycle-a', nodeCount: 1 },
|
||||||
|
{ id: 'orphan', name: 'orphan', parentId: 'missing-parent', nodeCount: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('keeps malformed empty and nonempty folders in every projection mode', () => {
|
||||||
|
for (const archivedBucket of [false, true]) {
|
||||||
|
for (const searchQuery of ['', 'does-not-match']) {
|
||||||
|
expect([...selectFolderIdsForProjection(malformedFolders, { archivedBucket, searchQuery })])
|
||||||
|
.toEqual(['cycle-a', 'cycle-b', 'orphan']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps normal archived/search nesting semantics', () => {
|
||||||
|
const folders = [
|
||||||
|
{ id: 'root', name: 'root', parentId: null, nodeCount: 0 },
|
||||||
|
{ id: 'child', name: 'matching-child', parentId: 'root', nodeCount: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect([...selectFolderIdsForProjection(folders, { archivedBucket: true, searchQuery: 'matching' })])
|
||||||
|
.toEqual(['root', 'child']);
|
||||||
|
});
|
||||||
|
});
|
||||||
+276
-256
@@ -1,6 +1,6 @@
|
|||||||
import { matchesRankQuery } from '@/lib/search/fuzzySearch';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
|
||||||
// Archived buckets routinely grow into the hundreds/thousands; virtualize
|
// Archived buckets routinely grow into the hundreds/thousands; virtualize
|
||||||
@@ -9,37 +9,40 @@ const ARCHIVED_VIRTUALIZE_THRESHOLD = 50;
|
|||||||
// Compact rows in the archived bucket without nested subagents render
|
// Compact rows in the archived bucket without nested subagents render
|
||||||
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
|
// around 24-32px; virtua measures mounted rows and uses this as the initial hint.
|
||||||
const ARCHIVED_ROW_ESTIMATE_PX = 28;
|
const ARCHIVED_ROW_ESTIMATE_PX = 28;
|
||||||
|
const EMPTY_FOLDERS: readonly never[] = [];
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { sessionEvents } from '@/lib/sessionEvents';
|
import { sessionEvents } from '@/lib/sessionEvents';
|
||||||
import { SessionFolderItem } from '../SessionFolderItem';
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { SessionFolderItem } from '../../SessionFolderItem';
|
||||||
import type { SortableDragHandleProps } from './sortableItems';
|
import type { SortableDragHandleProps } from './sortableItems';
|
||||||
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
|
import { DroppableFolderWrapper, SessionFolderDndScope } from '../folders/sessionFolderDnd';
|
||||||
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
|
import type { GroupSearchData, SessionGroup, SessionNode } from '../types';
|
||||||
import { isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
import { isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from '../utils';
|
||||||
import { compareSessionsByLifecycleOrder, EMPTY_SESSION_ORDER_RANKS } from '@/sync/session-ordering';
|
import { compareSessionsByLifecycleOrder, EMPTY_SESSION_ORDER_RANKS } from '@/sync/session-ordering';
|
||||||
import {
|
import {
|
||||||
collectSubtreeContainingId,
|
collectSubtreeContainingId,
|
||||||
computeNodeStructureKey,
|
computeNodeStructureKey,
|
||||||
nodeHasPinnedMembershipChange,
|
nodeHasPinnedMembershipChange,
|
||||||
nodeContainsSessionId,
|
nodeContainsSessionId,
|
||||||
|
normalizeFolderRoots,
|
||||||
resolveMenuOpenSessionId,
|
resolveMenuOpenSessionId,
|
||||||
|
selectFolderIdsForProjection,
|
||||||
selectFolderRootNodes,
|
selectFolderRootNodes,
|
||||||
} from './sessionNodeItemUtils';
|
} from '../sessions/sessionNodeItemUtils';
|
||||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
import type { SessionNodeRenderExtras } from '../sessions/sessionNodeItemUtils';
|
||||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
|
|
||||||
|
type FolderScope = { scopeKey: string; directory: string | null };
|
||||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { useChildStoreManager } from '@/sync/sync-context';
|
import { useChildStoreManager } from '@/sync/sync-context';
|
||||||
import { canRequestNativeDirectoryAccess, requestDirectoryAccess } from '@/lib/desktop';
|
import { canRequestNativeDirectoryAccess, requestDirectoryAccess } from '@/lib/desktop';
|
||||||
import { CollapsedActivityIndicator } from './collapsedActivityIndicator';
|
import { CollapsedSessionActivityIndicator, useCollapsedSessionActivityState } from '../sessions/collapsedActivityIndicator';
|
||||||
import {
|
import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||||
getSessionNodesActivityState,
|
import { FolderDeleteConfirmDialog } from '../shell/ConfirmDialogs';
|
||||||
mergeCollapsedActivityStates,
|
|
||||||
type CollapsedActivityState,
|
|
||||||
} from './collapsedActivityState';
|
|
||||||
|
|
||||||
type DeleteFolderConfirm = {
|
type DeleteFolderConfirm = {
|
||||||
scopeKey: string;
|
scopeKey: string;
|
||||||
@@ -49,7 +52,7 @@ type DeleteFolderConfirm = {
|
|||||||
sessionCount: number;
|
sessionCount: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
type Props = {
|
export type SessionGroupSectionProps = {
|
||||||
group: SessionGroup;
|
group: SessionGroup;
|
||||||
groupKey: string;
|
groupKey: string;
|
||||||
projectId?: string | null;
|
projectId?: string | null;
|
||||||
@@ -61,22 +64,6 @@ type Props = {
|
|||||||
sessionBatchSize?: number;
|
sessionBatchSize?: number;
|
||||||
collapsedGroups: Set<string>;
|
collapsedGroups: Set<string>;
|
||||||
hideDirectoryControls: boolean;
|
hideDirectoryControls: boolean;
|
||||||
collapsedFolderIds: Set<string>;
|
|
||||||
toggleFolderCollapse: (folderId: string) => void;
|
|
||||||
renameFolder: (scopeKey: string, folderId: string, name: string) => void;
|
|
||||||
deleteFolder: (scopeKey: string, folderId: string) => void;
|
|
||||||
showDeletionDialog: boolean;
|
|
||||||
setDeleteFolderConfirm: React.Dispatch<React.SetStateAction<DeleteFolderConfirm>>;
|
|
||||||
renderSessionNode: (
|
|
||||||
node: SessionNode,
|
|
||||||
depth?: number,
|
|
||||||
groupDirectory?: string | null,
|
|
||||||
projectId?: string | null,
|
|
||||||
archivedBucket?: boolean,
|
|
||||||
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
|
|
||||||
renderContext?: 'project' | 'recent',
|
|
||||||
renderExtras?: SessionNodeRenderExtras,
|
|
||||||
) => React.ReactNode;
|
|
||||||
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number, increment?: number) => void;
|
showMoreGroupSessions: (groupKey: string, currentVisibleCount: number, increment?: number) => void;
|
||||||
resetGroupSessionLimit: (groupKey: string) => void;
|
resetGroupSessionLimit: (groupKey: string) => void;
|
||||||
mobileVariant: boolean;
|
mobileVariant: boolean;
|
||||||
@@ -85,21 +72,14 @@ type Props = {
|
|||||||
setActiveProjectIdOnly: (id: string) => void;
|
setActiveProjectIdOnly: (id: string) => void;
|
||||||
setSessionSwitcherOpen: (open: boolean) => void;
|
setSessionSwitcherOpen: (open: boolean) => void;
|
||||||
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null; targetFolderId?: string; target?: 'chat' | 'project' }) => void;
|
openNewSessionDraft: (options?: { selectedProjectId?: string | null; directoryOverride?: string | null; targetFolderId?: string; target?: 'chat' | 'project' }) => void;
|
||||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
|
||||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
|
||||||
renamingFolderId: string | null;
|
|
||||||
renameFolderDraft: string;
|
|
||||||
setRenameFolderDraft: React.Dispatch<React.SetStateAction<string>>;
|
|
||||||
setRenamingFolderId: React.Dispatch<React.SetStateAction<string | null>>;
|
|
||||||
pinnedSessionIds: Set<string>;
|
pinnedSessionIds: Set<string>;
|
||||||
expandedParents: Set<string>;
|
|
||||||
sessionOrderIndex: Map<string, number>;
|
sessionOrderIndex: Map<string, number>;
|
||||||
|
notifyOnSubtasks: boolean;
|
||||||
|
expandedParents: Set<string>;
|
||||||
editingId: string | null;
|
editingId: string | null;
|
||||||
editTitle: string;
|
editTitle: string;
|
||||||
|
copiedSessionId: string | null;
|
||||||
openSidebarMenuKey: string | null;
|
openSidebarMenuKey: string | null;
|
||||||
activeActivitySessionIds: Set<string>;
|
|
||||||
unreadActivitySessionIds: Set<string>;
|
|
||||||
notifyOnSubtasks: boolean;
|
|
||||||
onToggleCollapsedGroup: (groupKey: string) => void;
|
onToggleCollapsedGroup: (groupKey: string) => void;
|
||||||
dragHandleProps?: SortableDragHandleProps | null;
|
dragHandleProps?: SortableDragHandleProps | null;
|
||||||
compactBodyPadding?: boolean;
|
compactBodyPadding?: boolean;
|
||||||
@@ -110,7 +90,34 @@ type Props = {
|
|||||||
* render of an expanded archived bucket.
|
* render of an expanded archived bucket.
|
||||||
*/
|
*/
|
||||||
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
scrollContainerRef?: React.RefObject<HTMLElement | null>;
|
||||||
};
|
folderRename: { scopeKey: string; folderId: string; draft: string } | null;
|
||||||
|
setFolderRenameDraft: (draft: string) => void;
|
||||||
|
clearFolderRename: () => void;
|
||||||
|
} & Pick<SessionTreeItemProps,
|
||||||
|
| 'setEditingId'
|
||||||
|
| 'setEditTitle'
|
||||||
|
| 'toggleParent'
|
||||||
|
| 'setOpenSidebarMenuKey'
|
||||||
|
| 'allowReselect'
|
||||||
|
| 'onSessionSelected'
|
||||||
|
| 'isSessionSearchOpen'
|
||||||
|
| 'sessionSearchQuery'
|
||||||
|
| 'setSessionSearchQuery'
|
||||||
|
| 'setIsSessionSearchOpen'
|
||||||
|
| 'deleteSessionConfirm'
|
||||||
|
| 'setDeleteSessionConfirm'
|
||||||
|
| 'startFolderRename'
|
||||||
|
| 'setCopiedSessionId'
|
||||||
|
>;
|
||||||
|
|
||||||
|
const CollapsedFolderActivity: React.FC<{
|
||||||
|
nodes: SessionNode[];
|
||||||
|
includeUnreadSubtasks: boolean;
|
||||||
|
children: (state: ReturnType<typeof useCollapsedSessionActivityState>) => React.ReactNode;
|
||||||
|
}> = ({ nodes, includeUnreadSubtasks, children }) => children(useCollapsedSessionActivityState({
|
||||||
|
nodes,
|
||||||
|
includeUnreadSubtasks,
|
||||||
|
}));
|
||||||
|
|
||||||
const groupContainsSessionId = (group: SessionGroup, sessionId: string | null): boolean => {
|
const groupContainsSessionId = (group: SessionGroup, sessionId: string | null): boolean => {
|
||||||
if (!sessionId) return false;
|
if (!sessionId) return false;
|
||||||
@@ -145,26 +152,6 @@ const groupHasSessionOrderChange = (
|
|||||||
return group.sessions.some(visit);
|
return group.sessions.some(visit);
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupHasActivityMembershipChange = (
|
|
||||||
group: SessionGroup,
|
|
||||||
prevSessionIds: Set<string>,
|
|
||||||
nextSessionIds: Set<string>,
|
|
||||||
): boolean => {
|
|
||||||
const visit = (node: SessionNode): boolean => {
|
|
||||||
if (prevSessionIds.has(node.session.id) !== nextSessionIds.has(node.session.id)) return true;
|
|
||||||
return node.children.some(visit);
|
|
||||||
};
|
|
||||||
return group.sessions.some(visit);
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupHasAnyActivityMembership = (group: SessionGroup, sessionIds: Set<string>): boolean => {
|
|
||||||
const visit = (node: SessionNode): boolean => {
|
|
||||||
if (sessionIds.has(node.session.id)) return true;
|
|
||||||
return node.children.some(visit);
|
|
||||||
};
|
|
||||||
return group.sessions.some(visit);
|
|
||||||
};
|
|
||||||
|
|
||||||
const groupHasExpansionMembershipChange = (
|
const groupHasExpansionMembershipChange = (
|
||||||
group: SessionGroup,
|
group: SessionGroup,
|
||||||
prevExpandedParents: Set<string>,
|
prevExpandedParents: Set<string>,
|
||||||
@@ -179,7 +166,7 @@ const groupHasExpansionMembershipChange = (
|
|||||||
return group.sessions.some(visit);
|
return group.sessions.some(visit);
|
||||||
};
|
};
|
||||||
|
|
||||||
const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
const areGroupPropsEqual = (prev: SessionGroupSectionProps, next: SessionGroupSectionProps): boolean => {
|
||||||
// Bail on Object.is for the props that drive the most work: the group
|
// Bail on Object.is for the props that drive the most work: the group
|
||||||
// itself, its key, and the group-level chrome. These change rarely and
|
// itself, its key, and the group-level chrome. These change rarely and
|
||||||
// any change should force a re-render of this group.
|
// any change should force a re-render of this group.
|
||||||
@@ -202,45 +189,36 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prev.expandedParents !== next.expandedParents
|
|
||||||
&& groupHasExpansionMembershipChange(next.group, prev.expandedParents, next.expandedParents)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prev.sessionOrderIndex !== next.sessionOrderIndex
|
if (prev.sessionOrderIndex !== next.sessionOrderIndex
|
||||||
&& groupHasSessionOrderChange(next.group, prev.sessionOrderIndex, next.sessionOrderIndex)) {
|
&& groupHasSessionOrderChange(next.group, prev.sessionOrderIndex, next.sessionOrderIndex)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prev.expandedParents !== next.expandedParents
|
||||||
|
&& groupHasExpansionMembershipChange(next.group, prev.expandedParents, next.expandedParents)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (prev.editingId !== next.editingId
|
if (prev.editingId !== next.editingId
|
||||||
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
|
&& (groupContainsSessionId(next.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (prev.editTitle !== next.editTitle && groupContainsSessionId(next.group, next.editingId)) return false;
|
||||||
if (prev.editTitle !== next.editTitle
|
if (prev.copiedSessionId !== next.copiedSessionId
|
||||||
&& (groupContainsSessionId(prev.group, prev.editingId) || groupContainsSessionId(next.group, next.editingId))) {
|
&& (groupContainsSessionId(next.group, prev.copiedSessionId) || groupContainsSessionId(next.group, next.copiedSessionId))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
|
if (prev.openSidebarMenuKey !== next.openSidebarMenuKey) {
|
||||||
const prevMenuSessionId = resolveMenuOpenSessionId(prev.group.sessions, prev.openSidebarMenuKey, 'project', Boolean(prev.group.isArchivedBucket));
|
const archived = next.group.isArchivedBucket === true;
|
||||||
const nextMenuSessionId = resolveMenuOpenSessionId(next.group.sessions, next.openSidebarMenuKey, 'project', Boolean(next.group.isArchivedBucket));
|
const previousMenuSessionId = resolveMenuOpenSessionId(next.group.sessions, prev.openSidebarMenuKey, 'project', archived);
|
||||||
if (prevMenuSessionId || nextMenuSessionId) return false;
|
const nextMenuSessionId = resolveMenuOpenSessionId(next.group.sessions, next.openSidebarMenuKey, 'project', archived);
|
||||||
|
if (previousMenuSessionId || nextMenuSessionId) return false;
|
||||||
}
|
}
|
||||||
|
if (prev.folderRename !== next.folderRename) {
|
||||||
if (prev.activeActivitySessionIds !== next.activeActivitySessionIds
|
const scopes = next.group.folderScopes?.map((scope) => scope.scopeKey)
|
||||||
&& groupHasActivityMembershipChange(next.group, prev.activeActivitySessionIds, next.activeActivitySessionIds)) {
|
?? [next.group.folderScopeKey ?? normalizePath(next.group.directory ?? null)];
|
||||||
return false;
|
if (scopes.includes(prev.folderRename?.scopeKey ?? null) || scopes.includes(next.folderRename?.scopeKey ?? null)) {
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
if (prev.unreadActivitySessionIds !== next.unreadActivitySessionIds
|
|
||||||
&& groupHasActivityMembershipChange(next.group, prev.unreadActivitySessionIds, next.unreadActivitySessionIds)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prev.notifyOnSubtasks !== next.notifyOnSubtasks
|
|
||||||
&& groupHasAnyActivityMembership(next.group, next.unreadActivitySessionIds)) {
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Other props are typically stable references from the parent. Default
|
// Other props are typically stable references from the parent. Default
|
||||||
@@ -250,13 +228,6 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
|||||||
prev.hasSessionSearchQuery === next.hasSessionSearchQuery
|
prev.hasSessionSearchQuery === next.hasSessionSearchQuery
|
||||||
&& prev.normalizedSessionSearchQuery === next.normalizedSessionSearchQuery
|
&& prev.normalizedSessionSearchQuery === next.normalizedSessionSearchQuery
|
||||||
&& prev.hideDirectoryControls === next.hideDirectoryControls
|
&& prev.hideDirectoryControls === next.hideDirectoryControls
|
||||||
&& prev.collapsedFolderIds === next.collapsedFolderIds
|
|
||||||
&& prev.toggleFolderCollapse === next.toggleFolderCollapse
|
|
||||||
&& prev.renameFolder === next.renameFolder
|
|
||||||
&& prev.deleteFolder === next.deleteFolder
|
|
||||||
&& prev.showDeletionDialog === next.showDeletionDialog
|
|
||||||
&& prev.setDeleteFolderConfirm === next.setDeleteFolderConfirm
|
|
||||||
&& prev.renderSessionNode === next.renderSessionNode
|
|
||||||
&& prev.showMoreGroupSessions === next.showMoreGroupSessions
|
&& prev.showMoreGroupSessions === next.showMoreGroupSessions
|
||||||
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit
|
&& prev.resetGroupSessionLimit === next.resetGroupSessionLimit
|
||||||
&& prev.mobileVariant === next.mobileVariant
|
&& prev.mobileVariant === next.mobileVariant
|
||||||
@@ -265,19 +236,30 @@ const areGroupPropsEqual = (prev: Props, next: Props): boolean => {
|
|||||||
&& prev.setActiveProjectIdOnly === next.setActiveProjectIdOnly
|
&& prev.setActiveProjectIdOnly === next.setActiveProjectIdOnly
|
||||||
&& prev.setSessionSwitcherOpen === next.setSessionSwitcherOpen
|
&& prev.setSessionSwitcherOpen === next.setSessionSwitcherOpen
|
||||||
&& prev.openNewSessionDraft === next.openNewSessionDraft
|
&& prev.openNewSessionDraft === next.openNewSessionDraft
|
||||||
&& prev.addSessionToFolder === next.addSessionToFolder
|
|
||||||
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
|
|
||||||
&& prev.renamingFolderId === next.renamingFolderId
|
|
||||||
&& prev.renameFolderDraft === next.renameFolderDraft
|
|
||||||
&& prev.setRenameFolderDraft === next.setRenameFolderDraft
|
|
||||||
&& prev.setRenamingFolderId === next.setRenamingFolderId
|
|
||||||
&& prev.onToggleCollapsedGroup === next.onToggleCollapsedGroup
|
&& prev.onToggleCollapsedGroup === next.onToggleCollapsedGroup
|
||||||
&& prev.dragHandleProps === next.dragHandleProps
|
&& prev.dragHandleProps === next.dragHandleProps
|
||||||
&& prev.scrollContainerRef === next.scrollContainerRef
|
&& prev.scrollContainerRef === next.scrollContainerRef
|
||||||
|
&& prev.notifyOnSubtasks === next.notifyOnSubtasks
|
||||||
|
&& prev.setEditingId === next.setEditingId
|
||||||
|
&& prev.setEditTitle === next.setEditTitle
|
||||||
|
&& prev.toggleParent === next.toggleParent
|
||||||
|
&& prev.setOpenSidebarMenuKey === next.setOpenSidebarMenuKey
|
||||||
|
&& prev.allowReselect === next.allowReselect
|
||||||
|
&& prev.onSessionSelected === next.onSessionSelected
|
||||||
|
&& prev.isSessionSearchOpen === next.isSessionSearchOpen
|
||||||
|
&& prev.sessionSearchQuery === next.sessionSearchQuery
|
||||||
|
&& prev.setSessionSearchQuery === next.setSessionSearchQuery
|
||||||
|
&& prev.setIsSessionSearchOpen === next.setIsSessionSearchOpen
|
||||||
|
&& prev.deleteSessionConfirm === next.deleteSessionConfirm
|
||||||
|
&& prev.setDeleteSessionConfirm === next.setDeleteSessionConfirm
|
||||||
|
&& prev.startFolderRename === next.startFolderRename
|
||||||
|
&& prev.setCopiedSessionId === next.setCopiedSessionId
|
||||||
|
&& prev.setFolderRenameDraft === next.setFolderRenameDraft
|
||||||
|
&& prev.clearFolderRename === next.clearFolderRename
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function SessionGroupSectionBase(props: Props): React.ReactNode {
|
function SessionGroupSectionBase(props: SessionGroupSectionProps): React.ReactNode {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const {
|
const {
|
||||||
group,
|
group,
|
||||||
@@ -291,13 +273,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
sessionBatchSize,
|
sessionBatchSize,
|
||||||
collapsedGroups,
|
collapsedGroups,
|
||||||
hideDirectoryControls,
|
hideDirectoryControls,
|
||||||
collapsedFolderIds,
|
|
||||||
toggleFolderCollapse,
|
|
||||||
renameFolder,
|
|
||||||
deleteFolder,
|
|
||||||
showDeletionDialog,
|
|
||||||
setDeleteFolderConfirm,
|
|
||||||
renderSessionNode,
|
|
||||||
showMoreGroupSessions,
|
showMoreGroupSessions,
|
||||||
resetGroupSessionLimit,
|
resetGroupSessionLimit,
|
||||||
mobileVariant,
|
mobileVariant,
|
||||||
@@ -306,26 +281,28 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
setActiveProjectIdOnly,
|
setActiveProjectIdOnly,
|
||||||
setSessionSwitcherOpen,
|
setSessionSwitcherOpen,
|
||||||
openNewSessionDraft,
|
openNewSessionDraft,
|
||||||
addSessionToFolder,
|
|
||||||
createFolderAndStartRename,
|
|
||||||
renamingFolderId,
|
|
||||||
renameFolderDraft,
|
|
||||||
setRenameFolderDraft,
|
|
||||||
setRenamingFolderId,
|
|
||||||
pinnedSessionIds,
|
pinnedSessionIds,
|
||||||
expandedParents,
|
|
||||||
sessionOrderIndex,
|
sessionOrderIndex,
|
||||||
editingId,
|
|
||||||
openSidebarMenuKey,
|
|
||||||
activeActivitySessionIds,
|
|
||||||
unreadActivitySessionIds,
|
|
||||||
notifyOnSubtasks,
|
notifyOnSubtasks,
|
||||||
onToggleCollapsedGroup,
|
onToggleCollapsedGroup,
|
||||||
dragHandleProps,
|
dragHandleProps,
|
||||||
compactBodyPadding = false,
|
compactBodyPadding = false,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
|
expandedParents,
|
||||||
|
editingId,
|
||||||
|
openSidebarMenuKey,
|
||||||
|
editTitle,
|
||||||
|
copiedSessionId,
|
||||||
|
folderRename,
|
||||||
|
setFolderRenameDraft,
|
||||||
|
clearFolderRename,
|
||||||
} = props;
|
} = props;
|
||||||
|
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
|
||||||
|
const renameFolder = useSessionFoldersStore((state) => state.renameFolder);
|
||||||
|
const deleteFolder = useSessionFoldersStore((state) => state.deleteFolder);
|
||||||
|
const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder);
|
||||||
|
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||||
|
const [deleteFolderConfirm, setDeleteFolderConfirm] = React.useState<DeleteFolderConfirm>(null);
|
||||||
const compareSessionNodes = React.useCallback((a: SessionNode, b: SessionNode) => {
|
const compareSessionNodes = React.useCallback((a: SessionNode, b: SessionNode) => {
|
||||||
const aIndex = sessionOrderIndex.get(a.session.id);
|
const aIndex = sessionOrderIndex.get(a.session.id);
|
||||||
const bIndex = sessionOrderIndex.get(b.session.id);
|
const bIndex = sessionOrderIndex.get(b.session.id);
|
||||||
@@ -338,7 +315,6 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
}, [pinnedSessionIds, sessionOrderIndex]);
|
}, [pinnedSessionIds, sessionOrderIndex]);
|
||||||
|
|
||||||
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
||||||
const foldersMap = useSessionFoldersStore((state) => state.foldersMap);
|
|
||||||
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
|
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
|
||||||
// PR state for the worktree sub-header (grouped display mode).
|
// PR state for the worktree sub-header (grouped display mode).
|
||||||
const groupPrKey = React.useMemo(() => {
|
const groupPrKey = React.useMemo(() => {
|
||||||
@@ -413,15 +389,26 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
||||||
// Merged flat groups list every contributing scope; single-scope groups
|
// Merged flat groups list every contributing scope; single-scope groups
|
||||||
// (archived buckets, VS Code workspaces) fall back to folderScopeKey.
|
// (archived buckets, VS Code workspaces) fall back to folderScopeKey.
|
||||||
const folderScopes = React.useMemo<Array<{ scopeKey: string; directory: string | null }>>(() => {
|
const folderScopes = React.useMemo<FolderScope[]>(() => {
|
||||||
if (group.folderScopes && group.folderScopes.length > 0) return group.folderScopes;
|
if (group.folderScopes && group.folderScopes.length > 0) return group.folderScopes;
|
||||||
return folderScopeKey ? [{ scopeKey: folderScopeKey, directory: group.directory ?? null }] : [];
|
return folderScopeKey ? [{ scopeKey: folderScopeKey, directory: group.directory ?? null }] : [];
|
||||||
}, [folderScopeKey, group.directory, group.folderScopes]);
|
}, [folderScopeKey, group.directory, group.folderScopes]);
|
||||||
const scopeFolders = React.useMemo(
|
// A group only needs folders and collapse state from its own scopes. The
|
||||||
() => folderScopes.flatMap(({ scopeKey, directory }) =>
|
// shallow projection retains its reference for mutations elsewhere.
|
||||||
(foldersMap[scopeKey] ?? []).map((folder) => ({ folder, scopeKey, scopeDirectory: directory }))),
|
const folderProjection = useSessionFoldersStore(useShallow(React.useCallback(
|
||||||
[folderScopes, foldersMap]
|
(state) => folderScopes.map(({ scopeKey }) => state.foldersMap[scopeKey] ?? EMPTY_FOLDERS),
|
||||||
);
|
[folderScopes],
|
||||||
|
)));
|
||||||
|
const scopeFolders = React.useMemo(() => folderScopes.flatMap(({ scopeKey, directory }, index) => {
|
||||||
|
const folders = folderProjection[index] ?? EMPTY_FOLDERS;
|
||||||
|
return folders.map((folder) => ({ folder, scopeKey, scopeDirectory: directory }));
|
||||||
|
}), [folderProjection, folderScopes]);
|
||||||
|
const collapsedFolderIds = useSessionFoldersStore(useShallow(React.useCallback(
|
||||||
|
(state) => new Set(folderProjection.flatMap((folders) => folders
|
||||||
|
.filter((folder) => state.collapsedFolderIds.has(folder.id))
|
||||||
|
.map((folder) => folder.id))),
|
||||||
|
[folderProjection],
|
||||||
|
)));
|
||||||
|
|
||||||
const nodeBySessionId = React.useMemo(() => {
|
const nodeBySessionId = React.useMemo(() => {
|
||||||
const map = new Map<string, SessionNode>();
|
const map = new Map<string, SessionNode>();
|
||||||
@@ -443,60 +430,45 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
}), [scopeFolders, nodeBySessionId, compareSessionNodes]);
|
}), [scopeFolders, nodeBySessionId, compareSessionNodes]);
|
||||||
|
|
||||||
const allFoldersForGroup = React.useMemo(() => {
|
const allFoldersForGroup = React.useMemo(() => {
|
||||||
const folderMapById = new Map(allFoldersForGroupBase.map((entry) => [entry.folder.id, entry]));
|
const visibleFolderIds = selectFolderIdsForProjection(
|
||||||
const childFolderIdsByParentId = new Map<string, string[]>();
|
allFoldersForGroupBase.map(({ folder, nodes }) => ({
|
||||||
for (const { folder } of allFoldersForGroupBase) {
|
id: folder.id,
|
||||||
if (!folder.parentId) continue;
|
name: folder.name,
|
||||||
const existing = childFolderIdsByParentId.get(folder.parentId);
|
parentId: folder.parentId,
|
||||||
if (existing) {
|
nodeCount: nodes.length,
|
||||||
existing.push(folder.id);
|
})),
|
||||||
} else {
|
{
|
||||||
childFolderIdsByParentId.set(folder.parentId, [folder.id]);
|
archivedBucket: group.isArchivedBucket === true,
|
||||||
}
|
searchQuery: hasSessionSearchQuery ? normalizedSessionSearchQuery : '',
|
||||||
}
|
},
|
||||||
|
);
|
||||||
const keepByFolderId = new Map<string, boolean>();
|
return allFoldersForGroupBase.filter(({ folder }) => visibleFolderIds.has(folder.id));
|
||||||
const shouldKeepFolder = (folderId: string): boolean => {
|
|
||||||
const cached = keepByFolderId.get(folderId);
|
|
||||||
if (cached !== undefined) return cached;
|
|
||||||
|
|
||||||
const entry = folderMapById.get(folderId);
|
|
||||||
if (!entry) {
|
|
||||||
keepByFolderId.set(folderId, false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const childFolderIds = childFolderIdsByParentId.get(folderId) ?? [];
|
|
||||||
|
|
||||||
// For archived buckets, hide folders with no sessions unless descendants have content.
|
|
||||||
if (group.isArchivedBucket && entry.nodes.length === 0) {
|
|
||||||
const hasContentInChildren = childFolderIds.some((childId) => shouldKeepFolder(childId));
|
|
||||||
keepByFolderId.set(folderId, hasContentInChildren);
|
|
||||||
return hasContentInChildren;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasSessionSearchQuery) {
|
|
||||||
keepByFolderId.set(folderId, true);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const folderMatches = matchesRankQuery([entry.folder.name], normalizedSessionSearchQuery);
|
|
||||||
if (folderMatches || entry.nodes.length > 0) {
|
|
||||||
keepByFolderId.set(folderId, true);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasMatchingChildren = childFolderIds.some((childId) => shouldKeepFolder(childId));
|
|
||||||
keepByFolderId.set(folderId, hasMatchingChildren);
|
|
||||||
return hasMatchingChildren;
|
|
||||||
};
|
|
||||||
|
|
||||||
return allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id));
|
|
||||||
}, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]);
|
}, [allFoldersForGroupBase, group.isArchivedBucket, hasSessionSearchQuery, normalizedSessionSearchQuery]);
|
||||||
|
|
||||||
|
const groupSessionIds = React.useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
const visit = (nodes: SessionNode[]) => nodes.forEach((node) => {
|
||||||
|
ids.add(node.session.id);
|
||||||
|
visit(node.children);
|
||||||
|
});
|
||||||
|
visit(sourceGroupNodes);
|
||||||
|
return ids;
|
||||||
|
}, [sourceGroupNodes]);
|
||||||
|
const groupExpansionKeys = React.useMemo(() => new Set(
|
||||||
|
[...groupSessionIds].map((id) => `project:${group.isArchivedBucket ? 'archived' : 'active'}:${id}`),
|
||||||
|
), [group.isArchivedBucket, groupSessionIds]);
|
||||||
|
const effectiveEditingId = editingId;
|
||||||
|
const effectiveOpenMenuKey = openSidebarMenuKey;
|
||||||
|
const effectiveExpandedParents = expandedParents;
|
||||||
|
|
||||||
const sessionIdsInFolders = React.useMemo(() => new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)), [allFoldersForGroup]);
|
const sessionIdsInFolders = React.useMemo(() => new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds)), [allFoldersForGroup]);
|
||||||
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
|
const ungroupedSessions = React.useMemo(() => sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id)), [sourceGroupNodes, sessionIdsInFolders]);
|
||||||
const rootFolders = React.useMemo(() => allFoldersForGroup.filter(({ folder }) => !folder.parentId), [allFoldersForGroup]);
|
const rootFolders = React.useMemo(() => {
|
||||||
|
const entryById = new Map(allFoldersForGroup.map((entry) => [entry.folder.id, entry]));
|
||||||
|
return normalizeFolderRoots(allFoldersForGroup.map((entry) => entry.folder))
|
||||||
|
.map((folder) => entryById.get(folder.id))
|
||||||
|
.filter((entry): entry is (typeof allFoldersForGroup)[number] => Boolean(entry));
|
||||||
|
}, [allFoldersForGroup]);
|
||||||
const childFoldersByParentId = React.useMemo(() => {
|
const childFoldersByParentId = React.useMemo(() => {
|
||||||
const map = new Map<string, typeof allFoldersForGroup>();
|
const map = new Map<string, typeof allFoldersForGroup>();
|
||||||
allFoldersForGroup.forEach((entry) => {
|
allFoldersForGroup.forEach((entry) => {
|
||||||
@@ -507,30 +479,25 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [allFoldersForGroup]);
|
}, [allFoldersForGroup]);
|
||||||
const folderActivityStateById = React.useMemo(() => {
|
const activityNodesByFolderId = React.useMemo(() => {
|
||||||
const foldersById = new Map(allFoldersForGroup.map((entry) => [entry.folder.id, entry] as const));
|
const foldersById = new Map(allFoldersForGroup.map((entry) => [entry.folder.id, entry] as const));
|
||||||
const result = new Map<string, CollapsedActivityState>();
|
const result = new Map<string, SessionNode[]>();
|
||||||
const visit = (folderId: string, seen: Set<string>): CollapsedActivityState => {
|
const visit = (folderId: string, seen: Set<string>): SessionNode[] => {
|
||||||
const cached = result.get(folderId);
|
const cached = result.get(folderId);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
if (seen.has(folderId)) return null;
|
if (seen.has(folderId)) return [];
|
||||||
seen.add(folderId);
|
seen.add(folderId);
|
||||||
|
|
||||||
const entry = foldersById.get(folderId);
|
const entry = foldersById.get(folderId);
|
||||||
let state = entry
|
const nodes = entry ? [...entry.nodes] : [];
|
||||||
? getSessionNodesActivityState(entry.nodes, activeActivitySessionIds, unreadActivitySessionIds, notifyOnSubtasks)
|
|
||||||
: null;
|
|
||||||
for (const child of childFoldersByParentId.get(folderId) ?? []) {
|
for (const child of childFoldersByParentId.get(folderId) ?? []) {
|
||||||
state = mergeCollapsedActivityStates(state, visit(child.folder.id, seen));
|
nodes.push(...visit(child.folder.id, seen));
|
||||||
if (state === 'active') break;
|
|
||||||
}
|
}
|
||||||
result.set(folderId, state);
|
result.set(folderId, nodes);
|
||||||
return state;
|
return nodes;
|
||||||
};
|
};
|
||||||
|
|
||||||
allFoldersForGroup.forEach(({ folder }) => visit(folder.id, new Set()));
|
allFoldersForGroup.forEach(({ folder }) => visit(folder.id, new Set()));
|
||||||
return result;
|
return result;
|
||||||
}, [activeActivitySessionIds, allFoldersForGroup, childFoldersByParentId, notifyOnSubtasks, unreadActivitySessionIds]);
|
}, [allFoldersForGroup, childFoldersByParentId]);
|
||||||
|
|
||||||
// Precompute the per-row "subtree contains editing session" lookup once per
|
// Precompute the per-row "subtree contains editing session" lookup once per
|
||||||
// render. The previous design walked the
|
// render. The previous design walked the
|
||||||
@@ -540,23 +507,23 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
const renderContextForGroup = 'project' as const;
|
const renderContextForGroup = 'project' as const;
|
||||||
const subtreeContainsEditing = React.useMemo(() => {
|
const subtreeContainsEditing = React.useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
collectSubtreeContainingId(sourceGroupNodes, editingId, set);
|
collectSubtreeContainingId(sourceGroupNodes, effectiveEditingId, set);
|
||||||
allFoldersForGroup.forEach(({ nodes }) => {
|
allFoldersForGroup.forEach(({ nodes }) => {
|
||||||
collectSubtreeContainingId(nodes, editingId, set);
|
collectSubtreeContainingId(nodes, effectiveEditingId, set);
|
||||||
});
|
});
|
||||||
return set;
|
return set;
|
||||||
}, [sourceGroupNodes, allFoldersForGroup, editingId]);
|
}, [sourceGroupNodes, allFoldersForGroup, effectiveEditingId]);
|
||||||
|
|
||||||
const menuOpenSessionId = React.useMemo(() => {
|
const menuOpenSessionId = React.useMemo(() => {
|
||||||
if (!openSidebarMenuKey) return null;
|
if (!effectiveOpenMenuKey) return null;
|
||||||
const fromSource = resolveMenuOpenSessionId(sourceGroupNodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
|
const fromSource = resolveMenuOpenSessionId(sourceGroupNodes, effectiveOpenMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
|
||||||
if (fromSource) return fromSource;
|
if (fromSource) return fromSource;
|
||||||
for (const { nodes } of allFoldersForGroup) {
|
for (const { nodes } of allFoldersForGroup) {
|
||||||
const id = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
|
const id = resolveMenuOpenSessionId(nodes, effectiveOpenMenuKey, renderContextForGroup, Boolean(group.isArchivedBucket));
|
||||||
if (id) return id;
|
if (id) return id;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [openSidebarMenuKey, sourceGroupNodes, allFoldersForGroup, group.isArchivedBucket]);
|
}, [effectiveOpenMenuKey, sourceGroupNodes, allFoldersForGroup, group.isArchivedBucket]);
|
||||||
|
|
||||||
const buildNodeStructureKeyByNode = React.useCallback((nodes: SessionNode[]): WeakMap<SessionNode, string> => {
|
const buildNodeStructureKeyByNode = React.useCallback((nodes: SessionNode[]): WeakMap<SessionNode, string> => {
|
||||||
const map = new WeakMap<SessionNode, string>();
|
const map = new WeakMap<SessionNode, string>();
|
||||||
@@ -620,7 +587,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
const hasExpandedParent = shouldVirtualize && visibleSessions.some((node) => {
|
const hasExpandedParent = shouldVirtualize && visibleSessions.some((node) => {
|
||||||
if (node.children.length === 0) return false;
|
if (node.children.length === 0) return false;
|
||||||
const expansionKey = `project:${bucketTag}:${node.session.id}`;
|
const expansionKey = `project:${bucketTag}:${node.session.id}`;
|
||||||
return expandedParents.has(expansionKey);
|
return effectiveExpandedParents.has(expansionKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null);
|
const archivedVirtualContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
@@ -649,7 +616,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
if (!shouldVirtualize) return;
|
if (!shouldVirtualize) return;
|
||||||
const container = archivedVirtualContainerRef.current;
|
const container = archivedVirtualContainerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
if (typeof ResizeObserver === 'undefined') return;
|
if (!globalThis.ResizeObserver) return;
|
||||||
const ro = new ResizeObserver(() => setLayoutVersion((v) => v + 1));
|
const ro = new ResizeObserver(() => setLayoutVersion((v) => v + 1));
|
||||||
ro.observe(container);
|
ro.observe(container);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
@@ -785,28 +752,23 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const showBranchSubtitle = !group.isMain && Boolean(group.branch);
|
const showBranchSubtitle = !group.isMain && Boolean(group.branch);
|
||||||
|
// SAFETY: null is the intentional no-color branch for a status line.
|
||||||
const statusLine = group.branch && isBranchDifferentFromLabel(group.branch, group.label)
|
const statusLine = group.branch && isBranchDifferentFromLabel(group.branch, group.label)
|
||||||
? { label: group.branch, color: null as string | null }
|
? { label: group.branch, color: null as string | null }
|
||||||
: null;
|
: null;
|
||||||
const groupActivityState = isCollapsed
|
const groupActivityIndicator = isCollapsed
|
||||||
? getSessionNodesActivityState(sourceGroupNodes, activeActivitySessionIds, unreadActivitySessionIds, notifyOnSubtasks)
|
? <CollapsedSessionActivityIndicator nodes={sourceGroupNodes} includeUnreadSubtasks={notifyOnSubtasks} />
|
||||||
: null;
|
: null;
|
||||||
const groupActivityIndicator = groupActivityState ? (
|
|
||||||
<CollapsedActivityIndicator
|
|
||||||
state={groupActivityState}
|
|
||||||
activeLabel={t('sessions.sidebar.session.status.active')}
|
|
||||||
unreadLabel={t('sessions.sidebar.session.status.unread')}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
type FolderEntry = (typeof allFoldersForGroup)[number];
|
type FolderEntry = (typeof allFoldersForGroup)[number];
|
||||||
|
|
||||||
const renderOneFolderItem = (entry: FolderEntry, displayName: string): React.ReactNode => {
|
const renderOneFolderItem = (entry: FolderEntry, displayName: string): React.ReactNode => {
|
||||||
const { folder, scopeKey, scopeDirectory, nodes } = entry;
|
const { folder, scopeKey, scopeDirectory, nodes } = entry;
|
||||||
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
|
const folderSessionsForDelete = folderSessionsForDeleteById.get(folder.id) ?? [];
|
||||||
|
const isRenamingFolder = folderRename?.folderId === folder.id && folderRename?.scopeKey === scopeKey;
|
||||||
|
|
||||||
const isFolderCollapsed = hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id);
|
const isFolderCollapsed = hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id);
|
||||||
return (
|
const item = (collapsedActivityState: ReturnType<typeof useCollapsedSessionActivityState>) => (
|
||||||
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
|
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
|
||||||
{(droppableRef, isDropTarget) => (
|
{(droppableRef, isDropTarget) => (
|
||||||
<SessionFolderItem
|
<SessionFolderItem
|
||||||
@@ -814,7 +776,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
sessions={nodes}
|
sessions={nodes}
|
||||||
isCollapsed={isFolderCollapsed}
|
isCollapsed={isFolderCollapsed}
|
||||||
collapsedActivityState={isFolderCollapsed ? (folderActivityStateById.get(folder.id) ?? null) : null}
|
collapsedActivityState={collapsedActivityState}
|
||||||
onToggle={() => toggleFolderCollapse(folder.id)}
|
onToggle={() => toggleFolderCollapse(folder.id)}
|
||||||
onRename={(name) => {
|
onRename={(name) => {
|
||||||
renameFolder(scopeKey, folder.id, name);
|
renameFolder(scopeKey, folder.id, name);
|
||||||
@@ -843,34 +805,21 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
sessionCount,
|
sessionCount,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
renderSessionNode={renderSessionNode}
|
|
||||||
getRenderExtras={resolveNodeStructureKey
|
|
||||||
? (node) => ({
|
|
||||||
subtreeContainsEditing,
|
|
||||||
menuOpenSessionId,
|
|
||||||
nodeStructureKey: resolveNodeStructureKey(node),
|
|
||||||
childRenderExtrasFor,
|
|
||||||
})
|
|
||||||
: undefined}
|
|
||||||
groupDirectory={scopeDirectory ?? group.directory}
|
groupDirectory={scopeDirectory ?? group.directory}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
mobileVariant={mobileVariant}
|
mobileVariant={mobileVariant}
|
||||||
alwaysShowActions={alwaysShowActions}
|
alwaysShowActions={alwaysShowActions}
|
||||||
isRenaming={renamingFolderId === folder.id}
|
isRenaming={isRenamingFolder}
|
||||||
renameDraft={renamingFolderId === folder.id ? renameFolderDraft : undefined}
|
renameDraft={isRenamingFolder ? folderRename?.draft : undefined}
|
||||||
onRenameDraftChange={(value) => setRenameFolderDraft(value)}
|
onRenameDraftChange={setFolderRenameDraft}
|
||||||
onRenameSave={() => {
|
onRenameSave={() => {
|
||||||
const trimmed = renameFolderDraft.trim();
|
const trimmed = folderRename?.draft.trim() ?? '';
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
renameFolder(scopeKey, folder.id, trimmed);
|
renameFolder(scopeKey, folder.id, trimmed);
|
||||||
}
|
}
|
||||||
setRenamingFolderId(null);
|
clearFolderRename();
|
||||||
setRenameFolderDraft('');
|
|
||||||
}}
|
|
||||||
onRenameCancel={() => {
|
|
||||||
setRenamingFolderId(null);
|
|
||||||
setRenameFolderDraft('');
|
|
||||||
}}
|
}}
|
||||||
|
onRenameCancel={clearFolderRename}
|
||||||
droppableRef={droppableRef}
|
droppableRef={droppableRef}
|
||||||
isDropTarget={isDropTarget}
|
isDropTarget={isDropTarget}
|
||||||
depth={0}
|
depth={0}
|
||||||
@@ -886,10 +835,50 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
}}
|
}}
|
||||||
hideActions={false}
|
hideActions={false}
|
||||||
archivedBucket={group.isArchivedBucket === true}
|
archivedBucket={group.isArchivedBucket === true}
|
||||||
/>
|
>
|
||||||
|
{nodes.map((node) => <SessionTreeItem
|
||||||
|
key={node.session.id}
|
||||||
|
node={node}
|
||||||
|
pinnedSessionIds={pinnedSessionIds}
|
||||||
|
expandedParents={expandedParents}
|
||||||
|
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||||
|
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||||
|
notifyOnSubtasks={notifyOnSubtasks}
|
||||||
|
editingId={editingId}
|
||||||
|
editTitle={editTitle}
|
||||||
|
copiedSessionId={copiedSessionId}
|
||||||
|
openSidebarMenuKey={openSidebarMenuKey}
|
||||||
|
mobileVariant={mobileVariant}
|
||||||
|
alwaysShowActions={alwaysShowActions}
|
||||||
|
groupDirectory={scopeDirectory ?? group.directory}
|
||||||
|
projectId={projectId}
|
||||||
|
archivedBucket={group.isArchivedBucket === true}
|
||||||
|
renderExtras={{ subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor }}
|
||||||
|
setEditingId={props.setEditingId}
|
||||||
|
setEditTitle={props.setEditTitle}
|
||||||
|
toggleParent={props.toggleParent}
|
||||||
|
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||||
|
allowReselect={props.allowReselect}
|
||||||
|
onSessionSelected={props.onSessionSelected}
|
||||||
|
isSessionSearchOpen={props.isSessionSearchOpen}
|
||||||
|
sessionSearchQuery={props.sessionSearchQuery}
|
||||||
|
setSessionSearchQuery={props.setSessionSearchQuery}
|
||||||
|
setIsSessionSearchOpen={props.setIsSessionSearchOpen}
|
||||||
|
deleteSessionConfirm={props.deleteSessionConfirm}
|
||||||
|
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||||
|
startFolderRename={props.startFolderRename}
|
||||||
|
setCopiedSessionId={props.setCopiedSessionId}
|
||||||
|
/>)}
|
||||||
|
</SessionFolderItem>
|
||||||
)}
|
)}
|
||||||
</DroppableFolderWrapper>
|
</DroppableFolderWrapper>
|
||||||
);
|
);
|
||||||
|
if (!isFolderCollapsed) return item(null);
|
||||||
|
return <CollapsedFolderActivity
|
||||||
|
key={folder.id}
|
||||||
|
nodes={activityNodesByFolderId.get(folder.id) ?? nodes}
|
||||||
|
includeUnreadSubtasks={notifyOnSubtasks}
|
||||||
|
>{item}</CollapsedFolderActivity>;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Folders render flat: nested folders keep their data-model parent link but
|
// Folders render flat: nested folders keep their data-model parent link but
|
||||||
@@ -905,7 +894,10 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
else childEntriesByParentId.set(parentId, [entry]);
|
else childEntriesByParentId.set(parentId, [entry]);
|
||||||
}
|
}
|
||||||
const out: React.ReactNode[] = [];
|
const out: React.ReactNode[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
const visit = (entry: FolderEntry, parentPath: string) => {
|
const visit = (entry: FolderEntry, parentPath: string) => {
|
||||||
|
if (visited.has(entry.folder.id)) return;
|
||||||
|
visited.add(entry.folder.id);
|
||||||
const displayName = parentPath ? `${parentPath} / ${entry.folder.name}` : entry.folder.name;
|
const displayName = parentPath ? `${parentPath} / ${entry.folder.name}` : entry.folder.name;
|
||||||
out.push(renderOneFolderItem(entry, displayName));
|
out.push(renderOneFolderItem(entry, displayName));
|
||||||
const isFolderCollapsed = !hasSessionSearchQuery && collapsedFolderIds.has(entry.folder.id);
|
const isFolderCollapsed = !hasSessionSearchQuery && collapsedFolderIds.has(entry.folder.id);
|
||||||
@@ -951,6 +943,40 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
</span>
|
</span>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
const renderSessionNode = (node: SessionNode): React.ReactNode => <SessionTreeItem
|
||||||
|
key={node.session.id}
|
||||||
|
node={node}
|
||||||
|
pinnedSessionIds={pinnedSessionIds}
|
||||||
|
expandedParents={expandedParents}
|
||||||
|
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||||
|
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||||
|
notifyOnSubtasks={notifyOnSubtasks}
|
||||||
|
editingId={editingId}
|
||||||
|
editTitle={editTitle}
|
||||||
|
copiedSessionId={copiedSessionId}
|
||||||
|
openSidebarMenuKey={openSidebarMenuKey}
|
||||||
|
mobileVariant={mobileVariant}
|
||||||
|
alwaysShowActions={alwaysShowActions}
|
||||||
|
groupDirectory={group.directory}
|
||||||
|
projectId={projectId}
|
||||||
|
archivedBucket={group.isArchivedBucket === true}
|
||||||
|
renderExtras={{ subtreeContainsEditing, menuOpenSessionId, nodeStructureKey: resolveNodeStructureKey(node), childRenderExtrasFor }}
|
||||||
|
setEditingId={props.setEditingId}
|
||||||
|
setEditTitle={props.setEditTitle}
|
||||||
|
toggleParent={props.toggleParent}
|
||||||
|
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||||
|
allowReselect={props.allowReselect}
|
||||||
|
onSessionSelected={props.onSessionSelected}
|
||||||
|
isSessionSearchOpen={props.isSessionSearchOpen}
|
||||||
|
sessionSearchQuery={props.sessionSearchQuery}
|
||||||
|
setSessionSearchQuery={props.setSessionSearchQuery}
|
||||||
|
setIsSessionSearchOpen={props.setIsSessionSearchOpen}
|
||||||
|
deleteSessionConfirm={props.deleteSessionConfirm}
|
||||||
|
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||||
|
startFolderRename={props.startFolderRename}
|
||||||
|
setCopiedSessionId={props.setCopiedSessionId}
|
||||||
|
/>;
|
||||||
|
|
||||||
const body = (
|
const body = (
|
||||||
<SessionFolderDndScope
|
<SessionFolderDndScope
|
||||||
scopeKey={folderScopes[0]?.scopeKey ?? folderScopeKey}
|
scopeKey={folderScopes[0]?.scopeKey ?? folderScopeKey}
|
||||||
@@ -979,12 +1005,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
// re-renders synchronously before paint. Rendering the plain rows
|
// re-renders synchronously before paint. Rendering the plain rows
|
||||||
// meanwhile keeps the container's height real so the scroller
|
// meanwhile keeps the container's height real so the scroller
|
||||||
// never collapses/clamps during the flip.
|
// never collapses/clamps during the flip.
|
||||||
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
visibleSessions.map(renderSessionNode)
|
||||||
subtreeContainsEditing,
|
|
||||||
menuOpenSessionId,
|
|
||||||
nodeStructureKey: resolveNodeStructureKey(node),
|
|
||||||
childRenderExtrasFor,
|
|
||||||
}))
|
|
||||||
) : (
|
) : (
|
||||||
<div style={{ height: sessionVirtualizer.getTotalSize(), position: 'relative' }}>
|
<div style={{ height: sessionVirtualizer.getTotalSize(), position: 'relative' }}>
|
||||||
{/* Absolutely positioned rows (canonical tanstack layout): with
|
{/* Absolutely positioned rows (canonical tanstack layout): with
|
||||||
@@ -1017,12 +1038,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
transform: `translateY(${item.start - archivedScrollMargin}px)`,
|
transform: `translateY(${item.start - archivedScrollMargin}px)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
{renderSessionNode(node)}
|
||||||
subtreeContainsEditing,
|
|
||||||
menuOpenSessionId,
|
|
||||||
nodeStructureKey: resolveNodeStructureKey(node),
|
|
||||||
childRenderExtrasFor,
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -1030,12 +1046,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true, undefined, 'project', {
|
visibleSessions.map(renderSessionNode)
|
||||||
subtreeContainsEditing,
|
|
||||||
menuOpenSessionId,
|
|
||||||
nodeStructureKey: resolveNodeStructureKey(node),
|
|
||||||
childRenderExtrasFor,
|
|
||||||
}))
|
|
||||||
)}
|
)}
|
||||||
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
|
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
|
||||||
// pl-[26px] lines the text up with the worktree sub-header label
|
// pl-[26px] lines the text up with the worktree sub-header label
|
||||||
@@ -1086,15 +1097,24 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
void compactBodyPadding;
|
void compactBodyPadding;
|
||||||
// Folder nesting is legacy-only: existing sub-folders keep working (path
|
// Folder nesting is legacy-only: existing sub-folders keep working (path
|
||||||
// labels), but the UI no longer offers creating new ones.
|
// labels), but the UI no longer offers creating new ones.
|
||||||
void createFolderAndStartRename;
|
|
||||||
const groupBodyPaddingClass = 'pb-2';
|
const groupBodyPaddingClass = 'pb-2';
|
||||||
|
const folderDeleteDialog = <FolderDeleteConfirmDialog
|
||||||
|
value={deleteFolderConfirm}
|
||||||
|
setValue={setDeleteFolderConfirm}
|
||||||
|
onConfirm={() => {
|
||||||
|
const value = deleteFolderConfirm;
|
||||||
|
if (!value) return;
|
||||||
|
deleteFolder(value.scopeKey, value.folderId);
|
||||||
|
setDeleteFolderConfirm(null);
|
||||||
|
}}
|
||||||
|
/>;
|
||||||
|
|
||||||
if (hideGroupLabel) {
|
if (hideGroupLabel) {
|
||||||
return <div className="oc-group"><div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div></div>;
|
return <><div className="oc-group"><div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div></div>{folderDeleteDialog}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="oc-group">
|
<><div className="oc-group">
|
||||||
<div
|
<div
|
||||||
className={cn('group/gh relative flex items-start justify-between gap-1 py-1 min-w-0 rounded-md', 'cursor-pointer')}
|
className={cn('group/gh relative flex items-start justify-between gap-1 py-1 min-w-0 rounded-md', 'cursor-pointer')}
|
||||||
onClick={() => onToggleCollapsedGroup(groupKey)}
|
onClick={() => onToggleCollapsedGroup(groupKey)}
|
||||||
@@ -1243,7 +1263,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
|
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
|
||||||
if (mobileVariant) setSessionSwitcherOpen(false);
|
if (mobileVariant) setSessionSwitcherOpen(false);
|
||||||
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: group.directory });
|
openNewSessionDraft({ selectedProjectId: projectId, directoryOverride: group.directory });
|
||||||
}}
|
}}
|
||||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
@@ -1258,7 +1278,7 @@ function SessionGroupSectionBase(props: Props): React.ReactNode {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{!isCollapsed ? <div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div> : null}
|
{!isCollapsed ? <div className={cn('oc-group-body', groupBodyPaddingClass)}>{body}</div> : null}
|
||||||
</div>
|
</div>{folderDeleteDialog}</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { buildGroupRenderDescriptors } from './sessionProjectRender';
|
||||||
|
import type { SessionGroup } from '../types';
|
||||||
|
|
||||||
|
const makeGroup = (id: string, overrides: Partial<SessionGroup> = {}): SessionGroup => ({
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
branch: null,
|
||||||
|
description: null,
|
||||||
|
isMain: id === 'main',
|
||||||
|
worktree: null,
|
||||||
|
directory: '/workspace',
|
||||||
|
sessions: [],
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildGroupRenderDescriptors', () => {
|
||||||
|
test('renders the main group and archived bucket for the main workspace', () => {
|
||||||
|
const section = {
|
||||||
|
project: { id: 'project-a', normalizedPath: '/workspace' },
|
||||||
|
groups: [makeGroup('main'), makeGroup('archived', { isArchivedBucket: true })],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: true })).toEqual([
|
||||||
|
{
|
||||||
|
group: section.groups[0],
|
||||||
|
groupKey: 'project-a:main',
|
||||||
|
projectId: 'project-a',
|
||||||
|
hideGroupLabel: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: section.groups[1],
|
||||||
|
groupKey: 'project-a:archived',
|
||||||
|
projectId: 'project-a',
|
||||||
|
hideGroupLabel: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the primary group without a label and nested groups with labels', () => {
|
||||||
|
const section = {
|
||||||
|
project: { id: 'project-a', normalizedPath: '/workspace' },
|
||||||
|
groups: [makeGroup('main'), makeGroup('feature')],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false })).toEqual([
|
||||||
|
{
|
||||||
|
group: section.groups[0],
|
||||||
|
groupKey: 'project-a:main',
|
||||||
|
projectId: 'project-a',
|
||||||
|
hideGroupLabel: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: section.groups[1],
|
||||||
|
groupKey: 'project-a:feature',
|
||||||
|
projectId: 'project-a',
|
||||||
|
hideGroupLabel: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps labels when a flat section has no main group', () => {
|
||||||
|
const section = {
|
||||||
|
project: { id: 'project-a', normalizedPath: '/workspace' },
|
||||||
|
groups: [makeGroup('feature', { isMain: false }), makeGroup('other', { isMain: false })],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(buildGroupRenderDescriptors(section, { mainWorkspaceOnly: false }).map((descriptor) => descriptor.hideGroupLabel)).toEqual([false, false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+168
-233
@@ -11,38 +11,120 @@ import {
|
|||||||
import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||||
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
|
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
|
||||||
import type { SessionGroup } from './types';
|
import type { SessionGroup } from '../types';
|
||||||
import type { SortableDragHandleProps } from './sortableItems';
|
|
||||||
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
|
import { ProjectHeaderIdentity, SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||||||
import { formatProjectLabel } from './utils';
|
import { SessionGroupSection, type SessionGroupSectionProps } from './SessionGroupSection';
|
||||||
|
import { buildGroupRenderDescriptors, type ProjectSection } from './sessionProjectRender';
|
||||||
|
import { formatProjectLabel } from '../utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
|
import type { ProjectSortOrder } from '@/stores/useSessionDisplayStore';
|
||||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||||
import { Icon } from '@/components/icon/Icon';
|
import { Icon } from '@/components/icon/Icon';
|
||||||
|
|
||||||
type ProjectSection = {
|
type SessionProjectScrollerState = Pick<SessionGroupSectionProps,
|
||||||
project: {
|
| 'editingId'
|
||||||
id: string;
|
| 'openSidebarMenuKey'
|
||||||
label?: string;
|
| 'setOpenSidebarMenuKey'
|
||||||
normalizedPath: string;
|
> & {
|
||||||
icon?: string;
|
visibleSessionCountByGroup: Map<string, number>;
|
||||||
color?: string;
|
};
|
||||||
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
|
||||||
iconBackground?: string;
|
type SessionProjectScrollerGroupProps = Pick<SessionGroupSectionProps,
|
||||||
};
|
| 'hasSessionSearchQuery'
|
||||||
groups: SessionGroup[];
|
| 'normalizedSessionSearchQuery'
|
||||||
|
| 'groupSearchDataByGroup'
|
||||||
|
| 'collapsedGroups'
|
||||||
|
| 'hideDirectoryControls'
|
||||||
|
| 'mobileVariant'
|
||||||
|
| 'alwaysShowActions'
|
||||||
|
| 'notifyOnSubtasks'
|
||||||
|
| 'expandedParents'
|
||||||
|
| 'editTitle'
|
||||||
|
| 'copiedSessionId'
|
||||||
|
| 'folderRename'
|
||||||
|
| 'setFolderRenameDraft'
|
||||||
|
| 'clearFolderRename'
|
||||||
|
| 'setEditingId'
|
||||||
|
| 'setEditTitle'
|
||||||
|
| 'toggleParent'
|
||||||
|
| 'allowReselect'
|
||||||
|
| 'onSessionSelected'
|
||||||
|
| 'isSessionSearchOpen'
|
||||||
|
| 'sessionSearchQuery'
|
||||||
|
| 'setSessionSearchQuery'
|
||||||
|
| 'setIsSessionSearchOpen'
|
||||||
|
| 'deleteSessionConfirm'
|
||||||
|
| 'setDeleteSessionConfirm'
|
||||||
|
| 'startFolderRename'
|
||||||
|
| 'setCopiedSessionId'
|
||||||
|
> & {
|
||||||
|
activeProjectId: string | null;
|
||||||
|
pinnedSessionIds: Set<string>;
|
||||||
|
sessionOrderIndex: Map<string, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionProjectScrollerGroupActions = Pick<SessionGroupSectionProps,
|
||||||
|
| 'showMoreGroupSessions'
|
||||||
|
| 'resetGroupSessionLimit'
|
||||||
|
| 'setActiveProjectIdOnly'
|
||||||
|
| 'setSessionSwitcherOpen'
|
||||||
|
| 'openNewSessionDraft'
|
||||||
|
| 'onToggleCollapsedGroup'
|
||||||
|
>;
|
||||||
|
|
||||||
|
type SessionProjectScrollerModel = {
|
||||||
|
topContent?: React.ReactNode;
|
||||||
|
hasSharedSessions?: boolean;
|
||||||
|
sectionsForRender: ProjectSection[];
|
||||||
|
projectSections: ProjectSection[];
|
||||||
|
activeProjectId: string | null;
|
||||||
|
emptyState: React.ReactNode;
|
||||||
|
searchEmptyState: React.ReactNode;
|
||||||
|
projectRepoStatus: Map<string, boolean | null>;
|
||||||
|
stuckProjectHeaders: Set<string>;
|
||||||
|
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||||
|
state: SessionProjectScrollerState;
|
||||||
|
groupProps: SessionProjectScrollerGroupProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionProjectScrollerView = {
|
||||||
|
homeDirectory: string | null;
|
||||||
|
collapsedProjects: Set<string>;
|
||||||
|
showOnlyMainWorkspace: boolean;
|
||||||
|
hasSessionSearchQuery: boolean;
|
||||||
|
normalizedSessionSearchQuery: string;
|
||||||
|
hideDirectoryControls: boolean;
|
||||||
|
isDesktopShellRuntime: boolean;
|
||||||
|
stickyZoneHeaders: boolean;
|
||||||
|
mobileVariant: boolean;
|
||||||
|
alwaysShowActions: boolean;
|
||||||
|
projectSortOrder: ProjectSortOrder;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionProjectScrollerActions = {
|
||||||
|
group: SessionProjectScrollerGroupActions;
|
||||||
|
toggleProject: (id: string) => void;
|
||||||
|
setActiveProjectIdOnly: (id: string) => void;
|
||||||
|
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;
|
||||||
|
reorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||||
|
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||||
|
renderProjectStatusIndicator?: (projectId: string, groups: SessionGroup[]) => React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
model: SessionProjectScrollerModel;
|
||||||
|
view: SessionProjectScrollerView;
|
||||||
|
actions: SessionProjectScrollerActions;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOP_FADE_MAX_SIZE = 48;
|
const TOP_FADE_MAX_SIZE = 48;
|
||||||
const TOP_FADE_MIN_SIZE = 32;
|
const TOP_FADE_MIN_SIZE = 32;
|
||||||
const TOP_FADE_CLEAR_MAX_SIZE = 24;
|
const TOP_FADE_CLEAR_MAX_SIZE = 24;
|
||||||
type ActivitySectionKey = 'chats' | 'active-now';
|
|
||||||
|
|
||||||
const readActivitySectionKey = (element: Element): ActivitySectionKey | null => {
|
|
||||||
const key = element.getAttribute('data-sidebar-activity-sentinel');
|
|
||||||
if (key === 'chats' || key === 'active-now') return key;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getProjectLabel = (project: ProjectSection['project'], homeDirectory: string | null): string => (
|
const getProjectLabel = (project: ProjectSection['project'], homeDirectory: string | null): string => (
|
||||||
formatProjectLabel(
|
formatProjectLabel(
|
||||||
@@ -52,62 +134,12 @@ const getProjectLabel = (project: ProjectSection['project'], homeDirectory: stri
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
type Props = {
|
function SessionProjectScrollerComponent(props: Props): React.ReactNode {
|
||||||
topContent?: React.ReactNode;
|
|
||||||
sharedSessionsOnly?: boolean;
|
|
||||||
hasSharedSessions?: boolean;
|
|
||||||
sectionsForRender: ProjectSection[];
|
|
||||||
projectSections: ProjectSection[];
|
|
||||||
projectPickerSections: ProjectSection[];
|
|
||||||
activeProjectId: string | null;
|
|
||||||
singleProjectMode: boolean;
|
|
||||||
singleProjectId: string | null;
|
|
||||||
setSingleProjectId: (id: string) => void;
|
|
||||||
showOnlyMainWorkspace: boolean;
|
|
||||||
hasSessionSearchQuery: boolean;
|
|
||||||
emptyState: React.ReactNode;
|
|
||||||
searchEmptyState: React.ReactNode;
|
|
||||||
renderGroupSessions: (
|
|
||||||
group: SessionGroup,
|
|
||||||
groupKey: string,
|
|
||||||
projectId?: string | null,
|
|
||||||
hideGroupLabel?: boolean,
|
|
||||||
dragHandleProps?: SortableDragHandleProps | null,
|
|
||||||
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;
|
|
||||||
projectRepoStatus: Map<string, boolean | null>;
|
|
||||||
isDesktopShellRuntime: boolean;
|
|
||||||
stickyZoneHeaders: boolean;
|
|
||||||
stuckProjectHeaders: Set<string>;
|
|
||||||
mobileVariant: boolean;
|
|
||||||
alwaysShowActions: boolean;
|
|
||||||
toggleProject: (id: string) => void;
|
|
||||||
setActiveProjectIdOnly: (id: string) => void;
|
|
||||||
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;
|
|
||||||
openSidebarMenuKey: string | null;
|
|
||||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
|
||||||
isInlineEditing: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|
||||||
streamPerfCount('ui.sidebar_projects_list.render');
|
streamPerfCount('ui.sidebar_projects_list.render');
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const enableStickyFade = props.isDesktopShellRuntime && props.stickyZoneHeaders && !props.singleProjectMode;
|
const { model, view, actions } = props;
|
||||||
|
const isInlineEditing = model.state.editingId !== null;
|
||||||
|
const enableStickyFade = view.isDesktopShellRuntime && view.stickyZoneHeaders;
|
||||||
const projectSensors = useSensors(
|
const projectSensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||||
@@ -115,51 +147,11 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
const groupSensors = useSensors(
|
const groupSensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||||
);
|
);
|
||||||
const selectedSingleProjectSection = props.singleProjectMode
|
|
||||||
? props.sectionsForRender.find((section) => section.project.id === props.singleProjectId)
|
|
||||||
: null;
|
|
||||||
const renderedProjectSections = props.singleProjectMode
|
|
||||||
? (selectedSingleProjectSection ? [selectedSingleProjectSection] : [])
|
|
||||||
: props.sectionsForRender;
|
|
||||||
const projectPickerOptions = React.useMemo(() => props.projectPickerSections.map((section) => ({
|
|
||||||
id: section.project.id,
|
|
||||||
projectLabel: getProjectLabel(section.project, props.homeDirectory),
|
|
||||||
projectDescription: formatPathForDisplay(section.project.normalizedPath, props.homeDirectory),
|
|
||||||
projectIcon: section.project.icon,
|
|
||||||
projectColor: section.project.color,
|
|
||||||
projectIconImage: section.project.iconImage,
|
|
||||||
projectIconBackground: section.project.iconBackground,
|
|
||||||
})), [props.homeDirectory, props.projectPickerSections]);
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
orderedGroupsCacheGetOrderedGroupsRef.current = props.getOrderedGroups;
|
|
||||||
orderedGroupsCacheRef.current.clear();
|
|
||||||
}
|
|
||||||
const cachedGetOrderedGroups = (projectId: string, groups: SessionGroup[]): SessionGroup[] => {
|
|
||||||
const cache = orderedGroupsCacheRef.current;
|
|
||||||
const hit = cache.get(projectId);
|
|
||||||
if (hit && hit.groups === groups) {
|
|
||||||
return hit.ordered;
|
|
||||||
}
|
|
||||||
const ordered = props.getOrderedGroups(projectId, groups);
|
|
||||||
cache.set(projectId, { groups, ordered });
|
|
||||||
if (cache.size > 256) {
|
|
||||||
const firstKey = cache.keys().next().value;
|
|
||||||
if (firstKey !== undefined) cache.delete(firstKey);
|
|
||||||
}
|
|
||||||
return ordered;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Threaded into SessionGroupSection so the archived-bucket virtualizer
|
// Threaded into SessionGroupSection so the archived-bucket virtualizer
|
||||||
// can resolve the scrolling ancestor synchronously (no getComputedStyle
|
// can resolve the scrolling ancestor synchronously (no getComputedStyle
|
||||||
// walk) and skip the cost of a style recalc on every render.
|
// walk) and skip the cost of a style recalc on every render.
|
||||||
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
|
const scrollContainerRef = React.useRef<HTMLElement | null>(null);
|
||||||
const [leadingActivitySection, setLeadingActivitySection] = React.useState<ActivitySectionKey>('chats');
|
|
||||||
// Keep per-scroll measurements out of React state so the interaction guard
|
// Keep per-scroll measurements out of React state so the interaction guard
|
||||||
// can read the current fade boundary without rerendering the sidebar.
|
// can read the current fade boundary without rerendering the sidebar.
|
||||||
const topFadeSizeRef = React.useRef(0);
|
const topFadeSizeRef = React.useRef(0);
|
||||||
@@ -180,53 +172,22 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
const blockObscuredInteraction = React.useCallback((
|
const blockObscuredInteraction = React.useCallback((
|
||||||
event: React.MouseEvent<HTMLDivElement> | React.PointerEvent<HTMLDivElement>,
|
event: React.MouseEvent<HTMLDivElement> | React.PointerEvent<HTMLDivElement>,
|
||||||
) => {
|
) => {
|
||||||
|
// SAFETY: React's mouse and pointer events are dispatched from Elements.
|
||||||
if ((event.target as Element).closest('[data-overlay-scrollbar-thumb], [data-sidebar-sticky-header]')) return;
|
if ((event.target as Element).closest('[data-overlay-scrollbar-thumb], [data-sidebar-sticky-header]')) return;
|
||||||
const eventY = event.clientY - event.currentTarget.getBoundingClientRect().top;
|
const eventY = event.clientY - event.currentTarget.getBoundingClientRect().top;
|
||||||
if (eventY >= topFadeSizeRef.current) return;
|
if (eventY >= topFadeSizeRef.current) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
}, []);
|
}, []);
|
||||||
const hasProjectScroller = props.projectSections.length > 0 && renderedProjectSections.length > 0;
|
const hasProjectScroller = model.projectSections.length > 0 && model.sectionsForRender.length > 0;
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
|
if (enableStickyFade && hasProjectScroller && scrollContainerRef.current) {
|
||||||
syncTopFade(scrollContainerRef.current);
|
syncTopFade(scrollContainerRef.current);
|
||||||
}
|
}
|
||||||
}, [enableStickyFade, hasProjectScroller, syncTopFade]);
|
}, [enableStickyFade, hasProjectScroller, syncTopFade]);
|
||||||
React.useEffect(() => {
|
|
||||||
const root = scrollContainerRef.current;
|
|
||||||
if (!enableStickyFade || !root || !props.hasSharedSessions) return;
|
|
||||||
|
|
||||||
const sentinels = Array.from(root.querySelectorAll<HTMLElement>('[data-sidebar-activity-sentinel]'));
|
|
||||||
if (sentinels.length === 0) return;
|
|
||||||
const stuckSections = new Set<ActivitySectionKey>();
|
|
||||||
const syncLeadingSection = (): void => {
|
|
||||||
let nextSection = sentinels[0] ? readActivitySectionKey(sentinels[0]) : null;
|
|
||||||
for (const sentinel of sentinels) {
|
|
||||||
const key = readActivitySectionKey(sentinel);
|
|
||||||
if (key && stuckSections.has(key)) nextSection = key;
|
|
||||||
}
|
|
||||||
if (nextSection) setLeadingActivitySection((current) => current === nextSection ? current : nextSection);
|
|
||||||
};
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
const rootTop = root.getBoundingClientRect().top;
|
|
||||||
for (const entry of entries) {
|
|
||||||
const key = readActivitySectionKey(entry.target);
|
|
||||||
if (!key) continue;
|
|
||||||
if (!entry.isIntersecting && entry.boundingClientRect.top < (entry.rootBounds?.top ?? rootTop)) {
|
|
||||||
stuckSections.add(key);
|
|
||||||
} else {
|
|
||||||
stuckSections.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
syncLeadingSection();
|
|
||||||
}, { root, threshold: 0 });
|
|
||||||
sentinels.forEach((sentinel) => observer.observe(sentinel));
|
|
||||||
syncLeadingSection();
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [enableStickyFade, props.hasSharedSessions, props.topContent]);
|
|
||||||
let stuckProject: ProjectSection['project'] | null = null;
|
let stuckProject: ProjectSection['project'] | null = null;
|
||||||
for (const section of props.projectSections) {
|
for (const section of model.projectSections) {
|
||||||
if (props.stuckProjectHeaders.has(section.project.id)) {
|
if (model.stuckProjectHeaders.has(section.project.id)) {
|
||||||
stuckProject = section.project;
|
stuckProject = section.project;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,24 +198,15 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
// ready in the same frame; the observer then corrects it. When shared sessions
|
// ready in the same frame; the observer then corrects it. When shared sessions
|
||||||
// lead the list, the Recent fallback below owns the top instead of a project.
|
// lead the list, the Recent fallback below owns the top instead of a project.
|
||||||
const leadingProject =
|
const leadingProject =
|
||||||
stuckProject ?? (props.hasSharedSessions ? null : renderedProjectSections[0]?.project ?? null);
|
stuckProject ?? (model.hasSharedSessions ? null : model.sectionsForRender[0]?.project ?? null);
|
||||||
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, props.homeDirectory) : null;
|
const leadingProjectLabel = leadingProject ? getProjectLabel(leadingProject, view.homeDirectory) : null;
|
||||||
|
|
||||||
if (props.sharedSessionsOnly) {
|
if (model.projectSections.length === 0) {
|
||||||
return (
|
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', view.mobileVariant ? '' : '')}>{model.topContent}{model.emptyState}</ScrollableOverlay>;
|
||||||
<ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pr-2', props.mobileVariant ? '' : '')}>
|
|
||||||
{props.topContent}
|
|
||||||
{!props.hasSharedSessions ? (props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState) : null}
|
|
||||||
</ScrollableOverlay>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.projectSections.length === 0) {
|
if (model.sectionsForRender.length === 0) {
|
||||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>{props.topContent}{props.emptyState}</ScrollableOverlay>;
|
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', view.mobileVariant ? '' : '')}>{model.searchEmptyState}</ScrollableOverlay>;
|
||||||
}
|
|
||||||
|
|
||||||
if (props.sectionsForRender.length === 0) {
|
|
||||||
return <ScrollableOverlay useScrollShadow scrollShadowSize={96} outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-2', props.mobileVariant ? '' : '')}>{props.searchEmptyState}</ScrollableOverlay>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -275,38 +227,27 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
hideTopScrollShadow={!enableStickyFade}
|
hideTopScrollShadow={!enableStickyFade}
|
||||||
scrollShadowSize={96}
|
scrollShadowSize={96}
|
||||||
outerClassName="flex-1 min-h-0"
|
outerClassName="flex-1 min-h-0"
|
||||||
className={cn('oc-sidebar-scroller oc-sticky-fade-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', props.mobileVariant ? '' : '')}
|
className={cn('oc-sidebar-scroller oc-sticky-fade-scroller space-y-1.5 pb-1 pl-2.5 pr-2 [overflow-anchor:none]', view.mobileVariant ? '' : '')}
|
||||||
|
// SAFETY: the custom property is the only dynamic CSS declaration here.
|
||||||
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
|
style={enableStickyFade ? { '--scroll-shadow-top-size': '0px' } as React.CSSProperties : undefined}
|
||||||
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
|
onScroll={enableStickyFade ? (event) => syncTopFade(event.currentTarget) : undefined}
|
||||||
>
|
>
|
||||||
{props.topContent}
|
{model.topContent}
|
||||||
{props.showOnlyMainWorkspace ? (
|
{view.showOnlyMainWorkspace ? (
|
||||||
<div className="space-y-[0.6rem] py-1">
|
<div className="space-y-[0.6rem] py-1">
|
||||||
{(() => {
|
{(() => {
|
||||||
const activeSection = props.sectionsForRender.find((section) => section.project.id === props.activeProjectId) ?? props.sectionsForRender[0];
|
const activeSection = model.sectionsForRender.find((section) => section.project.id === model.activeProjectId) ?? model.sectionsForRender[0];
|
||||||
if (!activeSection) {
|
if (!activeSection) {
|
||||||
return props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState;
|
return view.hasSessionSearchQuery ? model.searchEmptyState : model.emptyState;
|
||||||
}
|
}
|
||||||
const primaryGroup =
|
const descriptors = buildGroupRenderDescriptors(activeSection, { mainWorkspaceOnly: true });
|
||||||
activeSection.groups.find((candidate) => candidate.isMain && candidate.sessions.length > 0)
|
if (!descriptors.length) {
|
||||||
?? activeSection.groups.find((candidate) => candidate.sessions.length > 0)
|
|
||||||
?? activeSection.groups.find((candidate) => candidate.isMain)
|
|
||||||
?? activeSection.groups[0];
|
|
||||||
if (!primaryGroup) {
|
|
||||||
return <div className="py-1 text-left typography-micro text-muted-foreground">{t('sessions.sidebar.empty.noSessions.title')}</div>;
|
return <div className="py-1 text-left typography-micro text-muted-foreground">{t('sessions.sidebar.empty.noSessions.title')}</div>;
|
||||||
}
|
}
|
||||||
const archivedGroup = activeSection.groups.find((candidate) => candidate.isArchivedBucket);
|
return descriptors.map(({ group, groupKey, projectId, hideGroupLabel }) => {
|
||||||
const groupsToRender = [
|
|
||||||
primaryGroup,
|
|
||||||
...(archivedGroup && archivedGroup.id !== primaryGroup.id ? [archivedGroup] : []),
|
|
||||||
];
|
|
||||||
|
|
||||||
return groupsToRender.map((group) => {
|
|
||||||
const groupKey = `${activeSection.project.id}:${group.id}`;
|
|
||||||
const hideGroupLabel = group.id === primaryGroup.id;
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={groupKey}>
|
<React.Fragment key={groupKey}>
|
||||||
{props.renderGroupSessions(group, groupKey, activeSection.project.id, hideGroupLabel, null, true, scrollContainerRef)}
|
<SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={group} groupKey={groupKey} projectId={projectId} hideGroupLabel={hideGroupLabel} visibleSessionCount={model.state.visibleSessionCountByGroup.get(groupKey)} compactBodyPadding scrollContainerRef={scrollContainerRef} />
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -317,31 +258,31 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
sensors={projectSensors}
|
sensors={projectSensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
if (props.isInlineEditing) return;
|
if (isInlineEditing) return;
|
||||||
// Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes
|
// Drag only allowed in manual sort mode - indices from visual order don't match store order in other modes
|
||||||
if (props.projectSortOrder !== 'manual') return;
|
if (view.projectSortOrder !== 'manual') return;
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
const oldIndex = props.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
const oldIndex = model.sectionsForRender.findIndex((section) => section.project.id === active.id);
|
||||||
const newIndex = props.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
const newIndex = model.sectionsForRender.findIndex((section) => section.project.id === over.id);
|
||||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||||
props.reorderProjects(oldIndex, newIndex);
|
actions.reorderProjects(oldIndex, newIndex);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SortableContext items={renderedProjectSections.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={model.sectionsForRender.map((section) => section.project.id)} strategy={verticalListSortingStrategy}>
|
||||||
{renderedProjectSections.map((section) => {
|
{model.sectionsForRender.map((section) => {
|
||||||
const project = section.project;
|
const project = section.project;
|
||||||
const projectKey = project.id;
|
const projectKey = project.id;
|
||||||
const projectLabel = getProjectLabel(project, props.homeDirectory);
|
const projectLabel = getProjectLabel(project, view.homeDirectory);
|
||||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
const projectDescription = formatPathForDisplay(project.normalizedPath, view.homeDirectory);
|
||||||
const isCollapsed = props.singleProjectMode ? false : props.collapsedProjects.has(projectKey);
|
const isCollapsed = view.collapsedProjects.has(projectKey);
|
||||||
const isRepo = props.projectRepoStatus.get(projectKey);
|
const isRepo = model.projectRepoStatus.get(projectKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableProjectItem
|
<SortableProjectItem
|
||||||
key={projectKey}
|
key={projectKey}
|
||||||
id={projectKey}
|
id={projectKey}
|
||||||
disabled={props.singleProjectMode || props.projectSortOrder !== 'manual'}
|
disabled={view.projectSortOrder !== 'manual'}
|
||||||
projectLabel={projectLabel}
|
projectLabel={projectLabel}
|
||||||
projectDescription={projectDescription}
|
projectDescription={projectDescription}
|
||||||
projectIcon={project.icon}
|
projectIcon={project.icon}
|
||||||
@@ -350,40 +291,36 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
projectIconBackground={project.iconBackground}
|
projectIconBackground={project.iconBackground}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
isRepo={Boolean(isRepo)}
|
isRepo={Boolean(isRepo)}
|
||||||
isDesktopShell={props.isDesktopShellRuntime}
|
isDesktopShell={view.isDesktopShellRuntime}
|
||||||
hideDirectoryControls={props.hideDirectoryControls}
|
hideDirectoryControls={view.hideDirectoryControls}
|
||||||
mobileVariant={props.mobileVariant}
|
mobileVariant={view.mobileVariant}
|
||||||
alwaysShowActions={props.alwaysShowActions}
|
alwaysShowActions={view.alwaysShowActions}
|
||||||
statusIndicator={isCollapsed ? props.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
statusIndicator={isCollapsed ? actions.renderProjectStatusIndicator?.(projectKey, section.groups) : null}
|
||||||
onToggle={() => {
|
openSidebarMenuKey={model.state.openSidebarMenuKey}
|
||||||
if (!props.singleProjectMode) props.toggleProject(projectKey);
|
setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey}
|
||||||
}}
|
onToggle={() => actions.toggleProject(projectKey)}
|
||||||
onNewSession={() => {
|
onNewSession={() => {
|
||||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
|
||||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
if (view.mobileVariant) actions.setSessionSwitcherOpen(false);
|
||||||
props.openNewSessionDraft({
|
actions.openNewSessionDraft({
|
||||||
selectedProjectId: projectKey,
|
selectedProjectId: projectKey,
|
||||||
directoryOverride: project.normalizedPath,
|
directoryOverride: project.normalizedPath,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onNewWorktreeSession={() => {
|
onNewWorktreeSession={() => {
|
||||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
if (projectKey !== model.activeProjectId) actions.setActiveProjectIdOnly(projectKey);
|
||||||
props.openNewWorktreeDialog();
|
actions.openNewWorktreeDialog();
|
||||||
}}
|
}}
|
||||||
onManageWorktrees={() => props.openWorktreesPage(projectKey)}
|
onManageWorktrees={() => actions.openWorktreesPage(projectKey)}
|
||||||
onRenameStart={() => props.openProjectEditDialog(projectKey)}
|
onRenameStart={() => actions.openProjectEditDialog(projectKey)}
|
||||||
onClose={() => props.removeProject(projectKey)}
|
onClose={() => actions.removeProject(projectKey)}
|
||||||
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
sentinelRef={(el) => { model.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||||
showCreateButtons
|
showCreateButtons
|
||||||
openSidebarMenuKey={props.openSidebarMenuKey}
|
>
|
||||||
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
|
||||||
projectPickerOptions={props.singleProjectMode ? projectPickerOptions : undefined}
|
|
||||||
onProjectSelect={props.singleProjectMode ? props.setSingleProjectId : undefined}
|
|
||||||
>
|
|
||||||
{!isCollapsed ? (
|
{!isCollapsed ? (
|
||||||
<div className="space-y-0 pt-0.5 pb-0.5">
|
<div className="space-y-0 pt-0.5 pb-0.5">
|
||||||
{(() => {
|
{(() => {
|
||||||
const orderedGroups = cachedGetOrderedGroups(projectKey, section.groups);
|
const orderedGroups = section.groups;
|
||||||
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
|
const rootGroup = orderedGroups.find((group) => group.isMain) ?? null;
|
||||||
const nestedGroups = rootGroup
|
const nestedGroups = rootGroup
|
||||||
? orderedGroups.filter((group) => group.id !== rootGroup.id)
|
? orderedGroups.filter((group) => group.id !== rootGroup.id)
|
||||||
@@ -393,7 +330,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
sensors={groupSensors}
|
sensors={groupSensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
if (props.isInlineEditing) return;
|
if (isInlineEditing) return;
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
if (!over || active.id === over.id) return;
|
if (!over || active.id === over.id) return;
|
||||||
const oldIndex = nestedGroups.findIndex((item) => item.id === active.id);
|
const oldIndex = nestedGroups.findIndex((item) => item.id === active.id);
|
||||||
@@ -401,7 +338,7 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||||
const nextNested = arrayMove(nestedGroups, oldIndex, newIndex).map((item) => item.id);
|
const nextNested = arrayMove(nestedGroups, oldIndex, newIndex).map((item) => item.id);
|
||||||
const next = rootGroup ? [rootGroup.id, ...nextNested] : nextNested;
|
const next = rootGroup ? [rootGroup.id, ...nextNested] : nextNested;
|
||||||
props.setGroupOrderByProject((prev) => {
|
actions.setGroupOrderByProject((prev) => {
|
||||||
const map = new Map(prev);
|
const map = new Map(prev);
|
||||||
map.set(projectKey, next);
|
map.set(projectKey, next);
|
||||||
return map;
|
return map;
|
||||||
@@ -411,13 +348,13 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
{/* Root/flat sessions render directly under the
|
{/* Root/flat sessions render directly under the
|
||||||
project zone header; worktree and archived
|
project zone header; worktree and archived
|
||||||
groups keep their own slim sortable sub-header. */}
|
groups keep their own slim sortable sub-header. */}
|
||||||
{rootGroup ? props.renderGroupSessions(rootGroup, `${projectKey}:${rootGroup.id}`, projectKey, true, null, undefined, scrollContainerRef) : null}
|
{rootGroup ? <SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={rootGroup} groupKey={`${projectKey}:${rootGroup.id}`} projectId={projectKey} hideGroupLabel visibleSessionCount={model.state.visibleSessionCountByGroup.get(`${projectKey}:${rootGroup.id}`)} scrollContainerRef={scrollContainerRef} /> : null}
|
||||||
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={nestedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
|
||||||
{nestedGroups.map((group) => {
|
{nestedGroups.map((group) => {
|
||||||
const groupKey = `${projectKey}:${group.id}`;
|
const groupKey = `${projectKey}:${group.id}`;
|
||||||
return (
|
return (
|
||||||
<SortableGroupItem key={group.id} id={group.id} disabled={props.isInlineEditing}>
|
<SortableGroupItem key={group.id} id={group.id} disabled={isInlineEditing}>
|
||||||
{(dragHandleProps) => props.renderGroupSessions(group, groupKey, projectKey, false, dragHandleProps, undefined, scrollContainerRef)}
|
{(dragHandleProps) => <SessionGroupSection {...model.groupProps} {...actions.group} editingId={model.state.editingId} openSidebarMenuKey={model.state.openSidebarMenuKey} setOpenSidebarMenuKey={model.state.setOpenSidebarMenuKey} group={group} groupKey={groupKey} projectId={projectKey} visibleSessionCount={model.state.visibleSessionCountByGroup.get(groupKey)} dragHandleProps={dragHandleProps} scrollContainerRef={scrollContainerRef} />}
|
||||||
</SortableGroupItem>
|
</SortableGroupItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -436,14 +373,14 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
</DndContext>
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
</ScrollableOverlay>
|
</ScrollableOverlay>
|
||||||
{enableStickyFade && (leadingProject || props.hasSharedSessions) ? (
|
{enableStickyFade && (leadingProject || model.hasSharedSessions) ? (
|
||||||
<div
|
<div
|
||||||
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-1.5 py-1 pl-4 pr-5"
|
className="oc-sticky-fade-overlay pointer-events-none absolute inset-x-0 top-0 z-30 flex items-center gap-1.5 py-1 pl-4 pr-5"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{leadingProject && leadingProjectLabel ? (
|
{leadingProject && leadingProjectLabel ? (
|
||||||
<ProjectHeaderIdentity
|
<ProjectHeaderIdentity
|
||||||
id={leadingProject.id}
|
id={leadingProject.id}
|
||||||
projectLabel={leadingProjectLabel}
|
projectLabel={leadingProjectLabel}
|
||||||
projectIcon={leadingProject.icon}
|
projectIcon={leadingProject.icon}
|
||||||
projectColor={leadingProject.color}
|
projectColor={leadingProject.color}
|
||||||
@@ -452,11 +389,9 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Icon name={leadingActivitySection === 'chats' ? 'chat-4' : 'history'} className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
|
<Icon name="history" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80" />
|
||||||
<span className="truncate text-[14px] font-semibold lowercase text-foreground">
|
<span className="truncate text-[14px] font-semibold lowercase text-foreground">
|
||||||
{t(leadingActivitySection === 'chats'
|
{t('sessions.sidebar.activity.recentTitle')}
|
||||||
? 'sessions.sidebar.activity.chatsTitle'
|
|
||||||
: 'sessions.sidebar.activity.recentTitle')}
|
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -466,4 +401,4 @@ function SidebarProjectsListComponent(props: Props): React.ReactNode {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SidebarProjectsList = React.memo(SidebarProjectsListComponent);
|
export const SessionProjectScroller = React.memo(SessionProjectScrollerComponent);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { SessionGroup } from '../types';
|
||||||
|
|
||||||
|
export type ProjectSection = {
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
label?: string;
|
||||||
|
normalizedPath: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' };
|
||||||
|
iconBackground?: string;
|
||||||
|
};
|
||||||
|
groups: SessionGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupRenderDescriptor = {
|
||||||
|
group: SessionGroup;
|
||||||
|
groupKey: string;
|
||||||
|
projectId: string;
|
||||||
|
hideGroupLabel: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildGroupRenderDescriptors = (
|
||||||
|
section: ProjectSection,
|
||||||
|
options: { mainWorkspaceOnly: boolean },
|
||||||
|
): GroupRenderDescriptor[] => {
|
||||||
|
const primaryGroup = section.groups.find((group) => group.isMain && group.sessions.length > 0)
|
||||||
|
?? section.groups.find((group) => group.sessions.length > 0)
|
||||||
|
?? section.groups.find((group) => group.isMain)
|
||||||
|
?? section.groups[0];
|
||||||
|
if (!primaryGroup) return [];
|
||||||
|
|
||||||
|
const archivedGroup = section.groups.find((group) => group.isArchivedBucket && group.id !== primaryGroup.id);
|
||||||
|
const groups = options.mainWorkspaceOnly
|
||||||
|
? [primaryGroup, ...(archivedGroup ? [archivedGroup] : [])]
|
||||||
|
: [
|
||||||
|
...(section.groups.find((group) => group.isMain) ? [section.groups.find((group) => group.isMain)!] : []),
|
||||||
|
...section.groups.filter((group) => !group.isMain),
|
||||||
|
];
|
||||||
|
|
||||||
|
return groups.map((group) => ({
|
||||||
|
group,
|
||||||
|
groupKey: `${section.project.id}:${group.id}`,
|
||||||
|
projectId: section.project.id,
|
||||||
|
hideGroupLabel: options.mainWorkspaceOnly ? group.id === primaryGroup.id : group.isMain,
|
||||||
|
}));
|
||||||
|
};
|
||||||
+30
-92
@@ -30,10 +30,6 @@ type ProjectIdentityProps = {
|
|||||||
projectIconBackground?: string;
|
projectIconBackground?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProjectPickerOption = ProjectIdentityProps & {
|
|
||||||
projectDescription: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ProjectHeaderIdentityProps = ProjectIdentityProps & {
|
type ProjectHeaderIdentityProps = ProjectIdentityProps & {
|
||||||
isCollapsed?: boolean;
|
isCollapsed?: boolean;
|
||||||
alwaysShowActions?: boolean;
|
alwaysShowActions?: boolean;
|
||||||
@@ -121,12 +117,10 @@ export interface SortableProjectItemProps extends ProjectIdentityProps {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
showCreateButtons?: boolean;
|
showCreateButtons?: boolean;
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
openSidebarMenuKey: string | null;
|
|
||||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
|
||||||
/** Aggregated activity/attention indicator shown while the project is collapsed. */
|
/** Aggregated activity/attention indicator shown while the project is collapsed. */
|
||||||
statusIndicator?: React.ReactNode;
|
statusIndicator?: React.ReactNode;
|
||||||
projectPickerOptions?: ProjectPickerOption[];
|
openSidebarMenuKey: string | null;
|
||||||
onProjectSelect?: (projectId: string) => void;
|
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||||
@@ -153,11 +147,9 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
children,
|
children,
|
||||||
showCreateButtons = true,
|
showCreateButtons = true,
|
||||||
hideHeader = false,
|
hideHeader = false,
|
||||||
|
statusIndicator = null,
|
||||||
openSidebarMenuKey,
|
openSidebarMenuKey,
|
||||||
setOpenSidebarMenuKey,
|
setOpenSidebarMenuKey,
|
||||||
statusIndicator = null,
|
|
||||||
projectPickerOptions,
|
|
||||||
onProjectSelect,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||||
@@ -235,7 +227,6 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
}
|
}
|
||||||
onToggle();
|
onToggle();
|
||||||
}, [onToggle]);
|
}, [onToggle]);
|
||||||
const isProjectPicker = Boolean(projectPickerOptions && onProjectSelect);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -282,92 +273,39 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
className="relative flex items-center gap-1 py-1 pl-4 pr-3.5"
|
className="relative flex items-center gap-1 py-1 pl-4 pr-3.5"
|
||||||
{...attributes}
|
{...attributes}
|
||||||
>
|
>
|
||||||
{isProjectPicker ? (
|
<Tooltip>
|
||||||
<DropdownMenu>
|
<TooltipTrigger asChild>
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
title={projectDescription}
|
onMouseDown={handleToggleMouseDown}
|
||||||
|
onClick={handleToggleClick}
|
||||||
|
{...listeners}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md transition-[padding]',
|
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
|
||||||
isRepo && !hideDirectoryControls
|
isRepo && !hideDirectoryControls
|
||||||
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
|
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
|
||||||
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
|
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
|
||||||
)}
|
)}
|
||||||
aria-label={t('sessions.sidebar.project.selectAria', { project: projectLabel })}
|
|
||||||
>
|
>
|
||||||
<ProjectHeaderIdentity
|
<ProjectHeaderIdentity
|
||||||
id={id}
|
id={id}
|
||||||
projectLabel={projectLabel}
|
projectLabel={projectLabel}
|
||||||
projectIcon={projectIcon}
|
projectIcon={projectIcon}
|
||||||
projectColor={projectColor}
|
projectColor={projectColor}
|
||||||
projectIconImage={projectIconImage}
|
projectIconImage={projectIconImage}
|
||||||
projectIconBackground={projectIconBackground}
|
projectIconBackground={projectIconBackground}
|
||||||
/>
|
isCollapsed={isCollapsed}
|
||||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
alwaysShowActions={alwaysShowActions}
|
||||||
</button>
|
/>
|
||||||
</DropdownMenuTrigger>
|
{statusIndicator ? (
|
||||||
<DropdownMenuContent
|
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
|
||||||
align="start"
|
) : null}
|
||||||
className="min-w-[220px] max-w-[calc(100vw-2rem)] max-h-[min(var(--available-height),70vh)] overflow-y-auto overscroll-contain"
|
</button>
|
||||||
>
|
</TooltipTrigger>
|
||||||
{projectPickerOptions?.map((option) => (
|
<TooltipContent side="right" sideOffset={8}>
|
||||||
<DropdownMenuItem
|
{projectDescription}
|
||||||
key={option.id}
|
</TooltipContent>
|
||||||
onClick={() => onProjectSelect?.(option.id)}
|
</Tooltip>
|
||||||
className="flex items-center justify-between gap-3"
|
|
||||||
title={option.projectDescription}
|
|
||||||
>
|
|
||||||
<span className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<ProjectHeaderIdentity
|
|
||||||
id={option.id}
|
|
||||||
projectLabel={option.projectLabel}
|
|
||||||
projectIcon={option.projectIcon}
|
|
||||||
projectColor={option.projectColor}
|
|
||||||
projectIconImage={option.projectIconImage}
|
|
||||||
projectIconBackground={option.projectIconBackground}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
{option.id === id ? <Icon name="check" className="h-4 w-4 flex-shrink-0 text-primary" /> : null}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : (
|
|
||||||
<Tooltip delayDuration={800}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onMouseDown={handleToggleMouseDown}
|
|
||||||
onClick={handleToggleClick}
|
|
||||||
{...listeners}
|
|
||||||
className={cn(
|
|
||||||
'flex-1 min-w-0 flex items-center gap-1.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-md cursor-grab active:cursor-grabbing transition-[padding]',
|
|
||||||
isRepo && !hideDirectoryControls
|
|
||||||
? (alwaysShowActions ? 'pr-20' : 'pr-7 group-hover/project:pr-20 group-focus-within/project:pr-20')
|
|
||||||
: (alwaysShowActions ? 'pr-14' : 'pr-7 group-hover/project:pr-14 group-focus-within/project:pr-14'),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ProjectHeaderIdentity
|
|
||||||
id={id}
|
|
||||||
projectLabel={projectLabel}
|
|
||||||
projectIcon={projectIcon}
|
|
||||||
projectColor={projectColor}
|
|
||||||
projectIconImage={projectIconImage}
|
|
||||||
projectIconBackground={projectIconBackground}
|
|
||||||
isCollapsed={isCollapsed}
|
|
||||||
alwaysShowActions={alwaysShowActions}
|
|
||||||
/>
|
|
||||||
{statusIndicator ? (
|
|
||||||
<span className="ml-1 inline-flex flex-shrink-0 items-center">{statusIndicator}</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" sideOffset={8}>
|
|
||||||
{projectDescription}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
|
'absolute top-1/2 z-10 flex -translate-y-1/2 items-center gap-1',
|
||||||
@@ -474,7 +412,7 @@ export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
|||||||
const SortableGroupItemBase: React.FC<{
|
const SortableGroupItemBase: React.FC<{
|
||||||
id: string;
|
id: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
children: React.ReactNode | ((dragHandleProps: SortableDragHandleProps) => React.ReactNode);
|
children: (dragHandleProps: SortableDragHandleProps) => React.ReactNode;
|
||||||
}> = ({ id, disabled = false, children }) => {
|
}> = ({ id, disabled = false, children }) => {
|
||||||
const {
|
const {
|
||||||
listeners,
|
listeners,
|
||||||
@@ -502,7 +440,7 @@ const SortableGroupItemBase: React.FC<{
|
|||||||
isDragging && 'opacity-50',
|
isDragging && 'opacity-50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{typeof children === 'function' ? children(dragHandleProps) : children}
|
{children(dragHandleProps)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { SessionOwnershipIndex } from '../sessionOwnership';
|
import type { SessionOwnershipIndex } from '../sessions/sessionOwnership';
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
ownership: SessionOwnershipIndex;
|
ownership: SessionOwnershipIndex;
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import React from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import { I18nProvider } from '@/lib/i18n';
|
||||||
|
import { useSessionActions } from '../sessions/useSessionActions';
|
||||||
|
import { useSessionGrouping } from './useSessionGrouping';
|
||||||
|
import type { SessionNode } from '../types';
|
||||||
|
|
||||||
|
type FixtureSession = Session & { parentID?: string };
|
||||||
|
const session = (id: string, parentID?: string): Session => {
|
||||||
|
const value: FixtureSession = {
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID: 'project',
|
||||||
|
title: id,
|
||||||
|
version: '1',
|
||||||
|
directory: '/workspace',
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
};
|
||||||
|
if (parentID) value.parentID = parentID;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectIds = (nodes: SessionNode[]): string[] => {
|
||||||
|
const ids: string[] = [];
|
||||||
|
const visit = (items: SessionNode[]): void => {
|
||||||
|
for (const node of items) {
|
||||||
|
ids.push(node.session.id);
|
||||||
|
visit(node.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(nodes);
|
||||||
|
return ids;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useSessionGrouping malformed hierarchy fallbacks', () => {
|
||||||
|
test('renders a deterministic cycle/orphan fallback tree without duplicate sessions', async () => {
|
||||||
|
type GroupingCapture = { buildGroupedSessions?: ReturnType<typeof useSessionGrouping>['buildGroupedSessions'] };
|
||||||
|
const state: GroupingCapture = {};
|
||||||
|
const Harness = () => {
|
||||||
|
state.buildGroupedSessions = useSessionGrouping({
|
||||||
|
homeDirectory: null,
|
||||||
|
worktreeMetadata: new Map(),
|
||||||
|
pinnedSessionIds: new Set(),
|
||||||
|
sessionOrderRanks: new Map(),
|
||||||
|
gitBranches: new Map(),
|
||||||
|
isVSCode: false,
|
||||||
|
}).buildGroupedSessions;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(Harness)));
|
||||||
|
const buildGroupedSessions = state.buildGroupedSessions;
|
||||||
|
if (!buildGroupedSessions) throw new Error('grouping callback was not mounted');
|
||||||
|
|
||||||
|
const groups = buildGroupedSessions(
|
||||||
|
[session('a', 'b'), session('b', 'a'), session('orphan', 'missing')],
|
||||||
|
'/workspace',
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const rootGroup = groups.find((group) => group.isMain);
|
||||||
|
const ids = collectIds(rootGroup?.sessions ?? []);
|
||||||
|
|
||||||
|
expect(ids).toEqual(['orphan', 'a', 'b']);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the row-local descendant snapshot for archive and hard-delete actions', async () => {
|
||||||
|
type ActionsCapture = { handleDeleteSession?: ReturnType<typeof useSessionActions>['handleDeleteSession'] };
|
||||||
|
const state: ActionsCapture = {};
|
||||||
|
const Harness = () => {
|
||||||
|
state.handleDeleteSession = useSessionActions({
|
||||||
|
mobileVariant: false,
|
||||||
|
allowReselect: false,
|
||||||
|
isSessionSearchOpen: false,
|
||||||
|
sessionSearchQuery: '',
|
||||||
|
setSessionSearchQuery: () => undefined,
|
||||||
|
setIsSessionSearchOpen: () => undefined,
|
||||||
|
descendantIds: ['active-child', 'archived-child'],
|
||||||
|
showDeletionDialog: false,
|
||||||
|
setDeleteSessionConfirm: () => undefined,
|
||||||
|
deleteSessionConfirm: null,
|
||||||
|
setEditingId: () => undefined,
|
||||||
|
setEditTitle: () => undefined,
|
||||||
|
editingId: null,
|
||||||
|
editTitle: '',
|
||||||
|
copiedSessionId: null,
|
||||||
|
setCopiedSessionId: () => undefined,
|
||||||
|
}).handleDeleteSession;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderToStaticMarkup(React.createElement(I18nProvider, null, React.createElement(Harness)));
|
||||||
|
const handleDeleteSession = state.handleDeleteSession;
|
||||||
|
if (!handleDeleteSession) throw new Error('session actions callback was not mounted');
|
||||||
|
|
||||||
|
handleDeleteSession(session('root'));
|
||||||
|
handleDeleteSession(session('root'), { hardDelete: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
+26
-10
@@ -9,11 +9,11 @@ import {
|
|||||||
normalizeForBranchComparison,
|
normalizeForBranchComparison,
|
||||||
normalizePath,
|
normalizePath,
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { compareSessionsByLifecycleOrder, getSessionLifecycleOrderValue } from '@/sync/session-ordering';
|
import { getSessionLifecycleOrderValue } from '@/sync/session-ordering';
|
||||||
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||||
import { getWorktreeFirstSeenAt } from '../worktreeFirstSeen';
|
import { getWorktreeFirstSeenAt } from './worktreeFirstSeen';
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
homeDirectory: string | null;
|
homeDirectory: string | null;
|
||||||
@@ -70,8 +70,9 @@ export const useSessionGrouping = (args: Args) => {
|
|||||||
projectIsRepo: boolean,
|
projectIsRepo: boolean,
|
||||||
) => {
|
) => {
|
||||||
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
|
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
|
||||||
const sortedProjectSessions = dedupeSessionsById(projectSessions)
|
// `orderSessionsByLifecycleScopes` owns lifecycle ordering before project
|
||||||
.sort((a, b) => compareSessionsByLifecycleOrder(a, b, args.pinnedSessionIds, args.sessionOrderRanks));
|
// ownership buckets are built. Dedupe retains that root/sibling order.
|
||||||
|
const sortedProjectSessions = dedupeSessionsById(projectSessions);
|
||||||
|
|
||||||
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
|
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
|
||||||
const childrenMap = new Map<string, Session[]>();
|
const childrenMap = new Map<string, Session[]>();
|
||||||
@@ -86,7 +87,6 @@ export const useSessionGrouping = (args: Args) => {
|
|||||||
collection.push(session);
|
collection.push(session);
|
||||||
childrenMap.set(parentID, collection);
|
childrenMap.set(parentID, collection);
|
||||||
});
|
});
|
||||||
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByLifecycleOrder(a, b, args.pinnedSessionIds, args.sessionOrderRanks)));
|
|
||||||
|
|
||||||
const worktreeByPath = new Map<string, WorktreeMetadata>();
|
const worktreeByPath = new Map<string, WorktreeMetadata>();
|
||||||
availableWorktrees.forEach((meta) => {
|
availableWorktrees.forEach((meta) => {
|
||||||
@@ -109,12 +109,19 @@ export const useSessionGrouping = (args: Args) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const claimedSessionIds = new Set<string>();
|
||||||
const buildProjectNode = (session: Session): SessionNode => {
|
const buildProjectNode = (session: Session): SessionNode => {
|
||||||
|
claimedSessionIds.add(session.id);
|
||||||
const children = childrenMap.get(session.id) ?? [];
|
const children = childrenMap.get(session.id) ?? [];
|
||||||
return { session, children: children.map((child) => buildProjectNode(child)), worktree: getSessionWorktree(session) };
|
const childNodes: SessionNode[] = [];
|
||||||
|
for (const child of children) {
|
||||||
|
if (claimedSessionIds.has(child.id)) continue;
|
||||||
|
childNodes.push(buildProjectNode(child));
|
||||||
|
}
|
||||||
|
return { session, children: childNodes, worktree: getSessionWorktree(session) };
|
||||||
};
|
};
|
||||||
|
|
||||||
const roots = sortedProjectSessions.filter((session) => {
|
const rootCandidates = sortedProjectSessions.filter((session) => {
|
||||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||||
if (!parentID) return true;
|
if (!parentID) return true;
|
||||||
const parentSession = sessionMap.get(parentID);
|
const parentSession = sessionMap.get(parentID);
|
||||||
@@ -122,6 +129,16 @@ export const useSessionGrouping = (args: Args) => {
|
|||||||
return isArchivedSession(parentSession) !== isArchivedSession(session);
|
return isArchivedSession(parentSession) !== isArchivedSession(session);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A malformed cycle has no structural root. Start with normal roots,
|
||||||
|
// then expose each still-unclaimed component from its first input row.
|
||||||
|
const roots: SessionNode[] = [];
|
||||||
|
const addRoot = (session: Session): void => {
|
||||||
|
if (claimedSessionIds.has(session.id)) return;
|
||||||
|
roots.push(buildProjectNode(session));
|
||||||
|
};
|
||||||
|
rootCandidates.forEach(addRoot);
|
||||||
|
sortedProjectSessions.forEach(addRoot);
|
||||||
|
|
||||||
const groupedNodes = new Map<string, SessionNode[]>();
|
const groupedNodes = new Map<string, SessionNode[]>();
|
||||||
const archivedKey = '__archived__';
|
const archivedKey = '__archived__';
|
||||||
|
|
||||||
@@ -140,9 +157,8 @@ export const useSessionGrouping = (args: Args) => {
|
|||||||
return archivedKey;
|
return archivedKey;
|
||||||
};
|
};
|
||||||
|
|
||||||
roots.forEach((session) => {
|
roots.forEach((node) => {
|
||||||
const node = buildProjectNode(session);
|
const groupKey = getGroupKey(node.session);
|
||||||
const groupKey = getGroupKey(session);
|
|
||||||
if (!groupedNodes.has(groupKey)) groupedNodes.set(groupKey, []);
|
if (!groupedNodes.has(groupKey)) groupedNodes.set(groupKey, []);
|
||||||
groupedNodes.get(groupKey)?.push(node);
|
groupedNodes.get(groupKey)?.push(node);
|
||||||
});
|
});
|
||||||
+220
@@ -0,0 +1,220 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from 'bun:test';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||||
|
import type { SessionGroup } from '../types';
|
||||||
|
import { useSessionProjectViewState } from './useSessionProjectViewState';
|
||||||
|
|
||||||
|
class ElementStub implements Partial<Element> {
|
||||||
|
nodeType = 1;
|
||||||
|
}
|
||||||
|
type DocumentStub = {
|
||||||
|
nodeType: number;
|
||||||
|
defaultView: typeof globalThis;
|
||||||
|
activeElement: null;
|
||||||
|
addEventListener: () => void;
|
||||||
|
removeEventListener: () => void;
|
||||||
|
documentElement?: Element;
|
||||||
|
body?: Element;
|
||||||
|
};
|
||||||
|
type GlobalValue = typeof globalThis | typeof ElementStub | DocumentStub | boolean;
|
||||||
|
type HookCapture = {
|
||||||
|
state?: ReturnType<typeof useSessionProjectViewState>['state'];
|
||||||
|
actions?: ReturnType<typeof useSessionProjectViewState>['actions'];
|
||||||
|
renderCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const installMinimalDom = () => {
|
||||||
|
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||||
|
const setGlobal = (name: string, value: GlobalValue) => {
|
||||||
|
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||||
|
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||||
|
};
|
||||||
|
const documentStub: DocumentStub = {
|
||||||
|
nodeType: 9,
|
||||||
|
defaultView: globalThis,
|
||||||
|
activeElement: null,
|
||||||
|
addEventListener: () => undefined,
|
||||||
|
removeEventListener: () => undefined,
|
||||||
|
};
|
||||||
|
// SAFETY: React's test renderer only inspects this fixture's DOM identity fields and listeners.
|
||||||
|
const container = Object.create(ElementStub.prototype) as Element;
|
||||||
|
Object.assign(container, {
|
||||||
|
nodeType: 1,
|
||||||
|
tagName: 'DIV',
|
||||||
|
nodeName: 'DIV',
|
||||||
|
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
||||||
|
ownerDocument: documentStub,
|
||||||
|
addEventListener: () => undefined,
|
||||||
|
removeEventListener: () => undefined,
|
||||||
|
});
|
||||||
|
documentStub.documentElement = container;
|
||||||
|
documentStub.body = container;
|
||||||
|
setGlobal('document', documentStub);
|
||||||
|
setGlobal('window', globalThis);
|
||||||
|
setGlobal('Element', ElementStub);
|
||||||
|
setGlobal('HTMLElement', ElementStub);
|
||||||
|
setGlobal('HTMLIFrameElement', ElementStub);
|
||||||
|
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
restore: () => {
|
||||||
|
for (const [name, descriptor] of descriptors) {
|
||||||
|
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||||
|
else Reflect.deleteProperty(globalThis, name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useSessionProjectViewState', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const storage = getDeferredSafeStorage();
|
||||||
|
storage.removeItem('oc.sessions.projectCollapse');
|
||||||
|
storage.removeItem('oc.sessions.groupCollapse');
|
||||||
|
storage.removeItem('oc.sessions.groupOrder');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps stable state/actions and ignores selection-store updates', async () => {
|
||||||
|
const dom = installMinimalDom();
|
||||||
|
const root: Root = createRoot(dom.container);
|
||||||
|
const capture: HookCapture = { renderCount: 0 };
|
||||||
|
const projects = [{ id: 'project-a' }, { id: 'project-b' }];
|
||||||
|
const Harness = () => {
|
||||||
|
capture.renderCount += 1;
|
||||||
|
const viewState = useSessionProjectViewState({ isVSCode: true, projects });
|
||||||
|
capture.state = viewState.state;
|
||||||
|
capture.actions = viewState.actions;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(React.createElement(Harness)));
|
||||||
|
const initialState = capture.state;
|
||||||
|
const initialActions = capture.actions;
|
||||||
|
const initialRenderCount = capture.renderCount;
|
||||||
|
if (!initialState || !initialActions) throw new Error('hook did not mount');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
useSessionUIStore.setState({ currentSessionId: 'selection-only' });
|
||||||
|
});
|
||||||
|
expect(capture.renderCount).toBe(initialRenderCount);
|
||||||
|
expect(capture.state).toBe(initialState);
|
||||||
|
expect(capture.actions).toBe(initialActions);
|
||||||
|
|
||||||
|
await act(async () => initialActions.toggleProject('project-a'));
|
||||||
|
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
|
||||||
|
await act(async () => initialActions.collapseAllProjects());
|
||||||
|
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a', 'project-b']));
|
||||||
|
await act(async () => initialActions.expandAllProjects());
|
||||||
|
expect(capture.state?.collapsedProjects).toEqual(new Set());
|
||||||
|
|
||||||
|
await act(async () => initialActions.toggleGroup('project-a:group-a'));
|
||||||
|
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
initialActions.setGroupOrderByProject((previous) => {
|
||||||
|
const next = new Map(previous);
|
||||||
|
next.set('project-a', ['group-b', 'group-a']);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const group = (id: string): SessionGroup => ({
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
branch: null,
|
||||||
|
description: null,
|
||||||
|
isMain: false,
|
||||||
|
worktree: null,
|
||||||
|
directory: null,
|
||||||
|
sessions: [],
|
||||||
|
});
|
||||||
|
expect(capture.actions?.getOrderedGroups('project-a', [group('group-a'), group('group-b')])
|
||||||
|
.map((item) => item.id)).toEqual(['group-b', 'group-a']);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
const storage = getDeferredSafeStorage();
|
||||||
|
expect(JSON.parse(storage.getItem('oc.sessions.projectCollapse') ?? 'null')).toEqual([]);
|
||||||
|
expect(JSON.parse(storage.getItem('oc.sessions.groupCollapse') ?? 'null')).toEqual(['project-a:group-a']);
|
||||||
|
expect(JSON.parse(storage.getItem('oc.sessions.groupOrder') ?? 'null')).toEqual({
|
||||||
|
'project-a': ['group-b', 'group-a'],
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves malformed group storage until explicit user mutation', async () => {
|
||||||
|
const storage = getDeferredSafeStorage();
|
||||||
|
const malformedCollapse = '{malformed-collapse';
|
||||||
|
const malformedOrder = JSON.stringify({ 'project-a': ['group-a', 2] });
|
||||||
|
storage.setItem('oc.sessions.groupCollapse', malformedCollapse);
|
||||||
|
storage.setItem('oc.sessions.groupOrder', malformedOrder);
|
||||||
|
const dom = installMinimalDom();
|
||||||
|
const root = createRoot(dom.container);
|
||||||
|
const capture: HookCapture = { renderCount: 0 };
|
||||||
|
const Harness = () => {
|
||||||
|
capture.renderCount += 1;
|
||||||
|
const value = useSessionProjectViewState({ isVSCode: true, projects: [{ id: 'project-a' }] });
|
||||||
|
capture.state = value.state;
|
||||||
|
capture.actions = value.actions;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(React.createElement(Harness)));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(capture.state?.collapsedGroups).toEqual(new Set());
|
||||||
|
expect(capture.state?.groupOrderByProject).toEqual(new Map());
|
||||||
|
expect(storage.getItem('oc.sessions.groupCollapse')).toBe(malformedCollapse);
|
||||||
|
expect(storage.getItem('oc.sessions.groupOrder')).toBe(malformedOrder);
|
||||||
|
|
||||||
|
await act(async () => capture.actions!.toggleGroup('project-a:group-a'));
|
||||||
|
await act(async () => capture.actions!.setGroupOrderByProject(new Map([['project-a', ['group-a']]])));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(JSON.parse(storage.getItem('oc.sessions.groupCollapse') ?? 'null')).toEqual(['project-a:group-a']);
|
||||||
|
expect(JSON.parse(storage.getItem('oc.sessions.groupOrder') ?? 'null')).toEqual({ 'project-a': ['group-a'] });
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retains persisted project/group state while hidden and across a full remount', async () => {
|
||||||
|
const storage = getDeferredSafeStorage();
|
||||||
|
storage.setItem('oc.sessions.projectCollapse', JSON.stringify(['project-a']));
|
||||||
|
storage.setItem('oc.sessions.groupCollapse', JSON.stringify(['project-a:group-a']));
|
||||||
|
storage.setItem('oc.sessions.groupOrder', JSON.stringify({ 'project-a': ['group-b', 'group-a'] }));
|
||||||
|
const dom = installMinimalDom();
|
||||||
|
const root = createRoot(dom.container);
|
||||||
|
const capture: HookCapture = { renderCount: 0 };
|
||||||
|
const Harness = ({ hidden }: { hidden: boolean }) => {
|
||||||
|
void hidden;
|
||||||
|
capture.renderCount += 1;
|
||||||
|
const value = useSessionProjectViewState({ isVSCode: true, projects: [{ id: 'project-a' }] });
|
||||||
|
capture.state = value.state;
|
||||||
|
capture.actions = value.actions;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
|
||||||
|
await act(async () => root.render(React.createElement(Harness, { hidden: true })));
|
||||||
|
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
|
||||||
|
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
|
||||||
|
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
|
||||||
|
expect(capture.state?.groupOrderByProject).toEqual(new Map([['project-a', ['group-b', 'group-a']]]));
|
||||||
|
|
||||||
|
await act(async () => root.render(null));
|
||||||
|
await act(async () => root.render(React.createElement(Harness, { hidden: false })));
|
||||||
|
expect(capture.state?.collapsedProjects).toEqual(new Set(['project-a']));
|
||||||
|
expect(capture.state?.collapsedGroups).toEqual(new Set(['project-a:group-a']));
|
||||||
|
expect(capture.state?.groupOrderByProject).toEqual(new Map([['project-a', ['group-b', 'group-a']]]));
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
|
import { getDeferredSafeStorage } from '@/stores/utils/safeStorage';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useGroupOrdering } from './useGroupOrdering';
|
||||||
|
|
||||||
|
const PROJECT_COLLAPSE_STORAGE_KEY = 'oc.sessions.projectCollapse';
|
||||||
|
const GROUP_ORDER_STORAGE_KEY = 'oc.sessions.groupOrder';
|
||||||
|
const GROUP_COLLAPSE_STORAGE_KEY = 'oc.sessions.groupCollapse';
|
||||||
|
|
||||||
|
type Project = { id: string };
|
||||||
|
|
||||||
|
type SessionProjectViewStateArgs = {
|
||||||
|
isVSCode: boolean;
|
||||||
|
projects: readonly Project[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseStringSet = (raw: string | null): Set<string> => {
|
||||||
|
if (!raw) return new Set();
|
||||||
|
try {
|
||||||
|
const parsed = z.array(z.string()).safeParse(JSON.parse(raw));
|
||||||
|
return new Set(parsed.success ? parsed.data : []);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseGroupOrder = (raw: string | null): Map<string, string[]> => {
|
||||||
|
if (!raw) return new Map();
|
||||||
|
try {
|
||||||
|
const parsed = z.record(z.string(), z.array(z.string())).safeParse(JSON.parse(raw));
|
||||||
|
if (!parsed.success) return new Map();
|
||||||
|
const next = new Map<string, string[]>();
|
||||||
|
for (const [projectId, order] of Object.entries(parsed.data)) {
|
||||||
|
next.set(projectId, order);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
} catch {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSessionProjectViewState = ({
|
||||||
|
isVSCode,
|
||||||
|
projects,
|
||||||
|
}: SessionProjectViewStateArgs) => {
|
||||||
|
const safeStorage = React.useMemo(() => getDeferredSafeStorage(), []);
|
||||||
|
const [collapsedProjects, setCollapsedProjects] = React.useState<Set<string>>(() => (
|
||||||
|
parseStringSet(safeStorage.getItem(PROJECT_COLLAPSE_STORAGE_KEY))
|
||||||
|
));
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = React.useState<Set<string>>(() => (
|
||||||
|
parseStringSet(safeStorage.getItem(GROUP_COLLAPSE_STORAGE_KEY))
|
||||||
|
));
|
||||||
|
const [groupOrderByProject, setGroupOrderByProject] = React.useState<Map<string, string[]>>(() => (
|
||||||
|
parseGroupOrder(safeStorage.getItem(GROUP_ORDER_STORAGE_KEY))
|
||||||
|
));
|
||||||
|
const ignoreIntersectionUntil = React.useRef<number>(0);
|
||||||
|
const groupCollapseDirty = React.useRef(false);
|
||||||
|
const groupOrderDirty = React.useRef(false);
|
||||||
|
const persistCollapsedProjectsTimer = React.useRef<number | null>(null);
|
||||||
|
const pendingCollapsedProjects = React.useRef<Set<string> | null>(null);
|
||||||
|
|
||||||
|
const flushCollapsedProjectsPersist = React.useCallback(() => {
|
||||||
|
if (isVSCode) return;
|
||||||
|
const collapsed = pendingCollapsedProjects.current;
|
||||||
|
pendingCollapsedProjects.current = null;
|
||||||
|
persistCollapsedProjectsTimer.current = null;
|
||||||
|
if (!collapsed) return;
|
||||||
|
|
||||||
|
const { projects: storedProjects } = useProjectsStore.getState();
|
||||||
|
const updatedProjects = storedProjects.map((project) => ({
|
||||||
|
...project,
|
||||||
|
sidebarCollapsed: collapsed.has(project.id),
|
||||||
|
}));
|
||||||
|
void updateDesktopSettings({ projects: updatedProjects }).catch(() => {});
|
||||||
|
}, [isVSCode]);
|
||||||
|
|
||||||
|
const scheduleCollapsedProjectsPersist = React.useCallback((collapsed: Set<string>) => {
|
||||||
|
if (!globalThis.window || isVSCode) return;
|
||||||
|
pendingCollapsedProjects.current = collapsed;
|
||||||
|
if (persistCollapsedProjectsTimer.current !== null) {
|
||||||
|
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||||
|
}
|
||||||
|
persistCollapsedProjectsTimer.current = window.setTimeout(() => {
|
||||||
|
flushCollapsedProjectsPersist();
|
||||||
|
}, 700);
|
||||||
|
}, [flushCollapsedProjectsPersist, isVSCode]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (globalThis.window && persistCollapsedProjectsTimer.current !== null) {
|
||||||
|
window.clearTimeout(persistCollapsedProjectsTimer.current);
|
||||||
|
}
|
||||||
|
persistCollapsedProjectsTimer.current = null;
|
||||||
|
pendingCollapsedProjects.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!groupOrderDirty.current) return;
|
||||||
|
try {
|
||||||
|
safeStorage.setItem(GROUP_ORDER_STORAGE_KEY, JSON.stringify(Object.fromEntries(groupOrderByProject.entries())));
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}, [groupOrderByProject, safeStorage]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!groupCollapseDirty.current) return;
|
||||||
|
try {
|
||||||
|
safeStorage.setItem(GROUP_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(collapsedGroups)));
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}, [collapsedGroups, safeStorage]);
|
||||||
|
|
||||||
|
const collapseAllProjects = React.useCallback(() => {
|
||||||
|
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||||
|
groupCollapseDirty.current = true;
|
||||||
|
setCollapsedGroups(new Set());
|
||||||
|
setCollapsedProjects(() => {
|
||||||
|
const allIds = new Set(projects.map((project) => project.id));
|
||||||
|
try {
|
||||||
|
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(allIds)));
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
scheduleCollapsedProjectsPersist(allIds);
|
||||||
|
return allIds;
|
||||||
|
});
|
||||||
|
}, [projects, safeStorage, scheduleCollapsedProjectsPersist]);
|
||||||
|
|
||||||
|
const expandAllProjects = React.useCallback(() => {
|
||||||
|
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||||
|
groupCollapseDirty.current = true;
|
||||||
|
setCollapsedGroups(new Set());
|
||||||
|
setCollapsedProjects(() => {
|
||||||
|
const empty = new Set<string>();
|
||||||
|
try {
|
||||||
|
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify([]));
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
scheduleCollapsedProjectsPersist(empty);
|
||||||
|
return empty;
|
||||||
|
});
|
||||||
|
}, [safeStorage, scheduleCollapsedProjectsPersist]);
|
||||||
|
|
||||||
|
const toggleProject = React.useCallback((projectId: string) => {
|
||||||
|
ignoreIntersectionUntil.current = Date.now() + 150;
|
||||||
|
setCollapsedProjects((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (next.has(projectId)) next.delete(projectId);
|
||||||
|
else next.add(projectId);
|
||||||
|
try {
|
||||||
|
safeStorage.setItem(PROJECT_COLLAPSE_STORAGE_KEY, JSON.stringify(Array.from(next)));
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
scheduleCollapsedProjectsPersist(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [safeStorage, scheduleCollapsedProjectsPersist]);
|
||||||
|
|
||||||
|
const toggleGroup = React.useCallback((key: string) => {
|
||||||
|
groupCollapseDirty.current = true;
|
||||||
|
setCollapsedGroups((previous) => {
|
||||||
|
const next = new Set(previous);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const updateGroupOrderByProject = React.useCallback<React.Dispatch<React.SetStateAction<Map<string, string[]>>>>((update) => {
|
||||||
|
groupOrderDirty.current = true;
|
||||||
|
setGroupOrderByProject(update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { getOrderedGroups } = useGroupOrdering(groupOrderByProject);
|
||||||
|
const state = React.useMemo(() => ({
|
||||||
|
collapsedProjects,
|
||||||
|
collapsedGroups,
|
||||||
|
groupOrderByProject,
|
||||||
|
}), [collapsedGroups, collapsedProjects, groupOrderByProject]);
|
||||||
|
const actions = React.useMemo(() => ({
|
||||||
|
setCollapsedProjects,
|
||||||
|
toggleProject,
|
||||||
|
collapseAllProjects,
|
||||||
|
expandAllProjects,
|
||||||
|
scheduleCollapsedProjectsPersist,
|
||||||
|
setCollapsedGroups,
|
||||||
|
toggleGroup,
|
||||||
|
setGroupOrderByProject: updateGroupOrderByProject,
|
||||||
|
getOrderedGroups,
|
||||||
|
}), [collapseAllProjects, expandAllProjects, getOrderedGroups, scheduleCollapsedProjectsPersist, toggleGroup, toggleProject, updateGroupOrderByProject]);
|
||||||
|
|
||||||
|
return { state, actions };
|
||||||
|
};
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { normalizePath } from './utils';
|
import { normalizePath } from '../utils';
|
||||||
|
|
||||||
// In-memory first-seen tracker for worktree directories. Worktree metadata
|
// In-memory first-seen tracker for worktree directories. Worktree metadata
|
||||||
// carries no creation time, so we record when a path first appears during
|
// carries no creation time, so we record when a path first appears during
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { formatDirectoryName } from '@/lib/utils';
|
||||||
|
import type { WorktreeMetadata } from '@/types/worktree';
|
||||||
|
import { SidebarActivitySections } from './SidebarActivitySections';
|
||||||
|
import { deriveRecentActivitySections, type RecentSessionLocation } from './activitySections';
|
||||||
|
import type { SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||||
|
import type { SessionNode } from '../types';
|
||||||
|
import { formatProjectLabel, normalizePath } from '../utils';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
projects: { id: string; label?: string; normalizedPath: string }[];
|
||||||
|
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||||
|
gitBranches: Map<string, string | null>;
|
||||||
|
homeDirectory: string | null;
|
||||||
|
hasSessionSearchQuery: boolean;
|
||||||
|
normalizedSessionSearchQuery: string;
|
||||||
|
isDesktopShellRuntime: boolean;
|
||||||
|
sessions: Session[];
|
||||||
|
childrenMap: ReadonlyMap<string, readonly Session[]>;
|
||||||
|
pinnedSessionIds: Set<string>;
|
||||||
|
recentSessions: Session[];
|
||||||
|
expandedParents: Set<string>;
|
||||||
|
notifyOnSubtasks: boolean;
|
||||||
|
editingId: string | null;
|
||||||
|
editTitle: string;
|
||||||
|
copiedSessionId: string | null;
|
||||||
|
openSidebarMenuKey: string | null;
|
||||||
|
mobileVariant: boolean;
|
||||||
|
alwaysShowActions: boolean;
|
||||||
|
} & Pick<SessionTreeItemProps,
|
||||||
|
| 'setEditingId'
|
||||||
|
| 'setEditTitle'
|
||||||
|
| 'toggleParent'
|
||||||
|
| 'setOpenSidebarMenuKey'
|
||||||
|
| 'allowReselect'
|
||||||
|
| 'onSessionSelected'
|
||||||
|
| 'isSessionSearchOpen'
|
||||||
|
| 'sessionSearchQuery'
|
||||||
|
| 'setSessionSearchQuery'
|
||||||
|
| 'setIsSessionSearchOpen'
|
||||||
|
| 'deleteSessionConfirm'
|
||||||
|
| 'setDeleteSessionConfirm'
|
||||||
|
| 'startFolderRename'
|
||||||
|
| 'setCopiedSessionId'
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const RecentSessionSection: React.FC<Props> = (props) => {
|
||||||
|
const {
|
||||||
|
projects,
|
||||||
|
availableWorktreesByProject,
|
||||||
|
gitBranches,
|
||||||
|
homeDirectory,
|
||||||
|
hasSessionSearchQuery,
|
||||||
|
normalizedSessionSearchQuery,
|
||||||
|
isDesktopShellRuntime,
|
||||||
|
sessions,
|
||||||
|
childrenMap,
|
||||||
|
pinnedSessionIds,
|
||||||
|
recentSessions,
|
||||||
|
} = props;
|
||||||
|
const { t } = useI18n();
|
||||||
|
const sessionLocationById = React.useMemo(() => {
|
||||||
|
const locations = new Map<string, RecentSessionLocation>();
|
||||||
|
for (const session of sessions) {
|
||||||
|
const directory = normalizePath(session.directory ?? null);
|
||||||
|
if (!directory) continue;
|
||||||
|
let owner: Props['projects'][number] | null = null;
|
||||||
|
let ownerLength = -1;
|
||||||
|
for (const project of projects) {
|
||||||
|
const projectPath = normalizePath(project.normalizedPath);
|
||||||
|
if (projectPath && (directory === projectPath || directory.startsWith(`${projectPath}/`)) && projectPath.length > ownerLength) {
|
||||||
|
owner = project;
|
||||||
|
ownerLength = projectPath.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!owner) continue;
|
||||||
|
const worktree = availableWorktreesByProject.get(owner.normalizedPath)?.find((entry) => normalizePath(entry.path) === directory);
|
||||||
|
const projectLabel = formatProjectLabel(owner.label?.trim() || formatDirectoryName(owner.normalizedPath, homeDirectory) || owner.normalizedPath);
|
||||||
|
const branch = worktree?.branch?.trim() || gitBranches.get(directory)?.trim() || null;
|
||||||
|
locations.set(session.id, {
|
||||||
|
projectId: owner.id,
|
||||||
|
groupDirectory: directory,
|
||||||
|
projectLabel,
|
||||||
|
branchLabel: branch && branch !== 'HEAD' && branch !== projectLabel ? branch : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return locations;
|
||||||
|
}, [availableWorktreesByProject, sessions, gitBranches, homeDirectory, projects]);
|
||||||
|
const getSessionLocation = React.useCallback(
|
||||||
|
(sessionId: string) => sessionLocationById.get(sessionId) ?? null,
|
||||||
|
[sessionLocationById],
|
||||||
|
);
|
||||||
|
const getSessionNode = React.useCallback(
|
||||||
|
(session: Session): SessionNode => ({
|
||||||
|
session,
|
||||||
|
children: (childrenMap.get(session.id) ?? []).filter((child) => !child.time?.archived).map((child) => ({
|
||||||
|
session: child,
|
||||||
|
children: [],
|
||||||
|
worktree: null,
|
||||||
|
})),
|
||||||
|
worktree: null,
|
||||||
|
}),
|
||||||
|
[childrenMap],
|
||||||
|
);
|
||||||
|
const sections = React.useMemo(() => deriveRecentActivitySections({
|
||||||
|
sessions: recentSessions,
|
||||||
|
getSessionLocation,
|
||||||
|
getSessionNode,
|
||||||
|
query: hasSessionSearchQuery ? normalizedSessionSearchQuery : '',
|
||||||
|
}), [getSessionLocation, getSessionNode, hasSessionSearchQuery, normalizedSessionSearchQuery, recentSessions]);
|
||||||
|
return (
|
||||||
|
<SidebarActivitySections
|
||||||
|
sections={sections.map((section) => ({ ...section, title: t('sessions.sidebar.activity.recentTitle') }))}
|
||||||
|
variant="section"
|
||||||
|
isDesktopShellRuntime={isDesktopShellRuntime}
|
||||||
|
pinnedSessionIds={pinnedSessionIds}
|
||||||
|
expandedParents={props.expandedParents}
|
||||||
|
hasSessionSearchQuery={props.hasSessionSearchQuery}
|
||||||
|
normalizedSessionSearchQuery={props.normalizedSessionSearchQuery}
|
||||||
|
notifyOnSubtasks={props.notifyOnSubtasks}
|
||||||
|
editingId={props.editingId}
|
||||||
|
editTitle={props.editTitle}
|
||||||
|
copiedSessionId={props.copiedSessionId}
|
||||||
|
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||||
|
mobileVariant={props.mobileVariant}
|
||||||
|
alwaysShowActions={props.alwaysShowActions}
|
||||||
|
setEditingId={props.setEditingId}
|
||||||
|
setEditTitle={props.setEditTitle}
|
||||||
|
toggleParent={props.toggleParent}
|
||||||
|
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||||
|
allowReselect={props.allowReselect}
|
||||||
|
onSessionSelected={props.onSessionSelected}
|
||||||
|
isSessionSearchOpen={props.isSessionSearchOpen}
|
||||||
|
sessionSearchQuery={props.sessionSearchQuery}
|
||||||
|
setSessionSearchQuery={props.setSessionSearchQuery}
|
||||||
|
setIsSessionSearchOpen={props.setIsSessionSearchOpen}
|
||||||
|
deleteSessionConfirm={props.deleteSessionConfirm}
|
||||||
|
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||||
|
startFolderRename={props.startFolderRename}
|
||||||
|
setCopiedSessionId={props.setCopiedSessionId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
+78
-84
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { SessionNode } from './types';
|
import type { SessionNode } from '../types';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
collectSubtreeContainingId,
|
collectSubtreeContainingId,
|
||||||
computeNodeStructureKey,
|
computeNodeStructureKey,
|
||||||
resolveMenuOpenSessionId,
|
resolveMenuOpenSessionId,
|
||||||
} from './sessionNodeItemUtils';
|
} from '../sessions/sessionNodeItemUtils';
|
||||||
import type { SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
import type { SessionNodeRenderExtras } from '../sessions/sessionNodeItemUtils';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
import { SessionTreeItem, type SessionTreeItemProps } from '../sessions/SessionTreeItem';
|
||||||
|
|
||||||
export type ActivityItem = {
|
type ActivityItem = {
|
||||||
node: SessionNode;
|
node: SessionNode;
|
||||||
projectId: string | null;
|
projectId: string | null;
|
||||||
groupDirectory: string | null;
|
groupDirectory: string | null;
|
||||||
@@ -23,34 +23,45 @@ export type ActivityItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ActivitySection = {
|
type ActivitySection = {
|
||||||
key: 'active-now' | 'chats';
|
key: 'active-now';
|
||||||
title: string;
|
title: string;
|
||||||
items: ActivityItem[];
|
items: ActivityItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
sections: ActivitySection[];
|
sections: ActivitySection[];
|
||||||
renderSessionNode: (
|
|
||||||
node: SessionNode,
|
|
||||||
depth?: number,
|
|
||||||
groupDirectory?: string | null,
|
|
||||||
projectId?: string | null,
|
|
||||||
archivedBucket?: boolean,
|
|
||||||
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null,
|
|
||||||
renderContext?: 'project' | 'recent',
|
|
||||||
renderExtras?: SessionNodeRenderExtras,
|
|
||||||
) => React.ReactNode;
|
|
||||||
editingId: string | null;
|
|
||||||
openSidebarMenuKey: string | null;
|
|
||||||
expansionState?: ReadonlySet<string>;
|
expansionState?: ReadonlySet<string>;
|
||||||
variant?: 'section' | 'flat';
|
variant?: 'section' | 'flat';
|
||||||
initialVisibleCount?: number;
|
initialVisibleCount?: number;
|
||||||
batchSize?: number;
|
batchSize?: number;
|
||||||
isDesktopShellRuntime: boolean;
|
isDesktopShellRuntime: boolean;
|
||||||
onNewChat?: () => void;
|
pinnedSessionIds: Set<string>;
|
||||||
alwaysShowActions?: boolean;
|
expandedParents: Set<string>;
|
||||||
renderChatsSection?: (items: ActivityItem[]) => React.ReactNode;
|
hasSessionSearchQuery: boolean;
|
||||||
};
|
normalizedSessionSearchQuery: string;
|
||||||
|
notifyOnSubtasks: boolean;
|
||||||
|
editingId: string | null;
|
||||||
|
editTitle: string;
|
||||||
|
copiedSessionId: string | null;
|
||||||
|
openSidebarMenuKey: string | null;
|
||||||
|
mobileVariant: boolean;
|
||||||
|
alwaysShowActions: boolean;
|
||||||
|
} & Pick<SessionTreeItemProps,
|
||||||
|
| 'setEditingId'
|
||||||
|
| 'setEditTitle'
|
||||||
|
| 'toggleParent'
|
||||||
|
| 'setOpenSidebarMenuKey'
|
||||||
|
| 'allowReselect'
|
||||||
|
| 'onSessionSelected'
|
||||||
|
| 'isSessionSearchOpen'
|
||||||
|
| 'sessionSearchQuery'
|
||||||
|
| 'setSessionSearchQuery'
|
||||||
|
| 'setIsSessionSearchOpen'
|
||||||
|
| 'deleteSessionConfirm'
|
||||||
|
| 'setDeleteSessionConfirm'
|
||||||
|
| 'startFolderRename'
|
||||||
|
| 'setCopiedSessionId'
|
||||||
|
>;
|
||||||
|
|
||||||
type RenderExtras = SessionNodeRenderExtras;
|
type RenderExtras = SessionNodeRenderExtras;
|
||||||
|
|
||||||
@@ -59,14 +70,12 @@ const MAX_VISIBLE_RECENT_SESSIONS = 7;
|
|||||||
export function SidebarActivitySections(props: Props): React.ReactNode {
|
export function SidebarActivitySections(props: Props): React.ReactNode {
|
||||||
const {
|
const {
|
||||||
sections,
|
sections,
|
||||||
renderSessionNode,
|
|
||||||
editingId,
|
|
||||||
openSidebarMenuKey,
|
|
||||||
variant = 'section',
|
variant = 'section',
|
||||||
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
|
initialVisibleCount = MAX_VISIBLE_RECENT_SESSIONS,
|
||||||
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
batchSize = MAX_VISIBLE_RECENT_SESSIONS,
|
||||||
} = props;
|
} = props;
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const { pinnedSessionIds } = props;
|
||||||
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
const stickyZoneHeaders = useSessionDisplayStore((state) => state.stickyZoneHeaders);
|
||||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||||
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
const [visibleCountBySection, setVisibleCountBySection] = React.useState<Map<string, number>>(new Map());
|
||||||
@@ -109,8 +118,8 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
|||||||
|
|
||||||
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
|
const buildRenderExtras = React.useCallback((nodes: SessionNode[]) => {
|
||||||
const subtreeContainsEditing = new Set<string>();
|
const subtreeContainsEditing = new Set<string>();
|
||||||
collectSubtreeContainingId(nodes, editingId, subtreeContainsEditing);
|
collectSubtreeContainingId(nodes, props.editingId, subtreeContainsEditing);
|
||||||
const menuOpenSessionId = resolveMenuOpenSessionId(nodes, openSidebarMenuKey, 'recent', false);
|
const menuOpenSessionId = resolveMenuOpenSessionId(nodes, props.openSidebarMenuKey, 'recent', false);
|
||||||
const nodeStructureKeyByNode = new WeakMap<SessionNode, string>();
|
const nodeStructureKeyByNode = new WeakMap<SessionNode, string>();
|
||||||
const visit = (node: SessionNode): void => {
|
const visit = (node: SessionNode): void => {
|
||||||
nodeStructureKeyByNode.set(node, computeNodeStructureKey(node));
|
nodeStructureKeyByNode.set(node, computeNodeStructureKey(node));
|
||||||
@@ -131,11 +140,9 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
|||||||
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
|
nodeStructureKey: nodeStructureKeyByNode.get(node) ?? '',
|
||||||
childRenderExtrasFor,
|
childRenderExtrasFor,
|
||||||
});
|
});
|
||||||
}, [editingId, openSidebarMenuKey]);
|
}, [props.editingId, props.openSidebarMenuKey]);
|
||||||
|
|
||||||
const visibleSections = sections.filter((section) => (
|
const visibleSections = sections.filter((section) => section.items.length > 0);
|
||||||
section.items.length > 0 || (section.key === 'chats' && props.onNewChat)
|
|
||||||
));
|
|
||||||
if (visibleSections.length === 0) {
|
if (visibleSections.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -152,18 +159,43 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
|||||||
);
|
);
|
||||||
const visibleItems = section.items.slice(0, visibleLimit);
|
const visibleItems = section.items.slice(0, visibleLimit);
|
||||||
const remainingCount = section.items.length - visibleItems.length;
|
const remainingCount = section.items.length - visibleItems.length;
|
||||||
const usesCustomRenderer = section.key === 'chats' && Boolean(props.renderChatsSection);
|
const canShowFewer = !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
|
||||||
const canShowFewer = !usesCustomRenderer && !flatVariant && section.items.length > initialVisibleCount && remainingCount === 0;
|
|
||||||
const getRenderExtras = buildRenderExtras(visibleItems.map((item) => item.node));
|
const getRenderExtras = buildRenderExtras(visibleItems.map((item) => item.node));
|
||||||
const renderItem = (item: ActivityItem) => renderSessionNode(
|
const renderItem = (item: ActivityItem) => (
|
||||||
item.node,
|
<SessionTreeItem
|
||||||
0,
|
key={item.node.session.id}
|
||||||
item.groupDirectory,
|
node={item.node}
|
||||||
item.projectId,
|
pinnedSessionIds={pinnedSessionIds}
|
||||||
false,
|
expandedParents={props.expandedParents}
|
||||||
item.secondaryMeta,
|
hasSessionSearchQuery={props.hasSessionSearchQuery}
|
||||||
'recent',
|
normalizedSessionSearchQuery={props.normalizedSessionSearchQuery}
|
||||||
getRenderExtras(item.node),
|
notifyOnSubtasks={props.notifyOnSubtasks}
|
||||||
|
editingId={props.editingId}
|
||||||
|
editTitle={props.editTitle}
|
||||||
|
copiedSessionId={props.copiedSessionId}
|
||||||
|
openSidebarMenuKey={props.openSidebarMenuKey}
|
||||||
|
mobileVariant={props.mobileVariant}
|
||||||
|
alwaysShowActions={props.alwaysShowActions}
|
||||||
|
groupDirectory={item.groupDirectory}
|
||||||
|
projectId={item.projectId}
|
||||||
|
secondaryMeta={item.secondaryMeta}
|
||||||
|
renderContext="recent"
|
||||||
|
renderExtras={getRenderExtras(item.node)}
|
||||||
|
setEditingId={props.setEditingId}
|
||||||
|
setEditTitle={props.setEditTitle}
|
||||||
|
toggleParent={props.toggleParent}
|
||||||
|
setOpenSidebarMenuKey={props.setOpenSidebarMenuKey}
|
||||||
|
allowReselect={props.allowReselect}
|
||||||
|
onSessionSelected={props.onSessionSelected}
|
||||||
|
isSessionSearchOpen={props.isSessionSearchOpen}
|
||||||
|
sessionSearchQuery={props.sessionSearchQuery}
|
||||||
|
setSessionSearchQuery={props.setSessionSearchQuery}
|
||||||
|
setIsSessionSearchOpen={props.setIsSessionSearchOpen}
|
||||||
|
deleteSessionConfirm={props.deleteSessionConfirm}
|
||||||
|
setDeleteSessionConfirm={props.setDeleteSessionConfirm}
|
||||||
|
startFolderRename={props.startFolderRename}
|
||||||
|
setCopiedSessionId={props.setCopiedSessionId}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (flatVariant) {
|
if (flatVariant) {
|
||||||
@@ -185,67 +217,29 @@ export function SidebarActivitySections(props: Props): React.ReactNode {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={section.key} className="relative space-y-1">
|
<div key={section.key} className="relative space-y-1">
|
||||||
<div
|
|
||||||
className="absolute h-px w-px pointer-events-none"
|
|
||||||
data-sidebar-activity-sentinel={section.key}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'relative group/chats',
|
|
||||||
'-ml-2.5 -mr-2',
|
'-ml-2.5 -mr-2',
|
||||||
stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar',
|
stickyZoneHeaders && 'sticky top-0 z-20 bg-sidebar',
|
||||||
)} data-sidebar-sticky-header={stickyZoneHeaders ? 'true' : undefined}>
|
)} data-sidebar-sticky-header={stickyZoneHeaders ? 'true' : undefined}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleSection(section.key)}
|
onClick={() => toggleSection(section.key)}
|
||||||
className={cn(
|
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"
|
||||||
'group flex w-full items-center gap-1.5 py-1 pl-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50',
|
|
||||||
section.key === 'chats' && props.onNewChat ? 'pr-10' : 'pr-3.5',
|
|
||||||
)}
|
|
||||||
aria-expanded={!isCollapsed}
|
aria-expanded={!isCollapsed}
|
||||||
>
|
>
|
||||||
<span className="inline-flex h-3.5 w-3.5 items-center justify-center">
|
<span className="inline-flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<Icon name={section.key === 'chats' ? 'chat-4' : 'history'} className={cn('h-3.5 w-3.5 text-muted-foreground/80', 'group-hover:hidden')} />
|
<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">
|
<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" />}
|
{isCollapsed ? <Icon name="arrow-right-s" className="h-3.5 w-3.5" /> : <Icon name="arrow-down-s" className="h-3.5 w-3.5" />}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[14px] font-semibold lowercase text-foreground">{section.title}</span>
|
<span className="text-[14px] font-semibold lowercase text-foreground">{section.title}</span>
|
||||||
</button>
|
</button>
|
||||||
{section.key === 'chats' && props.onNewChat ? (
|
|
||||||
<div className="absolute right-0.5 top-1/2 z-10 -translate-y-1/2">
|
|
||||||
<Tooltip delayDuration={500}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
props.onNewChat?.();
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-6 w-6 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',
|
|
||||||
props.alwaysShowActions
|
|
||||||
? 'opacity-100'
|
|
||||||
: 'opacity-0 pointer-events-none group-hover/chats:opacity-100 group-hover/chats:pointer-events-auto group-focus-within/chats:opacity-100 group-focus-within/chats:pointer-events-auto',
|
|
||||||
)}
|
|
||||||
aria-label={t('sessions.sidebar.header.actions.newSession')}
|
|
||||||
>
|
|
||||||
<Icon name="add" className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="bottom" sideOffset={4}>
|
|
||||||
<p>{t('sessions.sidebar.header.actions.newSession')}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
{!isCollapsed ? (
|
{!isCollapsed ? (
|
||||||
<div className={cn('space-y-0.5')}>
|
<div className={cn('space-y-0.5')}>
|
||||||
{section.key === 'chats' && props.renderChatsSection
|
{visibleItems.map(renderItem)}
|
||||||
? props.renderChatsSection(section.items)
|
{remainingCount > 0 ? (
|
||||||
: visibleItems.map(renderItem)}
|
|
||||||
{!usesCustomRenderer && remainingCount > 0 ? (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
onClick={() => showMoreSessions(section.key, visibleItems.length, section.items.length)}
|
||||||
+37
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { deriveRecentSessions } from './activitySections';
|
import { deriveRecentActivitySections, deriveRecentSessions } from './activitySections';
|
||||||
|
|
||||||
const NOW = 200_000_000;
|
const NOW = 200_000_000;
|
||||||
const RECENT = NOW - (48 * 60 * 60 * 1000);
|
const RECENT = NOW - (48 * 60 * 60 * 1000);
|
||||||
@@ -37,3 +37,39 @@ describe('deriveRecentSessions', () => {
|
|||||||
expect(deriveRecentSessions([oldSession, recentSession], new Set(), NOW)).toEqual([recentSession]);
|
expect(deriveRecentSessions([oldSession, recentSession], new Set(), NOW)).toEqual([recentSession]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('deriveRecentActivitySections', () => {
|
||||||
|
test('filters recent roots by search text and falls back to topology metadata', () => {
|
||||||
|
const matching = {
|
||||||
|
...session('matching', { updated: RECENT }),
|
||||||
|
title: 'Deploy release',
|
||||||
|
directory: '/workspace/app/worktrees/release',
|
||||||
|
};
|
||||||
|
const excluded = {
|
||||||
|
...session('excluded', { updated: RECENT }),
|
||||||
|
title: 'Investigate failure',
|
||||||
|
directory: '/workspace/app',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sections = deriveRecentActivitySections({
|
||||||
|
sessions: [matching, excluded],
|
||||||
|
getSessionLocation: (sessionId) => sessionId === matching.id ? {
|
||||||
|
projectId: 'app',
|
||||||
|
groupDirectory: '/workspace/app/worktrees/release',
|
||||||
|
projectLabel: 'App',
|
||||||
|
branchLabel: 'release',
|
||||||
|
} : null,
|
||||||
|
query: 'deploy',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sections).toEqual([{
|
||||||
|
key: 'active-now',
|
||||||
|
items: [{
|
||||||
|
node: { session: matching, children: [], worktree: null },
|
||||||
|
projectId: 'app',
|
||||||
|
groupDirectory: '/workspace/app/worktrees/release',
|
||||||
|
secondaryMeta: { projectLabel: 'App', branchLabel: 'release' },
|
||||||
|
}],
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import type { SessionNode } from '../types';
|
||||||
|
|
||||||
|
export type RecentSessionLocation = {
|
||||||
|
projectId: string | null;
|
||||||
|
groupDirectory: string | null;
|
||||||
|
projectLabel: string | null;
|
||||||
|
branchLabel: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecentActivitySection = {
|
||||||
|
key: 'active-now';
|
||||||
|
items: Array<{
|
||||||
|
node: SessionNode;
|
||||||
|
projectId: string | null;
|
||||||
|
groupDirectory: string | null;
|
||||||
|
secondaryMeta: { projectLabel?: string | null; branchLabel?: string | null } | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RECENT_SESSION_MAX_AGE_MS = 48 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const isSubtaskSession = (session: Session): boolean => {
|
||||||
|
return Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isArchivedSession = (session: Session): boolean => {
|
||||||
|
return Boolean(session.time?.archived);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionUpdatedAt = (session: Session): number => {
|
||||||
|
const updated = session.time?.updated;
|
||||||
|
const created = session.time?.created;
|
||||||
|
if (typeof updated === 'number' && Number.isFinite(updated)) {
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
if (typeof created === 'number' && Number.isFinite(created)) {
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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; batching ("Show more") handles long
|
||||||
|
// windows in the UI.
|
||||||
|
export const deriveRecentSessions = (
|
||||||
|
sessions: Session[],
|
||||||
|
activeSessionIds: ReadonlySet<string>,
|
||||||
|
now = Date.now(),
|
||||||
|
): Session[] => {
|
||||||
|
const minUpdatedAt = now - RECENT_SESSION_MAX_AGE_MS;
|
||||||
|
return sessions.filter((session) => {
|
||||||
|
if (isArchivedSession(session) || isSubtaskSession(session)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return activeSessionIds.has(session.id) || getSessionUpdatedAt(session) >= minUpdatedAt;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deriveRecentActivitySections = ({
|
||||||
|
sessions,
|
||||||
|
getSessionLocation,
|
||||||
|
getSessionNode,
|
||||||
|
query,
|
||||||
|
}: {
|
||||||
|
sessions: Session[];
|
||||||
|
getSessionLocation: (sessionId: string) => RecentSessionLocation | null;
|
||||||
|
getSessionNode?: (session: Session) => SessionNode;
|
||||||
|
query: string;
|
||||||
|
}): RecentActivitySection[] => [{
|
||||||
|
key: 'active-now',
|
||||||
|
items: sessions.flatMap((session) => {
|
||||||
|
const title = typeof session.title === 'string' ? session.title.toLowerCase() : '';
|
||||||
|
if (query && !title.includes(query)) return [];
|
||||||
|
const location = getSessionLocation(session.id);
|
||||||
|
return [{
|
||||||
|
node: getSessionNode?.(session) ?? { session, children: [], worktree: null },
|
||||||
|
projectId: location?.projectId ?? null,
|
||||||
|
groupDirectory: location?.groupDirectory ?? session.directory ?? null,
|
||||||
|
secondaryMeta: location ? {
|
||||||
|
projectLabel: location.projectLabel,
|
||||||
|
branchLabel: location.branchLabel,
|
||||||
|
} : null,
|
||||||
|
}];
|
||||||
|
}),
|
||||||
|
}];
|
||||||
+72
-95
@@ -18,18 +18,18 @@ import { canUseElectronDesktopIPC, invokeDesktop, isVSCodeRuntime } from '@/lib/
|
|||||||
import { toast } from '@/components/ui';
|
import { toast } from '@/components/ui';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { isSessionPinned, type SessionPinnedTarget } from '@/stores/useSessionPinnedStore';
|
import { isSessionPinned, useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
import { buildExportFilename, downloadAsMarkdown, formatSessionAsMarkdown, getExportRevealLabelKey, revealExportedMarkdown, saveAsMarkdownDesktop } from '@/lib/exportSession';
|
||||||
import type { ChildSessionExport } from '@/lib/exportSession';
|
import type { ChildSessionExport } from '@/lib/exportSession';
|
||||||
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context';
|
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionPermissions, useSessionQuestionCount } from '@/sync/sync-context';
|
||||||
import { useSync } from '@/sync/use-sync';
|
import { useSync } from '@/sync/use-sync';
|
||||||
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
import { useViewportStore, viewportSessionKey } from '@/sync/viewport-store';
|
||||||
import { DraggableSessionRow } from './sessionFolderDnd';
|
import { DraggableSessionRow } from '../folders/sessionFolderDnd';
|
||||||
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
import { nodeContainsSessionId, nodeHasPinnedMembershipChange, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||||
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||||
import type { SessionNode } from './types';
|
import type { SessionNode } from '../types';
|
||||||
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from './utils';
|
import { formatProjectLabel, formatSessionCompactDateLabel, formatSessionDateLabel, normalizePath, renderHighlightedText } from '../utils';
|
||||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
import { getGitHubPrStatusKey, usePrVisualSummary } from '@/stores/useGitHubPrStatusStore';
|
||||||
@@ -51,15 +51,15 @@ import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext';
|
|||||||
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
|
import { startSessionTreeWorktreeMove, useIsSessionWorktreeMovePending } from '@/lib/worktrees/sessionWorktreeMove';
|
||||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
type Folder = { id: string; name: string; sessionIds: string[] };
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
|
||||||
type SecondaryMeta = {
|
type SecondaryMeta = {
|
||||||
projectLabel?: string | null;
|
projectLabel?: string | null;
|
||||||
branchLabel?: string | null;
|
branchLabel?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
export type SessionNodeItemProps = {
|
||||||
node: SessionNode;
|
node: SessionNode;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
groupDirectory?: string | null;
|
groupDirectory?: string | null;
|
||||||
@@ -79,7 +79,6 @@ type Props = {
|
|||||||
toggleParent: (expansionKey: string) => void;
|
toggleParent: (expansionKey: string) => void;
|
||||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
handleSessionSelect: (sessionId: string, sessionDirectory: string | null) => void;
|
||||||
handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void;
|
handleSessionDoubleClick: (sessionId: string, sessionTitle: string) => void;
|
||||||
togglePinnedSession: (target: SessionPinnedTarget) => void;
|
|
||||||
handleShareSession: (session: Session) => void;
|
handleShareSession: (session: Session) => void;
|
||||||
copiedSessionId: string | null;
|
copiedSessionId: string | null;
|
||||||
handleCopyShareUrl: (url: string, sessionId: string) => void;
|
handleCopyShareUrl: (url: string, sessionId: string) => void;
|
||||||
@@ -87,27 +86,11 @@ type Props = {
|
|||||||
handleUnshareSession: (sessionId: string) => void;
|
handleUnshareSession: (sessionId: string) => void;
|
||||||
openSidebarMenuKey: string | null;
|
openSidebarMenuKey: string | null;
|
||||||
setOpenSidebarMenuKey: (key: string | null) => void;
|
setOpenSidebarMenuKey: (key: string | null) => void;
|
||||||
renamingFolderId: string | null;
|
|
||||||
getFoldersForScope: (scopeKey: string) => Folder[];
|
|
||||||
getSessionFolderId: (scopeKey: string, sessionId: string) => string | null;
|
|
||||||
removeSessionFromFolder: (scopeKey: string, sessionId: string) => void;
|
|
||||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
|
||||||
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
createFolderAndStartRename: (scopeKey: string, parentId?: string | null) => { id: string } | null;
|
||||||
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string; sessionTitleFallback?: string; readOnly?: boolean }) => void;
|
|
||||||
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void;
|
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean; hardDelete?: boolean; skipConfirm?: boolean }) => void;
|
||||||
handleRestoreSession: (session: Session) => void;
|
handleRestoreSession: (session: Session) => void;
|
||||||
mobileVariant: boolean;
|
mobileVariant: boolean;
|
||||||
alwaysShowActions: boolean;
|
alwaysShowActions: boolean;
|
||||||
renderSessionNode: (
|
|
||||||
node: SessionNode,
|
|
||||||
depth?: number,
|
|
||||||
groupDirectory?: string | null,
|
|
||||||
projectId?: string | null,
|
|
||||||
archivedBucket?: boolean,
|
|
||||||
secondaryMeta?: SecondaryMeta | null,
|
|
||||||
renderContext?: 'project' | 'recent',
|
|
||||||
renderExtras?: SessionNodeRenderExtras,
|
|
||||||
) => React.ReactNode;
|
|
||||||
secondaryMeta?: SecondaryMeta | null;
|
secondaryMeta?: SecondaryMeta | null;
|
||||||
renderContext?: 'project' | 'recent';
|
renderContext?: 'project' | 'recent';
|
||||||
/**
|
/**
|
||||||
@@ -133,9 +116,14 @@ type Props = {
|
|||||||
* descendant; SessionNodeItem's recursive child render uses this lookup
|
* descendant; SessionNodeItem's recursive child render uses this lookup
|
||||||
* to fetch the right key for each child it produces.
|
* to fetch the right key for each child it produces.
|
||||||
*/
|
*/
|
||||||
childRenderExtrasFor?: (child: SessionNode) => SessionNodeChildRenderExtras;
|
children?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const areNodeWorktreeRenderSemanticsEqual = (prev: SessionNode, next: SessionNode): boolean => (
|
||||||
|
normalizePath(prev.worktree?.path ?? null) === normalizePath(next.worktree?.path ?? null)
|
||||||
|
&& prev.worktree?.branch === next.worktree?.branch
|
||||||
|
);
|
||||||
|
|
||||||
// Shared row geometry: the gutter edge matches the zone-header band padding
|
// 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
|
// (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
|
// text starts exactly where the zone-header label starts. Nested children
|
||||||
@@ -147,7 +135,6 @@ const ROW_TEXT_LEFT_PX = ROW_GUTTER_LEFT_PX + 14 + 6;
|
|||||||
const cancelScrollAnchorByContainer = new WeakMap<HTMLElement, () => void>();
|
const cancelScrollAnchorByContainer = new WeakMap<HTMLElement, () => void>();
|
||||||
|
|
||||||
const holdSessionRowPosition = (target: HTMLElement): void => {
|
const holdSessionRowPosition = (target: HTMLElement): void => {
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const row = target.closest<HTMLElement>('[data-session-row]');
|
const row = target.closest<HTMLElement>('[data-session-row]');
|
||||||
const container = row?.closest<HTMLElement>('.overlay-scrollbar-container');
|
const container = row?.closest<HTMLElement>('.overlay-scrollbar-container');
|
||||||
if (!row || !container) return;
|
if (!row || !container) return;
|
||||||
@@ -252,7 +239,7 @@ const QuickSessionAction = React.memo(function QuickSessionAction({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
function SessionNodeItemComponent(props: Props): React.ReactNode {
|
function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode {
|
||||||
streamPerfCount('ui.sidebar_session_node.render');
|
streamPerfCount('ui.sidebar_session_node.render');
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const {
|
const {
|
||||||
@@ -275,7 +262,6 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
toggleParent,
|
toggleParent,
|
||||||
handleSessionSelect,
|
handleSessionSelect,
|
||||||
handleSessionDoubleClick,
|
handleSessionDoubleClick,
|
||||||
togglePinnedSession,
|
|
||||||
handleShareSession,
|
handleShareSession,
|
||||||
copiedSessionId,
|
copiedSessionId,
|
||||||
handleCopyShareUrl,
|
handleCopyShareUrl,
|
||||||
@@ -283,24 +269,23 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
handleUnshareSession,
|
handleUnshareSession,
|
||||||
openSidebarMenuKey,
|
openSidebarMenuKey,
|
||||||
setOpenSidebarMenuKey,
|
setOpenSidebarMenuKey,
|
||||||
renamingFolderId,
|
|
||||||
getFoldersForScope,
|
|
||||||
getSessionFolderId,
|
|
||||||
removeSessionFromFolder,
|
|
||||||
addSessionToFolder,
|
|
||||||
createFolderAndStartRename,
|
createFolderAndStartRename,
|
||||||
openContextPanelTab,
|
|
||||||
handleDeleteSession,
|
handleDeleteSession,
|
||||||
handleRestoreSession,
|
handleRestoreSession,
|
||||||
mobileVariant,
|
mobileVariant,
|
||||||
alwaysShowActions,
|
alwaysShowActions,
|
||||||
renderSessionNode,
|
|
||||||
secondaryMeta,
|
secondaryMeta,
|
||||||
renderContext = 'project',
|
renderContext = 'project',
|
||||||
subtreeContainsEditing,
|
subtreeContainsEditing,
|
||||||
menuOpenSessionId,
|
menuOpenSessionId,
|
||||||
childRenderExtrasFor,
|
children,
|
||||||
} = props;
|
} = props;
|
||||||
|
const togglePinnedSession = useSessionPinnedStore((state) => state.toggle);
|
||||||
|
const getFoldersForScope = useSessionFoldersStore((state) => state.getFoldersForScope);
|
||||||
|
const getSessionFolderId = useSessionFoldersStore((state) => state.getSessionFolderId);
|
||||||
|
const removeSessionFromFolder = useSessionFoldersStore((state) => state.removeSessionFromFolder);
|
||||||
|
const addSessionToFolder = useSessionFoldersStore((state) => state.addSessionToFolder);
|
||||||
|
const openContextPanelTab = useUIStore((state) => state.openContextPanelTab);
|
||||||
|
|
||||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||||
const isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []);
|
const isElectron = React.useMemo(() => canUseElectronDesktopIPC(), []);
|
||||||
@@ -335,6 +320,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
const editingIdRef = React.useRef(editingId);
|
const editingIdRef = React.useRef(editingId);
|
||||||
editingIdRef.current = editingId;
|
editingIdRef.current = editingId;
|
||||||
const pendingRenameRef = React.useRef<{ id: string; title: string } | null>(null);
|
const pendingRenameRef = React.useRef<{ id: string; title: string } | null>(null);
|
||||||
|
const pendingFolderCreateRef = React.useRef(false);
|
||||||
const handleSaveEditRef = React.useRef(handleSaveEdit);
|
const handleSaveEditRef = React.useRef(handleSaveEdit);
|
||||||
handleSaveEditRef.current = handleSaveEdit;
|
handleSaveEditRef.current = handleSaveEdit;
|
||||||
const [renameDraft, setRenameDraft] = React.useState(editTitle);
|
const [renameDraft, setRenameDraft] = React.useState(editTitle);
|
||||||
@@ -395,9 +381,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
}, [prSummary, t]);
|
}, [prSummary, t]);
|
||||||
const isActive = useSessionUIStore((state) => state.currentSessionId === session.id);
|
const isActive = useSessionUIStore((state) => state.currentSessionId === session.id);
|
||||||
|
|
||||||
const sessionDirectory =
|
const sessionDirectory = normalizePath(session.directory ?? null) ?? normalizePath(groupDirectory ?? null);
|
||||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
|
||||||
?? normalizePath(groupDirectory ?? null);
|
|
||||||
// Multi-select scope: sessions are flat per project, so selection groups by
|
// Multi-select scope: sessions are flat per project, so selection groups by
|
||||||
// project (falling back to the directory when no project is known) — a
|
// project (falling back to the directory when no project is known) — a
|
||||||
// selection must survive mixing sessions from different worktrees.
|
// selection must survive mixing sessions from different worktrees.
|
||||||
@@ -455,6 +439,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false });
|
const sessionPermissions = useSessionPermissions(session.id, sessionDirectory ?? undefined, { bootstrap: false });
|
||||||
const sessionGoal = getSessionGoal(resolvedSession);
|
const sessionGoal = getSessionGoal(resolvedSession);
|
||||||
const sessionGoalGlyph = sessionGoal ? (
|
const sessionGoalGlyph = sessionGoal ? (
|
||||||
|
// SAFETY: sessionGoalStatusLabelKey contains an i18n key for every SessionGoalStatus.
|
||||||
<span
|
<span
|
||||||
className="inline-flex flex-shrink-0 items-center"
|
className="inline-flex flex-shrink-0 items-center"
|
||||||
title={t(sessionGoalStatusLabelKey[sessionGoal.status] as never)}
|
title={t(sessionGoalStatusLabelKey[sessionGoal.status] as never)}
|
||||||
@@ -476,7 +461,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
[isExpanded, node, sessionDirectory],
|
[isExpanded, node, sessionDirectory],
|
||||||
);
|
);
|
||||||
const pendingQuestionCount = useSessionQuestionCount(questionBadgeSessionScopes);
|
const pendingQuestionCount = useSessionQuestionCount(questionBadgeSessionScopes);
|
||||||
const isSubtaskSession = Boolean((resolvedSession as Session & { parentID?: string | null }).parentID);
|
const isSubtaskSession = Boolean(resolvedSession.parentID);
|
||||||
const unseenCount = useSessionUnseenCount(session.id);
|
const unseenCount = useSessionUnseenCount(session.id);
|
||||||
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
|
const needsAttention = unseenCount > 0 && (!isSubtaskSession || notifyOnSubtasks);
|
||||||
const sessionTimestamp = resolvedSession.time?.updated || resolvedSession.time?.created || Date.now();
|
const sessionTimestamp = resolvedSession.time?.updated || resolvedSession.time?.created || Date.now();
|
||||||
@@ -499,6 +484,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
await sync.loadCompleteHistory(child.session.id, sessionDirectory);
|
await sync.loadCompleteHistory(child.session.id, sessionDirectory);
|
||||||
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
|
const childRecords = buildSessionMessageRecordsSnapshot(directoryStore.getState(), child.session.id).list;
|
||||||
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
|
const childTitle = child.session.title || t('sessions.sidebar.session.export.untitledSubagent');
|
||||||
|
// SAFETY: OpenCode session payloads may carry the optional agent label used by exports.
|
||||||
const childAgent = (child.session as Session & { agent?: string }).agent;
|
const childAgent = (child.session as Session & { agent?: string }).agent;
|
||||||
const grandChildren = await collectChildExports(child.children);
|
const grandChildren = await collectChildExports(child.children);
|
||||||
skipped += grandChildren.skipped;
|
skipped += grandChildren.skipped;
|
||||||
@@ -603,7 +589,8 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
// its own rename form. A click inside ANY rename form for this session
|
// 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
|
// must not count as "outside", or the sibling instance would save and
|
||||||
// exit the rename mid-edit.
|
// exit the rename mid-edit.
|
||||||
const target = e.target as HTMLElement | null;
|
// SAFETY: DOM mousedown targets are Nodes; closest is used only when the target is an Element.
|
||||||
|
const target = e.target instanceof HTMLElement ? e.target : null;
|
||||||
const withinRenameForm = target?.closest?.(`[data-session-rename-form="${CSS.escape(session.id)}"]`);
|
const withinRenameForm = target?.closest?.(`[data-session-rename-form="${CSS.escape(session.id)}"]`);
|
||||||
if (formRef.current && !withinRenameForm) {
|
if (formRef.current && !withinRenameForm) {
|
||||||
handleSaveEditRef.current(renameDraftRef.current);
|
handleSaveEditRef.current(renameDraftRef.current);
|
||||||
@@ -846,7 +833,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
void runtimeApis?.vscode?.executeCommand('openchamber.openSessionInEditor', session.id, sessionTitle);
|
void runtimeApis?.vscode?.executeCommand('openchamber.openSessionInEditor', session.id, sessionTitle);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRowSelect = (event?: React.MouseEvent<HTMLButtonElement>) => {
|
const handleRowSelect = (event?: React.MouseEvent<HTMLElement>) => {
|
||||||
if (suppressNextSelectRef.current) {
|
if (suppressNextSelectRef.current) {
|
||||||
suppressNextSelectRef.current = false;
|
suppressNextSelectRef.current = false;
|
||||||
return;
|
return;
|
||||||
@@ -855,12 +842,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
event?.preventDefault();
|
event?.preventDefault();
|
||||||
event?.stopPropagation();
|
event?.stopPropagation();
|
||||||
if (event?.shiftKey) {
|
if (event?.shiftKey) {
|
||||||
const rows = typeof document !== 'undefined'
|
const rows = Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'));
|
||||||
? Array.from(document.querySelectorAll<HTMLElement>('[data-session-row]'))
|
|
||||||
: [];
|
|
||||||
const orderedIds = rows
|
const orderedIds = rows
|
||||||
.map((el) => el.getAttribute('data-session-row'))
|
.map((el) => el.getAttribute('data-session-row'))
|
||||||
.filter((id): id is string => typeof id === 'string' && id.length > 0);
|
.filter((id): id is string => id !== null && id.length > 0);
|
||||||
const currentAnchor = useSessionMultiSelectStore.getState().anchorId;
|
const currentAnchor = useSessionMultiSelectStore.getState().anchorId;
|
||||||
const descendantsById = new Map<string, string[]>();
|
const descendantsById = new Map<string, string[]>();
|
||||||
descendantsById.set(session.id, collectNodeDescendantIds(node));
|
descendantsById.set(session.id, collectNodeDescendantIds(node));
|
||||||
@@ -881,9 +866,10 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
// action menu), so nothing double-fires.
|
// action menu), so nothing double-fires.
|
||||||
const handleRowBackgroundClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
const handleRowBackgroundClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||||
if (event.defaultPrevented) return;
|
if (event.defaultPrevented) return;
|
||||||
const target = event.target as HTMLElement | null;
|
// SAFETY: React click targets are DOM EventTargets; closest is valid only for HTMLElements.
|
||||||
|
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||||
if (target?.closest('button, a, input, [role="menuitem"], [role="menu"]')) return;
|
if (target?.closest('button, a, input, [role="menuitem"], [role="menu"]')) return;
|
||||||
handleRowSelect(event as unknown as React.MouseEvent<HTMLButtonElement>);
|
handleRowSelect(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRowMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
|
const handleRowMouseDown = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
@@ -1053,8 +1039,9 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
)}
|
)}
|
||||||
<Separator />
|
<Separator />
|
||||||
<Item onClick={() => {
|
<Item onClick={() => {
|
||||||
const newFolder = createFolderAndStartRename(defaultScope);
|
const newFolder = createFolderAndStartRename(defaultScope);
|
||||||
if (!newFolder) return;
|
if (!newFolder) return;
|
||||||
|
pendingFolderCreateRef.current = true;
|
||||||
if (currentEntry && currentEntry.scope !== defaultScope) {
|
if (currentEntry && currentEntry.scope !== defaultScope) {
|
||||||
removeSessionFromFolder(currentEntry.scope, session.id);
|
removeSessionFromFolder(currentEntry.scope, session.id);
|
||||||
}
|
}
|
||||||
@@ -1127,7 +1114,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sessionMenuContent = (
|
const sessionMenuContent = (
|
||||||
<DropdownMenuContent align="end" className="min-w-[180px]" finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}>
|
<DropdownMenuContent align="end" className="min-w-[180px]" finalFocus={() => {
|
||||||
|
if (pendingFolderCreateRef.current) {
|
||||||
|
pendingFolderCreateRef.current = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return editingIdRef.current ? false : true;
|
||||||
|
}}>
|
||||||
{renderSessionMenuItems({
|
{renderSessionMenuItems({
|
||||||
Item: DropdownMenuItem,
|
Item: DropdownMenuItem,
|
||||||
Separator: DropdownMenuSeparator,
|
Separator: DropdownMenuSeparator,
|
||||||
@@ -1143,7 +1136,13 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
<ContextMenu.Positioner className="app-region-no-drag z-50">
|
<ContextMenu.Positioner className="app-region-no-drag z-50">
|
||||||
<ContextMenu.Popup
|
<ContextMenu.Popup
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
finalFocus={() => (renamingFolderId || editingIdRef.current) ? false : true}
|
finalFocus={() => {
|
||||||
|
if (pendingFolderCreateRef.current) {
|
||||||
|
pendingFolderCreateRef.current = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return editingIdRef.current ? false : true;
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
color: 'var(--surface-elevated-foreground)',
|
color: 'var(--surface-elevated-foreground)',
|
||||||
}}
|
}}
|
||||||
@@ -1423,27 +1422,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
{contextMenuContent}
|
{contextMenuContent}
|
||||||
</ContextMenu.Root>
|
</ContextMenu.Root>
|
||||||
</DraggableSessionRow>
|
</DraggableSessionRow>
|
||||||
{hasChildren && isExpanded
|
{hasChildren && isExpanded ? children : null}
|
||||||
? node.children.map((child): React.ReactNode => {
|
|
||||||
const childRenderExtras: SessionNodeChildRenderExtras = childRenderExtrasFor
|
|
||||||
? childRenderExtrasFor(child)
|
|
||||||
: {
|
|
||||||
subtreeContainsEditing,
|
|
||||||
menuOpenSessionId,
|
|
||||||
nodeStructureKey: '',
|
|
||||||
};
|
|
||||||
return renderSessionNode(
|
|
||||||
child,
|
|
||||||
depth + 1,
|
|
||||||
sessionDirectory ?? groupDirectory,
|
|
||||||
projectId,
|
|
||||||
archivedBucket,
|
|
||||||
undefined,
|
|
||||||
renderContext,
|
|
||||||
childRenderExtras,
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: null}
|
|
||||||
<Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
|
<Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
|
||||||
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
|
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
@@ -1497,7 +1476,7 @@ function SessionNodeItemComponent(props: Props): React.ReactNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getNodeSessionDirectory = (node: SessionNode): string | null => {
|
const getNodeSessionDirectory = (node: SessionNode): string | null => {
|
||||||
return normalizePath((node.session as Session & { directory?: string | null }).directory ?? null);
|
return normalizePath(node.session.directory ?? null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSecondaryMetaEqual = (prev?: SecondaryMeta | null, next?: SecondaryMeta | null): boolean => {
|
const isSecondaryMetaEqual = (prev?: SecondaryMeta | null, next?: SecondaryMeta | null): boolean => {
|
||||||
@@ -1505,7 +1484,7 @@ const isSecondaryMetaEqual = (prev?: SecondaryMeta | null, next?: SecondaryMeta
|
|||||||
&& (prev?.branchLabel ?? null) === (next?.branchLabel ?? null);
|
&& (prev?.branchLabel ?? null) === (next?.branchLabel ?? null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMenuSessionIdFromKey = (props: Props): string | null => {
|
const getMenuSessionIdFromKey = (props: SessionNodeItemProps): string | null => {
|
||||||
if (!props.openSidebarMenuKey) return null;
|
if (!props.openSidebarMenuKey) return null;
|
||||||
const bucketTag = props.archivedBucket ? 'archived' : 'active';
|
const bucketTag = props.archivedBucket ? 'archived' : 'active';
|
||||||
const prefix = `${props.renderContext ?? 'project'}:${bucketTag}:`;
|
const prefix = `${props.renderContext ?? 'project'}:${bucketTag}:`;
|
||||||
@@ -1514,12 +1493,12 @@ const getMenuSessionIdFromKey = (props: Props): string | null => {
|
|||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRelevantMenuSessionId = (props: Props): string | null => {
|
const getRelevantMenuSessionId = (props: SessionNodeItemProps): string | null => {
|
||||||
return props.menuOpenSessionId ?? getMenuSessionIdFromKey(props);
|
return props.menuOpenSessionId ?? getMenuSessionIdFromKey(props);
|
||||||
};
|
};
|
||||||
|
|
||||||
const subtreeContainsSession = (
|
const subtreeContainsSession = (
|
||||||
props: Props,
|
props: SessionNodeItemProps,
|
||||||
sessionId: string | null,
|
sessionId: string | null,
|
||||||
precomputed: Set<string>,
|
precomputed: Set<string>,
|
||||||
): boolean => {
|
): boolean => {
|
||||||
@@ -1547,7 +1526,7 @@ const hasSetMembershipChangeInNode = (
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
|
const hasExpansionMembershipChange = (prev: SessionNodeItemProps, next: SessionNodeItemProps): boolean => {
|
||||||
if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false;
|
if (prev.hasSessionSearchQuery || next.hasSessionSearchQuery) return false;
|
||||||
const prevBucketTag = prev.archivedBucket ? 'archived' : 'active';
|
const prevBucketTag = prev.archivedBucket ? 'archived' : 'active';
|
||||||
const nextBucketTag = next.archivedBucket ? 'archived' : 'active';
|
const nextBucketTag = next.archivedBucket ? 'archived' : 'active';
|
||||||
@@ -1566,9 +1545,21 @@ const hasExpansionMembershipChange = (prev: Props, next: Props): boolean => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
const areSessionRenderSemanticsEqual = (prev: Session, next: Session): boolean => (
|
||||||
|
prev.id === next.id
|
||||||
|
&& prev.title === next.title
|
||||||
|
&& prev.directory === next.directory
|
||||||
|
&& prev.parentID === next.parentID
|
||||||
|
&& prev.share?.url === next.share?.url
|
||||||
|
&& prev.time?.created === next.time?.created
|
||||||
|
&& prev.time?.updated === next.time?.updated
|
||||||
|
&& prev.time?.archived === next.time?.archived
|
||||||
|
);
|
||||||
|
|
||||||
|
const areSessionNodeItemPropsEqual = (prev: SessionNodeItemProps, next: SessionNodeItemProps): boolean => {
|
||||||
if (prev.node.session.id !== next.node.session.id) return false;
|
if (prev.node.session.id !== next.node.session.id) return false;
|
||||||
if (prev.node.session !== next.node.session) return false;
|
if (!areSessionRenderSemanticsEqual(prev.node.session, next.node.session)) return false;
|
||||||
|
if (!areNodeWorktreeRenderSemanticsEqual(prev.node, next.node)) return false;
|
||||||
if (prev.depth !== next.depth) return false;
|
if (prev.depth !== next.depth) return false;
|
||||||
if (prev.groupDirectory !== next.groupDirectory) return false;
|
if (prev.groupDirectory !== next.groupDirectory) return false;
|
||||||
if (prev.projectId !== next.projectId) return false;
|
if (prev.projectId !== next.projectId) return false;
|
||||||
@@ -1631,14 +1622,6 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prev.renamingFolderId !== next.renamingFolderId) {
|
|
||||||
const prevMenuSessionId = getRelevantMenuSessionId(prev);
|
|
||||||
const nextMenuSessionId = getRelevantMenuSessionId(next);
|
|
||||||
if (nodeContainsSessionId(prev.node, prevMenuSessionId) || nodeContainsSessionId(next.node, nextMenuSessionId)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return prev.setEditingId === next.setEditingId
|
return prev.setEditingId === next.setEditingId
|
||||||
&& prev.setEditTitle === next.setEditTitle
|
&& prev.setEditTitle === next.setEditTitle
|
||||||
&& prev.handleSaveEdit === next.handleSaveEdit
|
&& prev.handleSaveEdit === next.handleSaveEdit
|
||||||
@@ -1646,21 +1629,15 @@ const areSessionNodeItemPropsEqual = (prev: Props, next: Props): boolean => {
|
|||||||
&& prev.toggleParent === next.toggleParent
|
&& prev.toggleParent === next.toggleParent
|
||||||
&& prev.handleSessionSelect === next.handleSessionSelect
|
&& prev.handleSessionSelect === next.handleSessionSelect
|
||||||
&& prev.handleSessionDoubleClick === next.handleSessionDoubleClick
|
&& prev.handleSessionDoubleClick === next.handleSessionDoubleClick
|
||||||
&& prev.togglePinnedSession === next.togglePinnedSession
|
|
||||||
&& prev.handleShareSession === next.handleShareSession
|
&& prev.handleShareSession === next.handleShareSession
|
||||||
&& prev.handleCopyShareUrl === next.handleCopyShareUrl
|
&& prev.handleCopyShareUrl === next.handleCopyShareUrl
|
||||||
&& prev.handleCopySessionId === next.handleCopySessionId
|
&& prev.handleCopySessionId === next.handleCopySessionId
|
||||||
&& prev.handleUnshareSession === next.handleUnshareSession
|
&& prev.handleUnshareSession === next.handleUnshareSession
|
||||||
&& prev.setOpenSidebarMenuKey === next.setOpenSidebarMenuKey
|
&& prev.setOpenSidebarMenuKey === next.setOpenSidebarMenuKey
|
||||||
&& prev.getFoldersForScope === next.getFoldersForScope
|
|
||||||
&& prev.getSessionFolderId === next.getSessionFolderId
|
|
||||||
&& prev.removeSessionFromFolder === next.removeSessionFromFolder
|
|
||||||
&& prev.addSessionToFolder === next.addSessionToFolder
|
|
||||||
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
|
&& prev.createFolderAndStartRename === next.createFolderAndStartRename
|
||||||
&& prev.openContextPanelTab === next.openContextPanelTab
|
|
||||||
&& prev.handleDeleteSession === next.handleDeleteSession
|
&& prev.handleDeleteSession === next.handleDeleteSession
|
||||||
&& prev.handleRestoreSession === next.handleRestoreSession
|
&& prev.handleRestoreSession === next.handleRestoreSession
|
||||||
&& prev.renderSessionNode === next.renderSessionNode;
|
&& prev.children === next.children;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areSessionNodeItemPropsEqual);
|
export const SessionNodeItem = React.memo(SessionNodeItemComponent, areSessionNodeItemPropsEqual);
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import type { SessionNodeItemProps } from './SessionNodeItem';
|
||||||
|
import { installHookTestDom } from '../test-utils/testDom';
|
||||||
|
import { I18nProvider } from '@/lib/i18n';
|
||||||
|
|
||||||
|
const renderedRows: SessionNodeItemProps[] = [];
|
||||||
|
|
||||||
|
mock.module('./SessionNodeItem', () => ({
|
||||||
|
SessionNodeItem: (props: SessionNodeItemProps) => {
|
||||||
|
renderedRows.push(props);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('./hooks/useSessionActions', () => ({
|
||||||
|
useSessionActions: (args: {
|
||||||
|
setEditingId: (id: string | null) => void;
|
||||||
|
setEditTitle: (title: string) => void;
|
||||||
|
}) => ({
|
||||||
|
copiedSessionId: null,
|
||||||
|
handleSaveEdit: () => undefined,
|
||||||
|
handleCancelEdit: () => undefined,
|
||||||
|
handleSessionSelect: () => undefined,
|
||||||
|
handleSessionDoubleClick: (id: string, title: string) => {
|
||||||
|
args.setEditingId(id);
|
||||||
|
args.setEditTitle(title);
|
||||||
|
},
|
||||||
|
handleShareSession: () => undefined,
|
||||||
|
handleCopyShareUrl: () => undefined,
|
||||||
|
handleCopySessionId: () => undefined,
|
||||||
|
handleUnshareSession: () => undefined,
|
||||||
|
handleDeleteSession: () => undefined,
|
||||||
|
handleRestoreSession: () => undefined,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { SessionTreeItem } = await import('./SessionTreeItem');
|
||||||
|
|
||||||
|
const session = (id: string): Session => ({
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID: 'project',
|
||||||
|
title: 'Shared title',
|
||||||
|
version: '1',
|
||||||
|
directory: '/workspace',
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SessionTreeItem public behavior', () => {
|
||||||
|
test('coordinates duplicate project and Recent rows through their shared visible-list state', async () => {
|
||||||
|
const dom = installHookTestDom();
|
||||||
|
const root = createRoot(dom.container);
|
||||||
|
const sharedSession = session('same-session');
|
||||||
|
const rowNode = { session: sharedSession, children: [], worktree: null };
|
||||||
|
const noop = () => undefined;
|
||||||
|
const noopWithValue = (_value: string | null) => undefined;
|
||||||
|
|
||||||
|
const Harness = () => {
|
||||||
|
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||||
|
const [editTitle, setEditTitle] = React.useState('');
|
||||||
|
const [menuKey, setMenuKey] = React.useState<string | null>(null);
|
||||||
|
const [copiedSessionId, setCopiedSessionId] = React.useState<string | null>(null);
|
||||||
|
const rows = [
|
||||||
|
{ renderContext: 'project' as const, groupDirectory: '/workspace' },
|
||||||
|
{ renderContext: 'recent' as const, groupDirectory: '/workspace' },
|
||||||
|
];
|
||||||
|
return <>{rows.map((context) => <SessionTreeItem
|
||||||
|
key={context.renderContext}
|
||||||
|
node={rowNode}
|
||||||
|
pinnedSessionIds={new Set()}
|
||||||
|
expandedParents={new Set()}
|
||||||
|
hasSessionSearchQuery={false}
|
||||||
|
normalizedSessionSearchQuery=""
|
||||||
|
notifyOnSubtasks={false}
|
||||||
|
editingId={editingId}
|
||||||
|
setEditingId={setEditingId}
|
||||||
|
editTitle={editTitle}
|
||||||
|
setEditTitle={setEditTitle}
|
||||||
|
toggleParent={noop}
|
||||||
|
copiedSessionId={copiedSessionId}
|
||||||
|
openSidebarMenuKey={menuKey}
|
||||||
|
setOpenSidebarMenuKey={setMenuKey}
|
||||||
|
allowReselect={false}
|
||||||
|
isSessionSearchOpen={false}
|
||||||
|
sessionSearchQuery=""
|
||||||
|
setSessionSearchQuery={noop}
|
||||||
|
setIsSessionSearchOpen={noop}
|
||||||
|
deleteSessionConfirm={null}
|
||||||
|
setDeleteSessionConfirm={noop}
|
||||||
|
startFolderRename={noop}
|
||||||
|
setCopiedSessionId={setCopiedSessionId}
|
||||||
|
mobileVariant={false}
|
||||||
|
alwaysShowActions={false}
|
||||||
|
{...context}
|
||||||
|
/>)}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(<I18nProvider><Harness /></I18nProvider>));
|
||||||
|
expect(renderedRows).toHaveLength(2);
|
||||||
|
|
||||||
|
await act(async () => renderedRows[0]?.handleSessionDoubleClick(sharedSession.id, sharedSession.title));
|
||||||
|
expect(renderedRows).toHaveLength(4);
|
||||||
|
expect(renderedRows.slice(-2).map((row) => [row.editingId, row.editTitle]))
|
||||||
|
.toEqual([[sharedSession.id, sharedSession.title], [sharedSession.id, sharedSession.title]]);
|
||||||
|
|
||||||
|
await act(async () => renderedRows[3]?.setOpenSidebarMenuKey('recent:active:same-session'));
|
||||||
|
expect(renderedRows).toHaveLength(6);
|
||||||
|
expect(renderedRows.slice(-2).map((row) => row.openSidebarMenuKey))
|
||||||
|
.toEqual(['recent:active:same-session', 'recent:active:same-session']);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
renderedRows.length = 0;
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SessionNodeItem } from './SessionNodeItem';
|
||||||
|
import type { SessionNodeItemProps } from './SessionNodeItem';
|
||||||
|
import type { SessionNode } from '../types';
|
||||||
|
import type { SessionNodeChildRenderExtras, SessionNodeRenderExtras } from './sessionNodeItemUtils';
|
||||||
|
import { useSessionActions, type DeleteSessionConfirmState } from './useSessionActions';
|
||||||
|
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||||
|
import { useUIStore } from '@/stores/useUIStore';
|
||||||
|
import { SessionDeleteConfirmDialog } from '../shell/ConfirmDialogs';
|
||||||
|
|
||||||
|
type Context = {
|
||||||
|
groupDirectory?: string | null;
|
||||||
|
projectId?: string | null;
|
||||||
|
archivedBucket?: boolean;
|
||||||
|
secondaryMeta?: { projectLabel?: string | null; branchLabel?: string | null } | null;
|
||||||
|
renderContext?: 'project' | 'recent';
|
||||||
|
};
|
||||||
|
|
||||||
|
type SessionTreeItemRenderProps = Context & Pick<SessionNodeItemProps,
|
||||||
|
| 'expandedParents'
|
||||||
|
| 'hasSessionSearchQuery'
|
||||||
|
| 'normalizedSessionSearchQuery'
|
||||||
|
| 'notifyOnSubtasks'
|
||||||
|
| 'editingId'
|
||||||
|
| 'editTitle'
|
||||||
|
| 'copiedSessionId'
|
||||||
|
| 'openSidebarMenuKey'
|
||||||
|
| 'mobileVariant'
|
||||||
|
| 'alwaysShowActions'
|
||||||
|
> & {
|
||||||
|
node: SessionNode;
|
||||||
|
pinnedSessionIds: Set<string>;
|
||||||
|
depth?: number;
|
||||||
|
renderExtras?: SessionNodeRenderExtras;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SessionTreeItemProps = SessionTreeItemRenderProps & Pick<SessionNodeItemProps,
|
||||||
|
| 'setEditingId'
|
||||||
|
| 'setEditTitle'
|
||||||
|
| 'toggleParent'
|
||||||
|
| 'setOpenSidebarMenuKey'
|
||||||
|
> & {
|
||||||
|
allowReselect: boolean;
|
||||||
|
onSessionSelected?: (sessionId: string) => void;
|
||||||
|
isSessionSearchOpen: boolean;
|
||||||
|
sessionSearchQuery: string;
|
||||||
|
setSessionSearchQuery: (value: string) => void;
|
||||||
|
setIsSessionSearchOpen: (open: boolean) => void;
|
||||||
|
deleteSessionConfirm: DeleteSessionConfirmState;
|
||||||
|
setDeleteSessionConfirm: (value: DeleteSessionConfirmState) => void;
|
||||||
|
startFolderRename: (scopeKey: string, folder: { id: string; name: string }) => void;
|
||||||
|
setCopiedSessionId: (sessionId: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const EMPTY_SUBTREE_CONTAINS_EDITING: Set<string> = new Set();
|
||||||
|
|
||||||
|
// This is the recursive ownership boundary. Structural parents pass identity
|
||||||
|
// and stable UI actions; the row itself remains the leaf subscriber for live UI state.
|
||||||
|
export function SessionTreeItem({
|
||||||
|
node,
|
||||||
|
depth = 0,
|
||||||
|
groupDirectory,
|
||||||
|
projectId,
|
||||||
|
archivedBucket = false,
|
||||||
|
secondaryMeta,
|
||||||
|
renderContext = 'project',
|
||||||
|
renderExtras,
|
||||||
|
pinnedSessionIds,
|
||||||
|
expandedParents,
|
||||||
|
hasSessionSearchQuery,
|
||||||
|
normalizedSessionSearchQuery,
|
||||||
|
notifyOnSubtasks,
|
||||||
|
editingId,
|
||||||
|
setEditingId,
|
||||||
|
editTitle,
|
||||||
|
setEditTitle,
|
||||||
|
toggleParent,
|
||||||
|
openSidebarMenuKey,
|
||||||
|
setOpenSidebarMenuKey,
|
||||||
|
allowReselect,
|
||||||
|
onSessionSelected,
|
||||||
|
isSessionSearchOpen,
|
||||||
|
sessionSearchQuery,
|
||||||
|
setSessionSearchQuery,
|
||||||
|
setIsSessionSearchOpen,
|
||||||
|
deleteSessionConfirm,
|
||||||
|
setDeleteSessionConfirm,
|
||||||
|
startFolderRename,
|
||||||
|
copiedSessionId,
|
||||||
|
setCopiedSessionId,
|
||||||
|
mobileVariant,
|
||||||
|
alwaysShowActions,
|
||||||
|
}: SessionTreeItemProps): React.ReactNode {
|
||||||
|
const createFolder = useSessionFoldersStore((state) => state.createFolder);
|
||||||
|
const toggleFolderCollapse = useSessionFoldersStore((state) => state.toggleFolderCollapse);
|
||||||
|
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
|
||||||
|
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
|
||||||
|
const descendantIds = React.useMemo(() => {
|
||||||
|
const ids: string[] = [];
|
||||||
|
const visit = (current: SessionNode) => current.children.forEach((child) => {
|
||||||
|
ids.push(child.session.id);
|
||||||
|
visit(child);
|
||||||
|
});
|
||||||
|
visit(node);
|
||||||
|
return ids;
|
||||||
|
}, [node]);
|
||||||
|
const createFolderAndStartRename = React.useCallback((scopeKey: string, parentId?: string | null) => {
|
||||||
|
if (!scopeKey) return null;
|
||||||
|
if (parentId && useSessionFoldersStore.getState().collapsedFolderIds.has(parentId)) toggleFolderCollapse(parentId);
|
||||||
|
const folder = createFolder(scopeKey, 'New folder', parentId);
|
||||||
|
startFolderRename(scopeKey, folder);
|
||||||
|
return folder;
|
||||||
|
}, [createFolder, startFolderRename, toggleFolderCollapse]);
|
||||||
|
const sessionActions = useSessionActions({
|
||||||
|
mobileVariant,
|
||||||
|
allowReselect,
|
||||||
|
onSessionSelected,
|
||||||
|
isSessionSearchOpen,
|
||||||
|
sessionSearchQuery,
|
||||||
|
setSessionSearchQuery,
|
||||||
|
setIsSessionSearchOpen,
|
||||||
|
descendantIds,
|
||||||
|
showDeletionDialog,
|
||||||
|
setDeleteSessionConfirm,
|
||||||
|
deleteSessionConfirm,
|
||||||
|
editingId,
|
||||||
|
setEditingId,
|
||||||
|
editTitle,
|
||||||
|
setEditTitle,
|
||||||
|
copiedSessionId,
|
||||||
|
setCopiedSessionId,
|
||||||
|
});
|
||||||
|
const childRenderExtrasFor = renderExtras?.childRenderExtrasFor;
|
||||||
|
const childContext: Context = {
|
||||||
|
groupDirectory: node.session.directory ?? groupDirectory,
|
||||||
|
projectId,
|
||||||
|
archivedBucket,
|
||||||
|
renderContext,
|
||||||
|
};
|
||||||
|
return <>
|
||||||
|
<SessionNodeItem
|
||||||
|
expandedParents={expandedParents}
|
||||||
|
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||||
|
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||||
|
notifyOnSubtasks={notifyOnSubtasks}
|
||||||
|
editingId={editingId}
|
||||||
|
setEditingId={setEditingId}
|
||||||
|
editTitle={editTitle}
|
||||||
|
setEditTitle={setEditTitle}
|
||||||
|
handleSaveEdit={sessionActions.handleSaveEdit}
|
||||||
|
handleCancelEdit={sessionActions.handleCancelEdit}
|
||||||
|
toggleParent={toggleParent}
|
||||||
|
handleSessionSelect={sessionActions.handleSessionSelect}
|
||||||
|
handleSessionDoubleClick={sessionActions.handleSessionDoubleClick}
|
||||||
|
handleShareSession={sessionActions.handleShareSession}
|
||||||
|
copiedSessionId={copiedSessionId}
|
||||||
|
handleCopyShareUrl={sessionActions.handleCopyShareUrl}
|
||||||
|
handleCopySessionId={sessionActions.handleCopySessionId}
|
||||||
|
handleUnshareSession={sessionActions.handleUnshareSession}
|
||||||
|
openSidebarMenuKey={openSidebarMenuKey}
|
||||||
|
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||||
|
createFolderAndStartRename={createFolderAndStartRename}
|
||||||
|
handleDeleteSession={sessionActions.handleDeleteSession}
|
||||||
|
handleRestoreSession={sessionActions.handleRestoreSession}
|
||||||
|
mobileVariant={mobileVariant}
|
||||||
|
alwaysShowActions={alwaysShowActions}
|
||||||
|
pinnedSessionIds={pinnedSessionIds}
|
||||||
|
node={node}
|
||||||
|
depth={depth}
|
||||||
|
groupDirectory={groupDirectory}
|
||||||
|
projectId={projectId}
|
||||||
|
archivedBucket={archivedBucket}
|
||||||
|
secondaryMeta={secondaryMeta}
|
||||||
|
renderContext={renderContext}
|
||||||
|
subtreeContainsEditing={renderExtras?.subtreeContainsEditing ?? EMPTY_SUBTREE_CONTAINS_EDITING}
|
||||||
|
menuOpenSessionId={renderExtras?.menuOpenSessionId ?? null}
|
||||||
|
nodeStructureKey={renderExtras?.nodeStructureKey ?? ''}
|
||||||
|
>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<SessionTreeItem
|
||||||
|
key={child.session.id}
|
||||||
|
node={child}
|
||||||
|
pinnedSessionIds={pinnedSessionIds}
|
||||||
|
expandedParents={expandedParents}
|
||||||
|
hasSessionSearchQuery={hasSessionSearchQuery}
|
||||||
|
normalizedSessionSearchQuery={normalizedSessionSearchQuery}
|
||||||
|
notifyOnSubtasks={notifyOnSubtasks}
|
||||||
|
editingId={editingId}
|
||||||
|
setEditingId={setEditingId}
|
||||||
|
editTitle={editTitle}
|
||||||
|
copiedSessionId={copiedSessionId}
|
||||||
|
setEditTitle={setEditTitle}
|
||||||
|
toggleParent={toggleParent}
|
||||||
|
openSidebarMenuKey={openSidebarMenuKey}
|
||||||
|
setOpenSidebarMenuKey={setOpenSidebarMenuKey}
|
||||||
|
allowReselect={allowReselect}
|
||||||
|
onSessionSelected={onSessionSelected}
|
||||||
|
isSessionSearchOpen={isSessionSearchOpen}
|
||||||
|
sessionSearchQuery={sessionSearchQuery}
|
||||||
|
setSessionSearchQuery={setSessionSearchQuery}
|
||||||
|
setIsSessionSearchOpen={setIsSessionSearchOpen}
|
||||||
|
deleteSessionConfirm={deleteSessionConfirm}
|
||||||
|
setDeleteSessionConfirm={setDeleteSessionConfirm}
|
||||||
|
startFolderRename={startFolderRename}
|
||||||
|
setCopiedSessionId={setCopiedSessionId}
|
||||||
|
mobileVariant={mobileVariant}
|
||||||
|
alwaysShowActions={alwaysShowActions}
|
||||||
|
depth={depth + 1}
|
||||||
|
{...childContext}
|
||||||
|
renderExtras={childRenderExtrasFor?.(child)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SessionNodeItem>
|
||||||
|
{deleteSessionConfirm?.session.id === node.session.id ? <SessionDeleteConfirmDialog
|
||||||
|
value={deleteSessionConfirm}
|
||||||
|
setValue={setDeleteSessionConfirm}
|
||||||
|
showDeletionDialog={showDeletionDialog}
|
||||||
|
setShowDeletionDialog={setShowDeletionDialog}
|
||||||
|
onConfirm={sessionActions.confirmDeleteSession}
|
||||||
|
/> : null}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||||
|
import { useNotificationStore } from '@/sync/notification-store';
|
||||||
|
import { useCollapsedSessionActivityState } from './collapsedActivityIndicator';
|
||||||
|
import type { SessionNode } from '../types';
|
||||||
|
import { installHookTestDom } from '../test-utils/testDom';
|
||||||
|
|
||||||
|
// SAFETY: the fixture supplies the minimal SDK identity used by the selector.
|
||||||
|
const node = (id: string): SessionNode => ({ session: { id } as Session, children: [], worktree: null });
|
||||||
|
|
||||||
|
describe('collapsed activity scalar selector', () => {
|
||||||
|
test('does not rerender for unrelated updates and rerenders for relevant scalar changes', async () => {
|
||||||
|
const dom = installHookTestDom();
|
||||||
|
const root = createRoot(dom.container);
|
||||||
|
useGlobalSessionStatusStore.setState({ statusById: new Map() });
|
||||||
|
useNotificationStore.setState({
|
||||||
|
list: [],
|
||||||
|
index: { session: { unseenCount: {}, unseenHasError: {} }, project: { unseenCount: {}, unseenHasError: {} } },
|
||||||
|
});
|
||||||
|
type ActivityCapture = { renders: number; state: string | null };
|
||||||
|
const capture: ActivityCapture = { renders: 0, state: null };
|
||||||
|
const Harness = () => {
|
||||||
|
capture.renders += 1;
|
||||||
|
capture.state = useCollapsedSessionActivityState({ nodes: [node('relevant')], includeUnreadSubtasks: true });
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(React.createElement(Harness)));
|
||||||
|
const initialRenders = capture.renders;
|
||||||
|
await act(async () => useGlobalSessionStatusStore.setState({
|
||||||
|
statusById: new Map([['unrelated', { status: { type: 'busy' }, directory: '/other' }]]),
|
||||||
|
}));
|
||||||
|
await act(async () => useNotificationStore.getState().append({
|
||||||
|
type: 'turn-complete', session: 'unrelated', time: Date.now(), viewed: false,
|
||||||
|
}));
|
||||||
|
expect(capture.renders).toBe(initialRenders);
|
||||||
|
|
||||||
|
await act(async () => useNotificationStore.getState().append({
|
||||||
|
type: 'turn-complete', session: 'relevant', time: Date.now(), viewed: false,
|
||||||
|
}));
|
||||||
|
expect(capture.state).toBe('unread');
|
||||||
|
const unreadRenders = capture.renders;
|
||||||
|
await act(async () => useGlobalSessionStatusStore.setState({
|
||||||
|
statusById: new Map([['relevant', { status: { type: 'busy' }, directory: '/workspace' }]]),
|
||||||
|
}));
|
||||||
|
expect(capture.state).toBe('active');
|
||||||
|
expect(capture.renders).toBe(unreadRenders + 1);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
useGlobalSessionStatusStore.setState({ statusById: new Map() });
|
||||||
|
useNotificationStore.setState({
|
||||||
|
list: [],
|
||||||
|
index: { session: { unseenCount: {}, unseenHasError: {} }, project: { unseenCount: {}, unseenHasError: {} } },
|
||||||
|
});
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import type { Session } from '@opencode-ai/sdk/v2';
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
import { getSessionNodesActivityState } from './collapsedActivityState';
|
import { getSessionNodesActivityState } from './collapsedActivityIndicator';
|
||||||
import type { SessionNode } from './types';
|
import type { SessionNode } from '../types';
|
||||||
|
|
||||||
|
// SAFETY: the fixture supplies the minimal SDK identity fields used by the activity projection.
|
||||||
const node = (id: string, parentID?: string, children: SessionNode[] = []): SessionNode => ({
|
const node = (id: string, parentID?: string, children: SessionNode[] = []): SessionNode => ({
|
||||||
session: { id, parentID } as Session,
|
session: { id, parentID } as Session,
|
||||||
children,
|
children,
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||||
|
import { useNotificationStore } from '@/sync/notification-store';
|
||||||
|
import type { SessionNode } from '../types';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export type CollapsedActivityState = 'active' | 'unread' | null;
|
||||||
|
|
||||||
|
const mergeCollapsedActivityStates = (
|
||||||
|
current: CollapsedActivityState,
|
||||||
|
next: CollapsedActivityState,
|
||||||
|
): CollapsedActivityState => {
|
||||||
|
if (current === 'active' || next === 'active') return 'active';
|
||||||
|
if (current === 'unread' || next === 'unread') return 'unread';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSessionNodeActivityState = (
|
||||||
|
node: SessionNode,
|
||||||
|
activeSessionIds: Set<string>,
|
||||||
|
unreadSessionIds: Set<string>,
|
||||||
|
includeUnreadSubtasks: boolean,
|
||||||
|
): CollapsedActivityState => {
|
||||||
|
if (activeSessionIds.has(node.session.id)) return 'active';
|
||||||
|
|
||||||
|
let state: CollapsedActivityState = null;
|
||||||
|
// SAFETY: SessionNode sessions are SDK Session records; parentID is the optional hierarchy field.
|
||||||
|
const isSubtask = Boolean((node.session as Session & { parentID?: string | null }).parentID);
|
||||||
|
if (unreadSessionIds.has(node.session.id) && (includeUnreadSubtasks || !isSubtask)) state = 'unread';
|
||||||
|
|
||||||
|
for (const child of node.children) {
|
||||||
|
state = mergeCollapsedActivityStates(
|
||||||
|
state,
|
||||||
|
getSessionNodeActivityState(child, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
||||||
|
);
|
||||||
|
if (state === 'active') return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSessionNodesActivityState = (
|
||||||
|
nodes: SessionNode[],
|
||||||
|
activeSessionIds: Set<string>,
|
||||||
|
unreadSessionIds: Set<string>,
|
||||||
|
includeUnreadSubtasks: boolean,
|
||||||
|
): CollapsedActivityState => {
|
||||||
|
let state: CollapsedActivityState = null;
|
||||||
|
for (const node of nodes) {
|
||||||
|
state = mergeCollapsedActivityStates(
|
||||||
|
state,
|
||||||
|
getSessionNodeActivityState(node, activeSessionIds, unreadSessionIds, includeUnreadSubtasks),
|
||||||
|
);
|
||||||
|
if (state === 'active') return state;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CollapsedActivityIndicator({
|
||||||
|
state,
|
||||||
|
activeLabel,
|
||||||
|
unreadLabel,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
state: Exclude<CollapsedActivityState, null>;
|
||||||
|
activeLabel: string;
|
||||||
|
unreadLabel: string;
|
||||||
|
className?: string;
|
||||||
|
}): React.ReactNode {
|
||||||
|
const label = state === 'active' ? activeLabel : unreadLabel;
|
||||||
|
// Aggregate rows carry the dot only; the elapsed counter is per session and
|
||||||
|
// has no meaning for a collapsed group that may hold several running turns.
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'h-1.5 w-1.5 shrink-0 rounded-full',
|
||||||
|
state === 'active' ? 'bg-primary' : 'bg-[var(--status-info)]',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={label}
|
||||||
|
title={label}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionActivityProps = {
|
||||||
|
nodes: SessionNode[];
|
||||||
|
includeUnreadSubtasks: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectActivityIds = (nodes: SessionNode[], includeUnreadSubtasks: boolean) => {
|
||||||
|
const active = new Set<string>();
|
||||||
|
const unread = new Set<string>();
|
||||||
|
const visit = (node: SessionNode, isSubtask: boolean): void => {
|
||||||
|
active.add(node.session.id);
|
||||||
|
if (!isSubtask || includeUnreadSubtasks) unread.add(node.session.id);
|
||||||
|
node.children.forEach((child) => visit(child, true));
|
||||||
|
};
|
||||||
|
nodes.forEach((node) => visit(node, false));
|
||||||
|
return { active, unread };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCollapsedSessionActivityState = ({
|
||||||
|
nodes,
|
||||||
|
includeUnreadSubtasks,
|
||||||
|
enabled = true,
|
||||||
|
}: SessionActivityProps & { enabled?: boolean }): CollapsedActivityState => {
|
||||||
|
const ids = React.useMemo(() => collectActivityIds(nodes, includeUnreadSubtasks), [includeUnreadSubtasks, nodes]);
|
||||||
|
const active = useGlobalSessionStatusStore(React.useCallback((state): CollapsedActivityState => {
|
||||||
|
if (!enabled) return null;
|
||||||
|
for (const sessionId of ids.active) {
|
||||||
|
const status = state.statusById.get(sessionId)?.status.type;
|
||||||
|
if (status === 'busy' || status === 'retry') return 'active';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [enabled, ids.active]));
|
||||||
|
const unread = useNotificationStore(React.useCallback((state): CollapsedActivityState => {
|
||||||
|
if (!enabled) return null;
|
||||||
|
for (const sessionId of ids.unread) {
|
||||||
|
if ((state.index.session.unseenCount[sessionId] ?? 0) > 0) return 'unread';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [enabled, ids.unread]));
|
||||||
|
return active ?? unread;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CollapsedSessionActivityIndicator: React.FC<SessionActivityProps> = ({ nodes, includeUnreadSubtasks }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const resolved = useCollapsedSessionActivityState({ nodes, includeUnreadSubtasks });
|
||||||
|
if (!resolved) return null;
|
||||||
|
return <CollapsedActivityIndicator
|
||||||
|
state={resolved}
|
||||||
|
activeLabel={t('sessions.sidebar.session.status.active')}
|
||||||
|
unreadLabel={t('sessions.sidebar.session.status.unread')}
|
||||||
|
/>;
|
||||||
|
};
|
||||||
+1
-1
@@ -3,7 +3,7 @@ import type { Session } from '@opencode-ai/sdk/v2';
|
|||||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||||
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
||||||
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
import { computeNodeStructureKey, nodeHasPinnedMembershipChange, selectFolderRootNodes, selectQuestionBadgeSessionScopes } from './sessionNodeItemUtils';
|
||||||
import type { SessionNode } from './types';
|
import type { SessionNode } from '../types';
|
||||||
|
|
||||||
const session = (id: string, title: string): Session => ({
|
const session = (id: string, title: string): Session => ({
|
||||||
id,
|
id,
|
||||||
+112
-2
@@ -2,7 +2,7 @@ import { getRuntimeKey } from '@/lib/runtime-switch';
|
|||||||
import { normalizePath } from '@/lib/pathNormalization';
|
import { normalizePath } from '@/lib/pathNormalization';
|
||||||
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
import { resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||||
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
import { getPinnedSessionKey } from '@/stores/useSessionPinnedStore';
|
||||||
import type { SessionNode } from './types';
|
import type { SessionNode } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-row render extras precomputed once per group render and threaded down to
|
* Per-row render extras precomputed once per group render and threaded down to
|
||||||
@@ -127,7 +127,117 @@ export const selectFolderRootNodes = (
|
|||||||
parentID = (parentNode?.session as (SessionNode['session'] & { parentID?: string | null }) | undefined)?.parentID ?? null;
|
parentID = (parentNode?.session as (SessionNode['session'] & { parentID?: string | null }) | undefined)?.parentID ?? null;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
type FolderHierarchyEntry = {
|
||||||
|
id: string;
|
||||||
|
parentId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preserve stored folder order while projecting every disconnected or cyclic
|
||||||
|
* component from a deterministic root. The persisted parent links stay as-is.
|
||||||
|
*/
|
||||||
|
export const normalizeFolderRoots = <T extends FolderHierarchyEntry>(folders: readonly T[]): T[] => {
|
||||||
|
const folderById = new Map(folders.map((folder) => [folder.id, folder]));
|
||||||
|
const childrenByParentId = new Map<string, T[]>();
|
||||||
|
for (const folder of folders) {
|
||||||
|
if (!folder.parentId || !folderById.has(folder.parentId)) continue;
|
||||||
|
const children = childrenByParentId.get(folder.parentId) ?? [];
|
||||||
|
children.push(folder);
|
||||||
|
childrenByParentId.set(folder.parentId, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const roots: T[] = [];
|
||||||
|
const addRoot = (folder: T): void => {
|
||||||
|
if (visited.has(folder.id)) return;
|
||||||
|
roots.push(folder);
|
||||||
|
const stack = [folder.id];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const id = stack.pop();
|
||||||
|
if (!id || visited.has(id)) continue;
|
||||||
|
visited.add(id);
|
||||||
|
for (const child of childrenByParentId.get(id) ?? []) stack.push(child.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
folders.forEach((folder) => {
|
||||||
|
if (!folder.parentId || !folderById.has(folder.parentId)) addRoot(folder);
|
||||||
|
});
|
||||||
|
folders.forEach(addRoot);
|
||||||
|
return roots;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FolderProjectionEntry = FolderHierarchyEntry & {
|
||||||
|
name: string;
|
||||||
|
nodeCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FolderProjectionOptions = {
|
||||||
|
archivedBucket: boolean;
|
||||||
|
searchQuery: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectFolderIdsForProjection = (
|
||||||
|
entries: readonly FolderProjectionEntry[],
|
||||||
|
options: FolderProjectionOptions,
|
||||||
|
): Set<string> => {
|
||||||
|
const entryById = new Map(entries.map((entry) => [entry.id, entry]));
|
||||||
|
const childIdsByParentId = new Map<string, string[]>();
|
||||||
|
const malformedIds = new Set<string>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.parentId && !entryById.has(entry.parentId)) {
|
||||||
|
malformedIds.add(entry.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.parentId) {
|
||||||
|
const children = childIdsByParentId.get(entry.parentId) ?? [];
|
||||||
|
children.push(entry.id);
|
||||||
|
childIdsByParentId.set(entry.parentId, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
const visitedParents = new Set<string>();
|
||||||
|
let currentId: string | null | undefined = entry.id;
|
||||||
|
while (currentId) {
|
||||||
|
if (visitedParents.has(currentId)) {
|
||||||
|
malformedIds.add(entry.id);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
visitedParents.add(currentId);
|
||||||
|
currentId = entryById.get(currentId)?.parentId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keptIds = new Set<string>();
|
||||||
|
const visitingIds = new Set<string>();
|
||||||
|
const shouldKeep = (folderId: string): boolean => {
|
||||||
|
if (keptIds.has(folderId)) return true;
|
||||||
|
if (visitingIds.has(folderId)) return false;
|
||||||
|
|
||||||
|
const entry = entryById.get(folderId);
|
||||||
|
if (!entry) return false;
|
||||||
|
visitingIds.add(folderId);
|
||||||
|
|
||||||
|
let keep = malformedIds.has(folderId);
|
||||||
|
if (!keep && options.archivedBucket && entry.nodeCount === 0) {
|
||||||
|
// Preserve the archived empty-folder rule: search does not make an
|
||||||
|
// empty folder visible unless a descendant has archived content.
|
||||||
|
keep = (childIdsByParentId.get(folderId) ?? []).some(shouldKeep);
|
||||||
|
} else {
|
||||||
|
if (!keep && !options.searchQuery) keep = true;
|
||||||
|
if (!keep && (entry.nodeCount > 0 || entry.name.toLowerCase().includes(options.searchQuery))) keep = true;
|
||||||
|
if (!keep) keep = (childIdsByParentId.get(folderId) ?? []).some(shouldKeep);
|
||||||
|
}
|
||||||
|
|
||||||
|
visitingIds.delete(folderId);
|
||||||
|
if (keep) keptIds.add(folderId);
|
||||||
|
return keep;
|
||||||
|
};
|
||||||
|
|
||||||
|
entries.forEach((entry) => shouldKeep(entry.id));
|
||||||
|
return new Set(entries.filter((entry) => keptIds.has(entry.id)).map((entry) => entry.id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessionObjectVersions = new WeakMap<object, number>();
|
const sessionObjectVersions = new WeakMap<object, number>();
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { SESSION_EXPANDED_STORAGE_KEY, useExpandedParents } from './useExpandedParents';
|
||||||
|
import { installHookTestDom } from '../test-utils/testDom';
|
||||||
|
|
||||||
|
const createStorage = (initial: string | null = null, failWrites = false): Storage => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
if (initial !== null) values.set(SESSION_EXPANDED_STORAGE_KEY, initial);
|
||||||
|
return {
|
||||||
|
getItem: (key) => values.get(key) ?? null,
|
||||||
|
setItem: (key, value) => {
|
||||||
|
if (failWrites) throw new Error('write failed');
|
||||||
|
values.set(key, value);
|
||||||
|
},
|
||||||
|
removeItem: (key) => { values.delete(key); },
|
||||||
|
clear: () => values.clear(),
|
||||||
|
key: (index) => [...values.keys()][index] ?? null,
|
||||||
|
get length() { return values.size; },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ExpandedParentsCapture = { value?: ReturnType<typeof useExpandedParents> };
|
||||||
|
|
||||||
|
const mountHook = async (storage: Storage) => {
|
||||||
|
const dom = installHookTestDom(storage);
|
||||||
|
const root = createRoot(dom.container);
|
||||||
|
const capture: ExpandedParentsCapture = {};
|
||||||
|
const Harness = () => {
|
||||||
|
capture.value = useExpandedParents();
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
await act(async () => root.render(React.createElement(Harness)));
|
||||||
|
return { capture, root, dom };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parent expansion persistence', () => {
|
||||||
|
test('hydrates the complete v3 set and preserves unknown/context-isolated entries when toggling', async () => {
|
||||||
|
const initial = [
|
||||||
|
'project:active:parent-a',
|
||||||
|
'project:archived:parent-b',
|
||||||
|
'recent:active:parent-a',
|
||||||
|
'unknown:future:value',
|
||||||
|
];
|
||||||
|
const storage = createStorage(JSON.stringify(initial));
|
||||||
|
const mounted = await mountHook(storage);
|
||||||
|
try {
|
||||||
|
expect([...mounted.capture.value!.expandedParents]).toEqual(initial);
|
||||||
|
await act(async () => mounted.capture.value!.toggleParent('project:active:parent-a'));
|
||||||
|
expect(JSON.parse(storage.getItem(SESSION_EXPANDED_STORAGE_KEY) ?? 'null')).toEqual(initial.slice(1));
|
||||||
|
} finally {
|
||||||
|
await act(async () => mounted.root.unmount());
|
||||||
|
mounted.dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not write missing or malformed storage during initialization', async () => {
|
||||||
|
for (const initial of [null, '{malformed', JSON.stringify(['valid', 2])]) {
|
||||||
|
const storage = createStorage(initial);
|
||||||
|
const mounted = await mountHook(storage);
|
||||||
|
try {
|
||||||
|
expect(mounted.capture.value!.expandedParents.size).toBe(0);
|
||||||
|
expect(storage.getItem(SESSION_EXPANDED_STORAGE_KEY)).toBe(initial);
|
||||||
|
} finally {
|
||||||
|
await act(async () => mounted.root.unmount());
|
||||||
|
mounted.dom.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leaves durable data unchanged on write failure and rereads it on remount', async () => {
|
||||||
|
const raw = JSON.stringify(['recent:active:parent-a']);
|
||||||
|
const storage = createStorage(raw, true);
|
||||||
|
const first = await mountHook(storage);
|
||||||
|
await act(async () => first.capture.value!.toggleParent('project:active:parent-b'));
|
||||||
|
expect(first.capture.value!.expandedParents).toEqual(new Set([
|
||||||
|
'recent:active:parent-a',
|
||||||
|
'project:active:parent-b',
|
||||||
|
]));
|
||||||
|
expect(storage.getItem(SESSION_EXPANDED_STORAGE_KEY)).toBe(raw);
|
||||||
|
await act(async () => first.root.unmount());
|
||||||
|
first.dom.restore();
|
||||||
|
|
||||||
|
const second = await mountHook(storage);
|
||||||
|
try {
|
||||||
|
expect(second.capture.value!.expandedParents).toEqual(new Set(['recent:active:parent-a']));
|
||||||
|
} finally {
|
||||||
|
await act(async () => second.root.unmount());
|
||||||
|
second.dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { toggleExpandedParentKey } from '../utils';
|
||||||
|
|
||||||
|
export const SESSION_EXPANDED_STORAGE_KEY = 'oc.sessions.expandedParents.v3';
|
||||||
|
|
||||||
|
const expandedParentsSchema = z.array(z.string());
|
||||||
|
|
||||||
|
const readExpandedParents = (): Set<string> => {
|
||||||
|
try {
|
||||||
|
const raw = globalThis.localStorage.getItem(SESSION_EXPANDED_STORAGE_KEY);
|
||||||
|
if (raw === null) return new Set();
|
||||||
|
const parsed = expandedParentsSchema.safeParse(JSON.parse(raw));
|
||||||
|
return parsed.success ? new Set(parsed.data) : new Set();
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useExpandedParents = () => {
|
||||||
|
const [expandedParents, setExpandedParents] = React.useState(readExpandedParents);
|
||||||
|
const expandedParentsRef = React.useRef(expandedParents);
|
||||||
|
expandedParentsRef.current = expandedParents;
|
||||||
|
|
||||||
|
const toggleParent = React.useCallback((key: string) => {
|
||||||
|
const next = toggleExpandedParentKey(expandedParentsRef.current, key);
|
||||||
|
expandedParentsRef.current = next;
|
||||||
|
setExpandedParents(next);
|
||||||
|
try {
|
||||||
|
globalThis.localStorage.setItem(SESSION_EXPANDED_STORAGE_KEY, JSON.stringify([...next]));
|
||||||
|
} catch {
|
||||||
|
// The mounted list keeps the user's change; a remount rereads durable storage.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { expandedParents, toggleParent };
|
||||||
|
};
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import React, { act } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import type { Session } from '@opencode-ai/sdk/v2';
|
||||||
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
import { I18nProvider } from '@/lib/i18n';
|
||||||
|
import type { DeleteSessionConfirmState } from '../shell/ConfirmDialogs';
|
||||||
|
import { useSessionActions } from './useSessionActions';
|
||||||
|
import { installHookTestDom } from '../test-utils/testDom';
|
||||||
|
|
||||||
|
const session = (id: string): Session => ({
|
||||||
|
id,
|
||||||
|
slug: id,
|
||||||
|
projectID: 'project',
|
||||||
|
title: id,
|
||||||
|
version: '1',
|
||||||
|
directory: '/workspace',
|
||||||
|
time: { created: 1, updated: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('explicit session row behavior', () => {
|
||||||
|
test('shares edit and menu state across project and Recent render contexts', async () => {
|
||||||
|
const dom = installHookTestDom();
|
||||||
|
const root = createRoot(dom.container);
|
||||||
|
type SharedRowCapture = {
|
||||||
|
actions?: ReturnType<typeof useSessionActions>;
|
||||||
|
editingId?: string | null;
|
||||||
|
editTitle?: string;
|
||||||
|
menuKey?: string | null;
|
||||||
|
setMenuKey?: (key: string | null) => void;
|
||||||
|
project?: { editingId: string | null; editTitle: string; menuKey: string | null };
|
||||||
|
recent?: { editingId: string | null; editTitle: string; menuKey: string | null };
|
||||||
|
};
|
||||||
|
const capture: SharedRowCapture = {};
|
||||||
|
const RowConsumer = ({ context, editingId, editTitle, menuKey }: {
|
||||||
|
context: 'project' | 'recent';
|
||||||
|
editingId: string | null;
|
||||||
|
editTitle: string;
|
||||||
|
menuKey: string | null;
|
||||||
|
}) => {
|
||||||
|
capture[context] = { editingId, editTitle, menuKey };
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
const Harness = () => {
|
||||||
|
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||||
|
const [editTitle, setEditTitle] = React.useState('');
|
||||||
|
const [menuKey, setMenuKey] = React.useState<string | null>(null);
|
||||||
|
const [confirmation, setConfirmation] = React.useState<DeleteSessionConfirmState>(null);
|
||||||
|
capture.actions = useSessionActions({
|
||||||
|
mobileVariant: false,
|
||||||
|
allowReselect: false,
|
||||||
|
isSessionSearchOpen: false,
|
||||||
|
sessionSearchQuery: '',
|
||||||
|
setSessionSearchQuery: () => undefined,
|
||||||
|
setIsSessionSearchOpen: () => undefined,
|
||||||
|
descendantIds: [],
|
||||||
|
showDeletionDialog: true,
|
||||||
|
setDeleteSessionConfirm: setConfirmation,
|
||||||
|
deleteSessionConfirm: confirmation,
|
||||||
|
editingId,
|
||||||
|
setEditingId,
|
||||||
|
editTitle,
|
||||||
|
setEditTitle,
|
||||||
|
copiedSessionId: null,
|
||||||
|
setCopiedSessionId: () => undefined,
|
||||||
|
});
|
||||||
|
capture.editingId = editingId;
|
||||||
|
capture.editTitle = editTitle;
|
||||||
|
capture.menuKey = menuKey;
|
||||||
|
capture.setMenuKey = setMenuKey;
|
||||||
|
return React.createElement(React.Fragment, null,
|
||||||
|
React.createElement(RowConsumer, { context: 'project', editingId, editTitle, menuKey }),
|
||||||
|
React.createElement(RowConsumer, { context: 'recent', editingId, editTitle, menuKey }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(React.createElement(I18nProvider, null, React.createElement(Harness))));
|
||||||
|
await act(async () => capture.actions!.handleSessionDoubleClick('same-session', 'Shared title'));
|
||||||
|
expect(capture.editingId).toBe('same-session');
|
||||||
|
expect(capture.editTitle).toBe('Shared title');
|
||||||
|
expect(capture.project).toEqual(capture.recent);
|
||||||
|
await act(async () => capture.setMenuKey!('recent:active:same-session'));
|
||||||
|
expect(capture.menuKey).toBe('recent:active:same-session');
|
||||||
|
expect(capture.project).toEqual(capture.recent);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executes the immutable descendant snapshot captured when confirmation opens', async () => {
|
||||||
|
const dom = installHookTestDom();
|
||||||
|
const root = createRoot(dom.container);
|
||||||
|
const original = useSessionUIStore.getState();
|
||||||
|
const archivedCalls: string[][] = [];
|
||||||
|
useSessionUIStore.setState({
|
||||||
|
archiveSessions: async (ids) => {
|
||||||
|
archivedCalls.push(ids);
|
||||||
|
return { archivedIds: ids, failedIds: [] };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const descendants = ['child-a', 'child-b'];
|
||||||
|
type ConfirmationCapture = {
|
||||||
|
actions?: ReturnType<typeof useSessionActions>;
|
||||||
|
confirmation?: DeleteSessionConfirmState;
|
||||||
|
};
|
||||||
|
const capture: ConfirmationCapture = {};
|
||||||
|
const Harness = () => {
|
||||||
|
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||||
|
const [editTitle, setEditTitle] = React.useState('');
|
||||||
|
const [confirmation, setConfirmation] = React.useState<DeleteSessionConfirmState>(null);
|
||||||
|
capture.confirmation = confirmation;
|
||||||
|
capture.actions = useSessionActions({
|
||||||
|
mobileVariant: false,
|
||||||
|
allowReselect: false,
|
||||||
|
isSessionSearchOpen: false,
|
||||||
|
sessionSearchQuery: '',
|
||||||
|
setSessionSearchQuery: () => undefined,
|
||||||
|
setIsSessionSearchOpen: () => undefined,
|
||||||
|
descendantIds: descendants,
|
||||||
|
showDeletionDialog: true,
|
||||||
|
setDeleteSessionConfirm: setConfirmation,
|
||||||
|
deleteSessionConfirm: confirmation,
|
||||||
|
editingId,
|
||||||
|
setEditingId,
|
||||||
|
editTitle,
|
||||||
|
setEditTitle,
|
||||||
|
copiedSessionId: null,
|
||||||
|
setCopiedSessionId: () => undefined,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await act(async () => root.render(React.createElement(I18nProvider, null, React.createElement(Harness))));
|
||||||
|
await act(async () => capture.actions!.handleDeleteSession(session('root')));
|
||||||
|
expect(capture.confirmation?.descendantIds).toEqual(['child-a', 'child-b']);
|
||||||
|
await act(async () => capture.actions!.confirmDeleteSession());
|
||||||
|
expect(archivedCalls).toEqual([['root', 'child-a', 'child-b']]);
|
||||||
|
} finally {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
useSessionUIStore.setState({ archiveSessions: original.archiveSessions });
|
||||||
|
dom.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+98
-97
@@ -7,20 +7,20 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { streamPerfMark } from '@/stores/utils/streamDebug';
|
import { streamPerfMark } from '@/stores/utils/streamDebug';
|
||||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||||
|
|
||||||
type DeleteSessionConfirmSetter = React.Dispatch<React.SetStateAction<{
|
export type DeleteSessionSource = {
|
||||||
session: Session;
|
|
||||||
descendantCount: number;
|
|
||||||
descendantIds: string[];
|
|
||||||
archivedBucket: boolean;
|
|
||||||
} | null>>;
|
|
||||||
|
|
||||||
type DeleteSessionSource = {
|
|
||||||
archivedBucket?: boolean;
|
archivedBucket?: boolean;
|
||||||
hardDelete?: boolean;
|
hardDelete?: boolean;
|
||||||
/** Bypass the confirmation dialog and delete/archive immediately. */
|
/** Bypass the confirmation dialog and delete/archive immediately. */
|
||||||
skipConfirm?: boolean;
|
skipConfirm?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DeleteSessionConfirmState = {
|
||||||
|
session: Session;
|
||||||
|
descendantCount: number;
|
||||||
|
descendantIds: string[];
|
||||||
|
archivedBucket: boolean;
|
||||||
|
} | null;
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
mobileVariant: boolean;
|
mobileVariant: boolean;
|
||||||
allowReselect: boolean;
|
allowReselect: boolean;
|
||||||
@@ -29,30 +29,55 @@ type Args = {
|
|||||||
sessionSearchQuery: string;
|
sessionSearchQuery: string;
|
||||||
setSessionSearchQuery: (value: string) => void;
|
setSessionSearchQuery: (value: string) => void;
|
||||||
setIsSessionSearchOpen: (open: boolean) => void;
|
setIsSessionSearchOpen: (open: boolean) => void;
|
||||||
setSessionSwitcherOpen: (open: boolean) => void;
|
descendantIds: readonly string[];
|
||||||
setCurrentSession: (sessionId: string | null, directoryHint?: string | null) => void;
|
|
||||||
updateSessionTitle: (id: string, title: string) => Promise<void>;
|
|
||||||
shareSession: (id: string) => Promise<Session | null>;
|
|
||||||
unshareSession: (id: string) => Promise<Session | null>;
|
|
||||||
deleteSession: (id: string) => Promise<boolean>;
|
|
||||||
deleteSessions: (ids: string[]) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
|
||||||
archiveSession: (id: string) => Promise<boolean>;
|
|
||||||
archiveSessions: (ids: string[]) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
|
|
||||||
unarchiveSession: (id: string) => Promise<boolean>;
|
|
||||||
childrenMap: Map<string, Session[]>;
|
|
||||||
showDeletionDialog: boolean;
|
showDeletionDialog: boolean;
|
||||||
setDeleteSessionConfirm: DeleteSessionConfirmSetter;
|
setDeleteSessionConfirm: (value: DeleteSessionConfirmState) => void;
|
||||||
deleteSessionConfirm: { session: Session; descendantCount: number; descendantIds: string[]; archivedBucket: boolean } | null;
|
deleteSessionConfirm: DeleteSessionConfirmState;
|
||||||
setEditingId: (id: string | null) => void;
|
setEditingId: (id: string | null) => void;
|
||||||
setEditTitle: (value: string) => void;
|
setEditTitle: (value: string) => void;
|
||||||
editingId: string | null;
|
editingId: string | null;
|
||||||
editTitle: string;
|
editTitle: string;
|
||||||
|
copiedSessionId: string | null;
|
||||||
|
setCopiedSessionId: (sessionId: string | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSessionActions = (args: Args) => {
|
export const useSessionActions = (args: Args) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [copiedSessionId, setCopiedSessionId] = React.useState<string | null>(null);
|
|
||||||
const copyTimeout = React.useRef<number | null>(null);
|
const copyTimeout = React.useRef<number | null>(null);
|
||||||
|
const editingIdRef = React.useRef(args.editingId);
|
||||||
|
const editTitleRef = React.useRef(args.editTitle);
|
||||||
|
const deleteSessionConfirmRef = React.useRef(args.deleteSessionConfirm);
|
||||||
|
editingIdRef.current = args.editingId;
|
||||||
|
editTitleRef.current = args.editTitle;
|
||||||
|
deleteSessionConfirmRef.current = args.deleteSessionConfirm;
|
||||||
|
|
||||||
|
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
|
||||||
|
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||||
|
const updateSessionTitle = useSessionUIStore((state) => state.updateSessionTitle);
|
||||||
|
const shareSession = useSessionUIStore((state) => state.shareSession);
|
||||||
|
const unshareSession = useSessionUIStore((state) => state.unshareSession);
|
||||||
|
const deleteSession = useSessionUIStore((state) => state.deleteSession);
|
||||||
|
const deleteSessions = useSessionUIStore((state) => state.deleteSessions);
|
||||||
|
const archiveSession = useSessionUIStore((state) => state.archiveSession);
|
||||||
|
const archiveSessions = useSessionUIStore((state) => state.archiveSessions);
|
||||||
|
const unarchiveSession = useSessionUIStore((state) => state.unarchiveSession);
|
||||||
|
|
||||||
|
const {
|
||||||
|
mobileVariant,
|
||||||
|
allowReselect,
|
||||||
|
onSessionSelected,
|
||||||
|
isSessionSearchOpen,
|
||||||
|
sessionSearchQuery,
|
||||||
|
setSessionSearchQuery,
|
||||||
|
setIsSessionSearchOpen,
|
||||||
|
descendantIds,
|
||||||
|
showDeletionDialog,
|
||||||
|
setDeleteSessionConfirm,
|
||||||
|
setEditingId,
|
||||||
|
setEditTitle,
|
||||||
|
copiedSessionId,
|
||||||
|
setCopiedSessionId,
|
||||||
|
} = args;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -69,51 +94,52 @@ export const useSessionActions = (args: Args) => {
|
|||||||
// the session is already the current one (no store transition fires).
|
// the session is already the current one (no store transition fires).
|
||||||
useUIStore.getState().closeMainSurfaces();
|
useUIStore.getState().closeMainSurfaces();
|
||||||
const resetSessionSearch = () => {
|
const resetSessionSearch = () => {
|
||||||
if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) {
|
if (!isSessionSearchOpen && sessionSearchQuery.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
args.setSessionSearchQuery('');
|
setSessionSearchQuery('');
|
||||||
args.setIsSessionSearchOpen(false);
|
setIsSessionSearchOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (args.mobileVariant) {
|
if (mobileVariant) {
|
||||||
args.setSessionSwitcherOpen(false);
|
setSessionSwitcherOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sessionId === useSessionUIStore.getState().currentSessionId) {
|
if (sessionId === useSessionUIStore.getState().currentSessionId) {
|
||||||
if (args.allowReselect) {
|
if (allowReselect) {
|
||||||
args.onSessionSelected?.(sessionId);
|
onSessionSelected?.(sessionId);
|
||||||
}
|
}
|
||||||
resetSessionSearch();
|
resetSessionSearch();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
streamPerfMark('navigation.session_state_set');
|
streamPerfMark('navigation.session_state_set');
|
||||||
args.setCurrentSession(sessionId, sessionDirectory ?? null);
|
setCurrentSession(sessionId, sessionDirectory ?? null);
|
||||||
args.onSessionSelected?.(sessionId);
|
onSessionSelected?.(sessionId);
|
||||||
resetSessionSearch();
|
resetSessionSearch();
|
||||||
},
|
},
|
||||||
[args],
|
[allowReselect, isSessionSearchOpen, mobileVariant, onSessionSelected, sessionSearchQuery, setCurrentSession, setIsSessionSearchOpen, setSessionSearchQuery, setSessionSwitcherOpen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSessionDoubleClick = React.useCallback((sessionId: string, sessionTitle: string) => {
|
const handleSessionDoubleClick = React.useCallback((sessionId: string, sessionTitle: string) => {
|
||||||
args.setEditingId(sessionId);
|
setEditingId(sessionId);
|
||||||
args.setEditTitle(sessionTitle);
|
setEditTitle(sessionTitle);
|
||||||
}, [args]);
|
}, [setEditTitle, setEditingId]);
|
||||||
|
|
||||||
const handleSaveEdit = React.useCallback(async (titleOverride?: string) => {
|
const handleSaveEdit = React.useCallback(async (titleOverride?: string) => {
|
||||||
if (!args.editingId) return;
|
const editingId = editingIdRef.current;
|
||||||
const trimmed = (titleOverride ?? args.editTitle).trim();
|
if (!editingId) return;
|
||||||
|
const trimmed = (titleOverride ?? editTitleRef.current).trim();
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
await args.updateSessionTitle(args.editingId, trimmed);
|
await updateSessionTitle(editingId, trimmed);
|
||||||
}
|
}
|
||||||
args.setEditingId(null);
|
setEditingId(null);
|
||||||
args.setEditTitle('');
|
setEditTitle('');
|
||||||
}, [args]);
|
}, [setEditTitle, setEditingId, updateSessionTitle]);
|
||||||
|
|
||||||
const handleCancelEdit = React.useCallback(() => {
|
const handleCancelEdit = React.useCallback(() => {
|
||||||
args.setEditingId(null);
|
setEditingId(null);
|
||||||
args.setEditTitle('');
|
setEditTitle('');
|
||||||
}, [args]);
|
}, [setEditTitle, setEditingId]);
|
||||||
|
|
||||||
const copyShareUrl = React.useCallback(async (url: string, sessionId: string): Promise<boolean> => {
|
const copyShareUrl = React.useCallback(async (url: string, sessionId: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
@@ -132,7 +158,7 @@ export const useSessionActions = (args: Args) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleShareSession = React.useCallback(async (session: Session) => {
|
const handleShareSession = React.useCallback(async (session: Session) => {
|
||||||
const result = await args.shareSession(session.id);
|
const result = await shareSession(session.id);
|
||||||
if (!result?.share?.url) {
|
if (!result?.share?.url) {
|
||||||
toast.error(t('sessions.sidebar.session.share.error'));
|
toast.error(t('sessions.sidebar.session.share.error'));
|
||||||
return;
|
return;
|
||||||
@@ -143,7 +169,7 @@ export const useSessionActions = (args: Args) => {
|
|||||||
? 'sessions.sidebar.session.share.successDescription'
|
? 'sessions.sidebar.session.share.successDescription'
|
||||||
: 'sessions.sidebar.session.share.copyUrlError'),
|
: 'sessions.sidebar.session.share.copyUrlError'),
|
||||||
});
|
});
|
||||||
}, [args, copyShareUrl, t]);
|
}, [copyShareUrl, shareSession, t]);
|
||||||
|
|
||||||
const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => {
|
const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => {
|
||||||
void copyShareUrl(url, sessionId).then((copied) => {
|
void copyShareUrl(url, sessionId).then((copied) => {
|
||||||
@@ -164,37 +190,13 @@ export const useSessionActions = (args: Args) => {
|
|||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
const handleUnshareSession = React.useCallback(async (sessionId: string) => {
|
const handleUnshareSession = React.useCallback(async (sessionId: string) => {
|
||||||
const result = await args.unshareSession(sessionId);
|
const result = await unshareSession(sessionId);
|
||||||
if (result) {
|
if (result) {
|
||||||
toast.success(t('sessions.sidebar.session.unshare.success'));
|
toast.success(t('sessions.sidebar.session.unshare.success'));
|
||||||
} else {
|
} else {
|
||||||
toast.error(t('sessions.sidebar.session.unshare.error'));
|
toast.error(t('sessions.sidebar.session.unshare.error'));
|
||||||
}
|
}
|
||||||
}, [args, t]);
|
}, [t, unshareSession]);
|
||||||
|
|
||||||
const collectDescendants = React.useCallback((sessionId: string): Session[] => {
|
|
||||||
const collected: Session[] = [];
|
|
||||||
const visit = (id: string) => {
|
|
||||||
const children = args.childrenMap.get(id) ?? [];
|
|
||||||
children.forEach((child) => {
|
|
||||||
collected.push(child);
|
|
||||||
visit(child.id);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
visit(sessionId);
|
|
||||||
return collected;
|
|
||||||
}, [args.childrenMap]);
|
|
||||||
|
|
||||||
// Archive cascades to subagents that aren't already archived; hard-delete
|
|
||||||
// cascades to every descendant unconditionally. We collect once and filter
|
|
||||||
// per-action so the dialog count and the executed ID list always agree.
|
|
||||||
const filterDescendantsForAction = React.useCallback(
|
|
||||||
(descendants: Session[], shouldHardDelete: boolean): Session[] => {
|
|
||||||
if (shouldHardDelete) return descendants;
|
|
||||||
return descendants.filter((s) => !s.time?.archived);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const executeDeleteSession = React.useCallback(
|
const executeDeleteSession = React.useCallback(
|
||||||
async (
|
async (
|
||||||
@@ -206,12 +208,12 @@ export const useSessionActions = (args: Args) => {
|
|||||||
// Use the snapshot taken when the dialog opened (if any) so the
|
// Use the snapshot taken when the dialog opened (if any) so the
|
||||||
// executed list matches what the user was told. Fall back to a fresh
|
// executed list matches what the user was told. Fall back to a fresh
|
||||||
// collection for direct-execute (no-dialog) callers.
|
// collection for direct-execute (no-dialog) callers.
|
||||||
const descendantIds = precomputed?.descendantIds
|
const effectiveDescendantIds = precomputed?.descendantIds
|
||||||
?? filterDescendantsForAction(collectDescendants(session.id), shouldHardDelete).map((s) => s.id);
|
?? descendantIds;
|
||||||
if (descendantIds.length === 0) {
|
if (effectiveDescendantIds.length === 0) {
|
||||||
const success = shouldHardDelete
|
const success = shouldHardDelete
|
||||||
? await args.deleteSession(session.id)
|
? await deleteSession(session.id)
|
||||||
: await args.archiveSession(session.id);
|
: await archiveSession(session.id);
|
||||||
if (success) {
|
if (success) {
|
||||||
toast.success(shouldHardDelete
|
toast.success(shouldHardDelete
|
||||||
? t('sessions.sidebar.session.delete.success')
|
? t('sessions.sidebar.session.delete.success')
|
||||||
@@ -224,12 +226,12 @@ export const useSessionActions = (args: Args) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ids = [session.id, ...descendantIds];
|
const ids = [session.id, ...effectiveDescendantIds];
|
||||||
if (shouldHardDelete) {
|
if (shouldHardDelete) {
|
||||||
// Delete root + all descendants individually. If the server
|
// Delete root + all descendants individually. If the server
|
||||||
// cascade-deletes some children before we get to them, 404 is
|
// cascade-deletes some children before we get to them, 404 is
|
||||||
// treated as success by deleteSession and no rollback occurs.
|
// treated as success by deleteSession and no rollback occurs.
|
||||||
const { deletedIds, failedIds } = await args.deleteSessions(ids);
|
const { deletedIds, failedIds } = await deleteSessions(ids);
|
||||||
if (failedIds.length === 0) {
|
if (failedIds.length === 0) {
|
||||||
const totalDeleted = deletedIds.length;
|
const totalDeleted = deletedIds.length;
|
||||||
toast.success(totalDeleted === 1
|
toast.success(totalDeleted === 1
|
||||||
@@ -241,7 +243,7 @@ export const useSessionActions = (args: Args) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { archivedIds, failedIds } = await args.archiveSessions(ids);
|
const { archivedIds, failedIds } = await archiveSessions(ids);
|
||||||
if (archivedIds.length > 0) {
|
if (archivedIds.length > 0) {
|
||||||
toast.success(archivedIds.length === 1
|
toast.success(archivedIds.length === 1
|
||||||
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
|
? t('sessions.sidebar.bulkActions.archivedSingle', { count: archivedIds.length })
|
||||||
@@ -253,51 +255,48 @@ export const useSessionActions = (args: Args) => {
|
|||||||
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
|
: t('sessions.sidebar.bulkActions.failedArchivePlural', { count: failedIds.length }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[args, collectDescendants, filterDescendantsForAction, t],
|
[archiveSession, archiveSessions, deleteSession, deleteSessions, descendantIds, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteSession = React.useCallback(
|
const handleDeleteSession = React.useCallback(
|
||||||
(session: Session, source?: DeleteSessionSource) => {
|
(session: Session, source?: DeleteSessionSource) => {
|
||||||
const shouldHardDelete = source?.archivedBucket === true || source?.hardDelete === true;
|
const shouldHardDelete = source?.archivedBucket === true || source?.hardDelete === true;
|
||||||
const effectiveDescendantIds = filterDescendantsForAction(
|
const effectiveDescendantIds = [...descendantIds];
|
||||||
collectDescendants(session.id),
|
if (!showDeletionDialog || source?.skipConfirm === true) {
|
||||||
shouldHardDelete,
|
|
||||||
).map((s) => s.id);
|
|
||||||
if (!args.showDeletionDialog || source?.skipConfirm === true) {
|
|
||||||
void executeDeleteSession(session, source, { descendantIds: effectiveDescendantIds });
|
void executeDeleteSession(session, source, { descendantIds: effectiveDescendantIds });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
args.setDeleteSessionConfirm({
|
setDeleteSessionConfirm({
|
||||||
session,
|
session,
|
||||||
descendantCount: effectiveDescendantIds.length,
|
descendantCount: effectiveDescendantIds.length,
|
||||||
descendantIds: effectiveDescendantIds,
|
descendantIds: effectiveDescendantIds,
|
||||||
archivedBucket: shouldHardDelete,
|
archivedBucket: shouldHardDelete,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[args, collectDescendants, executeDeleteSession, filterDescendantsForAction],
|
[descendantIds, executeDeleteSession, setDeleteSessionConfirm, showDeletionDialog],
|
||||||
);
|
);
|
||||||
|
|
||||||
const confirmDeleteSession = React.useCallback(async () => {
|
const confirmDeleteSession = React.useCallback(async () => {
|
||||||
if (!args.deleteSessionConfirm) return;
|
const deleteSessionConfirm = deleteSessionConfirmRef.current;
|
||||||
const { session, archivedBucket, descendantIds } = args.deleteSessionConfirm;
|
if (!deleteSessionConfirm) return;
|
||||||
args.setDeleteSessionConfirm(null);
|
const { session, archivedBucket, descendantIds } = deleteSessionConfirm;
|
||||||
|
setDeleteSessionConfirm(null);
|
||||||
await executeDeleteSession(session, { archivedBucket }, { descendantIds });
|
await executeDeleteSession(session, { archivedBucket }, { descendantIds });
|
||||||
}, [args, executeDeleteSession]);
|
}, [executeDeleteSession, setDeleteSessionConfirm]);
|
||||||
|
|
||||||
const handleRestoreSession = React.useCallback(
|
const handleRestoreSession = React.useCallback(
|
||||||
async (session: Session) => {
|
async (session: Session) => {
|
||||||
const success = await args.unarchiveSession(session.id);
|
const success = await unarchiveSession(session.id);
|
||||||
if (success) {
|
if (success) {
|
||||||
toast.success(t('sessions.sidebar.session.restore.success'));
|
toast.success(t('sessions.sidebar.session.restore.success'));
|
||||||
} else {
|
} else {
|
||||||
toast.error(t('sessions.sidebar.session.restore.error'));
|
toast.error(t('sessions.sidebar.session.restore.error'));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[args, t],
|
[t, unarchiveSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return React.useMemo(() => ({
|
||||||
copiedSessionId,
|
|
||||||
handleSessionSelect,
|
handleSessionSelect,
|
||||||
handleSessionDoubleClick,
|
handleSessionDoubleClick,
|
||||||
handleSaveEdit,
|
handleSaveEdit,
|
||||||
@@ -309,5 +308,7 @@ export const useSessionActions = (args: Args) => {
|
|||||||
handleDeleteSession,
|
handleDeleteSession,
|
||||||
handleRestoreSession,
|
handleRestoreSession,
|
||||||
confirmDeleteSession,
|
confirmDeleteSession,
|
||||||
};
|
}), [copiedSessionId, handleCancelEdit, handleCopySessionId, handleCopyShareUrl, handleDeleteSession,
|
||||||
|
handleRestoreSession, handleSaveEdit, handleSessionDoubleClick, handleSessionSelect, handleShareSession,
|
||||||
|
handleUnshareSession, confirmDeleteSession]);
|
||||||
};
|
};
|
||||||
+5
-5
@@ -12,6 +12,7 @@ import { cn } from '@/lib/utils';
|
|||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||||
|
import { useSessionMultiSelectStore } from '@/stores/useSessionMultiSelectStore';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { updateDesktopSettings } from '@/lib/persistence';
|
import { updateDesktopSettings } from '@/lib/persistence';
|
||||||
|
|
||||||
@@ -35,8 +36,6 @@ type Props = {
|
|||||||
searchMatchCount: number;
|
searchMatchCount: number;
|
||||||
collapseAllProjects: () => void;
|
collapseAllProjects: () => void;
|
||||||
expandAllProjects: () => void;
|
expandAllProjects: () => void;
|
||||||
selectionModeEnabled: boolean;
|
|
||||||
onToggleSelectionMode: () => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SidebarHeader(props: Props): React.ReactNode {
|
export function SidebarHeader(props: Props): React.ReactNode {
|
||||||
@@ -61,10 +60,11 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
|||||||
searchMatchCount,
|
searchMatchCount,
|
||||||
collapseAllProjects,
|
collapseAllProjects,
|
||||||
expandAllProjects,
|
expandAllProjects,
|
||||||
selectionModeEnabled,
|
|
||||||
onToggleSelectionMode,
|
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const selectionModeEnabled = useSessionMultiSelectStore((state) => state.enabled);
|
||||||
|
const toggleSelectionMode = useSessionMultiSelectStore((state) => state.toggleMode);
|
||||||
|
|
||||||
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
|
const showRecentSection = useSessionDisplayStore((state) => state.showRecentSection);
|
||||||
const toggleRecentSection = useSessionDisplayStore((state) => state.toggleRecentSection);
|
const toggleRecentSection = useSessionDisplayStore((state) => state.toggleRecentSection);
|
||||||
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
|
const projectSortOrder = useSessionDisplayStore((state) => state.projectSortOrder);
|
||||||
@@ -168,7 +168,7 @@ export function SidebarHeader(props: Props): React.ReactNode {
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleSelectionMode}
|
onClick={toggleSelectionMode}
|
||||||
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent', selectionModeEnabled && 'bg-interactive-hover text-primary')}
|
className={cn(headerActionButtonClass, 'text-muted-foreground hover:text-foreground hover:bg-transparent', selectionModeEnabled && 'bg-interactive-hover text-primary')}
|
||||||
aria-label={selectionModeEnabled
|
aria-label={selectionModeEnabled
|
||||||
? t('sessions.sidebar.header.actions.exitSelection')
|
? t('sessions.sidebar.header.actions.exitSelection')
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
class ElementStub implements Partial<Element> {
|
||||||
|
nodeType = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DocumentStub = {
|
||||||
|
nodeType: number;
|
||||||
|
defaultView: typeof globalThis;
|
||||||
|
activeElement: null;
|
||||||
|
addEventListener: () => void;
|
||||||
|
removeEventListener: () => void;
|
||||||
|
querySelectorAll: () => never[];
|
||||||
|
createElement: (tagName: string) => Element;
|
||||||
|
createElementNS: (namespace: string, tagName: string) => Element;
|
||||||
|
createTextNode: (text: string) => Text;
|
||||||
|
documentElement?: Element;
|
||||||
|
body?: Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GlobalValue = typeof globalThis | typeof ElementStub | DocumentStub | Storage | boolean;
|
||||||
|
|
||||||
|
export const installHookTestDom = (storage?: Storage) => {
|
||||||
|
const descriptors = new Map<string, PropertyDescriptor | undefined>();
|
||||||
|
const setGlobal = (name: string, value: GlobalValue) => {
|
||||||
|
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||||
|
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
|
||||||
|
};
|
||||||
|
const createElement = (ownerDocument: DocumentStub): Element => {
|
||||||
|
// SAFETY: React only uses these DOM identity, child-list, and listener methods in this test fixture.
|
||||||
|
const element = Object.create(ElementStub.prototype) as Element;
|
||||||
|
Object.assign(element, {
|
||||||
|
nodeType: 1,
|
||||||
|
tagName: 'DIV',
|
||||||
|
nodeName: 'DIV',
|
||||||
|
namespaceURI: 'http://www.w3.org/1999/xhtml',
|
||||||
|
ownerDocument,
|
||||||
|
parentNode: null,
|
||||||
|
parentElement: null,
|
||||||
|
childNodes: [],
|
||||||
|
style: { setProperty: () => undefined, getPropertyValue: () => '' },
|
||||||
|
addEventListener: () => undefined,
|
||||||
|
removeEventListener: () => undefined,
|
||||||
|
appendChild<T extends Node>(child: T): T {
|
||||||
|
// SAFETY: every fixture child is a Node supplied by React's host renderer.
|
||||||
|
(this.childNodes as Node[]).push(child);
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
insertBefore<T extends Node>(child: T): T {
|
||||||
|
// SAFETY: every fixture child is a Node supplied by React's host renderer.
|
||||||
|
(this.childNodes as Node[]).push(child);
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
removeChild<T extends Node>(child: T): T {
|
||||||
|
// SAFETY: this fixture stores only Node children from React's host renderer.
|
||||||
|
const children = this.childNodes as Node[];
|
||||||
|
const index = children.indexOf(child);
|
||||||
|
if (index >= 0) children.splice(index, 1);
|
||||||
|
return child;
|
||||||
|
},
|
||||||
|
setAttribute: () => undefined,
|
||||||
|
removeAttribute: () => undefined,
|
||||||
|
getAttribute: () => null,
|
||||||
|
hasAttribute: () => false,
|
||||||
|
contains: () => false,
|
||||||
|
compareDocumentPosition: () => 0,
|
||||||
|
});
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
const documentStub: DocumentStub = {
|
||||||
|
nodeType: 9,
|
||||||
|
defaultView: globalThis,
|
||||||
|
activeElement: null,
|
||||||
|
addEventListener: () => undefined,
|
||||||
|
removeEventListener: () => undefined,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
createElement: () => createElement(documentStub),
|
||||||
|
createElementNS: () => createElement(documentStub),
|
||||||
|
// SAFETY: React only checks the text node identity field in this fixture.
|
||||||
|
createTextNode: () => ({ nodeType: 3 } as Text),
|
||||||
|
};
|
||||||
|
// SAFETY: React's test renderer only inspects this fixture's DOM identity fields and listeners.
|
||||||
|
const container = createElement(documentStub);
|
||||||
|
Object.assign(documentStub, { documentElement: container, body: container });
|
||||||
|
setGlobal('document', documentStub);
|
||||||
|
setGlobal('window', globalThis);
|
||||||
|
if (storage) setGlobal('localStorage', storage);
|
||||||
|
setGlobal('Element', ElementStub);
|
||||||
|
setGlobal('HTMLElement', ElementStub);
|
||||||
|
setGlobal('HTMLIFrameElement', ElementStub);
|
||||||
|
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
return {
|
||||||
|
container,
|
||||||
|
restore: () => {
|
||||||
|
for (const [name, descriptor] of descriptors) {
|
||||||
|
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||||
|
else Reflect.deleteProperty(globalThis, name);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -84,6 +84,19 @@ describe('useSessionFoldersStore folder assignments', () => {
|
|||||||
expect(storageSetCount).toBe(0);
|
expect(storageSetCount).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('bulk cross-scope move clears the former folder membership before assigning the target', () => {
|
||||||
|
const store = useSessionFoldersStore.getState();
|
||||||
|
const source = store.createFolder('/workspace/project', 'Source');
|
||||||
|
const target = store.createFolder('/workspace/project-worktree', 'Target');
|
||||||
|
store.addSessionsToFolder('/workspace/project', source.id, ['ses_1', 'ses_2']);
|
||||||
|
|
||||||
|
store.removeSessionsFromFolders('/workspace/project', ['ses_1', 'ses_2']);
|
||||||
|
store.addSessionsToFolder('/workspace/project-worktree', target.id, ['ses_1', 'ses_2']);
|
||||||
|
|
||||||
|
expect(useSessionFoldersStore.getState().getFoldersForScope('/workspace/project')[0]?.sessionIds).toEqual([]);
|
||||||
|
expect(useSessionFoldersStore.getState().getFoldersForScope('/workspace/project-worktree')[0]?.sessionIds).toEqual(['ses_1', 'ses_2']);
|
||||||
|
});
|
||||||
|
|
||||||
test('restores independent folder snapshots across runtime switches', async () => {
|
test('restores independent folder snapshots across runtime switches', async () => {
|
||||||
useSessionFoldersStore.getState().createFolder('/workspace/project', 'Runtime A');
|
useSessionFoldersStore.getState().createFolder('/workspace/project', 'Runtime A');
|
||||||
await waitForPersist();
|
await waitForPersist();
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ So:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `ChildStoreManager` and child directory stores | Priority-scheduled directory bootstrap plus `session`, `message`, `part`, `permission`, `question`, etc. | One runtime and one store per directory |
|
| `ChildStoreManager` and child directory stores | Priority-scheduled directory bootstrap plus `session`, `message`, `part`, `permission`, `question`, etc. | One runtime and one store per directory |
|
||||||
| `SessionMessageLoader` | Initial message loading, pagination, prefetch, retries, load state, and optimistic reconciliation | One runtime, directory, and session ID |
|
| `SessionMessageLoader` | Initial message loading, pagination, prefetch, retries, load state, and optimistic reconciliation | One runtime, directory, and session ID |
|
||||||
| `global-session-status.ts` | Incremental non-idle session status index reconciled from events and authoritative directory snapshots | All known directories in the active runtime |
|
| `global-session-status.ts` | Incremental non-idle session status index reconciled from events and authoritative directory snapshots, plus a reference-stable active-ID membership collection maintained from the same mutations | All known directories in the active runtime |
|
||||||
| `session-ordering.ts` | Ephemeral lifecycle rank used by every user-visible session list | All known sessions in the active runtime |
|
| `session-ordering.ts` | Ephemeral lifecycle rank used by every user-visible session list | All known sessions in the active runtime |
|
||||||
| `session-activity-timing.ts` | Elapsed time of the running turn and of the turn that just finished, plus the persisted starts that survive a reload | All known sessions in the active runtime |
|
| `session-activity-timing.ts` | Elapsed time of the running turn and of the turn that just finished, plus the persisted starts that survive a reload | All known sessions in the active runtime |
|
||||||
| `session-ui-store.ts` | Session selection, draft lifecycle, one-shot draft-materialization transition identity, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
|
| `session-ui-store.ts` | Session selection, draft lifecycle, one-shot draft-materialization transition identity, abort prompts, worktree metadata, SDK-facing action entrypoints | App UI state |
|
||||||
@@ -63,6 +63,10 @@ The composer compares normalized attachment MIME types with the selected model's
|
|||||||
|
|
||||||
## Session list rules
|
## Session list rules
|
||||||
|
|
||||||
|
### Layout-mounted session-list lifecycle
|
||||||
|
|
||||||
|
`MainLayout` and `VSCodeLayout` each call `useSessionListSync({ isVSCode })` directly and unconditionally, outside Sidebar visibility, responsive, editor, settings, and compact-view branches. The hook selects the real topology inputs, publishes complete directory bootstrap demand through `ChildStoreManager`, refreshes the global list once per layout mount, refreshes topology additions (including all VS Code directories on its first mount), coalesces OpenChamber control events for 500ms, and supplies a memoized complete global active+archived input to authoritative cleanup. MainLayout includes available worktrees; VS Code intentionally excludes them. Sidebar-local `session-created` worktree discovery is separate and full-app-only.
|
||||||
|
|
||||||
### Directory bootstrap scheduling
|
### Directory bootstrap scheduling
|
||||||
|
|
||||||
`ChildStoreManager` is the single owner of directory bootstrap scheduling. Consumers publish demand; they must not start bootstrap from row mount effects.
|
`ChildStoreManager` is the single owner of directory bootstrap scheduling. Consumers publish demand; they must not start bootstrap from row mount effects.
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ beforeEach(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("global session status index", () => {
|
describe("global session status index", () => {
|
||||||
|
const activeSessionIds = (): ReadonlySet<string> => useGlobalSessionStatusStore.getState().activeSessionIds
|
||||||
|
|
||||||
test("preserves full retry status details from live events", () => {
|
test("preserves full retry status details from live events", () => {
|
||||||
applyGlobalSessionStatusEvent("/repo", {
|
applyGlobalSessionStatusEvent("/repo", {
|
||||||
type: "session.status",
|
type: "session.status",
|
||||||
@@ -29,6 +31,62 @@ describe("global session status index", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps active membership stable across active status detail and directory updates", () => {
|
||||||
|
applyGlobalSessionStatusEvent("/repo", {
|
||||||
|
type: "session.status",
|
||||||
|
properties: { sessionID: "session-a", status: { type: "busy" } },
|
||||||
|
} as Event)
|
||||||
|
const before = activeSessionIds()
|
||||||
|
|
||||||
|
applyGlobalSessionStatusEvent("/other-repo", {
|
||||||
|
type: "session.status",
|
||||||
|
properties: { sessionID: "session-a", status: { type: "retry", attempt: 2, message: "waiting" } },
|
||||||
|
} as Event)
|
||||||
|
|
||||||
|
expect(activeSessionIds()).toBe(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("replaces active membership only when a session becomes idle or active", () => {
|
||||||
|
applyGlobalSessionStatusEvent("/repo", {
|
||||||
|
type: "session.status",
|
||||||
|
properties: { sessionID: "session-a", status: { type: "busy" } },
|
||||||
|
} as Event)
|
||||||
|
const active = activeSessionIds()
|
||||||
|
|
||||||
|
applyGlobalSessionStatusEvent("/repo", {
|
||||||
|
type: "session.idle",
|
||||||
|
properties: { sessionID: "session-a" },
|
||||||
|
} as Event)
|
||||||
|
const idle = activeSessionIds()
|
||||||
|
expect(idle).not.toBe(active)
|
||||||
|
expect(idle?.has("session-a")).toBe(false)
|
||||||
|
|
||||||
|
applyGlobalSessionStatusEvent("/repo", {
|
||||||
|
type: "session.status",
|
||||||
|
properties: { sessionID: "session-a", status: { type: "busy" } },
|
||||||
|
} as Event)
|
||||||
|
expect(activeSessionIds()).not.toBe(idle)
|
||||||
|
expect(activeSessionIds()?.has("session-a")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("removes deleted sessions from active membership", () => {
|
||||||
|
// SAFETY: This fixture matches the SDK event shape consumed by the status event reducer.
|
||||||
|
applyGlobalSessionStatusEvent("/repo", {
|
||||||
|
type: "session.status",
|
||||||
|
properties: { sessionID: "session-a", status: { type: "busy" } },
|
||||||
|
} as Event)
|
||||||
|
const active = activeSessionIds()
|
||||||
|
|
||||||
|
applyGlobalSessionStatusEvent("/repo", {
|
||||||
|
type: "session.deleted",
|
||||||
|
properties: { sessionID: "session-a" },
|
||||||
|
} as Event)
|
||||||
|
|
||||||
|
expect(activeSessionIds()).not.toBe(active)
|
||||||
|
expect(activeSessionIds().has("session-a")).toBe(false)
|
||||||
|
expect(useGlobalSessionStatusStore.getState().statusById.has("session-a")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test("promotes on active and settled lifecycle edges only", () => {
|
test("promotes on active and settled lifecycle edges only", () => {
|
||||||
applyGlobalSessionStatusEvent("/repo", {
|
applyGlobalSessionStatusEvent("/repo", {
|
||||||
type: "session.status",
|
type: "session.status",
|
||||||
@@ -64,6 +122,48 @@ describe("global session status index", () => {
|
|||||||
expect(useGlobalSessionStatusStore.getState().statusById.has("session-a")).toBe(false)
|
expect(useGlobalSessionStatusStore.getState().statusById.has("session-a")).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("keeps active membership stable for snapshots with the same active IDs", () => {
|
||||||
|
applyGlobalSessionStatusSnapshot("/repo", { "session-a": { type: "busy" } }, ["session-a"])
|
||||||
|
const before = activeSessionIds()
|
||||||
|
|
||||||
|
applyGlobalSessionStatusSnapshot("/repo", {
|
||||||
|
"session-a": { type: "retry" },
|
||||||
|
}, ["session-a"])
|
||||||
|
|
||||||
|
expect(activeSessionIds()).toBe(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("updates active membership when a snapshot adds and removes IDs", () => {
|
||||||
|
applyGlobalSessionStatusSnapshot("/repo", { "session-a": { type: "busy" } }, ["session-a"])
|
||||||
|
const before = activeSessionIds()
|
||||||
|
|
||||||
|
applyGlobalSessionStatusSnapshot("/repo", {
|
||||||
|
"session-a": { type: "busy" },
|
||||||
|
"session-b": { type: "busy" },
|
||||||
|
}, ["session-a", "session-b"])
|
||||||
|
const added = activeSessionIds()
|
||||||
|
expect(added).not.toBe(before)
|
||||||
|
expect(added?.has("session-a")).toBe(true)
|
||||||
|
expect(added?.has("session-b")).toBe(true)
|
||||||
|
|
||||||
|
applyGlobalSessionStatusSnapshot("/repo", { "session-b": { type: "busy" } }, ["session-a", "session-b"])
|
||||||
|
const removed = activeSessionIds()
|
||||||
|
expect(removed).not.toBe(added)
|
||||||
|
expect(removed?.has("session-a")).toBe(false)
|
||||||
|
expect(removed?.has("session-b")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clears active membership when a runtime reset replaces status state", () => {
|
||||||
|
applyGlobalSessionStatusEvent("/repo", {
|
||||||
|
type: "session.status",
|
||||||
|
properties: { sessionID: "session-a", status: { type: "busy" } },
|
||||||
|
} as Event)
|
||||||
|
|
||||||
|
useGlobalSessionStatusStore.setState({ statusById: new Map() })
|
||||||
|
|
||||||
|
expect(activeSessionIds()?.size).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
test("clears an explicitly idle known session when directory aliases differ", () => {
|
test("clears an explicitly idle known session when directory aliases differ", () => {
|
||||||
applyGlobalSessionStatusSnapshot("/canonical/repo", { "session-a": { type: "busy" } }, ["session-a"])
|
applyGlobalSessionStatusSnapshot("/canonical/repo", { "session-a": { type: "busy" } }, ["session-a"])
|
||||||
|
|
||||||
|
|||||||
@@ -26,13 +26,75 @@ type GlobalSessionStatusEntry = { status: SessionStatus; directory: string };
|
|||||||
|
|
||||||
type GlobalSessionStatusState = {
|
type GlobalSessionStatusState = {
|
||||||
statusById: Map<string, GlobalSessionStatusEntry>;
|
statusById: Map<string, GlobalSessionStatusEntry>;
|
||||||
|
activeSessionIds: ReadonlySet<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useGlobalSessionStatusStore = create<GlobalSessionStatusState>(() => ({
|
const EMPTY_ACTIVE_SESSION_IDS: ReadonlySet<string> = new Set();
|
||||||
statusById: new Map(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const normalizeStatusType = (type: unknown): ActiveStatusType | 'idle' => {
|
const initialState: GlobalSessionStatusState = {
|
||||||
|
statusById: new Map(),
|
||||||
|
activeSessionIds: EMPTY_ACTIVE_SESSION_IDS,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGlobalSessionStatusStore = create<GlobalSessionStatusState>(() => initialState);
|
||||||
|
|
||||||
|
// Runtime switching currently replaces statusById directly. Keep that boundary
|
||||||
|
// synchronized without making normal status mutations derive membership again.
|
||||||
|
const storeSetState = useGlobalSessionStatusStore.setState;
|
||||||
|
type GlobalSessionStatusStateUpdate = GlobalSessionStatusState
|
||||||
|
| Partial<GlobalSessionStatusState>
|
||||||
|
| ((state: GlobalSessionStatusState) => GlobalSessionStatusState | Partial<GlobalSessionStatusState>);
|
||||||
|
|
||||||
|
function setSynchronizedState(
|
||||||
|
partial: GlobalSessionStatusStateUpdate,
|
||||||
|
replace?: false,
|
||||||
|
): void;
|
||||||
|
function setSynchronizedState(
|
||||||
|
partial: GlobalSessionStatusState | ((state: GlobalSessionStatusState) => GlobalSessionStatusState),
|
||||||
|
replace: true,
|
||||||
|
): void;
|
||||||
|
function setSynchronizedState(partial: GlobalSessionStatusStateUpdate, replace?: boolean): void {
|
||||||
|
if (partial instanceof Function) {
|
||||||
|
if (replace === true) {
|
||||||
|
// SAFETY: Zustand's `replace: true` overload only accepts a complete state or a complete-state updater.
|
||||||
|
storeSetState(partial as GlobalSessionStatusState | ((state: GlobalSessionStatusState) => GlobalSessionStatusState), true);
|
||||||
|
} else {
|
||||||
|
storeSetState(partial, replace);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (partial.statusById === undefined || partial.activeSessionIds) {
|
||||||
|
if (replace === true) {
|
||||||
|
// SAFETY: Zustand's `replace: true` overload only accepts a complete state or a complete-state updater.
|
||||||
|
storeSetState(partial as GlobalSessionStatusState, true);
|
||||||
|
} else {
|
||||||
|
storeSetState(partial, replace);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStatusById = partial.statusById;
|
||||||
|
const current = useGlobalSessionStatusStore.getState();
|
||||||
|
const nextActiveSessionIds = new Set<string>();
|
||||||
|
for (const [sessionId, entry] of nextStatusById) {
|
||||||
|
if (entry.status.type === 'busy' || entry.status.type === 'retry') {
|
||||||
|
nextActiveSessionIds.add(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sameMembership = nextActiveSessionIds.size === current.activeSessionIds.size
|
||||||
|
&& [...nextActiveSessionIds].every((sessionId) => current.activeSessionIds.has(sessionId));
|
||||||
|
const nextState = {
|
||||||
|
...current,
|
||||||
|
...partial,
|
||||||
|
activeSessionIds: sameMembership ? current.activeSessionIds : nextActiveSessionIds,
|
||||||
|
};
|
||||||
|
if (replace === true) storeSetState(nextState, true);
|
||||||
|
else storeSetState(nextState, replace);
|
||||||
|
}
|
||||||
|
|
||||||
|
useGlobalSessionStatusStore.setState = setSynchronizedState;
|
||||||
|
|
||||||
|
const normalizeStatusType = (type: string | undefined): ActiveStatusType | 'idle' => {
|
||||||
if (type === 'busy') return 'busy';
|
if (type === 'busy') return 'busy';
|
||||||
if (type === 'retry') return 'retry';
|
if (type === 'retry') return 'retry';
|
||||||
return 'idle';
|
return 'idle';
|
||||||
@@ -55,12 +117,17 @@ const setStatus = (sessionId: string, directory: string, status: SessionStatus |
|
|||||||
if (!current) return state;
|
if (!current) return state;
|
||||||
const next = new Map(state.statusById);
|
const next = new Map(state.statusById);
|
||||||
next.delete(sessionId);
|
next.delete(sessionId);
|
||||||
return { statusById: next };
|
const nextActiveSessionIds = new Set(state.activeSessionIds);
|
||||||
|
nextActiveSessionIds.delete(sessionId);
|
||||||
|
return { statusById: next, activeSessionIds: nextActiveSessionIds };
|
||||||
}
|
}
|
||||||
if (current && current.directory === directory && statusesEqual(current.status, status)) return state;
|
if (current && current.directory === directory && statusesEqual(current.status, status)) return state;
|
||||||
const next = new Map(state.statusById);
|
const next = new Map(state.statusById);
|
||||||
next.set(sessionId, { status, directory });
|
next.set(sessionId, { status, directory });
|
||||||
return { statusById: next };
|
if (current) return { statusById: next };
|
||||||
|
const nextActiveSessionIds = new Set(state.activeSessionIds);
|
||||||
|
nextActiveSessionIds.add(sessionId);
|
||||||
|
return { statusById: next, activeSessionIds: nextActiveSessionIds };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,13 +137,16 @@ const setStatus = (sessionId: string, directory: string, status: SessionStatus |
|
|||||||
export const applyGlobalSessionStatusEvent = (directory: string, payload: Event): void => {
|
export const applyGlobalSessionStatusEvent = (directory: string, payload: Event): void => {
|
||||||
switch (payload.type) {
|
switch (payload.type) {
|
||||||
case 'session.status': {
|
case 'session.status': {
|
||||||
|
// SAFETY: OpenCode event properties for this event contain the optional session ID and status payload.
|
||||||
const props = payload.properties as { sessionID?: string; status?: { type?: string } } | undefined;
|
const props = payload.properties as { sessionID?: string; status?: { type?: string } } | undefined;
|
||||||
if (typeof props?.sessionID !== 'string' || !props.sessionID) return;
|
if (typeof props?.sessionID !== 'string' || !props.sessionID) return;
|
||||||
const type = normalizeStatusType(props.status?.type);
|
const type = normalizeStatusType(props.status?.type);
|
||||||
setStatus(
|
setStatus(
|
||||||
props.sessionID,
|
props.sessionID,
|
||||||
normalizeDirectory(directory),
|
normalizeDirectory(directory),
|
||||||
type === 'idle' ? { type: 'idle' } : { ...(props.status ?? {}), type } as SessionStatus,
|
type === 'idle' ? { type: 'idle' } : ( // SAFETY: the normalized discriminator is busy or retry.
|
||||||
|
{ ...(props.status ?? {}), type } as SessionStatus
|
||||||
|
),
|
||||||
);
|
);
|
||||||
observeSessionActivityEvent(props.sessionID, type === 'idle' ? 'settled' : 'active');
|
observeSessionActivityEvent(props.sessionID, type === 'idle' ? 'settled' : 'active');
|
||||||
// `retry` is still a running turn, so the elapsed counter keeps going.
|
// `retry` is still a running turn, so the elapsed counter keeps going.
|
||||||
@@ -85,6 +155,7 @@ export const applyGlobalSessionStatusEvent = (directory: string, payload: Event)
|
|||||||
}
|
}
|
||||||
case 'session.idle':
|
case 'session.idle':
|
||||||
case 'session.error': {
|
case 'session.error': {
|
||||||
|
// SAFETY: OpenCode terminal event properties contain the optional addressed session ID.
|
||||||
const props = payload.properties as { sessionID?: string } | undefined;
|
const props = payload.properties as { sessionID?: string } | undefined;
|
||||||
if (typeof props?.sessionID === 'string' && props.sessionID) {
|
if (typeof props?.sessionID === 'string' && props.sessionID) {
|
||||||
setStatus(props.sessionID, normalizeDirectory(directory), { type: 'idle' });
|
setStatus(props.sessionID, normalizeDirectory(directory), { type: 'idle' });
|
||||||
@@ -94,9 +165,11 @@ export const applyGlobalSessionStatusEvent = (directory: string, payload: Event)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case 'session.deleted': {
|
case 'session.deleted': {
|
||||||
|
// SAFETY: OpenCode deletion event properties identify the deleted session directly or through info.id.
|
||||||
const props = payload.properties as { sessionID?: string; info?: { id?: string } } | undefined;
|
const props = payload.properties as { sessionID?: string; info?: { id?: string } } | undefined;
|
||||||
const sessionId = props?.sessionID ?? props?.info?.id;
|
const sessionId = props?.sessionID ?? props?.info?.id;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
|
setStatus(sessionId, normalizeDirectory(directory), { type: 'idle' });
|
||||||
removeSessionOrdering(sessionId);
|
removeSessionOrdering(sessionId);
|
||||||
removeSessionActivityTiming(sessionId);
|
removeSessionActivityTiming(sessionId);
|
||||||
}
|
}
|
||||||
@@ -137,10 +210,25 @@ export const applyGlobalSessionStatusSnapshot = (
|
|||||||
useGlobalSessionStatusStore.setState((state) => {
|
useGlobalSessionStatusStore.setState((state) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const next = new Map(state.statusById);
|
const next = new Map(state.statusById);
|
||||||
|
let nextActiveSessionIds: Set<string> | null = null;
|
||||||
|
const hasActiveSession = (sessionId: string): boolean => (
|
||||||
|
(nextActiveSessionIds ?? state.activeSessionIds).has(sessionId)
|
||||||
|
);
|
||||||
|
const removeActiveSession = (sessionId: string): void => {
|
||||||
|
if (!hasActiveSession(sessionId)) return;
|
||||||
|
nextActiveSessionIds ??= new Set(state.activeSessionIds);
|
||||||
|
nextActiveSessionIds.delete(sessionId);
|
||||||
|
};
|
||||||
|
const addActiveSession = (sessionId: string): void => {
|
||||||
|
if (hasActiveSession(sessionId)) return;
|
||||||
|
nextActiveSessionIds ??= new Set(state.activeSessionIds);
|
||||||
|
nextActiveSessionIds.add(sessionId);
|
||||||
|
};
|
||||||
|
|
||||||
for (const [sessionId, entry] of state.statusById) {
|
for (const [sessionId, entry] of state.statusById) {
|
||||||
if ((entry.directory === directory || known.has(sessionId)) && !(sessionId in raw)) {
|
if ((entry.directory === directory || known.has(sessionId)) && !(sessionId in raw)) {
|
||||||
next.delete(sessionId);
|
next.delete(sessionId);
|
||||||
|
removeActiveSession(sessionId);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,17 +239,23 @@ export const applyGlobalSessionStatusSnapshot = (
|
|||||||
if (type === 'idle') {
|
if (type === 'idle') {
|
||||||
if (current && (current.directory === directory || known.has(sessionId))) {
|
if (current && (current.directory === directory || known.has(sessionId))) {
|
||||||
next.delete(sessionId);
|
next.delete(sessionId);
|
||||||
|
removeActiveSession(sessionId);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// SAFETY: normalizeStatusType has narrowed this snapshot entry to the SDK's busy/retry status discriminator.
|
||||||
const normalizedStatus = { ...status, type } as SessionStatus;
|
const normalizedStatus = { ...status, type } as SessionStatus;
|
||||||
if (!current || current.directory !== directory || !statusesEqual(current.status, normalizedStatus)) {
|
if (!current || current.directory !== directory || !statusesEqual(current.status, normalizedStatus)) {
|
||||||
next.set(sessionId, { status: normalizedStatus, directory });
|
next.set(sessionId, { status: normalizedStatus, directory });
|
||||||
|
if (!current) addActiveSession(sessionId);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return changed ? { statusById: next } : state;
|
return changed ? {
|
||||||
|
statusById: next,
|
||||||
|
activeSessionIds: nextActiveSessionIds ?? state.activeSessionIds,
|
||||||
|
} : state;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -119,6 +119,32 @@ describe('session lifecycle ordering', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('orders roots, siblings, orphan parents, and cyclic parent scopes deterministically', () => {
|
||||||
|
const rootOlder = session('root-older', 10);
|
||||||
|
const rootNewer = session('root-newer', 20);
|
||||||
|
const childOlder = session('child-older', 5, 'root-older');
|
||||||
|
const childNewer = session('child-newer', 6, 'root-older');
|
||||||
|
const orphanOlder = session('orphan-older', 10, 'missing-parent');
|
||||||
|
const orphanNewer = session('orphan-newer', 20, 'missing-parent');
|
||||||
|
const cycleOlder = session('cycle-older', 10, 'cycle-newer');
|
||||||
|
const cycleNewer = session('cycle-newer', 20, 'cycle-older');
|
||||||
|
|
||||||
|
expect(orderSessionsByLifecycleScopes(
|
||||||
|
[cycleOlder, rootOlder, childOlder, orphanOlder, cycleNewer, rootNewer, childNewer, orphanNewer],
|
||||||
|
new Set(),
|
||||||
|
new Map(),
|
||||||
|
).map((item) => item.id)).toEqual([
|
||||||
|
'orphan-newer',
|
||||||
|
'root-newer',
|
||||||
|
'orphan-older',
|
||||||
|
'root-older',
|
||||||
|
'child-newer',
|
||||||
|
'child-older',
|
||||||
|
'cycle-newer',
|
||||||
|
'cycle-older',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('does not promote a root when only its child has lifecycle activity', () => {
|
test('does not promote a root when only its child has lifecycle activity', () => {
|
||||||
const rootOlder = session('root-older', 10);
|
const rootOlder = session('root-older', 10);
|
||||||
const rootNewer = session('root-newer', 20);
|
const rootNewer = session('root-newer', 20);
|
||||||
|
|||||||
@@ -238,7 +238,8 @@ export const orderSessionsByLifecycleScopes = (
|
|||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
append(root);
|
append(root);
|
||||||
}
|
}
|
||||||
for (const session of sessions) {
|
const remaining = sessions.filter((session) => !visited.has(session.id)).sort(compare);
|
||||||
|
for (const session of remaining) {
|
||||||
append(session);
|
append(session);
|
||||||
}
|
}
|
||||||
return ordered;
|
return ordered;
|
||||||
|
|||||||
Reference in New Issue
Block a user