refactor: modularize session sidebar and add GitHub PR tracking (#610)
* feat: switch sessions sidebar to global paginated loading with archived flow Load sessions via global endpoint with progressive 500-item pagination and legacy fallback Add dedicated archived sidebar section for archived and unassigned sessions Change remove behavior to archive outside archived and hard-delete inside archived * feat: improve archived sessions UX and folder persistence Archive sessions on worktree removal while keeping worktree deletion Streamline archived sidebar actions, icons, metadata, and tooltips Persist session folders to ~/.config/openchamber/sessions-directories.json with startup hydration * fix: align archived session actions and clean empty archived folders Apply archived dropdown behavior consistently for folder-contained sessions Remove archived-only folder actions while keeping standard folder behavior elsewhere Auto-prune empty archived folders during session cleanup and persistence sync * refactor: modularize session sidebar and stabilize behavior Split monolithic sidebar logic into focused hooks and components Kept session, archive, folder, and project interactions working with cleaner state persistence Added sidebar DOCUMENTATION.md summarizing file roles and refactor outcomes * fix: improve fork PR detection and smart remote tracking Added centralized PR status store for shared polling and refresh Auto-selects the remote that has an existing PR when current remote has none Stops periodic polling for closed or merged PRs to reduce unnecessary requests * fix: make chat and toast corners follow active theme radius Toast corners now use theme radius tokens instead of hardcoded rounding User message bubble now uses theme-configured max radius with preserved tail corner Square-corner themes now consistently affect both toasts and chat bubbles * feat: show live PR status across git view and session sidebar Added a shared GitHub PR status store with adaptive background polling and terminal-state pause Improved fork remote detection and auto-selection so existing PRs are found more reliably Updated session group headers to show clickable PR number with branch and state-colored branch icon * feat: centralize GitHub PR tracking and enrich session sidebar PR details Moved PR status polling to a single global pipeline keyed by directory and branch Improved fork-aware PR resolution and reduced duplicate GitHub status fetches across views Added richer session sidebar PR display with clickable number, state-aware styling, and structured tooltip details * fix: adjust PR indicator icon vertical alignment Fine-tuned PR indicator icon vertical alignment in session sidebar Reduced icon translate-y from 2px to 0.5px for better visual balance * feat: improve session sidebar status display * feat: enhance session display logic for minimal mode and improve dropdown menu accessibility * feat: refactor session row to include tooltip for minimal display mode
This commit is contained in:
committed by
GitHub
parent
d54e1199df
commit
a7f11121e8
@@ -19,6 +19,7 @@ import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { usePwaManifestSync } from '@/hooks/usePwaManifestSync';
|
||||
import { usePwaInstallPrompt } from '@/hooks/usePwaInstallPrompt';
|
||||
import { useWindowTitle } from '@/hooks/useWindowTitle';
|
||||
import { useGitHubPrBackgroundTracking } from '@/hooks/useGitHubPrBackgroundTracking';
|
||||
import { GitPollingProvider } from '@/hooks/useGitPolling';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { hasModifier } from '@/lib/utils';
|
||||
@@ -133,6 +134,8 @@ function App({ apis }: AppProps) {
|
||||
void refreshGitHubAuthStatus(apis.github, { force: true });
|
||||
}, [apis.github, embeddedSessionChat, refreshGitHubAuthStatus]);
|
||||
|
||||
useGitHubPrBackgroundTracking(embeddedBackgroundWorkEnabled ? apis.github : undefined, apis.git);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof document === 'undefined') {
|
||||
return;
|
||||
|
||||
@@ -979,7 +979,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
<FadeInOnReveal>
|
||||
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
|
||||
<div className="max-w-[85%]">
|
||||
<div style={{ backgroundColor: 'var(--chat-user-message-bg)' }} className="rounded-2xl rounded-br-sm px-5 py-3 shadow-none border border-primary/5">
|
||||
<div style={{ backgroundColor: 'var(--chat-user-message-bg)' }} className="rounded-[var(--radius-xl)] rounded-br-[var(--radius-sm)] px-5 py-3 shadow-none border border-primary/5">
|
||||
<MessageBody
|
||||
messageId={message.info.id}
|
||||
parts={displayParts}
|
||||
|
||||
@@ -62,6 +62,8 @@ export const SessionDialogs: React.FC = () => {
|
||||
const {
|
||||
deleteSession,
|
||||
deleteSessions,
|
||||
archiveSession,
|
||||
archiveSessions,
|
||||
loadSessions,
|
||||
getWorktreeMetadata,
|
||||
} = useSessionStore();
|
||||
@@ -423,15 +425,17 @@ export const SessionDialogs: React.FC = () => {
|
||||
|
||||
if (deleteDialog.sessions.length === 1) {
|
||||
const target = deleteDialog.sessions[0];
|
||||
const success = await deleteSession(target.id, {
|
||||
// In "worktree" mode, remove the selected worktree explicitly below.
|
||||
// Don't try to derive worktree removal from per-session metadata (may be missing).
|
||||
archiveWorktree: isWorktreeDelete ? false : shouldArchive,
|
||||
deleteRemoteBranch: removeRemoteBranch,
|
||||
deleteLocalBranch,
|
||||
});
|
||||
const success = isWorktreeDelete
|
||||
? await archiveSession(target.id)
|
||||
: await deleteSession(target.id, {
|
||||
// In "worktree" mode, remove the selected worktree explicitly below.
|
||||
// Don't try to derive worktree removal from per-session metadata (may be missing).
|
||||
archiveWorktree: false,
|
||||
deleteRemoteBranch: removeRemoteBranch,
|
||||
deleteLocalBranch,
|
||||
});
|
||||
if (!success) {
|
||||
toast.error('Failed to delete session');
|
||||
toast.error(isWorktreeDelete ? 'Failed to archive session' : 'Failed to delete session');
|
||||
setIsProcessingDelete(false);
|
||||
return;
|
||||
}
|
||||
@@ -440,7 +444,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
? 'Worktree and remote branch removed.'
|
||||
: 'Attached worktree archived.'
|
||||
: undefined;
|
||||
toast.success('Session deleted', {
|
||||
toast.success(isWorktreeDelete ? 'Session archived' : 'Session deleted', {
|
||||
description: renderToastDescription(archiveNote),
|
||||
action: {
|
||||
label: 'OK',
|
||||
@@ -449,11 +453,21 @@ export const SessionDialogs: React.FC = () => {
|
||||
});
|
||||
} else {
|
||||
const ids = deleteDialog.sessions.map((session) => session.id);
|
||||
const { deletedIds, failedIds } = await deleteSessions(ids, {
|
||||
archiveWorktree: isWorktreeDelete ? false : shouldArchive,
|
||||
deleteRemoteBranch: removeRemoteBranch,
|
||||
deleteLocalBranch,
|
||||
});
|
||||
let deletedIds: string[] = [];
|
||||
let failedIds: string[] = [];
|
||||
if (isWorktreeDelete) {
|
||||
const result = await archiveSessions(ids);
|
||||
deletedIds = result.archivedIds;
|
||||
failedIds = result.failedIds;
|
||||
} else {
|
||||
const result = await deleteSessions(ids, {
|
||||
archiveWorktree: false,
|
||||
deleteRemoteBranch: removeRemoteBranch,
|
||||
deleteLocalBranch,
|
||||
});
|
||||
deletedIds = result.deletedIds;
|
||||
failedIds = result.failedIds;
|
||||
}
|
||||
|
||||
if (isWorktreeDelete && deleteDialog.worktree && failedIds.length === 0) {
|
||||
// Remove selected worktree even if per-session metadata is missing.
|
||||
@@ -472,12 +486,12 @@ export const SessionDialogs: React.FC = () => {
|
||||
: undefined;
|
||||
const successDescription =
|
||||
failedIds.length > 0
|
||||
? `${failedIds.length} session${failedIds.length === 1 ? '' : 's'} could not be deleted.`
|
||||
? `${failedIds.length} session${failedIds.length === 1 ? '' : 's'} could not be ${isWorktreeDelete ? 'archived' : 'deleted'}.`
|
||||
: deleteDialog.dateLabel
|
||||
? `Removed all sessions from ${deleteDialog.dateLabel}.`
|
||||
: undefined;
|
||||
const combinedDescription = [successDescription, archiveNote].filter(Boolean).join(' ');
|
||||
toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, {
|
||||
toast.success(`${isWorktreeDelete ? 'Archived' : 'Deleted'} ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, {
|
||||
description: renderToastDescription(combinedDescription || undefined),
|
||||
action: {
|
||||
label: 'OK',
|
||||
@@ -487,7 +501,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
}
|
||||
|
||||
if (failedIds.length > 0) {
|
||||
toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`, {
|
||||
toast.error(`Failed to ${isWorktreeDelete ? 'archive' : 'delete'} ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`, {
|
||||
description: renderToastDescription('Please try again in a moment.'),
|
||||
});
|
||||
if (deletedIds.length === 0) {
|
||||
@@ -514,6 +528,8 @@ export const SessionDialogs: React.FC = () => {
|
||||
deleteDialogShouldDeleteLocalBranch,
|
||||
deleteSession,
|
||||
deleteSessions,
|
||||
archiveSession,
|
||||
archiveSessions,
|
||||
closeDeleteDialog,
|
||||
shouldArchiveWorktree,
|
||||
isWorktreeDelete,
|
||||
@@ -527,7 +543,7 @@ export const SessionDialogs: React.FC = () => {
|
||||
? deleteDialog.mode === 'worktree'
|
||||
? deleteDialog.sessions.length === 0
|
||||
? 'This removes the selected worktree.'
|
||||
: `This removes the selected worktree and ${deleteDialog.sessions.length === 1 ? '1 linked session' : `${deleteDialog.sessions.length} linked sessions`}.`
|
||||
: `This removes the selected worktree and archives ${deleteDialog.sessions.length === 1 ? '1 linked session' : `${deleteDialog.sessions.length} linked sessions`}.`
|
||||
: `This action permanently removes ${deleteDialog.sessions.length === 1 ? '1 session' : `${deleteDialog.sessions.length} sessions`}${deleteDialog.dateLabel ? ` from ${deleteDialog.dateLabel}` : ''
|
||||
}.`
|
||||
: '';
|
||||
|
||||
@@ -28,6 +28,7 @@ interface SessionFolderItemProps<TSessionNode> {
|
||||
depth?: number,
|
||||
groupDir?: string | null,
|
||||
projectId?: string | null,
|
||||
archivedBucket?: boolean,
|
||||
) => React.ReactNode;
|
||||
groupDirectory?: string | null;
|
||||
projectId?: string | null;
|
||||
@@ -47,6 +48,10 @@ interface SessionFolderItemProps<TSessionNode> {
|
||||
onNewSubFolder?: () => void;
|
||||
/** Visual indent depth (0 = root folder, 1 = sub-folder) */
|
||||
depth?: number;
|
||||
/** Hide folder action buttons (rename/delete/new) */
|
||||
hideActions?: boolean;
|
||||
/** Whether folder belongs to archived section */
|
||||
archivedBucket?: boolean;
|
||||
}
|
||||
|
||||
const SessionFolderItemBase = <TSessionNode,>({
|
||||
@@ -71,6 +76,8 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
onNewSession,
|
||||
onNewSubFolder,
|
||||
depth = 0,
|
||||
hideActions = false,
|
||||
archivedBucket = false,
|
||||
}: SessionFolderItemProps<TSessionNode>) => {
|
||||
const [localRenaming, setLocalRenaming] = React.useState(false);
|
||||
const [localDraft, setLocalDraft] = React.useState('');
|
||||
@@ -228,7 +235,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{!renaming ? (
|
||||
{!renaming && !hideActions ? (
|
||||
<div className="flex items-center gap-0.5 px-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
@@ -300,7 +307,7 @@ const SessionFolderItemBase = <TSessionNode,>({
|
||||
{/* Then sessions */}
|
||||
{sessions.length > 0 ? (
|
||||
sessions.map((node) =>
|
||||
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null),
|
||||
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket),
|
||||
)
|
||||
) : !subFolderItems ? (
|
||||
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { RiCheckboxBlankLine, RiCheckboxLine } from '@remixicon/react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
export type DeleteSessionConfirmState = {
|
||||
session: Session;
|
||||
descendantCount: number;
|
||||
archivedBucket: boolean;
|
||||
} | null;
|
||||
|
||||
export function SessionDeleteConfirmDialog(props: {
|
||||
value: DeleteSessionConfirmState;
|
||||
setValue: (next: DeleteSessionConfirmState) => void;
|
||||
showDeletionDialog: boolean;
|
||||
setShowDeletionDialog: (next: boolean) => void;
|
||||
onConfirm: () => Promise<void> | void;
|
||||
}): React.ReactNode {
|
||||
const { value, setValue, showDeletionDialog, setShowDeletionDialog, onConfirm } = props;
|
||||
|
||||
return (
|
||||
<Dialog open={Boolean(value)} onOpenChange={(open) => { if (!open) setValue(null); }}>
|
||||
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{value?.archivedBucket ? 'Delete session?' : 'Archive session?'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{value && value.descendantCount > 0
|
||||
? value.archivedBucket
|
||||
? `"${value.session.title || 'Untitled Session'}" and its ${value.descendantCount} sub-task${value.descendantCount === 1 ? '' : 's'} will be permanently deleted.`
|
||||
: `"${value.session.title || 'Untitled Session'}" and its ${value.descendantCount} sub-task${value.descendantCount === 1 ? '' : 's'} will be archived.`
|
||||
: value?.archivedBucket
|
||||
? `"${value?.session.title || 'Untitled Session'}" will be permanently deleted.`
|
||||
: `"${value?.session.title || 'Untitled Session'}" will be archived.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="w-full sm:items-center sm:justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowDeletionDialog(!showDeletionDialog)}
|
||||
className="inline-flex items-center gap-1.5 typography-ui-label text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50"
|
||||
aria-pressed={!showDeletionDialog}
|
||||
>
|
||||
{!showDeletionDialog ? <RiCheckboxLine className="h-4 w-4 text-primary" /> : <RiCheckboxBlankLine className="h-4 w-4" />}
|
||||
Never ask
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue(null)}
|
||||
className="inline-flex h-8 items-center justify-center rounded-md border border-border px-3 typography-ui-label text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onConfirm()}
|
||||
className="inline-flex h-8 items-center justify-center rounded-md bg-destructive px-3 typography-ui-label text-destructive-foreground hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
|
||||
>
|
||||
{value?.archivedBucket ? 'Delete' : 'Archive'}
|
||||
</button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export type DeleteFolderConfirmState = {
|
||||
scopeKey: string;
|
||||
folderId: string;
|
||||
folderName: string;
|
||||
subFolderCount: number;
|
||||
sessionCount: number;
|
||||
} | null;
|
||||
|
||||
export function FolderDeleteConfirmDialog(props: {
|
||||
value: DeleteFolderConfirmState;
|
||||
setValue: (next: DeleteFolderConfirmState) => void;
|
||||
onConfirm: () => void;
|
||||
}): React.ReactNode {
|
||||
const { value, setValue, onConfirm } = props;
|
||||
|
||||
return (
|
||||
<Dialog open={Boolean(value)} onOpenChange={(open) => { if (!open) setValue(null); }}>
|
||||
<DialogContent showCloseButton={false} className="max-w-sm gap-5">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete folder?</DialogTitle>
|
||||
<DialogDescription>
|
||||
{value && (value.subFolderCount > 0 || value.sessionCount > 0)
|
||||
? `"${value.folderName}" will be deleted${value.subFolderCount > 0 ? ` along with ${value.subFolderCount} sub-folder${value.subFolderCount === 1 ? '' : 's'}` : ''}. Sessions inside will not be deleted.`
|
||||
: `"${value?.folderName}" will be permanently deleted.`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setValue(null)}
|
||||
className="inline-flex h-8 items-center justify-center rounded-md border border-border px-3 typography-ui-label text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="inline-flex h-8 items-center justify-center rounded-md bg-destructive px-3 typography-ui-label text-destructive-foreground hover:bg-destructive/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive/50"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# Session Sidebar Documentation
|
||||
|
||||
## Refactor result
|
||||
|
||||
- `SessionSidebar.tsx` now acts mainly as orchestration; core logic moved to focused hooks/components.
|
||||
- Sidebar behavior stays intact: global+archived session grouping, folder operations, delete/archive semantics, project/worktree rendering, and search.
|
||||
- Recent migration gaps were fixed (persistence + repo-status hooks fully wired).
|
||||
- New extractions in latest pass reduced local effect/callback bulk further:
|
||||
- project session list builders
|
||||
- folder cleanup sync
|
||||
- sticky project header observer
|
||||
- Baseline checks pass after refactor: `type-check`, `lint`, `build`.
|
||||
|
||||
## File summaries
|
||||
|
||||
### Components
|
||||
|
||||
- `SidebarHeader.tsx`: Top header UI (project selector/rename, search, add/open actions, notes/worktree entry points).
|
||||
- `SidebarProjectsList.tsx`: Main scrollable list renderer for project sections/groups, empty states, and project-level interactions.
|
||||
- `SessionGroupSection.tsx`: Renders a single group (root sessions + folders), collapse/expand, and group-level controls.
|
||||
- `SessionNodeItem.tsx`: Renders one session row/tree node with metadata, menu actions, inline rename, and nested children.
|
||||
- `ConfirmDialogs.tsx`: Shared confirm dialog wrappers for session delete and folder delete flows.
|
||||
- `sortableItems.tsx`: DnD sortable wrappers for project and group ordering with drag handles/overlays.
|
||||
- `sessionFolderDnd.tsx`: Folder/session DnD scope and wrappers for dropping/moving sessions into folders.
|
||||
|
||||
### 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`: Prefetches messages for nearby/active sessions to improve perceived load speed.
|
||||
- `hooks/useDirectoryStatusProbe.ts`: Probes and caches directory existence status for session/path indicators.
|
||||
- `hooks/useSessionGrouping.ts`: Builds grouped session structures and search text/filter helpers.
|
||||
- `hooks/useSessionSidebarSections.ts`: Composes final per-project sections and group search metadata for rendering.
|
||||
- `hooks/useProjectSessionSelection.ts`: Resolves active/current project-session selection logic and session-directory context.
|
||||
- `hooks/useGroupOrdering.ts`: Applies persisted/custom group order with stable fallback ordering.
|
||||
- `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`: Builds live and archived session lists for a given project (including worktrees + dedupe).
|
||||
- `hooks/useSessionFolderCleanup.ts`: Cleans stale folder session IDs by reconciling known sessions/archived scopes.
|
||||
- `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).
|
||||
- `utils.tsx`: Shared sidebar utilities (path normalization, sorting, dedupe, archived scope keys, project relation checks, text highlight, labels).
|
||||
@@ -0,0 +1,580 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import {
|
||||
RiAddLine,
|
||||
RiArchiveLine,
|
||||
RiArrowDownSLine,
|
||||
RiArrowLeftLongLine,
|
||||
RiArrowRightSLine,
|
||||
RiDeleteBinLine,
|
||||
RiGitBranchLine,
|
||||
} from '@remixicon/react';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sessionEvents } from '@/lib/sessionEvents';
|
||||
import type { MainTab } from '@/stores/useUIStore';
|
||||
import { SessionFolderItem } from '../SessionFolderItem';
|
||||
import { DroppableFolderWrapper, SessionFolderDndScope } from './sessionFolderDnd';
|
||||
import type { GroupSearchData, SessionGroup, SessionNode } from './types';
|
||||
import { compareSessionsByPinnedAndTime, isBranchDifferentFromLabel, normalizePath, renderHighlightedText } from './utils';
|
||||
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
|
||||
|
||||
type DeleteFolderConfirm = {
|
||||
scopeKey: string;
|
||||
folderId: string;
|
||||
folderName: string;
|
||||
subFolderCount: number;
|
||||
sessionCount: number;
|
||||
} | null;
|
||||
|
||||
type Props = {
|
||||
group: SessionGroup;
|
||||
groupKey: string;
|
||||
projectId?: string | null;
|
||||
hideGroupLabel?: boolean;
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
groupSearchDataByGroup: WeakMap<SessionGroup, GroupSearchData>;
|
||||
expandedSessionGroups: Set<string>;
|
||||
collapsedGroups: Set<string>;
|
||||
hideDirectoryControls: boolean;
|
||||
getFoldersForScope: (scopeKey: string) => SessionFolder[];
|
||||
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) => React.ReactNode;
|
||||
currentSessionDirectory: string | null;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
lastRepoStatus: boolean;
|
||||
toggleGroupSessionLimit: (groupKey: string) => void;
|
||||
mobileVariant: boolean;
|
||||
activeProjectId: string | null;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setActiveMainTab: (tab: MainTab) => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { directoryOverride?: string | null; targetFolderId?: string }) => 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>;
|
||||
prVisualStateByDirectoryBranch: Map<string, {
|
||||
visualState: 'draft' | 'open' | 'blocked' | 'merged' | 'closed';
|
||||
number: number;
|
||||
url: string | null;
|
||||
state: 'open' | 'closed' | 'merged';
|
||||
draft: boolean;
|
||||
title: string | null;
|
||||
base: string | null;
|
||||
head: string | null;
|
||||
checks: {
|
||||
state: 'success' | 'failure' | 'pending' | 'unknown';
|
||||
total: number;
|
||||
success: number;
|
||||
failure: number;
|
||||
pending: number;
|
||||
} | null;
|
||||
canMerge: boolean | null;
|
||||
mergeableState: string | null;
|
||||
repo: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
} | null;
|
||||
}>;
|
||||
onToggleCollapsedGroup: (groupKey: string) => void;
|
||||
};
|
||||
|
||||
export function SessionGroupSection(props: Props): React.ReactNode {
|
||||
const {
|
||||
group,
|
||||
groupKey,
|
||||
projectId,
|
||||
hideGroupLabel,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
groupSearchDataByGroup,
|
||||
expandedSessionGroups,
|
||||
collapsedGroups,
|
||||
hideDirectoryControls,
|
||||
getFoldersForScope,
|
||||
collapsedFolderIds,
|
||||
toggleFolderCollapse,
|
||||
renameFolder,
|
||||
deleteFolder,
|
||||
showDeletionDialog,
|
||||
setDeleteFolderConfirm,
|
||||
renderSessionNode,
|
||||
currentSessionDirectory,
|
||||
projectRepoStatus,
|
||||
lastRepoStatus,
|
||||
toggleGroupSessionLimit,
|
||||
mobileVariant,
|
||||
activeProjectId,
|
||||
setActiveProjectIdOnly,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
openNewSessionDraft,
|
||||
addSessionToFolder,
|
||||
createFolderAndStartRename,
|
||||
renamingFolderId,
|
||||
renameFolderDraft,
|
||||
setRenameFolderDraft,
|
||||
setRenamingFolderId,
|
||||
pinnedSessionIds,
|
||||
prVisualStateByDirectoryBranch,
|
||||
onToggleCollapsedGroup,
|
||||
} = props;
|
||||
|
||||
const searchData = hasSessionSearchQuery ? groupSearchDataByGroup.get(group) : null;
|
||||
const isExpanded = expandedSessionGroups.has(groupKey);
|
||||
const isCollapsed = hasSessionSearchQuery ? false : collapsedGroups.has(groupKey);
|
||||
const maxVisible = hideDirectoryControls ? 10 : 5;
|
||||
const groupMatchesSearch = hasSessionSearchQuery ? searchData?.groupMatches === true : false;
|
||||
const shouldFilterGroupContents = hasSessionSearchQuery;
|
||||
const sourceGroupNodes = shouldFilterGroupContents ? (searchData?.filteredNodes ?? []) : group.sessions;
|
||||
const folderScopeKey = group.folderScopeKey ?? normalizePath(group.directory ?? null);
|
||||
const scopeFolders = folderScopeKey ? getFoldersForScope(folderScopeKey) : [];
|
||||
|
||||
const nodeBySessionId = new Map<string, SessionNode>();
|
||||
const collectNodeLookup = (nodes: SessionNode[]) => {
|
||||
nodes.forEach((node) => {
|
||||
nodeBySessionId.set(node.session.id, node);
|
||||
if (node.children.length > 0) {
|
||||
collectNodeLookup(node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
collectNodeLookup(sourceGroupNodes);
|
||||
|
||||
const allFoldersForGroupBase = scopeFolders.map((folder) => {
|
||||
const nodes = folder.sessionIds
|
||||
.map((sid) => nodeBySessionId.get(sid))
|
||||
.filter((n): n is SessionNode => Boolean(n))
|
||||
.sort((a, b) => compareSessionsByPinnedAndTime(a.session, b.session, pinnedSessionIds));
|
||||
return { folder, nodes };
|
||||
});
|
||||
|
||||
const folderMapById = new Map(allFoldersForGroupBase.map((entry) => [entry.folder.id, entry]));
|
||||
const shouldKeepFolder = (folderId: string): boolean => {
|
||||
const entry = folderMapById.get(folderId);
|
||||
if (!entry) return false;
|
||||
if (!hasSessionSearchQuery) return true;
|
||||
const folderMatches = entry.folder.name.toLowerCase().includes(normalizedSessionSearchQuery);
|
||||
if (folderMatches || entry.nodes.length > 0) return true;
|
||||
return allFoldersForGroupBase
|
||||
.filter(({ folder }) => folder.parentId === folderId)
|
||||
.some(({ folder }) => shouldKeepFolder(folder.id));
|
||||
};
|
||||
|
||||
const allFoldersForGroup = hasSessionSearchQuery
|
||||
? allFoldersForGroupBase.filter(({ folder }) => shouldKeepFolder(folder.id))
|
||||
: allFoldersForGroupBase;
|
||||
|
||||
const sessionIdsInFolders = new Set(allFoldersForGroup.flatMap((f) => f.folder.sessionIds));
|
||||
const ungroupedSessions = sourceGroupNodes.filter((node) => !sessionIdsInFolders.has(node.session.id));
|
||||
const rootFolders = allFoldersForGroup.filter(({ folder }) => !folder.parentId);
|
||||
|
||||
if (hasSessionSearchQuery && !groupMatchesSearch && rootFolders.length === 0 && ungroupedSessions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalSessions = ungroupedSessions.length;
|
||||
const visibleSessions = group.isArchivedBucket
|
||||
? ungroupedSessions
|
||||
: hasSessionSearchQuery
|
||||
? ungroupedSessions
|
||||
: (isExpanded ? ungroupedSessions : ungroupedSessions.slice(0, maxVisible));
|
||||
const remainingCount = totalSessions - visibleSessions.length;
|
||||
|
||||
const collectGroupSessions = (nodes: SessionNode[]): Session[] => {
|
||||
const collected: Session[] = [];
|
||||
const visit = (list: SessionNode[]) => {
|
||||
list.forEach((node) => {
|
||||
collected.push(node.session);
|
||||
if (node.children.length > 0) visit(node.children);
|
||||
});
|
||||
};
|
||||
visit(nodes);
|
||||
return collected;
|
||||
};
|
||||
|
||||
const allGroupSessions = collectGroupSessions(sourceGroupNodes);
|
||||
const normalizedGroupDirectory = normalizePath(group.directory ?? null);
|
||||
const isGitProject = projectId && projectRepoStatus.has(projectId)
|
||||
? Boolean(projectRepoStatus.get(projectId))
|
||||
: lastRepoStatus;
|
||||
const isActiveGroup = Boolean(
|
||||
normalizedGroupDirectory
|
||||
&& currentSessionDirectory
|
||||
&& normalizedGroupDirectory === currentSessionDirectory,
|
||||
);
|
||||
const groupDirectoryKey = normalizePath(group.directory ?? null);
|
||||
const groupBranchKey = group.branch?.trim() ?? null;
|
||||
const prIndicator = groupDirectoryKey && groupBranchKey
|
||||
? (prVisualStateByDirectoryBranch.get(`${groupDirectoryKey}::${groupBranchKey}`) ?? null)
|
||||
: null;
|
||||
const showInlinePrTitle = Boolean(prIndicator && group.branch);
|
||||
const showBranchSubtitle = !group.isMain && (isBranchDifferentFromLabel(group.branch, group.label) || Boolean(prIndicator));
|
||||
const prVisualState = prIndicator?.visualState ?? null;
|
||||
const branchIconColor = prVisualState ? `var(--pr-${prVisualState})` : undefined;
|
||||
const checksSummary = prIndicator && prIndicator.state === 'open' && prIndicator.checks
|
||||
? `${prIndicator.checks.success}/${prIndicator.checks.total} checks passed`
|
||||
: null;
|
||||
const checksTail = prIndicator && prIndicator.state === 'open' && prIndicator.checks
|
||||
? [
|
||||
prIndicator.checks.failure > 0 ? `${prIndicator.checks.failure} failing` : null,
|
||||
prIndicator.checks.pending > 0 ? `${prIndicator.checks.pending} pending` : null,
|
||||
].filter((item): item is string => Boolean(item)).join(', ')
|
||||
: null;
|
||||
const mergeabilityLabel = prIndicator && prIndicator.state === 'open'
|
||||
? (prIndicator.canMerge === true
|
||||
? 'Mergeable'
|
||||
: (prIndicator.canMerge === false ? 'Conflicts or blocked' : null))
|
||||
: null;
|
||||
const mergeStateLabel = prIndicator && prIndicator.state === 'open' && prIndicator.mergeableState
|
||||
? `Merge state: ${prIndicator.mergeableState}`
|
||||
: null;
|
||||
const baseBranchLabel = prIndicator?.base ?? null;
|
||||
const headBranchLabel = prIndicator?.head ?? null;
|
||||
const handlePrLinkClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const url = prIndicator?.url;
|
||||
if (!url || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const tauri = (window as unknown as { __TAURI__?: { shell?: { open?: (target: string) => Promise<unknown> } } }).__TAURI__;
|
||||
if (tauri?.shell?.open) {
|
||||
void tauri.shell.open(url).catch(() => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const renderOneFolderItem = (folder: SessionFolder, nodes: SessionNode[], depth: number): React.ReactNode => {
|
||||
const directSubFolders = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id);
|
||||
const subFolderItems = directSubFolders.length > 0
|
||||
? <>{directSubFolders.map(({ folder: sf, nodes: sn }) => renderOneFolderItem(sf, sn, depth + 1))}</>
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<DroppableFolderWrapper key={folder.id} folderId={folder.id}>
|
||||
{(droppableRef, isDropTarget) => (
|
||||
<SessionFolderItem
|
||||
folder={folder}
|
||||
sessions={nodes}
|
||||
subFolderItems={subFolderItems}
|
||||
isCollapsed={hasSessionSearchQuery ? false : collapsedFolderIds.has(folder.id)}
|
||||
onToggle={() => toggleFolderCollapse(folder.id)}
|
||||
onRename={(name) => {
|
||||
if (folderScopeKey) renameFolder(folderScopeKey, folder.id, name);
|
||||
}}
|
||||
onDelete={() => {
|
||||
if (!folderScopeKey) return;
|
||||
if (!showDeletionDialog) {
|
||||
deleteFolder(folderScopeKey, folder.id);
|
||||
return;
|
||||
}
|
||||
const subFolderCount = allFoldersForGroup.filter(({ folder: f }) => f.parentId === folder.id).length;
|
||||
const sessionCount = nodes.length;
|
||||
setDeleteFolderConfirm({
|
||||
scopeKey: folderScopeKey,
|
||||
folderId: folder.id,
|
||||
folderName: folder.name,
|
||||
subFolderCount,
|
||||
sessionCount,
|
||||
});
|
||||
}}
|
||||
renderSessionNode={renderSessionNode}
|
||||
groupDirectory={group.directory}
|
||||
projectId={projectId}
|
||||
mobileVariant={mobileVariant}
|
||||
isRenaming={renamingFolderId === folder.id}
|
||||
renameDraft={renamingFolderId === folder.id ? renameFolderDraft : undefined}
|
||||
onRenameDraftChange={(value) => setRenameFolderDraft(value)}
|
||||
onRenameSave={() => {
|
||||
const trimmed = renameFolderDraft.trim();
|
||||
if (trimmed && folderScopeKey) {
|
||||
renameFolder(folderScopeKey, folder.id, trimmed);
|
||||
}
|
||||
setRenamingFolderId(null);
|
||||
setRenameFolderDraft('');
|
||||
}}
|
||||
onRenameCancel={() => {
|
||||
setRenamingFolderId(null);
|
||||
setRenameFolderDraft('');
|
||||
}}
|
||||
droppableRef={droppableRef}
|
||||
isDropTarget={isDropTarget}
|
||||
depth={depth}
|
||||
onNewSession={() => {
|
||||
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) setSessionSwitcherOpen(false);
|
||||
openNewSessionDraft({ directoryOverride: group.directory, targetFolderId: folder.id });
|
||||
}}
|
||||
onNewSubFolder={depth === 0 ? () => {
|
||||
if (!folderScopeKey) return;
|
||||
createFolderAndStartRename(folderScopeKey, folder.id);
|
||||
} : undefined}
|
||||
hideActions={group.isArchivedBucket === true}
|
||||
archivedBucket={group.isArchivedBucket === true}
|
||||
/>
|
||||
)}
|
||||
</DroppableFolderWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFolderItems = () => rootFolders.map(({ folder, nodes }) => renderOneFolderItem(folder, nodes, 0));
|
||||
|
||||
const body = (
|
||||
<SessionFolderDndScope
|
||||
scopeKey={folderScopeKey}
|
||||
hasFolders={allFoldersForGroup.length > 0}
|
||||
onSessionDroppedOnFolder={(sessionId, folderId) => {
|
||||
if (folderScopeKey) addSessionToFolder(folderScopeKey, folderId, sessionId);
|
||||
}}
|
||||
>
|
||||
{renderFolderItems()}
|
||||
{visibleSessions.map((node) => renderSessionNode(node, 0, group.directory, projectId, group.isArchivedBucket === true))}
|
||||
{totalSessions === 0 && allFoldersForGroup.length === 0 ? (
|
||||
<div className="py-1 text-left typography-micro text-muted-foreground">
|
||||
{group.isArchivedBucket ? 'No archived sessions yet.' : 'No sessions in this workspace yet.'}
|
||||
</div>
|
||||
) : null}
|
||||
{remainingCount > 0 && !isExpanded ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroupSessionLimit(groupKey)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
Show {remainingCount} more {remainingCount === 1 ? 'session' : 'sessions'}
|
||||
</button>
|
||||
) : null}
|
||||
{isExpanded && totalSessions > maxVisible ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroupSessionLimit(groupKey)}
|
||||
className="mt-0.5 flex items-center justify-start rounded-md px-1.5 py-0.5 text-left text-xs text-muted-foreground/70 leading-tight hover:text-foreground hover:underline"
|
||||
>
|
||||
Show fewer sessions
|
||||
</button>
|
||||
) : null}
|
||||
</SessionFolderDndScope>
|
||||
);
|
||||
|
||||
if (hideGroupLabel) {
|
||||
return <div className="oc-group"><div className="oc-group-body pb-3">{body}</div></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="oc-group">
|
||||
<div
|
||||
className={cn('group/gh relative flex items-center justify-between gap-1 py-1 min-w-0 rounded-sm', 'hover:bg-interactive-hover/50 cursor-pointer')}
|
||||
onClick={() => onToggleCollapsedGroup(groupKey)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onToggleCollapsedGroup(groupKey);
|
||||
}
|
||||
}}
|
||||
aria-label={isCollapsed ? `Expand ${group.label}` : `Collapse ${group.label}`}
|
||||
>
|
||||
<div className={cn(
|
||||
'min-w-0 flex items-center gap-1.5 pl-1.5 transition-[padding]',
|
||||
mobileVariant
|
||||
? (!group.isMain && group.worktree ? 'pr-14' : 'pr-7')
|
||||
: (!group.isMain && group.worktree ? 'group-hover/gh:pr-14 group-focus-within/gh:pr-14' : 'group-hover/gh:pr-7 group-focus-within/gh:pr-7'),
|
||||
)}>
|
||||
{group.isArchivedBucket ? (
|
||||
<RiArchiveLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (!group.isMain || isGitProject) ? (
|
||||
showInlinePrTitle && prIndicator ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex">
|
||||
<RiGitBranchLine
|
||||
className="h-3.5 w-3.5 flex-shrink-0 translate-y-[0.5px] text-muted-foreground"
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
|
||||
<div className="space-y-1 text-xs">
|
||||
{(baseBranchLabel || headBranchLabel) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{baseBranchLabel && headBranchLabel ? (
|
||||
<>
|
||||
<span>{baseBranchLabel}</span>
|
||||
<RiArrowLeftLongLine className="mx-0.5 inline h-3 w-3 align-[-2px]" />
|
||||
<span>{headBranchLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
|
||||
{(mergeabilityLabel || checksSummary) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{mergeabilityLabel ?? ''}
|
||||
{mergeabilityLabel && checksSummary ? ' • ' : ''}
|
||||
{checksSummary ?? ''}
|
||||
{checksTail ? ` (${checksTail})` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<RiGitBranchLine
|
||||
className="h-3.5 w-3.5 flex-shrink-0 translate-y-[0.5px] text-muted-foreground"
|
||||
style={branchIconColor ? { color: branchIconColor } : undefined}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
<div className="min-w-0 flex flex-col justify-center">
|
||||
<p className={cn('text-[14px] font-semibold truncate', isActiveGroup ? 'text-primary' : 'text-muted-foreground')}>
|
||||
{showInlinePrTitle && prIndicator ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-baseline gap-1">
|
||||
{prIndicator.url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-baseline leading-none underline hover:no-underline"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={handlePrLinkClick}
|
||||
>
|
||||
#{prIndicator.number}
|
||||
</button>
|
||||
) : (
|
||||
<span className="leading-none">#{prIndicator.number}</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={6} align="start" className="max-w-sm">
|
||||
<div className="space-y-1 text-xs">
|
||||
{(baseBranchLabel || headBranchLabel) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{baseBranchLabel && headBranchLabel ? (
|
||||
<>
|
||||
<span>{baseBranchLabel}</span>
|
||||
<RiArrowLeftLongLine className="mx-0.5 inline h-3 w-3 align-[-2px]" />
|
||||
<span>{headBranchLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{baseBranchLabel ?? headBranchLabel ?? ''}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{mergeStateLabel ? <div className="text-muted-foreground truncate">{mergeStateLabel}</div> : null}
|
||||
{(mergeabilityLabel || checksSummary) ? (
|
||||
<div className="text-muted-foreground truncate">
|
||||
{mergeabilityLabel ?? ''}
|
||||
{mergeabilityLabel && checksSummary ? ' • ' : ''}
|
||||
{checksSummary ?? ''}
|
||||
{checksTail ? ` (${checksTail})` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<span>{` ${group.branch}`}</span>
|
||||
</>
|
||||
) : (
|
||||
renderHighlightedText(group.label, normalizedSessionSearchQuery)
|
||||
)}
|
||||
</p>
|
||||
{!showInlinePrTitle && showBranchSubtitle ? (
|
||||
<span className="text-[10px] sm:text-[11px] text-muted-foreground/80 truncate leading-tight">
|
||||
{prIndicator ? (
|
||||
<>
|
||||
{prIndicator.url ? (
|
||||
<button
|
||||
type="button"
|
||||
className="underline hover:no-underline"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={handlePrLinkClick}
|
||||
>
|
||||
#{prIndicator.number}
|
||||
</button>
|
||||
) : (
|
||||
<span>#{prIndicator.number}</span>
|
||||
)}
|
||||
{group.branch ? <span>{` ${group.branch}`}</span> : null}
|
||||
</>
|
||||
) : (
|
||||
group.branch
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<RiArrowRightSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<RiArrowDownSLine className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{group.directory && !group.isMain && group.worktree ? (
|
||||
<div className={cn('absolute right-7 top-1/2 -translate-y-1/2 z-10 transition-opacity', mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
sessionEvents.requestDelete({
|
||||
sessions: allGroupSessions,
|
||||
mode: 'worktree',
|
||||
worktree: group.worktree,
|
||||
});
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground hover:text-destructive hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label={`Delete ${group.label}`}
|
||||
>
|
||||
<RiDeleteBinLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Delete worktree</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
{group.directory ? (
|
||||
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/gh:opacity-100 group-focus-within/gh:opacity-100')}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (projectId && projectId !== activeProjectId) setActiveProjectIdOnly(projectId);
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) setSessionSwitcherOpen(false);
|
||||
openNewSessionDraft({ 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"
|
||||
aria-label={`New session in ${group.label}`}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New session</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!isCollapsed ? <div className="oc-group-body pb-3">{body}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { GridLoader } from '@/components/ui/grid-loader';
|
||||
import {
|
||||
RiAddLine,
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiChat4Line,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiDeleteBinLine,
|
||||
RiErrorWarningLine,
|
||||
RiFileCopyLine,
|
||||
RiFileEditLine,
|
||||
RiFolderLine,
|
||||
RiLinkUnlinkM,
|
||||
RiMore2Line,
|
||||
RiPencilAiLine,
|
||||
RiPushpinLine,
|
||||
RiRobot2Line,
|
||||
RiShare2Line,
|
||||
RiShieldLine,
|
||||
RiUnpinLine,
|
||||
} from '@remixicon/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { DraggableSessionRow } from './sessionFolderDnd';
|
||||
import type { SessionNode, SessionSummaryMeta } from './types';
|
||||
import { formatSessionDateLabel, normalizePath, renderHighlightedText, resolveSessionDiffStats } from './utils';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
|
||||
const ATTENTION_DIAMOND_INDICES = new Set([1, 3, 4, 5, 7]);
|
||||
|
||||
const getAttentionDiamondDelay = (index: number): string => {
|
||||
return index === 4 ? '0ms' : '130ms';
|
||||
};
|
||||
|
||||
type Folder = { id: string; name: string; sessionIds: string[] };
|
||||
|
||||
type Props = {
|
||||
node: SessionNode;
|
||||
depth?: number;
|
||||
groupDirectory?: string | null;
|
||||
projectId?: string | null;
|
||||
archivedBucket?: boolean;
|
||||
directoryStatus: Map<string, 'unknown' | 'exists' | 'missing'>;
|
||||
sessionMemoryState: Map<string, { isZombie?: boolean }>;
|
||||
currentSessionId: string | null;
|
||||
pinnedSessionIds: Set<string>;
|
||||
expandedParents: Set<string>;
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
sessionAttentionStates: Map<string, { needsAttention?: boolean }>;
|
||||
notifyOnSubtasks: boolean;
|
||||
sessionStatus?: Map<string, { type?: string }>;
|
||||
permissions: Map<string, unknown[]>;
|
||||
editingId: string | null;
|
||||
setEditingId: (id: string | null) => void;
|
||||
editTitle: string;
|
||||
setEditTitle: (value: string) => void;
|
||||
handleSaveEdit: () => void;
|
||||
handleCancelEdit: () => void;
|
||||
toggleParent: (sessionId: string) => void;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, isMissingDirectory: boolean, projectId?: string | null) => void;
|
||||
handleSessionDoubleClick: () => void;
|
||||
togglePinnedSession: (sessionId: string) => void;
|
||||
handleShareSession: (session: Session) => void;
|
||||
copiedSessionId: string | null;
|
||||
handleCopyShareUrl: (url: string, sessionId: string) => void;
|
||||
handleUnshareSession: (sessionId: string) => void;
|
||||
openMenuSessionId: string | null;
|
||||
setOpenMenuSessionId: (id: 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;
|
||||
openContextPanelTab: (directory: string, options: { mode: 'chat'; dedupeKey: string; label: string }) => void;
|
||||
handleDeleteSession: (session: Session, source?: { archivedBucket?: boolean }) => void;
|
||||
mobileVariant: boolean;
|
||||
renderSessionNode: (node: SessionNode, depth?: number, groupDirectory?: string | null, projectId?: string | null, archivedBucket?: boolean) => React.ReactNode;
|
||||
};
|
||||
|
||||
export function SessionNodeItem(props: Props): React.ReactNode {
|
||||
const {
|
||||
node,
|
||||
depth = 0,
|
||||
groupDirectory,
|
||||
projectId,
|
||||
archivedBucket = false,
|
||||
directoryStatus,
|
||||
sessionMemoryState,
|
||||
currentSessionId,
|
||||
pinnedSessionIds,
|
||||
expandedParents,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
sessionAttentionStates,
|
||||
notifyOnSubtasks,
|
||||
sessionStatus,
|
||||
permissions,
|
||||
editingId,
|
||||
setEditingId,
|
||||
editTitle,
|
||||
setEditTitle,
|
||||
handleSaveEdit,
|
||||
handleCancelEdit,
|
||||
toggleParent,
|
||||
handleSessionSelect,
|
||||
handleSessionDoubleClick,
|
||||
togglePinnedSession,
|
||||
handleShareSession,
|
||||
copiedSessionId,
|
||||
handleCopyShareUrl,
|
||||
handleUnshareSession,
|
||||
openMenuSessionId,
|
||||
setOpenMenuSessionId,
|
||||
renamingFolderId,
|
||||
getFoldersForScope,
|
||||
getSessionFolderId,
|
||||
removeSessionFromFolder,
|
||||
addSessionToFolder,
|
||||
createFolderAndStartRename,
|
||||
openContextPanelTab,
|
||||
handleDeleteSession,
|
||||
mobileVariant,
|
||||
renderSessionNode,
|
||||
} = props;
|
||||
|
||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||
const isMinimalMode = displayMode === 'minimal';
|
||||
|
||||
const session = node.session;
|
||||
const sessionDirectory =
|
||||
normalizePath((session as Session & { directory?: string | null }).directory ?? null)
|
||||
?? normalizePath(groupDirectory ?? null);
|
||||
const directoryState = sessionDirectory ? directoryStatus.get(sessionDirectory) : null;
|
||||
const isMissingDirectory = directoryState === 'missing';
|
||||
const memoryState = sessionMemoryState.get(session.id);
|
||||
const isActive = currentSessionId === session.id;
|
||||
const sessionTitle = session.title || 'Untitled Session';
|
||||
const hasChildren = node.children.length > 0;
|
||||
const isPinnedSession = pinnedSessionIds.has(session.id);
|
||||
const isExpanded = hasSessionSearchQuery ? true : expandedParents.has(session.id);
|
||||
const isSubtaskSession = Boolean((session as Session & { parentID?: string | null }).parentID);
|
||||
const rawNeedsAttention = sessionAttentionStates.get(session.id)?.needsAttention === true;
|
||||
const needsAttention = rawNeedsAttention && (!isSubtaskSession || notifyOnSubtasks);
|
||||
const sessionSummary = session.summary as SessionSummaryMeta | undefined;
|
||||
const sessionDiffStats = resolveSessionDiffStats(sessionSummary);
|
||||
|
||||
if (editingId === session.id) {
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
className={cn('group relative flex items-center rounded-md px-1.5 py-1', 'bg-interactive-selection', depth > 0 && 'pl-[20px]')}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0">
|
||||
<form
|
||||
className="flex w-full items-center gap-2"
|
||||
data-keyboard-avoid="true"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSaveEdit();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={editTitle}
|
||||
onChange={(event) => setEditTitle(event.target.value)}
|
||||
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
placeholder="Rename session"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
handleCancelEdit();
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="shrink-0 text-muted-foreground hover:text-foreground"><RiCheckLine className="size-4" /></button>
|
||||
<button type="button" onClick={handleCancelEdit} className="shrink-0 text-muted-foreground hover:text-foreground"><RiCloseLine className="size-4" /></button>
|
||||
</form>
|
||||
{!isMinimalMode ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
|
||||
{hasChildren ? <span className="inline-flex items-center justify-center flex-shrink-0">{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}</span> : null}
|
||||
<span className="flex-shrink-0">{formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())}</span>
|
||||
{sessionDiffStats ? <span className="flex-shrink-0"><span className="text-status-success/80">+{sessionDiffStats.additions}</span><span className="text-muted-foreground/60">/</span><span className="text-status-error/80">-{sessionDiffStats.deletions}</span></span> : null}
|
||||
{session.share ? <RiShare2Line className="h-3 w-3 text-[color:var(--status-info)] flex-shrink-0" /> : null}
|
||||
{(sessionSummary?.files ?? 0) > 0 || hasChildren ? (
|
||||
<span className="flex items-center gap-2 flex-shrink-0">
|
||||
{(sessionSummary?.files ?? 0) > 0 ? <Tooltip><TooltipTrigger asChild><span className="inline-flex items-center gap-0.5"><RiFileEditLine className="h-3 w-3 text-muted-foreground/70" /><span>{sessionSummary!.files}</span></span></TooltipTrigger><TooltipContent side="bottom" sideOffset={4}><p>{sessionSummary!.files} changed {sessionSummary!.files === 1 ? 'file' : 'files'}</p></TooltipContent></Tooltip> : null}
|
||||
{hasChildren ? <Tooltip><TooltipTrigger asChild><span className="inline-flex items-center gap-0.5"><RiRobot2Line className="h-3 w-3 text-muted-foreground/70" /><span>{node.children.length}</span></span></TooltipTrigger><TooltipContent side="bottom" sideOffset={4}><p>{node.children.length} {node.children.length === 1 ? 'sub-session' : 'sub-sessions'}</p></TooltipContent></Tooltip> : null}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statusType = sessionStatus?.get(session.id)?.type ?? 'idle';
|
||||
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
||||
const pendingPermissionCount = permissions.get(session.id)?.length ?? 0;
|
||||
const showUnreadStatus = !isStreaming && needsAttention && !isActive;
|
||||
const showStatusMarker = isStreaming || showUnreadStatus;
|
||||
|
||||
const streamingIndicator = memoryState?.isZombie
|
||||
? <RiErrorWarningLine className="h-4 w-4 text-status-warning" />
|
||||
: null;
|
||||
|
||||
return (
|
||||
<React.Fragment key={session.id}>
|
||||
<DraggableSessionRow sessionId={session.id} sessionDirectory={sessionDirectory ?? null} sessionTitle={sessionTitle}>
|
||||
<div
|
||||
className={cn('group relative flex items-center rounded-md px-1.5 py-1', isActive ? 'bg-interactive-selection' : 'hover:bg-interactive-hover', isMissingDirectory ? 'opacity-75' : '', depth > 0 && 'pl-[20px]')}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setOpenMenuSessionId(session.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
{isMinimalMode ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isMissingDirectory}
|
||||
onClick={() => handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSessionDoubleClick();
|
||||
}}
|
||||
className={cn('flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]', mobileVariant ? 'pr-7' : 'group-hover:pr-5 group-focus-within:pr-5')}
|
||||
>
|
||||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-2')}>
|
||||
{isMinimalMode && hasChildren ? (
|
||||
<span role="button" tabIndex={0} onClick={(event) => { event.stopPropagation(); toggleParent(session.id); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); toggleParent(session.id); } }} className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 flex-shrink-0 rounded-sm" aria-label={isExpanded ? 'Collapse subsessions' : 'Expand subsessions'}>
|
||||
{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
|
||||
</span>
|
||||
) : null}
|
||||
{showStatusMarker ? (
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
{isStreaming ? (
|
||||
<GridLoader size="xs" className="text-primary" />
|
||||
) : (
|
||||
<span className="grid grid-cols-3 gap-[1px] text-[var(--status-info)]" aria-label="Unread updates" title="Unread updates">
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
ATTENTION_DIAMOND_INDICES.has(i) ? (
|
||||
<span key={i} className="h-[3px] w-[3px] rounded-full bg-current animate-attention-diamond-pulse" style={{ animationDelay: getAttentionDiamondDelay(i) }} />
|
||||
) : (
|
||||
<span key={i} className="h-[3px] w-[3px]" />
|
||||
)
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
|
||||
<div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title="Permission required" aria-label="Permission required">
|
||||
<RiShieldLine className="h-3 w-3" />
|
||||
<span className="leading-none">{pendingPermissionCount}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8} className="max-w-xs">
|
||||
<div className="flex flex-col gap-1 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())}</span>
|
||||
{sessionDiffStats ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-status-success">+{sessionDiffStats.additions}</span>
|
||||
<span className="text-muted-foreground">/</span>
|
||||
<span className="text-status-error">-{sessionDiffStats.deletions}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{session.share ? (
|
||||
<div className="flex items-center gap-1 text-[color:var(--status-info)]">
|
||||
<RiShare2Line className="h-3 w-3" />
|
||||
<span>Shared session</span>
|
||||
</div>
|
||||
) : null}
|
||||
{(sessionSummary?.files ?? 0) > 0 ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<RiFileEditLine className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{sessionSummary!.files} changed {sessionSummary!.files === 1 ? 'file' : 'files'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{hasChildren ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<RiRobot2Line className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">{node.children.length} {node.children.length === 1 ? 'sub-session' : 'sub-sessions'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{isMissingDirectory ? (
|
||||
<div className="flex items-center gap-1 text-status-warning">
|
||||
<RiErrorWarningLine className="h-3 w-3" />
|
||||
<span>Directory missing</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isMissingDirectory}
|
||||
onClick={() => handleSessionSelect(session.id, sessionDirectory, isMissingDirectory, projectId)}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSessionDoubleClick();
|
||||
}}
|
||||
className={cn('flex min-w-0 flex-1 cursor-pointer flex-col gap-0 overflow-hidden rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 text-foreground select-none disabled:cursor-not-allowed transition-[padding]', mobileVariant ? 'pr-7' : 'group-hover:pr-5 group-focus-within:pr-5')}
|
||||
>
|
||||
<div className={cn('flex w-full items-center min-w-0 flex-1 overflow-hidden', isMinimalMode ? 'gap-1' : 'gap-2')}>
|
||||
{showStatusMarker ? (
|
||||
<span className="inline-flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
{isStreaming ? (
|
||||
<GridLoader size="xs" className="text-primary" />
|
||||
) : (
|
||||
<span className="grid grid-cols-3 gap-[1px] text-[var(--status-info)]" aria-label="Unread updates" title="Unread updates">
|
||||
{Array.from({ length: 9 }, (_, i) => (
|
||||
ATTENTION_DIAMOND_INDICES.has(i) ? (
|
||||
<span key={i} className="h-[3px] w-[3px] rounded-full bg-current animate-attention-diamond-pulse" style={{ animationDelay: getAttentionDiamondDelay(i) }} />
|
||||
) : (
|
||||
<span key={i} className="h-[3px] w-[3px]" />
|
||||
)
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
{isPinnedSession ? <RiPushpinLine className="h-3 w-3 flex-shrink-0 text-primary" aria-label="Pinned session" /> : null}
|
||||
<div className="block min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">{renderHighlightedText(sessionTitle, normalizedSessionSearchQuery)}</div>
|
||||
{pendingPermissionCount > 0 ? (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-destructive/10 px-1 py-0.5 text-[0.7rem] text-destructive flex-shrink-0" title="Permission required" aria-label="Permission required">
|
||||
<RiShieldLine className="h-3 w-3" />
|
||||
<span className="leading-none">{pendingPermissionCount}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isMinimalMode ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground/60 min-w-0 overflow-hidden leading-tight" style={{ fontSize: 'calc(var(--text-ui-label) * 0.85)' }}>
|
||||
{hasChildren ? (
|
||||
<span role="button" tabIndex={0} onClick={(event) => { event.stopPropagation(); toggleParent(session.id); }} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); event.stopPropagation(); toggleParent(session.id); } }} className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 flex-shrink-0 rounded-sm" aria-label={isExpanded ? 'Collapse subsessions' : 'Expand subsessions'}>
|
||||
{isExpanded ? <RiArrowDownSLine className="h-3 w-3" /> : <RiArrowRightSLine className="h-3 w-3" />}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="flex-shrink-0">{formatSessionDateLabel(session.time?.updated || session.time?.created || Date.now())}</span>
|
||||
{sessionDiffStats ? <span className="flex-shrink-0"><span className="text-status-success/80">+{sessionDiffStats.additions}</span><span className="text-muted-foreground/60">/</span><span className="text-status-error/80">-{sessionDiffStats.deletions}</span></span> : null}
|
||||
{session.share ? <RiShare2Line className="h-3 w-3 text-[color:var(--status-info)] flex-shrink-0" /> : null}
|
||||
{(sessionSummary?.files ?? 0) > 0 || hasChildren ? (
|
||||
<span className="flex items-center gap-2 flex-shrink-0">
|
||||
{(sessionSummary?.files ?? 0) > 0 ? <Tooltip><TooltipTrigger asChild><span className="inline-flex items-center gap-0.5"><RiFileEditLine className="h-3 w-3 text-muted-foreground/70" /><span>{sessionSummary!.files}</span></span></TooltipTrigger><TooltipContent side="bottom" sideOffset={4}><p>{sessionSummary!.files} changed {sessionSummary!.files === 1 ? 'file' : 'files'}</p></TooltipContent></Tooltip> : null}
|
||||
{hasChildren ? <Tooltip><TooltipTrigger asChild><span className="inline-flex items-center gap-0.5"><RiRobot2Line className="h-3 w-3 text-muted-foreground/70" /><span>{node.children.length}</span></span></TooltipTrigger><TooltipContent side="bottom" sideOffset={4}><p>{node.children.length} {node.children.length === 1 ? 'sub-session' : 'sub-sessions'}</p></TooltipContent></Tooltip> : null}
|
||||
</span>
|
||||
) : null}
|
||||
{isMissingDirectory ? <span className="inline-flex items-center gap-0.5 text-status-warning flex-shrink-0"><RiErrorWarningLine className="h-3 w-3" />Missing</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{streamingIndicator && !mobileVariant ? (
|
||||
<div className={cn('absolute top-1/2 -translate-y-1/2 z-10', isMinimalMode ? 'right-7' : 'right-[30px]')}>
|
||||
{streamingIndicator}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cn('absolute right-0.5 top-1/2 -translate-y-1/2 z-10 transition-opacity', mobileVariant ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100')}>
|
||||
<DropdownMenu open={openMenuSessionId === session.id} onOpenChange={(open) => setOpenMenuSessionId(open ? session.id : null)}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="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" aria-label="Session menu" onClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]" onCloseAutoFocus={(event) => { if (renamingFolderId) event.preventDefault(); }}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setEditingId(session.id);
|
||||
setEditTitle(sessionTitle);
|
||||
}}
|
||||
className="[&>svg]:mr-1"
|
||||
>
|
||||
<RiPencilAiLine className="mr-1 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => togglePinnedSession(session.id)} className="[&>svg]:mr-1">
|
||||
{isPinnedSession ? <RiUnpinLine className="mr-1 h-4 w-4" /> : <RiPushpinLine className="mr-1 h-4 w-4" />}
|
||||
{isPinnedSession ? 'Unpin session' : 'Pin session'}
|
||||
</DropdownMenuItem>
|
||||
{!session.share ? (
|
||||
<DropdownMenuItem onClick={() => handleShareSession(session)} className="[&>svg]:mr-1">
|
||||
<RiShare2Line className="mr-1 h-4 w-4" />
|
||||
Share
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => { if (session.share?.url) handleCopyShareUrl(session.share.url, session.id); }} className="[&>svg]:mr-1">
|
||||
{copiedSessionId === session.id ? <><RiCheckLine className="mr-1 h-4 w-4" style={{ color: 'var(--status-success)' }} />Copied</> : <><RiFileCopyLine className="mr-1 h-4 w-4" />Copy link</>}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleUnshareSession(session.id)} className="[&>svg]:mr-1">
|
||||
<RiLinkUnlinkM className="mr-1 h-4 w-4" />
|
||||
Unshare
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{sessionDirectory && !archivedBucket ? (() => {
|
||||
const scopeFolders = getFoldersForScope(sessionDirectory);
|
||||
const currentFolderId = getSessionFolderId(sessionDirectory, session.id);
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="[&>svg]:mr-1"><RiFolderLine className="h-4 w-4" />Move to folder</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[180px]">
|
||||
{scopeFolders.length === 0 ? (
|
||||
<DropdownMenuItem disabled className="text-muted-foreground">No folders yet</DropdownMenuItem>
|
||||
) : (
|
||||
scopeFolders.map((folder) => (
|
||||
<DropdownMenuItem key={folder.id} onClick={() => { if (currentFolderId === folder.id) removeSessionFromFolder(sessionDirectory, session.id); else addSessionToFolder(sessionDirectory, folder.id, session.id); }}>
|
||||
<span className="flex-1 truncate">{folder.name}</span>
|
||||
{currentFolderId === folder.id ? <RiCheckLine className="ml-2 h-3.5 w-3.5 text-primary flex-shrink-0" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => { const newFolder = createFolderAndStartRename(sessionDirectory); if (!newFolder) return; addSessionToFolder(sessionDirectory, newFolder.id, session.id); }}>
|
||||
<RiAddLine className="mr-1 h-4 w-4" />
|
||||
New folder...
|
||||
</DropdownMenuItem>
|
||||
{currentFolderId ? (
|
||||
<DropdownMenuItem onClick={() => { removeSessionFromFolder(sessionDirectory, session.id); }} className="text-destructive focus:text-destructive">
|
||||
<RiCloseLine className="mr-1 h-4 w-4" />
|
||||
Remove from folder
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</>
|
||||
);
|
||||
})() : null}
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={!sessionDirectory}
|
||||
onClick={() => {
|
||||
if (!sessionDirectory) return;
|
||||
openContextPanelTab(sessionDirectory, {
|
||||
mode: 'chat',
|
||||
dedupeKey: `session:${session.id}`,
|
||||
label: sessionTitle,
|
||||
});
|
||||
}}
|
||||
className="[&>svg]:mr-1"
|
||||
>
|
||||
<RiChat4Line className="mr-1 h-4 w-4" />
|
||||
<span className="truncate">Open in Side Panel</span>
|
||||
<span className="shrink-0 typography-micro px-1 rounded leading-none pb-px text-[var(--status-warning)] bg-[var(--status-warning)]/10">beta</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive focus:text-destructive [&>svg]:mr-1" onClick={() => handleDeleteSession(session, { archivedBucket })}>
|
||||
<RiDeleteBinLine className="mr-1 h-4 w-4" />
|
||||
{archivedBucket ? 'Delete' : 'Archive'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</DraggableSessionRow>
|
||||
{hasChildren && isExpanded
|
||||
? node.children.map((child) => renderSessionNode(child, depth + 1, sessionDirectory ?? groupDirectory, projectId, archivedBucket))
|
||||
: null}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiEqualizer2Line,
|
||||
RiNodeTree,
|
||||
RiPencilAiLine,
|
||||
RiSearchLine,
|
||||
RiStickyNoteLine,
|
||||
} from '@remixicon/react';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import { ProjectNotesTodoPanel } from '../ProjectNotesTodoPanel';
|
||||
import { formatDirectoryName } from '@/lib/utils';
|
||||
import { formatProjectLabel } from './utils';
|
||||
import type { ProjectRef } from '@/lib/openchamberConfig';
|
||||
import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore';
|
||||
|
||||
type ProjectItem = {
|
||||
id: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
type ActiveProject = {
|
||||
id: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
} | null;
|
||||
|
||||
type Props = {
|
||||
hideDirectoryControls: boolean;
|
||||
hideProjectSelector: boolean;
|
||||
activeProjectForHeader: ActiveProject;
|
||||
homeDirectory: string | null;
|
||||
normalizedProjects: ProjectItem[];
|
||||
activeProjectId: string | null;
|
||||
setActiveProjectIdOnly: (projectId: string) => void;
|
||||
isProjectRenameInline: boolean;
|
||||
setIsProjectRenameInline: (value: boolean) => void;
|
||||
handleStartInlineProjectRename: () => void;
|
||||
handleSaveInlineProjectRename: () => void;
|
||||
projectRenameDraft: string;
|
||||
setProjectRenameDraft: (value: string) => void;
|
||||
removeProject: (projectId: string) => void;
|
||||
handleOpenDirectoryDialog: () => void;
|
||||
addProjectButtonClass: string;
|
||||
headerActionIconClass: string;
|
||||
reserveHeaderActionsSpace: boolean;
|
||||
stableActiveProjectIsRepo: boolean;
|
||||
useMobileNotesPanel: boolean;
|
||||
projectNotesPanelOpen: boolean;
|
||||
setProjectNotesPanelOpen: (open: boolean) => void;
|
||||
activeProjectRefForHeader: ProjectRef | null;
|
||||
openMultiRunLauncher: () => void;
|
||||
headerActionButtonClass: string;
|
||||
setNewWorktreeDialogOpen: (open: boolean) => void;
|
||||
setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void;
|
||||
isSessionSearchOpen: boolean;
|
||||
setIsSessionSearchOpen: (open: boolean | ((prev: boolean) => boolean)) => void;
|
||||
sessionSearchInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
sessionSearchQuery: string;
|
||||
setSessionSearchQuery: (value: string) => void;
|
||||
hasSessionSearchQuery: boolean;
|
||||
searchMatchCount: number;
|
||||
};
|
||||
|
||||
export function SidebarHeader(props: Props): React.ReactNode {
|
||||
const {
|
||||
hideDirectoryControls,
|
||||
hideProjectSelector,
|
||||
activeProjectForHeader,
|
||||
homeDirectory,
|
||||
normalizedProjects,
|
||||
activeProjectId,
|
||||
setActiveProjectIdOnly,
|
||||
isProjectRenameInline,
|
||||
setIsProjectRenameInline,
|
||||
handleStartInlineProjectRename,
|
||||
handleSaveInlineProjectRename,
|
||||
projectRenameDraft,
|
||||
setProjectRenameDraft,
|
||||
removeProject,
|
||||
addProjectButtonClass,
|
||||
headerActionIconClass,
|
||||
reserveHeaderActionsSpace,
|
||||
stableActiveProjectIsRepo,
|
||||
useMobileNotesPanel,
|
||||
projectNotesPanelOpen,
|
||||
setProjectNotesPanelOpen,
|
||||
activeProjectRefForHeader,
|
||||
openMultiRunLauncher,
|
||||
headerActionButtonClass,
|
||||
setNewWorktreeDialogOpen,
|
||||
setActiveMainTab,
|
||||
isSessionSearchOpen,
|
||||
setIsSessionSearchOpen,
|
||||
sessionSearchInputRef,
|
||||
sessionSearchQuery,
|
||||
setSessionSearchQuery,
|
||||
hasSessionSearchQuery,
|
||||
searchMatchCount,
|
||||
} = props;
|
||||
|
||||
const displayMode = useSessionDisplayStore((state) => state.displayMode);
|
||||
const setDisplayMode = useSessionDisplayStore((state) => state.setDisplayMode);
|
||||
|
||||
if (hideDirectoryControls) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`select-none pl-3.5 pr-2 flex-shrink-0 border-b border-border/60 ${hideProjectSelector ? 'py-1' : 'py-1.5'}`}>
|
||||
{!hideProjectSelector && (
|
||||
<div className="flex h-8 items-center justify-between gap-2">
|
||||
<DropdownMenu
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setIsProjectRenameInline(false);
|
||||
}}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-8 min-w-0 max-w-[calc(100%-2.5rem)] cursor-pointer items-center gap-1 rounded-md px-2 text-left text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
<span className="text-base font-semibold truncate">
|
||||
{activeProjectForHeader
|
||||
? formatProjectLabel(
|
||||
activeProjectForHeader.label?.trim()
|
||||
|| formatDirectoryName(activeProjectForHeader.normalizedPath, homeDirectory)
|
||||
|| activeProjectForHeader.normalizedPath,
|
||||
)
|
||||
: 'Projects'}
|
||||
</span>
|
||||
<RiArrowDownSLine className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-[220px] max-w-[320px]">
|
||||
{normalizedProjects.map((project) => {
|
||||
const label = formatProjectLabel(
|
||||
project.label?.trim()
|
||||
|| formatDirectoryName(project.normalizedPath, homeDirectory)
|
||||
|| project.normalizedPath,
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onClick={() => setActiveProjectIdOnly(project.id)}
|
||||
className={`truncate ${project.id === activeProjectId ? 'text-primary' : ''}`}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<div className="my-1 h-px bg-border/70" />
|
||||
{!isProjectRenameInline ? (
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
handleStartInlineProjectRename();
|
||||
}}
|
||||
className="gap-2"
|
||||
>
|
||||
<RiPencilAiLine className="h-4 w-4" />
|
||||
Rename project
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<div className="px-2 py-1.5">
|
||||
<form
|
||||
className="flex items-center gap-1"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
handleSaveInlineProjectRename();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={projectRenameDraft}
|
||||
onChange={(event) => setProjectRenameDraft(event.target.value)}
|
||||
className="h-7 flex-1 rounded border border-border bg-transparent px-2 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
placeholder="Rename project"
|
||||
autoFocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
setIsProjectRenameInline(false);
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="submit" className="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground">
|
||||
<RiCheckLine className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsProjectRenameInline(false)}
|
||||
className="inline-flex h-7 w-7 cursor-pointer items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!activeProjectForHeader) return;
|
||||
removeProject(activeProjectForHeader.id);
|
||||
}}
|
||||
className="text-destructive focus:text-destructive gap-2"
|
||||
>
|
||||
<RiCloseLine className="h-4 w-4" />
|
||||
Close project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={addProjectButtonClass}
|
||||
aria-label="Session display mode"
|
||||
>
|
||||
<RiEqualizer2Line className={headerActionIconClass} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Display mode</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[160px]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('default')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Default</span>
|
||||
{displayMode === 'default' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('minimal')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Minimal</span>
|
||||
{displayMode === 'minimal' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reserveHeaderActionsSpace ? (
|
||||
<div className="-ml-1 flex h-auto min-h-8 flex-col gap-1">
|
||||
{activeProjectForHeader ? (
|
||||
<>
|
||||
<div className="flex h-8 -translate-y-px items-center justify-between gap-1.5 rounded-md pl-0 pr-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{stableActiveProjectIsRepo ? (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!activeProjectForHeader) return;
|
||||
if (activeProjectForHeader.id !== activeProjectId) {
|
||||
setActiveProjectIdOnly(activeProjectForHeader.id);
|
||||
}
|
||||
setActiveMainTab('chat');
|
||||
setNewWorktreeDialogOpen(true);
|
||||
}}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="New worktree"
|
||||
>
|
||||
<RiNodeTree className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New worktree</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openMultiRunLauncher}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="New multi-run"
|
||||
>
|
||||
<ArrowsMerge className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>New multi-run</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{useMobileNotesPanel ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProjectNotesPanelOpen(true)}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Project notes and todos"
|
||||
>
|
||||
<RiStickyNoteLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Project notes</p></TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<DropdownMenu open={projectNotesPanelOpen} onOpenChange={setProjectNotesPanelOpen} modal={false}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Project notes and todos"
|
||||
>
|
||||
<RiStickyNoteLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Project notes</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="start" className="w-[340px] p-0">
|
||||
<ProjectNotesTodoPanel
|
||||
projectRef={activeProjectRefForHeader}
|
||||
canCreateWorktree={stableActiveProjectIsRepo}
|
||||
onActionComplete={() => setProjectNotesPanelOpen(false)}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSessionSearchOpen((prev) => !prev)}
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Search sessions"
|
||||
aria-expanded={isSessionSearchOpen}
|
||||
>
|
||||
<RiSearchLine className={headerActionIconClass} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Search sessions</p></TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={headerActionButtonClass}
|
||||
aria-label="Session display mode"
|
||||
>
|
||||
<RiEqualizer2Line className={headerActionIconClass} />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}><p>Display mode</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end" className="min-w-[160px]">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('default')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Default</span>
|
||||
{displayMode === 'default' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setDisplayMode('minimal')}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span>Minimal</span>
|
||||
{displayMode === 'minimal' ? <RiCheckLine className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{isSessionSearchOpen ? (
|
||||
<div className="px-1 pb-1">
|
||||
<div className="mb-1 flex items-center justify-between px-0.5 typography-micro text-muted-foreground/80">
|
||||
{hasSessionSearchQuery ? (
|
||||
<span>{searchMatchCount} {searchMatchCount === 1 ? 'match' : 'matches'}</span>
|
||||
) : <span />}
|
||||
<span>Esc to clear</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<RiSearchLine className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<input
|
||||
ref={sessionSearchInputRef}
|
||||
value={sessionSearchQuery}
|
||||
onChange={(event) => setSessionSearchQuery(event.target.value)}
|
||||
placeholder="Search sessions..."
|
||||
className="h-8 w-full rounded-md border border-border bg-transparent pl-8 pr-8 typography-ui-label text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
if (hasSessionSearchQuery) {
|
||||
setSessionSearchQuery('');
|
||||
} else {
|
||||
setIsSessionSearchOpen(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{sessionSearchQuery.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSessionSearchQuery('')}
|
||||
className="absolute right-1 top-1/2 inline-flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-interactive-hover/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<RiCloseLine className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { SortableContext, arrayMove, sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { formatDirectoryName, formatPathForDisplay, cn } from '@/lib/utils';
|
||||
import type { SessionGroup } from './types';
|
||||
import { SortableGroupItem, SortableProjectItem } from './sortableItems';
|
||||
import { formatProjectLabel } from './utils';
|
||||
|
||||
type ProjectSection = {
|
||||
project: {
|
||||
id: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
};
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
sectionsForRender: ProjectSection[];
|
||||
projectSections: ProjectSection[];
|
||||
activeProjectId: string | null;
|
||||
showOnlyMainWorkspace: boolean;
|
||||
hasSessionSearchQuery: boolean;
|
||||
emptyState: React.ReactNode;
|
||||
searchEmptyState: React.ReactNode;
|
||||
renderGroupSessions: (group: SessionGroup, groupKey: string, projectId?: string | null, hideGroupLabel?: boolean) => React.ReactNode;
|
||||
homeDirectory: string | null;
|
||||
collapsedProjects: Set<string>;
|
||||
hideDirectoryControls: boolean;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
hoveredProjectId: string | null;
|
||||
setHoveredProjectId: (id: string | null) => void;
|
||||
isDesktopShellRuntime: boolean;
|
||||
stuckProjectHeaders: Set<string>;
|
||||
mobileVariant: boolean;
|
||||
toggleProject: (id: string) => void;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void;
|
||||
createWorktreeSession: () => void;
|
||||
openMultiRunLauncher: () => void;
|
||||
setEditingProjectId: (id: string | null) => void;
|
||||
setEditProjectTitle: (title: string) => void;
|
||||
editingProjectId: string | null;
|
||||
editProjectTitle: string;
|
||||
handleSaveProjectEdit: () => void;
|
||||
handleCancelProjectEdit: () => void;
|
||||
removeProject: (id: string) => void;
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
settingsAutoCreateWorktree: boolean;
|
||||
getOrderedGroups: (projectId: string, groups: SessionGroup[]) => SessionGroup[];
|
||||
setGroupOrderByProject: React.Dispatch<React.SetStateAction<Map<string, string[]>>>;
|
||||
};
|
||||
|
||||
export function SidebarProjectsList(props: Props): React.ReactNode {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
if (props.projectSections.length === 0) {
|
||||
return <ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-1', props.mobileVariant ? '' : '')}>{props.emptyState}</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
if (props.sectionsForRender.length === 0) {
|
||||
return <ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-1', props.mobileVariant ? '' : '')}>{props.searchEmptyState}</ScrollableOverlay>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className={cn('space-y-1 pb-1 pl-2.5 pr-1', props.mobileVariant ? '' : '')}>
|
||||
{props.showOnlyMainWorkspace ? (
|
||||
<div className="space-y-[0.6rem] py-1">
|
||||
{(() => {
|
||||
const activeSection = props.sectionsForRender.find((section) => section.project.id === props.activeProjectId) ?? props.sectionsForRender[0];
|
||||
if (!activeSection) {
|
||||
return props.hasSessionSearchQuery ? props.searchEmptyState : props.emptyState;
|
||||
}
|
||||
const group =
|
||||
activeSection.groups.find((candidate) => candidate.isMain && candidate.sessions.length > 0)
|
||||
?? activeSection.groups.find((candidate) => candidate.sessions.length > 0)
|
||||
?? activeSection.groups.find((candidate) => candidate.isMain)
|
||||
?? activeSection.groups[0];
|
||||
if (!group) {
|
||||
return <div className="py-1 text-left typography-micro text-muted-foreground">No sessions yet.</div>;
|
||||
}
|
||||
const groupKey = `${activeSection.project.id}:${group.id}`;
|
||||
return props.renderGroupSessions(group, groupKey, activeSection.project.id, props.showOnlyMainWorkspace);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{props.sectionsForRender.map((section) => {
|
||||
const project = section.project;
|
||||
const projectKey = project.id;
|
||||
const projectLabel = formatProjectLabel(
|
||||
project.label?.trim()
|
||||
|| formatDirectoryName(project.normalizedPath, props.homeDirectory)
|
||||
|| project.normalizedPath,
|
||||
);
|
||||
const projectDescription = formatPathForDisplay(project.normalizedPath, props.homeDirectory);
|
||||
const isCollapsed = props.collapsedProjects.has(projectKey) && props.hideDirectoryControls;
|
||||
const isActiveProject = projectKey === props.activeProjectId;
|
||||
const isRepo = props.projectRepoStatus.get(projectKey);
|
||||
const isHovered = props.hoveredProjectId === projectKey;
|
||||
const orderedGroups = props.getOrderedGroups(projectKey, section.groups);
|
||||
|
||||
return (
|
||||
<SortableProjectItem
|
||||
key={projectKey}
|
||||
id={projectKey}
|
||||
projectLabel={projectLabel}
|
||||
projectDescription={projectDescription}
|
||||
isCollapsed={isCollapsed}
|
||||
isActiveProject={isActiveProject}
|
||||
isRepo={Boolean(isRepo)}
|
||||
isHovered={isHovered}
|
||||
isDesktopShell={props.isDesktopShellRuntime}
|
||||
isStuck={props.stuckProjectHeaders.has(projectKey)}
|
||||
hideDirectoryControls={props.hideDirectoryControls}
|
||||
mobileVariant={props.mobileVariant}
|
||||
onToggle={() => props.toggleProject(projectKey)}
|
||||
onHoverChange={(hovered) => props.setHoveredProjectId(hovered ? projectKey : null)}
|
||||
onNewSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||
props.openNewSessionDraft({ directoryOverride: project.normalizedPath });
|
||||
}}
|
||||
onNewWorktreeSession={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.setActiveMainTab('chat');
|
||||
if (props.mobileVariant) props.setSessionSwitcherOpen(false);
|
||||
props.createWorktreeSession();
|
||||
}}
|
||||
onOpenMultiRunLauncher={() => {
|
||||
if (projectKey !== props.activeProjectId) props.setActiveProjectIdOnly(projectKey);
|
||||
props.openMultiRunLauncher();
|
||||
}}
|
||||
onRenameStart={() => {
|
||||
props.setEditingProjectId(projectKey);
|
||||
props.setEditProjectTitle(project.label?.trim() || formatDirectoryName(project.normalizedPath, props.homeDirectory) || project.normalizedPath);
|
||||
}}
|
||||
onRenameSave={props.handleSaveProjectEdit}
|
||||
onRenameCancel={props.handleCancelProjectEdit}
|
||||
onRenameValueChange={props.setEditProjectTitle}
|
||||
renameValue={props.editingProjectId === projectKey ? props.editProjectTitle : ''}
|
||||
isRenaming={props.editingProjectId === projectKey}
|
||||
onClose={() => props.removeProject(projectKey)}
|
||||
sentinelRef={(el) => { props.projectHeaderSentinelRefs.current.set(projectKey, el); }}
|
||||
settingsAutoCreateWorktree={props.settingsAutoCreateWorktree}
|
||||
showCreateButtons={false}
|
||||
hideHeader
|
||||
>
|
||||
{!isCollapsed ? (
|
||||
<div className="space-y-[0.6rem] py-1">
|
||||
{section.groups.length > 0 ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = orderedGroups.findIndex((item) => item.id === active.id);
|
||||
const newIndex = orderedGroups.findIndex((item) => item.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1 || oldIndex === newIndex) return;
|
||||
const next = arrayMove(orderedGroups, oldIndex, newIndex).map((item) => item.id);
|
||||
props.setGroupOrderByProject((prev) => {
|
||||
const map = new Map(prev);
|
||||
map.set(projectKey, next);
|
||||
return map;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SortableContext items={orderedGroups.map((group) => group.id)} strategy={verticalListSortingStrategy}>
|
||||
{orderedGroups.map((group) => {
|
||||
const groupKey = `${projectKey}:${group.id}`;
|
||||
return (
|
||||
<SortableGroupItem key={group.id} id={group.id}>
|
||||
{props.renderGroupSessions(group, groupKey, projectKey)}
|
||||
</SortableGroupItem>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null} />
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="py-1 text-left typography-micro text-muted-foreground">No sessions yet.</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</SortableProjectItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { dedupeSessionsById, getArchivedScopeKey, isSessionRelatedToProject, normalizePath, resolveArchivedFolderName } from '../utils';
|
||||
|
||||
export type ProjectForArchivedFolders = {
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
type FolderEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
sessionIds: string[];
|
||||
};
|
||||
|
||||
type Args = {
|
||||
normalizedProjects: ProjectForArchivedFolders[];
|
||||
sessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
isVSCode: boolean;
|
||||
isSessionsLoading: boolean;
|
||||
foldersMap: Record<string, FolderEntry[]>;
|
||||
createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry;
|
||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
||||
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>) => void;
|
||||
};
|
||||
|
||||
const getArchivedSessionsForProject = (
|
||||
project: ProjectForArchivedFolders,
|
||||
params: Pick<Args, 'sessions' | 'archivedSessions' | 'availableWorktreesByProject' | 'isVSCode'>,
|
||||
): Session[] => {
|
||||
const worktreesForProject = params.isVSCode ? [] : (params.availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||
const validDirectories = new Set<string>([
|
||||
project.normalizedPath,
|
||||
...worktreesForProject
|
||||
.map((meta) => normalizePath(meta.path) ?? meta.path)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
]);
|
||||
|
||||
const collect = (input: Session[]): Session[] => input.filter((session) =>
|
||||
isSessionRelatedToProject(session, project.normalizedPath, validDirectories),
|
||||
);
|
||||
|
||||
const archived = collect(params.archivedSessions);
|
||||
const unassignedLive = params.sessions.filter((session) => {
|
||||
if (session.time?.archived) {
|
||||
return false;
|
||||
}
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (sessionDirectory) {
|
||||
return false;
|
||||
}
|
||||
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories);
|
||||
});
|
||||
|
||||
return dedupeSessionsById([...archived, ...unassignedLive]);
|
||||
};
|
||||
|
||||
export const useArchivedAutoFolders = (args: Args): void => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
isVSCode,
|
||||
isSessionsLoading,
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
cleanupSessions,
|
||||
} = args;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSessionsLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalizedProjects.forEach((project) => {
|
||||
const scopeKey = getArchivedScopeKey(project.normalizedPath);
|
||||
const projectArchivedSessions = getArchivedSessionsForProject(project, {
|
||||
sessions,
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
isVSCode,
|
||||
});
|
||||
const sessionIds = new Set(projectArchivedSessions.map((session) => session.id));
|
||||
|
||||
const existingFolders = foldersMap[scopeKey] ?? [];
|
||||
const folderByName = new Map(existingFolders.map((folder) => [folder.name.toLowerCase(), folder]));
|
||||
|
||||
projectArchivedSessions.forEach((session) => {
|
||||
const folderName = resolveArchivedFolderName(session, project.normalizedPath);
|
||||
const key = folderName.toLowerCase();
|
||||
let folder = folderByName.get(key);
|
||||
if (!folder) {
|
||||
folder = createFolder(scopeKey, folderName);
|
||||
folderByName.set(key, folder);
|
||||
}
|
||||
|
||||
if (!folder.sessionIds.includes(session.id)) {
|
||||
addSessionToFolder(scopeKey, folder.id, session.id);
|
||||
}
|
||||
});
|
||||
|
||||
cleanupSessions(scopeKey, sessionIds);
|
||||
});
|
||||
}, [
|
||||
normalizedProjects,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
isVSCode,
|
||||
isSessionsLoading,
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
cleanupSessions,
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { normalizePath } from '../utils';
|
||||
|
||||
type ProjectLike = { path: string };
|
||||
|
||||
type Args = {
|
||||
sortedSessions: Session[];
|
||||
projects: ProjectLike[];
|
||||
directoryStatus: Map<string, 'unknown' | 'exists' | 'missing'>;
|
||||
setDirectoryStatus: React.Dispatch<React.SetStateAction<Map<string, 'unknown' | 'exists' | 'missing'>>>;
|
||||
};
|
||||
|
||||
export const useDirectoryStatusProbe = ({
|
||||
sortedSessions,
|
||||
projects,
|
||||
directoryStatus,
|
||||
setDirectoryStatus,
|
||||
}: Args): void => {
|
||||
const directoryStatusRef = React.useRef<Map<string, 'unknown' | 'exists' | 'missing'>>(new Map());
|
||||
const checkingDirectories = React.useRef<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
directoryStatusRef.current = directoryStatus;
|
||||
}, [directoryStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const directories = new Set<string>();
|
||||
sortedSessions.forEach((session) => {
|
||||
const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (dir) {
|
||||
directories.add(dir);
|
||||
}
|
||||
});
|
||||
projects.forEach((project) => {
|
||||
const normalized = normalizePath(project.path);
|
||||
if (normalized) {
|
||||
directories.add(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
directories.forEach((directory) => {
|
||||
const known = directoryStatusRef.current.get(directory);
|
||||
if ((known && known !== 'unknown') || checkingDirectories.current.has(directory)) {
|
||||
return;
|
||||
}
|
||||
checkingDirectories.current.add(directory);
|
||||
opencodeClient
|
||||
.listLocalDirectory(directory)
|
||||
.then(() => {
|
||||
setDirectoryStatus((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.get(directory) === 'exists') {
|
||||
return prev;
|
||||
}
|
||||
next.set(directory, 'exists');
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(async () => {
|
||||
const looksLikeSdkWorktree =
|
||||
directory.includes('/opencode/worktree/') ||
|
||||
directory.includes('/.opencode/data/worktree/') ||
|
||||
directory.includes('/.local/share/opencode/worktree/');
|
||||
|
||||
if (looksLikeSdkWorktree) {
|
||||
const ok = await opencodeClient.probeDirectory(directory).catch(() => false);
|
||||
if (ok) {
|
||||
setDirectoryStatus((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.get(directory) === 'exists') {
|
||||
return prev;
|
||||
}
|
||||
next.set(directory, 'exists');
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDirectoryStatus((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.get(directory) === 'missing') {
|
||||
return prev;
|
||||
}
|
||||
next.set(directory, 'missing');
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
checkingDirectories.current.delete(directory);
|
||||
});
|
||||
});
|
||||
}, [sortedSessions, projects, setDirectoryStatus]);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import type { SessionGroup } from '../types';
|
||||
|
||||
export const useGroupOrdering = (groupOrderByProject: Map<string, string[]>) => {
|
||||
const getOrderedGroups = React.useCallback(
|
||||
(projectId: string, groups: SessionGroup[]) => {
|
||||
const archivedGroup = groups.find((group) => group.isArchivedBucket === true) ?? null;
|
||||
const reorderableGroups = archivedGroup ? groups.filter((group) => group !== archivedGroup) : groups;
|
||||
const preferredOrder = groupOrderByProject.get(projectId);
|
||||
if (!preferredOrder || preferredOrder.length === 0) {
|
||||
return archivedGroup ? [...reorderableGroups, archivedGroup] : reorderableGroups;
|
||||
}
|
||||
const groupById = new Map(reorderableGroups.map((group) => [group.id, group]));
|
||||
const ordered: SessionGroup[] = [];
|
||||
preferredOrder.forEach((id) => {
|
||||
const group = groupById.get(id);
|
||||
if (group) {
|
||||
ordered.push(group);
|
||||
groupById.delete(id);
|
||||
}
|
||||
});
|
||||
reorderableGroups.forEach((group) => {
|
||||
if (groupById.has(group.id)) {
|
||||
ordered.push(group);
|
||||
}
|
||||
});
|
||||
return archivedGroup ? [...ordered, archivedGroup] : ordered;
|
||||
},
|
||||
[groupOrderByProject],
|
||||
);
|
||||
|
||||
return { getOrderedGroups };
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import { checkIsGitRepository } from '@/lib/gitApi';
|
||||
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
|
||||
|
||||
type Project = { id: string; path: string; normalizedPath: string };
|
||||
|
||||
type DirectoryState = { status?: { current?: string | null } | null };
|
||||
|
||||
type Args = {
|
||||
projects: Array<{ id: string; path: string }>;
|
||||
normalizedProjects: Project[];
|
||||
normalizePath: (value?: string | null) => string | null;
|
||||
gitDirectories: Map<string, DirectoryState>;
|
||||
setProjectRepoStatus: React.Dispatch<React.SetStateAction<Map<string, boolean | null>>>;
|
||||
setProjectRootBranches: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
||||
};
|
||||
|
||||
export const useProjectRepoStatus = (args: Args): void => {
|
||||
const {
|
||||
projects,
|
||||
normalizedProjects,
|
||||
normalizePath,
|
||||
gitDirectories,
|
||||
setProjectRepoStatus,
|
||||
setProjectRootBranches,
|
||||
} = args;
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const normalized = projects
|
||||
.map((project) => ({ id: project.id, path: normalizePath(project.path) }))
|
||||
.filter((project): project is { id: string; path: string } => Boolean(project.path));
|
||||
|
||||
setProjectRepoStatus(new Map());
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
normalized.forEach((project) => {
|
||||
checkIsGitRepository(project.path)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setProjectRepoStatus((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(project.id, result);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setProjectRepoStatus((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(project.id, null);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [normalizePath, projects, setProjectRepoStatus]);
|
||||
|
||||
const projectGitBranchesKey = React.useMemo(() => {
|
||||
return normalizedProjects
|
||||
.map((project) => {
|
||||
const dirState = gitDirectories.get(project.normalizedPath);
|
||||
return `${project.id}:${dirState?.status?.current ?? ''}`;
|
||||
})
|
||||
.join('|');
|
||||
}, [normalizedProjects, gitDirectories]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
const entries = await Promise.all(
|
||||
normalizedProjects.map(async (project) => {
|
||||
const branch = await getRootBranch(project.normalizedPath).catch(() => null);
|
||||
return { id: project.id, branch };
|
||||
}),
|
||||
);
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setProjectRootBranches((prev) => {
|
||||
const next = new Map(prev);
|
||||
entries.forEach(({ id, branch }) => {
|
||||
if (branch) {
|
||||
next.set(id, branch);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [normalizedProjects, projectGitBranchesKey, setProjectRootBranches]);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils';
|
||||
|
||||
type WorktreeMeta = { path: string };
|
||||
|
||||
type Args = {
|
||||
isVSCode: boolean;
|
||||
sessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
sessionsByDirectory: Map<string, Session[]>;
|
||||
getSessionsByDirectory: (directory: string) => Session[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
|
||||
};
|
||||
|
||||
export const useProjectSessionLists = (args: Args) => {
|
||||
const {
|
||||
isVSCode,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
sessionsByDirectory,
|
||||
getSessionsByDirectory,
|
||||
availableWorktreesByProject,
|
||||
} = args;
|
||||
|
||||
const getSessionsForProject = React.useCallback(
|
||||
(project: { normalizedPath: string }) => {
|
||||
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||
const directories = [
|
||||
project.normalizedPath,
|
||||
...worktreesForProject
|
||||
.map((meta) => normalizePath(meta.path) ?? meta.path)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const collected: Session[] = [];
|
||||
|
||||
directories.forEach((directory) => {
|
||||
const sessionsForDirectory = sessionsByDirectory.get(directory) ?? getSessionsByDirectory(directory);
|
||||
sessionsForDirectory.forEach((session) => {
|
||||
if (seen.has(session.id)) {
|
||||
return;
|
||||
}
|
||||
seen.add(session.id);
|
||||
collected.push(session);
|
||||
});
|
||||
});
|
||||
|
||||
return collected;
|
||||
},
|
||||
[availableWorktreesByProject, getSessionsByDirectory, isVSCode, sessionsByDirectory],
|
||||
);
|
||||
|
||||
const getArchivedSessionsForProject = React.useCallback(
|
||||
(project: { normalizedPath: string }) => {
|
||||
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||
const validDirectories = new Set<string>([
|
||||
project.normalizedPath,
|
||||
...worktreesForProject
|
||||
.map((meta) => normalizePath(meta.path) ?? meta.path)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
]);
|
||||
|
||||
const collect = (input: Session[]): Session[] => input.filter((session) =>
|
||||
isSessionRelatedToProject(session, project.normalizedPath, validDirectories),
|
||||
);
|
||||
|
||||
const archived = collect(archivedSessions);
|
||||
const unassignedLive = sessions.filter((session) => {
|
||||
if (session.time?.archived) {
|
||||
return false;
|
||||
}
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (sessionDirectory) {
|
||||
return false;
|
||||
}
|
||||
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
||||
if (!projectWorktree) {
|
||||
return false;
|
||||
}
|
||||
return projectWorktree === project.normalizedPath || projectWorktree.startsWith(`${project.normalizedPath}/`);
|
||||
});
|
||||
|
||||
return dedupeSessionsById([...archived, ...unassignedLive]);
|
||||
},
|
||||
[archivedSessions, availableWorktreesByProject, isVSCode, sessions],
|
||||
);
|
||||
|
||||
return {
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroup, SessionNode } from '../types';
|
||||
import { normalizePath } from '../utils';
|
||||
|
||||
type ProjectSection = {
|
||||
project: { id: string; normalizedPath: string };
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
type Args = {
|
||||
projectSections: ProjectSection[];
|
||||
activeProjectId: string | null;
|
||||
activeSessionByProject: Map<string, string>;
|
||||
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
||||
currentSessionId: string | null;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, isMissingDirectory: boolean, projectId?: string | null) => void;
|
||||
isVSCode: boolean;
|
||||
newSessionDraftOpen: boolean;
|
||||
mobileVariant: boolean;
|
||||
openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void;
|
||||
setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
sessions: Session[];
|
||||
worktreeMetadata: Map<string, { path?: string | null }>;
|
||||
};
|
||||
|
||||
export const useProjectSessionSelection = (args: Args): { currentSessionDirectory: string | null } => {
|
||||
const {
|
||||
projectSections,
|
||||
activeProjectId,
|
||||
activeSessionByProject,
|
||||
setActiveSessionByProject,
|
||||
currentSessionId,
|
||||
handleSessionSelect,
|
||||
isVSCode,
|
||||
newSessionDraftOpen,
|
||||
mobileVariant,
|
||||
openNewSessionDraft,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
sessions,
|
||||
worktreeMetadata,
|
||||
} = args;
|
||||
|
||||
const projectSessionMeta = React.useMemo(() => {
|
||||
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
|
||||
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
|
||||
|
||||
const visitNodes = (
|
||||
projectId: string,
|
||||
projectRoot: string,
|
||||
fallbackDirectory: string | null,
|
||||
nodes: SessionNode[],
|
||||
) => {
|
||||
if (!metaByProject.has(projectId)) {
|
||||
metaByProject.set(projectId, new Map());
|
||||
}
|
||||
const projectMap = metaByProject.get(projectId)!;
|
||||
nodes.forEach((node) => {
|
||||
const sessionDirectory = normalizePath(
|
||||
node.worktree?.path
|
||||
?? (node.session as Session & { directory?: string | null }).directory
|
||||
?? fallbackDirectory
|
||||
?? projectRoot,
|
||||
);
|
||||
projectMap.set(node.session.id, { directory: sessionDirectory });
|
||||
if (!firstSessionByProject.has(projectId)) {
|
||||
firstSessionByProject.set(projectId, { id: node.session.id, directory: sessionDirectory });
|
||||
}
|
||||
if (node.children.length > 0) {
|
||||
visitNodes(projectId, projectRoot, sessionDirectory, node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
projectSections.forEach((section) => {
|
||||
section.groups.forEach((group) => {
|
||||
visitNodes(section.project.id, section.project.normalizedPath, group.directory, group.sessions);
|
||||
});
|
||||
});
|
||||
|
||||
return { metaByProject, firstSessionByProject };
|
||||
}, [projectSections]);
|
||||
|
||||
const previousActiveProjectRef = React.useRef<string | null>(null);
|
||||
const lastSeenActiveProjectRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!activeProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSeenProjectId = lastSeenActiveProjectRef.current;
|
||||
const isProjectSwitch = Boolean(previousSeenProjectId && previousSeenProjectId !== activeProjectId);
|
||||
lastSeenActiveProjectRef.current = activeProjectId;
|
||||
|
||||
if (newSessionDraftOpen && (isVSCode || !isProjectSwitch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousActiveProjectRef.current === activeProjectId) {
|
||||
return;
|
||||
}
|
||||
const section = projectSections.find((item) => item.project.id === activeProjectId);
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
previousActiveProjectRef.current = activeProjectId;
|
||||
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
|
||||
|
||||
if (currentSessionId && projectMap && projectMap.has(currentSessionId)) {
|
||||
setActiveSessionByProject((prev) => {
|
||||
if (prev.get(activeProjectId) === currentSessionId) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(activeProjectId, currentSessionId);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectMap || projectMap.size === 0) {
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) {
|
||||
setSessionSwitcherOpen(false);
|
||||
}
|
||||
openNewSessionDraft({ directoryOverride: section.project.normalizedPath });
|
||||
return;
|
||||
}
|
||||
|
||||
const rememberedSessionId = activeSessionByProject.get(activeProjectId);
|
||||
const remembered = rememberedSessionId && projectMap.has(rememberedSessionId)
|
||||
? rememberedSessionId
|
||||
: null;
|
||||
const fallback = projectSessionMeta.firstSessionByProject.get(activeProjectId)?.id ?? null;
|
||||
const targetSessionId = remembered ?? fallback;
|
||||
if (!targetSessionId || targetSessionId === currentSessionId) {
|
||||
return;
|
||||
}
|
||||
const targetDirectory = projectMap.get(targetSessionId)?.directory ?? null;
|
||||
handleSessionSelect(targetSessionId, targetDirectory, false, activeProjectId);
|
||||
}, [
|
||||
activeProjectId,
|
||||
activeSessionByProject,
|
||||
currentSessionId,
|
||||
handleSessionSelect,
|
||||
isVSCode,
|
||||
newSessionDraftOpen,
|
||||
mobileVariant,
|
||||
openNewSessionDraft,
|
||||
projectSections,
|
||||
projectSessionMeta,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
setActiveSessionByProject,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeProjectId || !currentSessionId) {
|
||||
return;
|
||||
}
|
||||
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
|
||||
if (!projectMap || !projectMap.has(currentSessionId)) {
|
||||
return;
|
||||
}
|
||||
setActiveSessionByProject((prev) => {
|
||||
if (prev.get(activeProjectId) === currentSessionId) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(activeProjectId, currentSessionId);
|
||||
return next;
|
||||
});
|
||||
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
|
||||
|
||||
const currentSessionDirectory = React.useMemo(() => {
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
}
|
||||
const metadataPath = worktreeMetadata.get(currentSessionId)?.path;
|
||||
if (metadataPath) {
|
||||
return normalizePath(metadataPath) ?? metadataPath;
|
||||
}
|
||||
const activeSession = sessions.find((session) => session.id === currentSessionId);
|
||||
if (!activeSession) {
|
||||
return null;
|
||||
}
|
||||
return normalizePath((activeSession as Session & { directory?: string | null }).directory ?? null);
|
||||
}, [currentSessionId, sessions, worktreeMetadata]);
|
||||
|
||||
return { currentSessionDirectory };
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { toast } from '@/components/ui';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
type DeleteSessionConfirmSetter = React.Dispatch<React.SetStateAction<{
|
||||
session: Session;
|
||||
descendantCount: number;
|
||||
archivedBucket: boolean;
|
||||
} | null>>;
|
||||
|
||||
type Args = {
|
||||
activeProjectId: string | null;
|
||||
currentDirectory: string | null;
|
||||
currentSessionId: string | null;
|
||||
mobileVariant: boolean;
|
||||
allowReselect: boolean;
|
||||
onSessionSelected?: (sessionId: string) => void;
|
||||
isSessionSearchOpen: boolean;
|
||||
sessionSearchQuery: string;
|
||||
setSessionSearchQuery: (value: string) => void;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
setActiveProjectIdOnly: (id: string) => void;
|
||||
setDirectory: (directory: string, options?: { showOverlay?: boolean }) => void;
|
||||
setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
setCurrentSession: (sessionId: 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[] }>;
|
||||
childrenMap: Map<string, Session[]>;
|
||||
showDeletionDialog: boolean;
|
||||
setDeleteSessionConfirm: DeleteSessionConfirmSetter;
|
||||
deleteSessionConfirm: { session: Session; descendantCount: number; archivedBucket: boolean } | null;
|
||||
setEditingId: (id: string | null) => void;
|
||||
setEditTitle: (value: string) => void;
|
||||
editingId: string | null;
|
||||
editTitle: string;
|
||||
};
|
||||
|
||||
export const useSessionActions = (args: Args) => {
|
||||
const [copiedSessionId, setCopiedSessionId] = React.useState<string | null>(null);
|
||||
const copyTimeout = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (copyTimeout.current) {
|
||||
clearTimeout(copyTimeout.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSessionSelect = React.useCallback(
|
||||
(sessionId: string, sessionDirectory?: string | null, disabled?: boolean, projectId?: string | null) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resetSessionSearch = () => {
|
||||
if (!args.isSessionSearchOpen && args.sessionSearchQuery.length === 0) {
|
||||
return;
|
||||
}
|
||||
args.setSessionSearchQuery('');
|
||||
args.setIsSessionSearchOpen(false);
|
||||
};
|
||||
|
||||
if (projectId && projectId !== args.activeProjectId) {
|
||||
args.setActiveProjectIdOnly(projectId);
|
||||
}
|
||||
|
||||
if (sessionDirectory && sessionDirectory !== args.currentDirectory) {
|
||||
args.setDirectory(sessionDirectory, { showOverlay: false });
|
||||
}
|
||||
|
||||
if (args.mobileVariant) {
|
||||
args.setActiveMainTab('chat');
|
||||
args.setSessionSwitcherOpen(false);
|
||||
}
|
||||
|
||||
if (sessionId === args.currentSessionId) {
|
||||
if (args.allowReselect) {
|
||||
args.onSessionSelected?.(sessionId);
|
||||
}
|
||||
resetSessionSearch();
|
||||
return;
|
||||
}
|
||||
args.setCurrentSession(sessionId);
|
||||
args.onSessionSelected?.(sessionId);
|
||||
resetSessionSearch();
|
||||
},
|
||||
[args],
|
||||
);
|
||||
|
||||
const handleSessionDoubleClick = React.useCallback(() => {
|
||||
args.setActiveMainTab('chat');
|
||||
}, [args]);
|
||||
|
||||
const handleSaveEdit = React.useCallback(async () => {
|
||||
if (args.editingId && args.editTitle.trim()) {
|
||||
await args.updateSessionTitle(args.editingId, args.editTitle.trim());
|
||||
args.setEditingId(null);
|
||||
args.setEditTitle('');
|
||||
}
|
||||
}, [args]);
|
||||
|
||||
const handleCancelEdit = React.useCallback(() => {
|
||||
args.setEditingId(null);
|
||||
args.setEditTitle('');
|
||||
}, [args]);
|
||||
|
||||
const handleShareSession = React.useCallback(async (session: Session) => {
|
||||
const result = await args.shareSession(session.id);
|
||||
if (result && result.share?.url) {
|
||||
toast.success('Session shared', {
|
||||
description: 'You can copy the link from the menu.',
|
||||
});
|
||||
} else {
|
||||
toast.error('Unable to share session');
|
||||
}
|
||||
}, [args]);
|
||||
|
||||
const handleCopyShareUrl = React.useCallback((url: string, sessionId: string) => {
|
||||
void copyTextToClipboard(url)
|
||||
.then((result) => {
|
||||
if (!result.ok) {
|
||||
toast.error('Failed to copy URL');
|
||||
return;
|
||||
}
|
||||
setCopiedSessionId(sessionId);
|
||||
if (copyTimeout.current) {
|
||||
clearTimeout(copyTimeout.current);
|
||||
}
|
||||
copyTimeout.current = window.setTimeout(() => {
|
||||
setCopiedSessionId(null);
|
||||
copyTimeout.current = null;
|
||||
}, 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error('Failed to copy URL');
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleUnshareSession = React.useCallback(async (sessionId: string) => {
|
||||
const result = await args.unshareSession(sessionId);
|
||||
if (result) {
|
||||
toast.success('Session unshared');
|
||||
} else {
|
||||
toast.error('Unable to unshare session');
|
||||
}
|
||||
}, [args]);
|
||||
|
||||
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]);
|
||||
|
||||
const executeDeleteSession = React.useCallback(
|
||||
async (session: Session, source?: { archivedBucket?: boolean }) => {
|
||||
const descendants = collectDescendants(session.id);
|
||||
const shouldHardDelete = source?.archivedBucket === true;
|
||||
if (descendants.length === 0) {
|
||||
const success = shouldHardDelete
|
||||
? await args.deleteSession(session.id)
|
||||
: await args.archiveSession(session.id);
|
||||
if (success) {
|
||||
toast.success(shouldHardDelete ? 'Session deleted' : 'Session archived');
|
||||
} else {
|
||||
toast.error(shouldHardDelete ? 'Failed to delete session' : 'Failed to archive session');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const ids = [session.id, ...descendants.map((s) => s.id)];
|
||||
if (shouldHardDelete) {
|
||||
const { deletedIds, failedIds } = await args.deleteSessions(ids);
|
||||
if (deletedIds.length > 0) {
|
||||
toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { archivedIds, failedIds } = await args.archiveSessions(ids);
|
||||
if (archivedIds.length > 0) {
|
||||
toast.success(`Archived ${archivedIds.length} session${archivedIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
if (failedIds.length > 0) {
|
||||
toast.error(`Failed to archive ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
},
|
||||
[args, collectDescendants],
|
||||
);
|
||||
|
||||
const handleDeleteSession = React.useCallback(
|
||||
(session: Session, source?: { archivedBucket?: boolean }) => {
|
||||
const descendants = collectDescendants(session.id);
|
||||
if (!args.showDeletionDialog) {
|
||||
void executeDeleteSession(session, source);
|
||||
return;
|
||||
}
|
||||
args.setDeleteSessionConfirm({ session, descendantCount: descendants.length, archivedBucket: source?.archivedBucket === true });
|
||||
},
|
||||
[args, collectDescendants, executeDeleteSession],
|
||||
);
|
||||
|
||||
const confirmDeleteSession = React.useCallback(async () => {
|
||||
if (!args.deleteSessionConfirm) return;
|
||||
const { session, archivedBucket } = args.deleteSessionConfirm;
|
||||
args.setDeleteSessionConfirm(null);
|
||||
await executeDeleteSession(session, { archivedBucket });
|
||||
}, [args, executeDeleteSession]);
|
||||
|
||||
return {
|
||||
copiedSessionId,
|
||||
handleSessionSelect,
|
||||
handleSessionDoubleClick,
|
||||
handleSaveEdit,
|
||||
handleCancelEdit,
|
||||
handleShareSession,
|
||||
handleCopyShareUrl,
|
||||
handleUnshareSession,
|
||||
handleDeleteSession,
|
||||
confirmDeleteSession,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { dedupeSessionsById, getArchivedScopeKey, isSessionRelatedToProject, normalizePath } from '../utils';
|
||||
|
||||
type NormalizedProject = {
|
||||
id: string;
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
type WorktreeMeta = { path: string };
|
||||
|
||||
type Args = {
|
||||
isSessionsLoading: boolean;
|
||||
sessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
normalizedProjects: NormalizedProject[];
|
||||
isVSCode: boolean;
|
||||
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
|
||||
cleanupSessions: (scopeKey: string, validSessionIds: Set<string>) => void;
|
||||
};
|
||||
|
||||
export const useSessionFolderCleanup = (args: Args): void => {
|
||||
const {
|
||||
isSessionsLoading,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
normalizedProjects,
|
||||
isVSCode,
|
||||
availableWorktreesByProject,
|
||||
cleanupSessions,
|
||||
} = args;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSessionsLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idsByScope = new Map<string, Set<string>>();
|
||||
sessions.forEach((session) => {
|
||||
const directory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (!directory) {
|
||||
return;
|
||||
}
|
||||
const existing = idsByScope.get(directory);
|
||||
if (existing) {
|
||||
existing.add(session.id);
|
||||
return;
|
||||
}
|
||||
idsByScope.set(directory, new Set([session.id]));
|
||||
});
|
||||
|
||||
normalizedProjects.forEach((project) => {
|
||||
const scopeKey = getArchivedScopeKey(project.normalizedPath);
|
||||
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||
const validDirectories = new Set<string>([
|
||||
project.normalizedPath,
|
||||
...worktreesForProject
|
||||
.map((meta) => normalizePath(meta.path) ?? meta.path)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
]);
|
||||
|
||||
const archivedForProject = dedupeSessionsById([
|
||||
...archivedSessions,
|
||||
...sessions.filter((session) => {
|
||||
if (session.time?.archived) {
|
||||
return false;
|
||||
}
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (sessionDirectory) {
|
||||
return false;
|
||||
}
|
||||
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories);
|
||||
}),
|
||||
]).filter((session) => isSessionRelatedToProject(session, project.normalizedPath, validDirectories));
|
||||
|
||||
idsByScope.set(scopeKey, new Set(archivedForProject.map((session) => session.id)));
|
||||
});
|
||||
|
||||
const currentFoldersMap = useSessionFoldersStore.getState().foldersMap;
|
||||
const allScopeKeys = new Set([...Object.keys(currentFoldersMap), ...idsByScope.keys()]);
|
||||
allScopeKeys.forEach((scopeKey) => {
|
||||
cleanupSessions(scopeKey, idsByScope.get(scopeKey) ?? new Set<string>());
|
||||
});
|
||||
}, [
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
cleanupSessions,
|
||||
isSessionsLoading,
|
||||
isVSCode,
|
||||
normalizedProjects,
|
||||
sessions,
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import type { SessionGroup, SessionNode } from '../types';
|
||||
import {
|
||||
compareSessionsByPinnedAndTime,
|
||||
dedupeSessionsById,
|
||||
getArchivedScopeKey,
|
||||
normalizeForBranchComparison,
|
||||
normalizePath,
|
||||
} from '../utils';
|
||||
import { formatDirectoryName, formatPathForDisplay } from '@/lib/utils';
|
||||
|
||||
type Args = {
|
||||
homeDirectory: string | null;
|
||||
worktreeMetadata: Map<string, WorktreeMetadata>;
|
||||
pinnedSessionIds: Set<string>;
|
||||
gitDirectories: Map<string, { status?: { current?: string | null } | null }>;
|
||||
};
|
||||
|
||||
export const useSessionGrouping = (args: Args) => {
|
||||
const buildGroupSearchText = React.useCallback((group: SessionGroup): string => {
|
||||
return [group.label, group.branch ?? '', group.description ?? '', group.directory ?? ''].join(' ').toLowerCase();
|
||||
}, []);
|
||||
|
||||
const buildSessionSearchText = React.useCallback((session: Session): string => {
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null) ?? '';
|
||||
const sessionTitle = (session.title || 'Untitled Session').trim();
|
||||
return `${sessionTitle} ${sessionDirectory}`.toLowerCase();
|
||||
}, []);
|
||||
|
||||
const filterSessionNodesForSearch = React.useCallback(
|
||||
(nodes: SessionNode[], query: string): SessionNode[] => {
|
||||
if (!query) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
return nodes.flatMap((node) => {
|
||||
const nodeMatches = buildSessionSearchText(node.session).includes(query);
|
||||
if (nodeMatches) {
|
||||
return [node];
|
||||
}
|
||||
|
||||
const filteredChildren = filterSessionNodesForSearch(node.children, query);
|
||||
if (filteredChildren.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{ ...node, children: filteredChildren }];
|
||||
});
|
||||
},
|
||||
[buildSessionSearchText],
|
||||
);
|
||||
|
||||
const buildGroupedSessions = React.useCallback(
|
||||
(
|
||||
projectSessions: Session[],
|
||||
projectRoot: string | null,
|
||||
availableWorktrees: WorktreeMetadata[],
|
||||
projectRootBranch: string | null,
|
||||
projectIsRepo: boolean,
|
||||
) => {
|
||||
const normalizedProjectRoot = normalizePath(projectRoot ?? null);
|
||||
const sortedProjectSessions = dedupeSessionsById(projectSessions)
|
||||
.sort((a, b) => compareSessionsByPinnedAndTime(a, b, args.pinnedSessionIds));
|
||||
|
||||
const sessionMap = new Map(sortedProjectSessions.map((session) => [session.id, session]));
|
||||
const childrenMap = new Map<string, Session[]>();
|
||||
sortedProjectSessions.forEach((session) => {
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentID) return;
|
||||
const collection = childrenMap.get(parentID) ?? [];
|
||||
collection.push(session);
|
||||
childrenMap.set(parentID, collection);
|
||||
});
|
||||
childrenMap.forEach((list) => list.sort((a, b) => compareSessionsByPinnedAndTime(a, b, args.pinnedSessionIds)));
|
||||
|
||||
const worktreeByPath = new Map<string, WorktreeMetadata>();
|
||||
availableWorktrees.forEach((meta) => {
|
||||
if (meta.path) {
|
||||
const normalized = normalizePath(meta.path) ?? meta.path;
|
||||
worktreeByPath.set(normalized, meta);
|
||||
}
|
||||
});
|
||||
|
||||
const getSessionWorktree = (session: Session): WorktreeMetadata | null => {
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
const sessionWorktreeMeta = args.worktreeMetadata.get(session.id) ?? null;
|
||||
if (sessionWorktreeMeta) return sessionWorktreeMeta;
|
||||
if (sessionDirectory) {
|
||||
const worktree = worktreeByPath.get(sessionDirectory) ?? null;
|
||||
if (worktree && sessionDirectory !== normalizedProjectRoot) {
|
||||
return worktree;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const buildProjectNode = (session: Session): SessionNode => {
|
||||
const children = childrenMap.get(session.id) ?? [];
|
||||
return { session, children: children.map((child) => buildProjectNode(child)), worktree: getSessionWorktree(session) };
|
||||
};
|
||||
|
||||
const roots = sortedProjectSessions.filter((session) => {
|
||||
const parentID = (session as Session & { parentID?: string | null }).parentID;
|
||||
if (!parentID) return true;
|
||||
return !sessionMap.has(parentID);
|
||||
});
|
||||
|
||||
const groupedNodes = new Map<string, SessionNode[]>();
|
||||
const archivedKey = '__archived__';
|
||||
|
||||
const getGroupKey = (session: Session) => {
|
||||
if (session.time?.archived) return archivedKey;
|
||||
const metadataPath = normalizePath(args.worktreeMetadata.get(session.id)?.path ?? null);
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (!metadataPath && !sessionDirectory) return archivedKey;
|
||||
const fallbackDirectory = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
||||
const normalizedDir = metadataPath ?? sessionDirectory ?? fallbackDirectory;
|
||||
if (!normalizedDir) return archivedKey;
|
||||
if (normalizedDir !== normalizedProjectRoot && worktreeByPath.has(normalizedDir)) return normalizedDir;
|
||||
if (normalizedDir === normalizedProjectRoot) return normalizedProjectRoot ?? '__project_root__';
|
||||
return archivedKey;
|
||||
};
|
||||
|
||||
roots.forEach((session) => {
|
||||
const node = buildProjectNode(session);
|
||||
const groupKey = getGroupKey(session);
|
||||
if (!groupedNodes.has(groupKey)) groupedNodes.set(groupKey, []);
|
||||
groupedNodes.get(groupKey)?.push(node);
|
||||
});
|
||||
|
||||
const rootKey = normalizedProjectRoot ?? '__project_root__';
|
||||
const groups: SessionGroup[] = [{
|
||||
id: 'root',
|
||||
label: (projectIsRepo && projectRootBranch && projectRootBranch !== 'HEAD') ? `project root: ${projectRootBranch}` : 'project root',
|
||||
branch: projectRootBranch ?? null,
|
||||
description: normalizedProjectRoot ? formatPathForDisplay(normalizedProjectRoot, args.homeDirectory) : null,
|
||||
isMain: true,
|
||||
isArchivedBucket: false,
|
||||
worktree: null,
|
||||
directory: normalizedProjectRoot,
|
||||
folderScopeKey: normalizedProjectRoot,
|
||||
sessions: groupedNodes.get(rootKey) ?? [],
|
||||
}];
|
||||
|
||||
const sortedWorktrees = [...availableWorktrees].sort((a, b) => {
|
||||
const aLabel = (a.label || a.branch || a.name || a.path || '').toLowerCase();
|
||||
const bLabel = (b.label || b.branch || b.name || b.path || '').toLowerCase();
|
||||
return aLabel.localeCompare(bLabel);
|
||||
});
|
||||
|
||||
sortedWorktrees.forEach((meta) => {
|
||||
const directory = normalizePath(meta.path) ?? meta.path;
|
||||
const currentBranch = args.gitDirectories.get(directory)?.status?.current?.trim() || null;
|
||||
const metadataBranch = meta.branch?.trim() || null;
|
||||
const shouldSyncLabelWithBranch = Boolean(
|
||||
currentBranch && metadataBranch && meta.label && normalizeForBranchComparison(meta.label) === normalizeForBranchComparison(metadataBranch),
|
||||
);
|
||||
const label = shouldSyncLabelWithBranch
|
||||
? currentBranch!
|
||||
: (meta.label || meta.name || formatDirectoryName(directory, args.homeDirectory) || directory);
|
||||
|
||||
groups.push({
|
||||
id: `worktree:${directory}`,
|
||||
label,
|
||||
branch: currentBranch || metadataBranch,
|
||||
description: formatPathForDisplay(directory, args.homeDirectory),
|
||||
isMain: false,
|
||||
isArchivedBucket: false,
|
||||
worktree: meta,
|
||||
directory,
|
||||
folderScopeKey: directory,
|
||||
sessions: groupedNodes.get(directory) ?? [],
|
||||
});
|
||||
});
|
||||
|
||||
groups.push({
|
||||
id: 'archived',
|
||||
label: 'archived',
|
||||
branch: null,
|
||||
description: 'Archived and unassigned sessions',
|
||||
isMain: false,
|
||||
isArchivedBucket: true,
|
||||
worktree: null,
|
||||
directory: null,
|
||||
folderScopeKey: normalizedProjectRoot ? getArchivedScopeKey(normalizedProjectRoot) : null,
|
||||
sessions: groupedNodes.get(archivedKey) ?? [],
|
||||
});
|
||||
|
||||
return groups;
|
||||
},
|
||||
[args.homeDirectory, args.worktreeMetadata, args.pinnedSessionIds, args.gitDirectories],
|
||||
);
|
||||
|
||||
return {
|
||||
buildGroupSearchText,
|
||||
buildSessionSearchText,
|
||||
filterSessionNodesForSearch,
|
||||
buildGroupedSessions,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
|
||||
const SESSION_PREFETCH_HOVER_DELAY_MS = 180;
|
||||
const SESSION_PREFETCH_CONCURRENCY = 1;
|
||||
const SESSION_PREFETCH_PENDING_LIMIT = 6;
|
||||
|
||||
type Args = {
|
||||
currentSessionId: string | null;
|
||||
sortedSessions: Session[];
|
||||
loadMessages: (sessionId: string, limit?: number) => Promise<void>;
|
||||
};
|
||||
|
||||
export const useSessionPrefetch = ({ currentSessionId, sortedSessions, loadMessages }: Args): void => {
|
||||
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
|
||||
const sessionPrefetchQueueRef = React.useRef<string[]>([]);
|
||||
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
const pumpSessionPrefetchQueue = React.useCallback(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
while (sessionPrefetchInFlightRef.current.size < SESSION_PREFETCH_CONCURRENCY && sessionPrefetchQueueRef.current.length > 0) {
|
||||
const nextSessionId = sessionPrefetchQueueRef.current.shift();
|
||||
if (!nextSessionId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const state = useSessionStore.getState();
|
||||
if (state.currentSessionId === nextSessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasMessages = state.messages.has(nextSessionId);
|
||||
const memory = state.sessionMemoryState.get(nextSessionId);
|
||||
const isHydrated = hasMessages && memory?.historyComplete !== undefined;
|
||||
if (isHydrated) {
|
||||
continue;
|
||||
}
|
||||
|
||||
sessionPrefetchInFlightRef.current.add(nextSessionId);
|
||||
void loadMessages(nextSessionId)
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
sessionPrefetchInFlightRef.current.delete(nextSessionId);
|
||||
pumpSessionPrefetchQueue();
|
||||
});
|
||||
}
|
||||
}, [loadMessages]);
|
||||
|
||||
const scheduleSessionPrefetch = React.useCallback((sessionId: string | null | undefined) => {
|
||||
if (!sessionId || sessionId === currentSessionId || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = useSessionStore.getState();
|
||||
const hasMessages = state.messages.has(sessionId);
|
||||
const memory = state.sessionMemoryState.get(sessionId);
|
||||
const isHydrated = hasMessages && memory?.historyComplete !== undefined;
|
||||
if (isHydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchInFlightRef.current.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchQueueRef.current.includes(sessionId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionPrefetchQueueRef.current.length >= SESSION_PREFETCH_PENDING_LIMIT) {
|
||||
sessionPrefetchQueueRef.current.shift();
|
||||
}
|
||||
|
||||
const existingTimer = sessionPrefetchTimersRef.current.get(sessionId);
|
||||
if (existingTimer !== undefined) {
|
||||
window.clearTimeout(existingTimer);
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
sessionPrefetchTimersRef.current.delete(sessionId);
|
||||
sessionPrefetchQueueRef.current.push(sessionId);
|
||||
pumpSessionPrefetchQueue();
|
||||
}, SESSION_PREFETCH_HOVER_DELAY_MS);
|
||||
sessionPrefetchTimersRef.current.set(sessionId, timer);
|
||||
}, [currentSessionId, pumpSessionPrefetchQueue]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId || sortedSessions.length === 0) {
|
||||
return;
|
||||
}
|
||||
const currentIndex = sortedSessions.findIndex((session) => session.id === currentSessionId);
|
||||
if (currentIndex < 0) {
|
||||
return;
|
||||
}
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex - 1]?.id);
|
||||
scheduleSessionPrefetch(sortedSessions[currentIndex + 1]?.id);
|
||||
}, [currentSessionId, scheduleSessionPrefetch, sortedSessions]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const prefetchTimers = sessionPrefetchTimersRef.current;
|
||||
return () => {
|
||||
prefetchTimers.forEach((timer) => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
prefetchTimers.clear();
|
||||
sessionPrefetchQueueRef.current = [];
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
type Args = {
|
||||
isSessionSearchOpen: boolean;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
sessionSearchInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
sessionSearchContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export const useSessionSearchEffects = ({
|
||||
isSessionSearchOpen,
|
||||
setIsSessionSearchOpen,
|
||||
sessionSearchInputRef,
|
||||
sessionSearchContainerRef,
|
||||
}: Args): void => {
|
||||
React.useEffect(() => {
|
||||
if (!isSessionSearchOpen || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
sessionSearchInputRef.current?.focus();
|
||||
sessionSearchInputRef.current?.select();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [isSessionSearchOpen, sessionSearchInputRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isSessionSearchOpen || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!sessionSearchContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!sessionSearchContainerRef.current.contains(event.target as Node)) {
|
||||
setIsSessionSearchOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchContainerRef]);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroup, SessionNode, GroupSearchData } from '../types';
|
||||
import { dedupeSessionsById, normalizePath } from '../utils';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
type ProjectItem = {
|
||||
id: string;
|
||||
path: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
type ProjectSection = {
|
||||
project: ProjectItem;
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
type Args = {
|
||||
normalizedProjects: ProjectItem[];
|
||||
activeProjectId: string | null;
|
||||
getSessionsForProject: (project: { normalizedPath: string }) => Session[];
|
||||
getArchivedSessionsForProject: (project: { normalizedPath: string }) => Session[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
projectRootBranches: Map<string, string | null>;
|
||||
lastRepoStatus: boolean;
|
||||
buildGroupedSessions: (
|
||||
sessions: Session[],
|
||||
projectRoot: string,
|
||||
availableWorktrees: WorktreeMetadata[],
|
||||
rootBranch: string | null,
|
||||
isRepo: boolean,
|
||||
) => SessionGroup[];
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
filterSessionNodesForSearch: (nodes: SessionNode[], query: string) => SessionNode[];
|
||||
buildGroupSearchText: (group: SessionGroup) => string;
|
||||
getFoldersForScope: (scopeKey: string) => Array<{ name: string }>;
|
||||
};
|
||||
|
||||
export const useSessionSidebarSections = (args: Args) => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
activeProjectId,
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
availableWorktreesByProject,
|
||||
projectRepoStatus,
|
||||
projectRootBranches,
|
||||
lastRepoStatus,
|
||||
buildGroupedSessions,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
filterSessionNodesForSearch,
|
||||
buildGroupSearchText,
|
||||
getFoldersForScope,
|
||||
} = args;
|
||||
|
||||
const projectSections = React.useMemo<ProjectSection[]>(() => {
|
||||
return normalizedProjects.map((project) => {
|
||||
const projectSessions = dedupeSessionsById([
|
||||
...getSessionsForProject(project),
|
||||
...getArchivedSessionsForProject(project),
|
||||
]);
|
||||
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
|
||||
const isRepo = projectRepoStatus.has(project.id)
|
||||
? Boolean(projectRepoStatus.get(project.id))
|
||||
: lastRepoStatus;
|
||||
const groups = buildGroupedSessions(
|
||||
projectSessions,
|
||||
project.normalizedPath,
|
||||
worktreesForProject,
|
||||
projectRootBranches.get(project.id) ?? null,
|
||||
isRepo,
|
||||
);
|
||||
return { project, groups };
|
||||
});
|
||||
}, [
|
||||
normalizedProjects,
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
availableWorktreesByProject,
|
||||
projectRepoStatus,
|
||||
lastRepoStatus,
|
||||
buildGroupedSessions,
|
||||
projectRootBranches,
|
||||
]);
|
||||
|
||||
const visibleProjectSections = React.useMemo(() => {
|
||||
if (projectSections.length === 0) {
|
||||
return projectSections;
|
||||
}
|
||||
const active = projectSections.find((section) => section.project.id === activeProjectId);
|
||||
return active ? [active] : [projectSections[0]];
|
||||
}, [projectSections, activeProjectId]);
|
||||
|
||||
const groupSearchDataByGroup = React.useMemo(() => {
|
||||
const result = new WeakMap<SessionGroup, GroupSearchData>();
|
||||
if (!hasSessionSearchQuery) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const countNodes = (nodes: SessionNode[]): number => nodes.reduce((total, node) => total + 1 + countNodes(node.children), 0);
|
||||
|
||||
visibleProjectSections.forEach((section) => {
|
||||
section.groups.forEach((group) => {
|
||||
const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery);
|
||||
const matchedSessionCount = countNodes(filteredNodes);
|
||||
const groupMatches = buildGroupSearchText(group).includes(normalizedSessionSearchQuery);
|
||||
const scopeKey = normalizePath(group.directory ?? null);
|
||||
const folderNameMatchCount = scopeKey
|
||||
? getFoldersForScope(scopeKey).filter((folder) => folder.name.toLowerCase().includes(normalizedSessionSearchQuery)).length
|
||||
: 0;
|
||||
|
||||
result.set(group, {
|
||||
filteredNodes,
|
||||
matchedSessionCount,
|
||||
folderNameMatchCount,
|
||||
groupMatches,
|
||||
hasMatch: groupMatches || matchedSessionCount > 0 || folderNameMatchCount > 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [
|
||||
hasSessionSearchQuery,
|
||||
visibleProjectSections,
|
||||
filterSessionNodesForSearch,
|
||||
normalizedSessionSearchQuery,
|
||||
buildGroupSearchText,
|
||||
getFoldersForScope,
|
||||
]);
|
||||
|
||||
const searchableProjectSections = React.useMemo(() => {
|
||||
if (!hasSessionSearchQuery) {
|
||||
return visibleProjectSections;
|
||||
}
|
||||
|
||||
return visibleProjectSections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
groups: section.groups.filter((group) => groupSearchDataByGroup.get(group)?.hasMatch === true),
|
||||
}))
|
||||
.filter((section) => section.groups.length > 0);
|
||||
}, [hasSessionSearchQuery, visibleProjectSections, groupSearchDataByGroup]);
|
||||
|
||||
const sectionsForRender = hasSessionSearchQuery ? searchableProjectSections : visibleProjectSections;
|
||||
|
||||
const searchMatchCount = React.useMemo(() => {
|
||||
if (!hasSessionSearchQuery) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return sectionsForRender.reduce((total, section) => {
|
||||
return total + section.groups.reduce((groupTotal, group) => {
|
||||
const data = groupSearchDataByGroup.get(group);
|
||||
if (!data) {
|
||||
return groupTotal;
|
||||
}
|
||||
const metadataMatches = data.folderNameMatchCount + (data.groupMatches ? 1 : 0);
|
||||
return groupTotal + data.matchedSessionCount + metadataMatches;
|
||||
}, 0);
|
||||
}, 0);
|
||||
}, [hasSessionSearchQuery, sectionsForRender, groupSearchDataByGroup]);
|
||||
|
||||
return {
|
||||
projectSections,
|
||||
visibleProjectSections,
|
||||
groupSearchDataByGroup,
|
||||
searchableProjectSections,
|
||||
sectionsForRender,
|
||||
searchMatchCount,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
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;
|
||||
sessionPinned: string;
|
||||
groupOrder: string;
|
||||
projectActiveSession: string;
|
||||
groupCollapse: string;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
isVSCode: boolean;
|
||||
safeStorage: SafeStorageLike;
|
||||
keys: Keys;
|
||||
sessions: Session[];
|
||||
pinnedSessionIds: Set<string>;
|
||||
setPinnedSessionIds: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
groupOrderByProject: Map<string, string[]>;
|
||||
activeSessionByProject: 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,
|
||||
sessions,
|
||||
pinnedSessionIds,
|
||||
setPinnedSessionIds,
|
||||
groupOrderByProject,
|
||||
activeSessionByProject,
|
||||
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(() => {
|
||||
const existingSessionIds = new Set(sessions.map((session) => session.id));
|
||||
setPinnedSessionIds((prev) => {
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((id) => {
|
||||
if (existingSessionIds.has(id)) {
|
||||
next.add(id);
|
||||
} else {
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [sessions, setPinnedSessionIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
safeStorage.setItem(keys.sessionPinned, JSON.stringify(Array.from(pinnedSessionIds)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [keys.sessionPinned, pinnedSessionIds, safeStorage]);
|
||||
|
||||
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 {
|
||||
const serialized = Object.fromEntries(activeSessionByProject.entries());
|
||||
safeStorage.setItem(keys.projectActiveSession, JSON.stringify(serialized));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [activeSessionByProject, keys.projectActiveSession, safeStorage]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
safeStorage.setItem(keys.groupCollapse, JSON.stringify(Array.from(collapsedGroups)));
|
||||
} catch {
|
||||
// ignored
|
||||
}
|
||||
}, [collapsedGroups, keys.groupCollapse, safeStorage]);
|
||||
|
||||
return { scheduleCollapsedProjectsPersist };
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
|
||||
type Args = {
|
||||
isDesktopShellRuntime: boolean;
|
||||
projectSections: unknown[];
|
||||
projectHeaderSentinelRefs: React.MutableRefObject<Map<string, HTMLDivElement | null>>;
|
||||
};
|
||||
|
||||
export const useStickyProjectHeaders = (args: Args): Set<string> => {
|
||||
const { isDesktopShellRuntime, projectSections, projectHeaderSentinelRefs } = args;
|
||||
const [stuckProjectHeaders, setStuckProjectHeaders] = React.useState<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDesktopShellRuntime) {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const projectId = (entry.target as HTMLElement).dataset.projectId;
|
||||
if (!projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStuckProjectHeaders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!entry.isIntersecting) {
|
||||
next.add(projectId);
|
||||
} else {
|
||||
next.delete(projectId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
},
|
||||
{ threshold: 0 },
|
||||
);
|
||||
|
||||
projectHeaderSentinelRefs.current.forEach((el) => {
|
||||
if (el) {
|
||||
observer.observe(el);
|
||||
}
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [isDesktopShellRuntime, projectHeaderSentinelRefs, projectSections]);
|
||||
|
||||
return stuckProjectHeaders;
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import { RiStickyNoteLine } from '@remixicon/react';
|
||||
|
||||
export const DraggableSessionRow: React.FC<{
|
||||
sessionId: string;
|
||||
sessionDirectory: string | null;
|
||||
sessionTitle: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ sessionId, sessionDirectory, sessionTitle, children }) => {
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: `session-drag:${sessionId}`,
|
||||
data: { type: 'session', sessionId, sessionDirectory, sessionTitle },
|
||||
});
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
if (listeners?.onPointerDown) {
|
||||
(listeners.onPointerDown as (event: React.PointerEvent) => void)(e);
|
||||
}
|
||||
},
|
||||
[listeners],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
onPointerDown={handlePointerDown}
|
||||
className={isDragging ? 'opacity-30' : undefined}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DroppableFolderWrapper: React.FC<{
|
||||
folderId: string;
|
||||
children: (
|
||||
droppableRef: (node: HTMLElement | null) => void,
|
||||
isOver: boolean,
|
||||
) => React.ReactNode;
|
||||
}> = ({ folderId, children }) => {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `folder-drop:${folderId}`,
|
||||
data: { type: 'folder', folderId },
|
||||
});
|
||||
return <>{children(setNodeRef, isOver)}</>;
|
||||
};
|
||||
|
||||
export const SessionFolderDndScope: React.FC<{
|
||||
scopeKey: string | null;
|
||||
hasFolders: boolean;
|
||||
onSessionDroppedOnFolder: (sessionId: string, folderId: string) => void;
|
||||
children: React.ReactNode;
|
||||
}> = ({ scopeKey, hasFolders, onSessionDroppedOnFolder, children }) => {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
const [activeDragId, setActiveDragId] = React.useState<string | null>(null);
|
||||
const [activeDragTitle, setActiveDragTitle] = React.useState<string>('Session');
|
||||
const [activeDragWidth, setActiveDragWidth] = React.useState<number | null>(null);
|
||||
const [activeDragHeight, setActiveDragHeight] = React.useState<number | null>(null);
|
||||
|
||||
if (!scopeKey) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setActiveDragId(null);
|
||||
setActiveDragWidth(null);
|
||||
setActiveDragHeight(null);
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
const activeData = active.data.current as { type?: string; sessionId?: string } | undefined;
|
||||
const overData = over.data.current as { type?: string; folderId?: string } | undefined;
|
||||
if (activeData?.type === 'session' && activeData.sessionId && overData?.type === 'folder' && overData.folderId) {
|
||||
onSessionDroppedOnFolder(activeData.sessionId, overData.folderId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={(event) => {
|
||||
const data = event.active.data.current as { type?: string; sessionId?: string; sessionTitle?: string } | undefined;
|
||||
if (data?.type === 'session' && data.sessionId) {
|
||||
setActiveDragId(data.sessionId);
|
||||
setActiveDragTitle(data.sessionTitle ?? 'Session');
|
||||
const width = event.active.rect.current.initial?.width;
|
||||
const height = event.active.rect.current.initial?.height;
|
||||
setActiveDragWidth(typeof width === 'number' ? width : null);
|
||||
setActiveDragHeight(typeof height === 'number' ? height : null);
|
||||
}
|
||||
}}
|
||||
onDragCancel={() => {
|
||||
setActiveDragId(null);
|
||||
setActiveDragWidth(null);
|
||||
setActiveDragHeight(null);
|
||||
}}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{children}
|
||||
<DragOverlay>
|
||||
{activeDragId && hasFolders ? (
|
||||
<div
|
||||
style={{
|
||||
width: activeDragWidth ? `${activeDragWidth}px` : 'auto',
|
||||
height: activeDragHeight ? `${activeDragHeight}px` : 'auto',
|
||||
}}
|
||||
className="flex items-center rounded-lg border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-2.5 py-1 shadow-none pointer-events-none"
|
||||
>
|
||||
<RiStickyNoteLine className="h-4 w-4 text-muted-foreground mr-2 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1 truncate typography-ui-label font-normal text-foreground">
|
||||
{activeDragTitle}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,336 @@
|
||||
import React from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip';
|
||||
import {
|
||||
RiAddLine,
|
||||
RiCheckLine,
|
||||
RiCloseLine,
|
||||
RiGitBranchLine,
|
||||
RiMore2Line,
|
||||
RiPencilAiLine,
|
||||
} from '@remixicon/react';
|
||||
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SortableProjectItemProps {
|
||||
id: string;
|
||||
projectLabel: string;
|
||||
projectDescription: string;
|
||||
isCollapsed: boolean;
|
||||
isActiveProject: boolean;
|
||||
isRepo: boolean;
|
||||
isHovered: boolean;
|
||||
isDesktopShell: boolean;
|
||||
isStuck: boolean;
|
||||
hideDirectoryControls: boolean;
|
||||
mobileVariant: boolean;
|
||||
onToggle: () => void;
|
||||
onHoverChange: (hovered: boolean) => void;
|
||||
onNewSession: () => void;
|
||||
onNewWorktreeSession?: () => void;
|
||||
onOpenMultiRunLauncher: () => void;
|
||||
onRenameStart: () => void;
|
||||
onRenameSave: () => void;
|
||||
onRenameCancel: () => void;
|
||||
onRenameValueChange: (value: string) => void;
|
||||
renameValue: string;
|
||||
isRenaming: boolean;
|
||||
onClose: () => void;
|
||||
sentinelRef: (el: HTMLDivElement | null) => void;
|
||||
children?: React.ReactNode;
|
||||
settingsAutoCreateWorktree: boolean;
|
||||
showCreateButtons?: boolean;
|
||||
hideHeader?: boolean;
|
||||
}
|
||||
|
||||
export const SortableProjectItem: React.FC<SortableProjectItemProps> = ({
|
||||
id,
|
||||
projectLabel,
|
||||
projectDescription,
|
||||
isCollapsed,
|
||||
isActiveProject,
|
||||
isRepo,
|
||||
isHovered,
|
||||
isDesktopShell,
|
||||
isStuck,
|
||||
hideDirectoryControls,
|
||||
mobileVariant,
|
||||
onToggle,
|
||||
onHoverChange,
|
||||
onNewSession,
|
||||
onNewWorktreeSession,
|
||||
onOpenMultiRunLauncher,
|
||||
onRenameStart,
|
||||
onRenameSave,
|
||||
onRenameCancel,
|
||||
onRenameValueChange,
|
||||
renameValue,
|
||||
isRenaming,
|
||||
onClose,
|
||||
sentinelRef,
|
||||
children,
|
||||
settingsAutoCreateWorktree,
|
||||
showCreateButtons = true,
|
||||
hideHeader = false,
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{ transform: CSS.Transform.toString(transform), transition }}
|
||||
className={cn('relative', isDragging && 'opacity-30')}
|
||||
>
|
||||
{!hideHeader ? (
|
||||
<>
|
||||
{isDesktopShell && (
|
||||
<div
|
||||
ref={sentinelRef}
|
||||
data-project-id={id}
|
||||
className="absolute top-0 h-px w-full pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'sticky top-0 z-10 pt-2 pb-1.5 w-full text-left cursor-pointer group/project border-b select-none',
|
||||
!isDesktopShell && 'bg-transparent',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isDesktopShell
|
||||
? (isStuck ? 'transparent' : 'transparent')
|
||||
: undefined,
|
||||
borderColor: isHovered
|
||||
? 'var(--color-border-hover)'
|
||||
: isCollapsed
|
||||
? 'color-mix(in srgb, var(--color-border) 35%, transparent)'
|
||||
: 'var(--color-border)',
|
||||
}}
|
||||
onMouseEnter={() => onHoverChange(true)}
|
||||
onMouseLeave={() => onHoverChange(false)}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
if (!isRenaming) {
|
||||
setIsMenuOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative flex items-center gap-1 px-1" {...attributes}>
|
||||
{isRenaming ? (
|
||||
<form
|
||||
className="flex min-w-0 flex-1 items-center gap-2"
|
||||
data-keyboard-avoid="true"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onRenameSave();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
value={renameValue}
|
||||
onChange={(event) => onRenameValueChange(event.target.value)}
|
||||
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
|
||||
autoFocus
|
||||
placeholder="Rename project"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation();
|
||||
onRenameCancel();
|
||||
return;
|
||||
}
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.stopPropagation();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RiCheckLine className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRenameCancel}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<Tooltip delayDuration={1500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
{...listeners}
|
||||
className="flex-1 min-w-0 flex items-center gap-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 rounded-sm cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<span className={cn(
|
||||
'typography-ui font-semibold truncate',
|
||||
isActiveProject ? 'text-primary' : 'text-foreground group-hover/project:text-foreground',
|
||||
)}>
|
||||
{projectLabel}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{projectDescription}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{!isRenaming ? (
|
||||
<DropdownMenu
|
||||
open={isMenuOpen}
|
||||
onOpenChange={setIsMenuOpen}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground',
|
||||
mobileVariant ? 'opacity-70' : 'opacity-0 group-hover/project:opacity-100',
|
||||
)}
|
||||
aria-label="Project menu"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<RiMore2Line className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-[180px]">
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && settingsAutoCreateWorktree && onNewSession && (
|
||||
<DropdownMenuItem onClick={onNewSession}>
|
||||
<RiAddLine className="mr-1.5 h-4 w-4" />
|
||||
New Session
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && !settingsAutoCreateWorktree && onNewWorktreeSession && (
|
||||
<DropdownMenuItem onClick={onNewWorktreeSession}>
|
||||
<RiGitBranchLine className="mr-1.5 h-4 w-4" />
|
||||
New Session in Worktree
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && (
|
||||
<DropdownMenuItem onClick={onOpenMultiRunLauncher}>
|
||||
<ArrowsMerge className="mr-1.5 h-4 w-4" />
|
||||
New Multi-Run
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={onRenameStart}>
|
||||
<RiPencilAiLine className="mr-1.5 h-4 w-4" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onClose}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<RiCloseLine className="mr-1.5 h-4 w-4" />
|
||||
Close Project
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null}
|
||||
|
||||
{showCreateButtons && isRepo && !hideDirectoryControls && onNewWorktreeSession && settingsAutoCreateWorktree && !isRenaming && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNewWorktreeSession();
|
||||
}}
|
||||
className={cn(
|
||||
'inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 hover:text-foreground hover:bg-interactive-hover/50 flex-shrink-0',
|
||||
mobileVariant ? 'opacity-70' : 'opacity-100',
|
||||
)}
|
||||
aria-label="New session in worktree"
|
||||
>
|
||||
<RiGitBranchLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
<p>New session in worktree</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showCreateButtons && (!settingsAutoCreateWorktree || !isRepo) && !isRenaming && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNewSession();
|
||||
}}
|
||||
className="inline-flex h-6 w-6 items-center justify-center text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 flex-shrink-0 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
aria-label="New session"
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
<p>New session</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SortableGroupItemBase: React.FC<{
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ id, children }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}}
|
||||
className={cn(
|
||||
'space-y-0.5 rounded-md',
|
||||
isDragging && 'opacity-50',
|
||||
)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SortableGroupItem = React.memo(SortableGroupItemBase);
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
export type SessionSummaryMeta = {
|
||||
additions?: number | string | null;
|
||||
deletions?: number | string | null;
|
||||
files?: number | null;
|
||||
diffs?: Array<{ additions?: number | string | null; deletions?: number | string | null }>;
|
||||
};
|
||||
|
||||
export type SessionNode = {
|
||||
session: Session;
|
||||
children: SessionNode[];
|
||||
worktree: WorktreeMetadata | null;
|
||||
};
|
||||
|
||||
export type SessionGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
branch: string | null;
|
||||
description: string | null;
|
||||
isMain: boolean;
|
||||
isArchivedBucket?: boolean;
|
||||
worktree: WorktreeMetadata | null;
|
||||
directory: string | null;
|
||||
folderScopeKey?: string | null;
|
||||
sessions: SessionNode[];
|
||||
};
|
||||
|
||||
export type GroupSearchData = {
|
||||
filteredNodes: SessionNode[];
|
||||
matchedSessionCount: number;
|
||||
folderNameMatchCount: number;
|
||||
groupMatches: boolean;
|
||||
hasMatch: boolean;
|
||||
};
|
||||
@@ -0,0 +1,242 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionSummaryMeta } from './types';
|
||||
|
||||
const formatDateLabel = (value: string | number) => {
|
||||
const targetDate = new Date(value);
|
||||
const today = new Date();
|
||||
const isSameDay = (a: Date, b: Date) =>
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(today.getDate() - 1);
|
||||
|
||||
if (isSameDay(targetDate, today)) {
|
||||
return 'Today';
|
||||
}
|
||||
if (isSameDay(targetDate, yesterday)) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
const formatted = targetDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
return formatted.replace(',', '');
|
||||
};
|
||||
|
||||
export const formatSessionDateLabel = (updatedMs: number): string => {
|
||||
const today = new Date();
|
||||
const updatedDate = new Date(updatedMs);
|
||||
const isSameDay = (a: Date, b: Date) =>
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate();
|
||||
|
||||
if (isSameDay(updatedDate, today)) {
|
||||
const diff = Date.now() - updatedMs;
|
||||
if (diff < 60_000) return 'Just now';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}min ago`;
|
||||
return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
}
|
||||
|
||||
return formatDateLabel(updatedMs);
|
||||
};
|
||||
|
||||
export const normalizePath = (value?: string | null) => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
return normalized.length === 0 ? '/' : normalized;
|
||||
};
|
||||
|
||||
export const normalizeForBranchComparison = (value: string): string => {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/^opencode[/-]?/i, '')
|
||||
.replace(/[-_]/g, '')
|
||||
.trim();
|
||||
};
|
||||
|
||||
export const isBranchDifferentFromLabel = (branch: string | null, label: string): boolean => {
|
||||
if (!branch) return false;
|
||||
return normalizeForBranchComparison(branch) !== normalizeForBranchComparison(label);
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const getSessionCreatedAt = (session: Session): number => {
|
||||
return toFiniteNumber(session.time?.created) ?? 0;
|
||||
};
|
||||
|
||||
const getSessionUpdatedAt = (session: Session): number => {
|
||||
return toFiniteNumber(session.time?.updated) ?? toFiniteNumber(session.time?.created) ?? 0;
|
||||
};
|
||||
|
||||
export const compareSessionsByPinnedAndTime = (
|
||||
a: Session,
|
||||
b: Session,
|
||||
pinnedSessionIds: Set<string>,
|
||||
): number => {
|
||||
const aPinned = pinnedSessionIds.has(a.id);
|
||||
const bPinned = pinnedSessionIds.has(b.id);
|
||||
if (aPinned !== bPinned) {
|
||||
return aPinned ? -1 : 1;
|
||||
}
|
||||
|
||||
if (aPinned && bPinned) {
|
||||
return getSessionCreatedAt(b) - getSessionCreatedAt(a);
|
||||
}
|
||||
|
||||
return getSessionUpdatedAt(b) - getSessionUpdatedAt(a);
|
||||
};
|
||||
|
||||
export const dedupeSessionsById = (sessions: Session[]): Session[] => {
|
||||
const byId = new Map<string, Session>();
|
||||
sessions.forEach((session) => {
|
||||
byId.set(session.id, session);
|
||||
});
|
||||
return Array.from(byId.values());
|
||||
};
|
||||
|
||||
export const getArchivedScopeKey = (projectRoot: string): string => `__archived__:${projectRoot}`;
|
||||
|
||||
export const resolveArchivedFolderName = (session: Session, projectRoot: string | null): string => {
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
||||
const resolved = sessionDirectory ?? projectWorktree;
|
||||
if (!resolved) {
|
||||
return 'unassigned';
|
||||
}
|
||||
if (projectRoot && resolved === projectRoot) {
|
||||
return 'project root';
|
||||
}
|
||||
const source = projectRoot && resolved.startsWith(`${projectRoot}/`)
|
||||
? resolved.slice(projectRoot.length + 1)
|
||||
: resolved;
|
||||
const segments = source.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1] ?? 'unassigned';
|
||||
};
|
||||
|
||||
export const isSessionRelatedToProject = (
|
||||
session: Session,
|
||||
projectRoot: string,
|
||||
validDirectories?: Set<string>,
|
||||
): boolean => {
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
||||
|
||||
if (projectWorktree && (projectWorktree === projectRoot || projectWorktree.startsWith(`${projectRoot}/`))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!sessionDirectory) {
|
||||
return false;
|
||||
}
|
||||
if (validDirectories && validDirectories.has(sessionDirectory)) {
|
||||
return true;
|
||||
}
|
||||
return sessionDirectory === projectRoot || sessionDirectory.startsWith(`${projectRoot}/`);
|
||||
};
|
||||
|
||||
const parseSummaryCount = (value: number | string | null | undefined): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveSessionDiffStats = (summary?: SessionSummaryMeta): { additions: number; deletions: number } | null => {
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directAdditions = parseSummaryCount(summary.additions);
|
||||
const directDeletions = parseSummaryCount(summary.deletions);
|
||||
if (directAdditions !== null || directDeletions !== null) {
|
||||
const stats = {
|
||||
additions: Math.max(0, directAdditions ?? 0),
|
||||
deletions: Math.max(0, directDeletions ?? 0),
|
||||
};
|
||||
return stats.additions === 0 && stats.deletions === 0 ? null : stats;
|
||||
}
|
||||
|
||||
const diffs = Array.isArray(summary.diffs) ? summary.diffs : [];
|
||||
if (diffs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let additions = 0;
|
||||
let deletions = 0;
|
||||
diffs.forEach((diff) => {
|
||||
additions += Math.max(0, parseSummaryCount(diff.additions) ?? 0);
|
||||
deletions += Math.max(0, parseSummaryCount(diff.deletions) ?? 0);
|
||||
});
|
||||
return additions === 0 && deletions === 0 ? null : { additions, deletions };
|
||||
};
|
||||
|
||||
export const formatProjectLabel = (label: string): string => {
|
||||
return label
|
||||
.replace(/[-_]/g, ' ')
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
};
|
||||
|
||||
export const renderHighlightedText = (text: string, query: string): React.ReactNode => {
|
||||
if (!query) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const loweredText = text.toLowerCase();
|
||||
const loweredQuery = query.toLowerCase();
|
||||
const queryLength = loweredQuery.length;
|
||||
if (queryLength === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const parts: React.ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
let matchIndex = loweredText.indexOf(loweredQuery, cursor);
|
||||
|
||||
while (matchIndex !== -1) {
|
||||
if (matchIndex > cursor) {
|
||||
parts.push(text.slice(cursor, matchIndex));
|
||||
}
|
||||
const matchText = text.slice(matchIndex, matchIndex + queryLength);
|
||||
parts.push(
|
||||
<mark
|
||||
key={`${matchIndex}-${matchText}`}
|
||||
className="bg-primary text-primary-foreground ring-1 ring-primary/90"
|
||||
>
|
||||
{matchText}
|
||||
</mark>,
|
||||
);
|
||||
cursor = matchIndex + queryLength;
|
||||
matchIndex = loweredText.indexOf(loweredQuery, cursor);
|
||||
}
|
||||
|
||||
if (cursor < text.length) {
|
||||
parts.push(text.slice(cursor));
|
||||
}
|
||||
|
||||
return parts.length > 0 ? parts : text;
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useSessionStore, MEMORY_LIMITS } from '@/stores/useSessionStore';
|
||||
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { getMessageLimit, getBackgroundTrimLimit } from '@/stores/types/sessionTypes';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -20,6 +21,7 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
|
||||
trimToViewportWindow,
|
||||
evictLeastRecentlyUsed
|
||||
} = useSessionStore();
|
||||
const totalGitHubRequests = useGitHubPrStatusStore((state) => state.totalRequestCount);
|
||||
|
||||
const totalMessages = React.useMemo(() => {
|
||||
let total = 0;
|
||||
@@ -96,6 +98,10 @@ export const MemoryDebugPanel: React.FC<MemoryDebugPanelProps> = ({ onClose }) =
|
||||
<span className="text-muted-foreground">Zombie Timeout:</span>
|
||||
<span>{MEMORY_LIMITS.ZOMBIE_TIMEOUT / 1000 / 60} minutes</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">GitHub Total Requests:</span>
|
||||
<span>{totalGitHubRequests}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{}
|
||||
|
||||
@@ -12,6 +12,17 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
closeButton={false}
|
||||
toastOptions={{
|
||||
style: {
|
||||
borderRadius: "var(--radius-md)",
|
||||
},
|
||||
classNames: {
|
||||
toast: "rounded-[var(--radius-md)]",
|
||||
actionButton: "rounded-[var(--radius-sm)]",
|
||||
cancelButton: "rounded-[var(--radius-sm)]",
|
||||
closeButton: "rounded-[var(--radius-sm)]",
|
||||
},
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
|
||||
@@ -1190,13 +1190,14 @@ export const GitView: React.FC = () => {
|
||||
|
||||
const selectedCount = selectedPaths.size;
|
||||
const isBusy = isLoading || syncAction !== null || commitAction !== null;
|
||||
const currentBranch = status?.current ?? null;
|
||||
const canShowIntegrateCommitsSection = Boolean(
|
||||
worktreeMetadata && repoRootForIntegrate && sourceBranchForIntegrate && shouldShowIntegrateCommits
|
||||
);
|
||||
const canShowPullRequestSection = Boolean(
|
||||
currentDirectory && status?.current && status?.tracking && status.current !== baseBranch
|
||||
currentDirectory && currentBranch && status?.tracking && currentBranch !== baseBranch
|
||||
);
|
||||
const canShowBranchWorkflows = Boolean(status?.current);
|
||||
const canShowBranchWorkflows = Boolean(currentBranch);
|
||||
const integrateCommitsProps =
|
||||
canShowIntegrateCommitsSection && repoRootForIntegrate && sourceBranchForIntegrate && worktreeMetadata
|
||||
? {
|
||||
@@ -1205,13 +1206,15 @@ export const GitView: React.FC = () => {
|
||||
worktreeMetadata,
|
||||
}
|
||||
: null;
|
||||
const pullRequestProps =
|
||||
canShowPullRequestSection && currentDirectory && status?.current
|
||||
? {
|
||||
directory: currentDirectory,
|
||||
branch: status.current,
|
||||
}
|
||||
: null;
|
||||
const pullRequestProps = React.useMemo(() => {
|
||||
if (!canShowPullRequestSection || !currentDirectory || !currentBranch) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
directory: currentDirectory,
|
||||
branch: currentBranch,
|
||||
};
|
||||
}, [canShowPullRequestSection, currentBranch, currentDirectory]);
|
||||
// Keep these sections stable in layout; individual cards render placeholders when unavailable.
|
||||
|
||||
const toggleFileSelection = (path: string) => {
|
||||
|
||||
@@ -54,6 +54,7 @@ import { useMessageStore } from '@/stores/messageStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { getGitHubPrStatusKey, useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import type {
|
||||
GitHubPullRequest,
|
||||
GitHubCheckRun,
|
||||
@@ -64,10 +65,6 @@ import type {
|
||||
|
||||
type MergeMethod = 'merge' | 'squash' | 'rebase';
|
||||
|
||||
const PR_REVALIDATE_TTL_MS = 90_000;
|
||||
const PR_REVALIDATE_INTERVAL_MS = 30_000;
|
||||
const PR_DISCOVERY_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
const statusColor = (state: string | undefined | null): string => {
|
||||
switch (state) {
|
||||
case 'success':
|
||||
@@ -215,6 +212,35 @@ const pickInitialPrRemote = (
|
||||
return remotes[0] ?? null;
|
||||
};
|
||||
|
||||
const isEphemeralPrRemote = (name: string): boolean => name.startsWith('pr-');
|
||||
|
||||
const rankRemotesForAutoSelect = (
|
||||
remotes: GitRemote[],
|
||||
trackingBranch?: string,
|
||||
): GitRemote[] => {
|
||||
const trackingRemote = getTrackingRemoteName(trackingBranch);
|
||||
const byName = new Map(remotes.map((remote) => [remote.name, remote]));
|
||||
const ordered: GitRemote[] = [];
|
||||
const pushUnique = (remote: GitRemote | null | undefined) => {
|
||||
if (!remote) return;
|
||||
if (ordered.some((item) => item.name === remote.name)) return;
|
||||
ordered.push(remote);
|
||||
};
|
||||
|
||||
if (trackingRemote) {
|
||||
pushUnique(byName.get(trackingRemote));
|
||||
}
|
||||
pushUnique(byName.get('upstream'));
|
||||
pushUnique(byName.get('origin'));
|
||||
|
||||
remotes
|
||||
.filter((remote) => !isEphemeralPrRemote(remote.name))
|
||||
.forEach((remote) => pushUnique(remote));
|
||||
remotes.forEach((remote) => pushUnique(remote));
|
||||
|
||||
return ordered;
|
||||
};
|
||||
|
||||
type TimelineCommentItem = {
|
||||
id: string;
|
||||
body: string;
|
||||
@@ -236,7 +262,6 @@ type ChatDispatchTarget = {
|
||||
};
|
||||
|
||||
const pullRequestDraftSnapshots = new Map<string, PullRequestDraftSnapshot>();
|
||||
const pullRequestStatusSnapshots = new Map<string, GitHubPullRequestStatus>();
|
||||
|
||||
type TauriShell = {
|
||||
shell?: {
|
||||
@@ -293,15 +318,12 @@ export const PullRequestSection: React.FC<{
|
||||
() => pullRequestDraftSnapshots.get(snapshotKey) ?? null,
|
||||
[snapshotKey]
|
||||
);
|
||||
const initialStatusSnapshot = React.useMemo(
|
||||
() => pullRequestStatusSnapshots.get(snapshotKey) ?? null,
|
||||
[snapshotKey]
|
||||
);
|
||||
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [status, setStatus] = React.useState<GitHubPullRequestStatus | null>(() => initialStatusSnapshot);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [isInitialStatusResolved, setIsInitialStatusResolved] = React.useState(() => Boolean(initialStatusSnapshot));
|
||||
const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry);
|
||||
const setPrStatusParams = useGitHubPrStatusStore((state) => state.setParams);
|
||||
const startPrStatusWatching = useGitHubPrStatusStore((state) => state.startWatching);
|
||||
const stopPrStatusWatching = useGitHubPrStatusStore((state) => state.stopWatching);
|
||||
const refreshPrStatus = useGitHubPrStatusStore((state) => state.refresh);
|
||||
const updatePrStatus = useGitHubPrStatusStore((state) => state.updateStatus);
|
||||
|
||||
const [title, setTitle] = React.useState(() => initialSnapshot?.title ?? branchToTitle(branch));
|
||||
const [body, setBody] = React.useState(() => initialSnapshot?.body ?? '');
|
||||
@@ -337,6 +359,17 @@ export const PullRequestSection: React.FC<{
|
||||
})
|
||||
);
|
||||
|
||||
const prStatusKey = React.useMemo(
|
||||
() => getGitHubPrStatusKey(directory, branch),
|
||||
[directory, branch],
|
||||
);
|
||||
const statusEntry = useGitHubPrStatusStore((state) => state.entries[prStatusKey]);
|
||||
|
||||
const isLoading = statusEntry?.isLoading ?? false;
|
||||
const status = statusEntry?.status ?? null;
|
||||
const error = statusEntry?.error ?? null;
|
||||
const isInitialStatusResolved = statusEntry?.isInitialStatusResolved ?? false;
|
||||
|
||||
const availableBaseBranches = React.useMemo(() => {
|
||||
const selectedRemoteName = selectedRemote?.name?.trim() || null;
|
||||
const unique = new Set<string>();
|
||||
@@ -412,13 +445,10 @@ export const PullRequestSection: React.FC<{
|
||||
const [commentsDetails, setCommentsDetails] = React.useState<GitHubPullRequestContextResult | null>(null);
|
||||
const [isLoadingCommentsDetails, setIsLoadingCommentsDetails] = React.useState(false);
|
||||
|
||||
const isRefreshInFlightRef = React.useRef(false);
|
||||
const lastRefreshAtRef = React.useRef(0);
|
||||
const lastDiscoveryPollAtRef = React.useRef(0);
|
||||
const statusRef = React.useRef<GitHubPullRequestStatus | null>(null);
|
||||
const selectedRemoteNameRef = React.useRef<string | null>(selectedRemote?.name ?? null);
|
||||
const attemptedBodyHydrationRef = React.useRef<Set<string>>(new Set());
|
||||
const lastSyncedPrNumberRef = React.useRef<number | null>(null);
|
||||
const didUserOverrideRemoteRef = React.useRef(false);
|
||||
const autoRemoteProbeDoneRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
const canShow = Boolean(directory && branch && baseBranch && branch !== baseBranch);
|
||||
|
||||
@@ -454,7 +484,7 @@ export const PullRequestSection: React.FC<{
|
||||
if (!ctxPr) {
|
||||
return;
|
||||
}
|
||||
setStatus((prev) => {
|
||||
updatePrStatus(prStatusKey, (prev) => {
|
||||
if (!prev?.pr || prev.pr.number !== pr.number) {
|
||||
return prev;
|
||||
}
|
||||
@@ -478,7 +508,7 @@ export const PullRequestSection: React.FC<{
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [directory, github, pr]);
|
||||
}, [directory, github, pr, prStatusKey, updatePrStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pr) {
|
||||
@@ -923,116 +953,100 @@ export const PullRequestSection: React.FC<{
|
||||
dispatchSyntheticPrompt(target, visibleText, instructionsText, payloadText);
|
||||
}, [commentsDetails, dispatchSyntheticPrompt, pr, resolveChatDispatchTarget, setActiveMainTab]);
|
||||
|
||||
React.useEffect(() => {
|
||||
statusRef.current = status;
|
||||
}, [status]);
|
||||
|
||||
React.useEffect(() => {
|
||||
selectedRemoteNameRef.current = selectedRemote?.name ?? null;
|
||||
}, [selectedRemote?.name]);
|
||||
|
||||
const refresh = React.useCallback(async (options?: { force?: boolean; onlyExistingPr?: boolean; silent?: boolean; markInitialResolved?: boolean }) => {
|
||||
if (!canShow) return;
|
||||
if (options?.onlyExistingPr && !statusRef.current?.pr) {
|
||||
return;
|
||||
}
|
||||
if (!options?.force && Date.now() - lastRefreshAtRef.current < PR_REVALIDATE_TTL_MS) {
|
||||
return;
|
||||
}
|
||||
if (isRefreshInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRefreshInFlightRef.current = true;
|
||||
lastRefreshAtRef.current = Date.now();
|
||||
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
||||
setStatus({ connected: false });
|
||||
setError(null);
|
||||
if (!options?.silent) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
if (options?.markInitialResolved !== false) {
|
||||
setIsInitialStatusResolved(true);
|
||||
}
|
||||
isRefreshInFlightRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!github?.prStatus) {
|
||||
setStatus(null);
|
||||
setError('GitHub runtime API unavailable');
|
||||
if (options?.markInitialResolved !== false) {
|
||||
setIsInitialStatusResolved(true);
|
||||
}
|
||||
isRefreshInFlightRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (!options?.silent) {
|
||||
setIsLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const next = await github.prStatus(directory, branch, selectedRemoteNameRef.current ?? undefined);
|
||||
setStatus((prev) => {
|
||||
const nextPr = next.pr;
|
||||
const prevPr = prev?.pr;
|
||||
// Some runtimes occasionally return PR status without body.
|
||||
// Keep already hydrated description for the same PR number.
|
||||
const shouldCarryBody = Boolean(
|
||||
nextPr
|
||||
&& prevPr
|
||||
&& nextPr.number === prevPr.number
|
||||
&& (!nextPr.body || !nextPr.body.trim())
|
||||
&& typeof prevPr.body === 'string'
|
||||
&& prevPr.body.trim().length > 0,
|
||||
);
|
||||
|
||||
if (!shouldCarryBody || !nextPr) {
|
||||
return next;
|
||||
}
|
||||
|
||||
const carriedBody = prevPr?.body;
|
||||
if (!carriedBody) {
|
||||
return next;
|
||||
}
|
||||
|
||||
return {
|
||||
...next,
|
||||
pr: {
|
||||
...nextPr,
|
||||
body: carriedBody,
|
||||
},
|
||||
};
|
||||
});
|
||||
if (next.connected === false) {
|
||||
setError(null);
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
setError(message || 'Failed to load PR status');
|
||||
} finally {
|
||||
if (!options?.silent) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
if (options?.markInitialResolved !== false) {
|
||||
setIsInitialStatusResolved(true);
|
||||
}
|
||||
isRefreshInFlightRef.current = false;
|
||||
}
|
||||
}, [branch, canShow, directory, github, githubAuthChecked, githubAuthStatus]);
|
||||
await refreshPrStatus(prStatusKey, options);
|
||||
}, [prStatusKey, refreshPrStatus]);
|
||||
|
||||
// Refetch PR status when selected remote changes
|
||||
const handleRemoteChange = React.useCallback((remote: GitRemote) => {
|
||||
didUserOverrideRemoteRef.current = true;
|
||||
setSelectedRemote((prev) => (prev?.name === remote.name ? prev : remote));
|
||||
// Clear current status and refetch
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
lastRefreshAtRef.current = 0; // Force refresh
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!github?.prStatus || !canShow || remotes.length <= 1) {
|
||||
return;
|
||||
}
|
||||
if (didUserOverrideRemoteRef.current) {
|
||||
return;
|
||||
}
|
||||
if (status?.pr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const probeKey = `${snapshotKey}::${selectedRemote?.name ?? ''}`;
|
||||
if (autoRemoteProbeDoneRef.current.has(probeKey)) {
|
||||
return;
|
||||
}
|
||||
autoRemoteProbeDoneRef.current.add(probeKey);
|
||||
|
||||
const candidates = rankRemotesForAutoSelect(remotes, trackingBranch)
|
||||
.filter((remote) => remote.name !== selectedRemote?.name);
|
||||
if (candidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
for (const candidate of candidates) {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const next = await github.prStatus(directory, branch, candidate.name);
|
||||
if (!next?.pr) {
|
||||
continue;
|
||||
}
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setSelectedRemote((prev) => (prev?.name === candidate.name ? prev : candidate));
|
||||
return;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [branch, canShow, directory, github, remotes, selectedRemote?.name, snapshotKey, status?.pr, trackingBranch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
ensurePrStatusEntry(prStatusKey);
|
||||
setPrStatusParams(prStatusKey, {
|
||||
directory,
|
||||
branch,
|
||||
remoteName: selectedRemote?.name ?? null,
|
||||
canShow,
|
||||
github,
|
||||
githubAuthChecked,
|
||||
githubConnected: githubAuthStatus?.connected ?? null,
|
||||
});
|
||||
}, [
|
||||
branch,
|
||||
canShow,
|
||||
directory,
|
||||
ensurePrStatusEntry,
|
||||
github,
|
||||
githubAuthChecked,
|
||||
githubAuthStatus?.connected,
|
||||
prStatusKey,
|
||||
selectedRemote?.name,
|
||||
setPrStatusParams,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
startPrStatusWatching(prStatusKey);
|
||||
return () => {
|
||||
stopPrStatusWatching(prStatusKey);
|
||||
};
|
||||
}, [prStatusKey, startPrStatusWatching, stopPrStatusWatching]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null;
|
||||
const statusSnapshot = pullRequestStatusSnapshots.get(snapshotKey) ?? null;
|
||||
setTitle(snapshot?.title ?? branchToTitle(branch));
|
||||
setBody(snapshot?.body ?? '');
|
||||
setDraft(snapshot?.draft ?? false);
|
||||
@@ -1042,29 +1056,35 @@ export const PullRequestSection: React.FC<{
|
||||
trackingBranch,
|
||||
});
|
||||
setSelectedRemote((prev) => (prev?.name === nextRemote?.name ? prev : nextRemote));
|
||||
setStatus(statusSnapshot);
|
||||
setError(null);
|
||||
setIsInitialStatusResolved(Boolean(statusSnapshot));
|
||||
}, [baseBranch, branch, remotes, snapshotKey, trackingBranch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void refresh({ force: true, markInitialResolved: true });
|
||||
}, [snapshotKey, refresh]);
|
||||
void refresh({ markInitialResolved: true });
|
||||
}, [prStatusKey, refresh]);
|
||||
|
||||
// Refetch when selected remote changes
|
||||
React.useEffect(() => {
|
||||
if (selectedRemote?.name) {
|
||||
void refresh({ force: true, markInitialResolved: true });
|
||||
if (!canShow || !selectedRemote?.name) {
|
||||
return;
|
||||
}
|
||||
}, [selectedRemote?.name, refresh]);
|
||||
void refresh({ force: true, silent: true, markInitialResolved: true });
|
||||
}, [canShow, refresh, selectedRemote?.name]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const isTerminal = status?.pr?.state === 'closed' || status?.pr?.state === 'merged';
|
||||
const lastRefreshAt = statusEntry?.lastRefreshAt ?? 0;
|
||||
const isStale = Date.now() - lastRefreshAt > 60_000;
|
||||
const shouldRefresh = !isTerminal && isStale;
|
||||
|
||||
const onFocus = () => {
|
||||
void refresh({ force: true, silent: true });
|
||||
if (shouldRefresh) {
|
||||
void refresh({ force: true, silent: true });
|
||||
}
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void refresh({ force: true, silent: true });
|
||||
if (shouldRefresh) {
|
||||
void refresh({ force: true, silent: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1074,40 +1094,13 @@ export const PullRequestSection: React.FC<{
|
||||
window.removeEventListener('focus', onFocus);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
if (document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPr = Boolean(statusRef.current?.pr);
|
||||
if (!hasPr) {
|
||||
const now = Date.now();
|
||||
const shouldRunDiscovery = now - lastDiscoveryPollAtRef.current >= PR_DISCOVERY_INTERVAL_MS;
|
||||
if (!shouldRunDiscovery) {
|
||||
return;
|
||||
}
|
||||
lastDiscoveryPollAtRef.current = now;
|
||||
void refresh({ force: true, silent: true });
|
||||
return;
|
||||
}
|
||||
|
||||
void refresh({ onlyExistingPr: true, force: true, silent: true });
|
||||
}, PR_REVALIDATE_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [refresh]);
|
||||
}, [refresh, status?.pr?.state, statusEntry?.lastRefreshAt]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (githubAuthChecked && githubAuthStatus?.connected === false) {
|
||||
setStatus({ connected: false });
|
||||
setError(null);
|
||||
void refresh({ force: true, silent: true, markInitialResolved: true });
|
||||
}
|
||||
}, [githubAuthChecked, githubAuthStatus]);
|
||||
}, [githubAuthChecked, githubAuthStatus, refresh]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!directory || !branch) {
|
||||
@@ -1123,13 +1116,6 @@ export const PullRequestSection: React.FC<{
|
||||
});
|
||||
}, [snapshotKey, title, body, draft, additionalContext, targetBaseBranch, selectedRemote?.name, directory, branch]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
pullRequestStatusSnapshots.set(snapshotKey, status);
|
||||
}, [snapshotKey, status]);
|
||||
|
||||
const generateDescription = React.useCallback(async () => {
|
||||
if (isGenerating) return;
|
||||
if (!directory) return;
|
||||
@@ -1194,7 +1180,7 @@ export const PullRequestSection: React.FC<{
|
||||
...(selectedRemote ? { remote: selectedRemote.name } : {}),
|
||||
});
|
||||
toast.success('PR created');
|
||||
setStatus((prev) => (prev ? { ...prev, pr } : prev));
|
||||
updatePrStatus(prStatusKey, (prev) => (prev ? { ...prev, pr } : prev));
|
||||
await refresh({ force: true });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
@@ -1202,7 +1188,7 @@ export const PullRequestSection: React.FC<{
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [body, branch, directory, draft, github, refresh, selectedRemote, targetBaseBranch, title]);
|
||||
}, [body, branch, directory, draft, github, prStatusKey, refresh, selectedRemote, targetBaseBranch, title, updatePrStatus]);
|
||||
|
||||
const mergePr = React.useCallback(async (pr: GitHubPullRequest) => {
|
||||
if (!github?.prMerge) {
|
||||
@@ -1270,7 +1256,7 @@ export const PullRequestSection: React.FC<{
|
||||
title: trimmedTitle,
|
||||
body: editBody,
|
||||
});
|
||||
setStatus((prev) => (prev
|
||||
updatePrStatus(prStatusKey, (prev) => (prev
|
||||
? {
|
||||
...prev,
|
||||
pr: {
|
||||
@@ -1288,7 +1274,7 @@ export const PullRequestSection: React.FC<{
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [directory, editBody, editTitle, github, refresh]);
|
||||
}, [directory, editBody, editTitle, github, prStatusKey, refresh, updatePrStatus]);
|
||||
|
||||
if (!canShow) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionStore } from '@/stores/useSessionStore';
|
||||
|
||||
const MAX_BACKGROUND_PR_DIRECTORIES = 50;
|
||||
const BRANCH_REFRESH_TTL_MS = 2 * 60_000;
|
||||
const BRANCH_REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
const normalizePath = (value?: string | null): string | null => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const normalized = trimmed.replace(/\\/g, '/');
|
||||
if (normalized === '/') {
|
||||
return '/';
|
||||
}
|
||||
return normalized.replace(/\/+$/, '');
|
||||
};
|
||||
|
||||
type SessionLike = Session & {
|
||||
directory?: string | null;
|
||||
project?: { worktree?: string | null } | null;
|
||||
};
|
||||
|
||||
type BranchCacheEntry = {
|
||||
branch: string | null;
|
||||
fetchedAt: number;
|
||||
};
|
||||
|
||||
export const useGitHubPrBackgroundTracking = (
|
||||
github: RuntimeAPIs['github'] | undefined,
|
||||
git: RuntimeAPIs['git'],
|
||||
): void => {
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const projects = useProjectsStore((state) => state.projects);
|
||||
const sessions = useSessionStore((state) => state.sessions);
|
||||
const archivedSessions = useSessionStore((state) => state.archivedSessions);
|
||||
const availableWorktreesByProject = useSessionStore((state) => state.availableWorktreesByProject);
|
||||
const worktreeMetadata = useSessionStore((state) => state.worktreeMetadata);
|
||||
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
|
||||
|
||||
const syncBackgroundTargets = useGitHubPrStatusStore((state) => state.syncBackgroundTargets);
|
||||
|
||||
const [branchCache, setBranchCache] = React.useState<Map<string, BranchCacheEntry>>(new Map());
|
||||
const branchCacheRef = React.useRef<Map<string, BranchCacheEntry>>(new Map());
|
||||
|
||||
React.useEffect(() => {
|
||||
branchCacheRef.current = branchCache;
|
||||
}, [branchCache]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!github || githubAuthChecked) {
|
||||
return;
|
||||
}
|
||||
void refreshGitHubAuthStatus(github);
|
||||
}, [github, githubAuthChecked, refreshGitHubAuthStatus]);
|
||||
|
||||
const candidateDirectories = React.useMemo(() => {
|
||||
const ordered = new Map<string, string>();
|
||||
const add = (value?: string | null) => {
|
||||
const normalized = normalizePath(value);
|
||||
if (!normalized || ordered.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
ordered.set(normalized, normalized);
|
||||
};
|
||||
|
||||
add(currentDirectory);
|
||||
projects.forEach((project) => {
|
||||
add(project.path);
|
||||
});
|
||||
availableWorktreesByProject.forEach((worktrees) => {
|
||||
worktrees.forEach((worktree) => {
|
||||
add(worktree.path);
|
||||
});
|
||||
});
|
||||
worktreeMetadata.forEach((metadata) => {
|
||||
add(metadata.path);
|
||||
});
|
||||
|
||||
[...sessions, ...archivedSessions]
|
||||
.sort((a, b) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
|
||||
.forEach((rawSession) => {
|
||||
const session = rawSession as SessionLike;
|
||||
add(session.directory ?? null);
|
||||
add(session.project?.worktree ?? null);
|
||||
});
|
||||
|
||||
return Array.from(ordered.values()).slice(0, MAX_BACKGROUND_PR_DIRECTORIES);
|
||||
}, [archivedSessions, availableWorktreesByProject, currentDirectory, projects, sessions, worktreeMetadata]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const refreshBranches = async (force = false) => {
|
||||
const now = Date.now();
|
||||
const directoriesToFetch = candidateDirectories.filter((directory) => {
|
||||
const cached = branchCacheRef.current.get(directory);
|
||||
if (!cached) {
|
||||
return true;
|
||||
}
|
||||
if (force) {
|
||||
return true;
|
||||
}
|
||||
return now - cached.fetchedAt > BRANCH_REFRESH_TTL_MS;
|
||||
});
|
||||
|
||||
if (directoriesToFetch.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
directoriesToFetch.map(async (directory) => {
|
||||
try {
|
||||
const status = await git.getGitStatus(directory);
|
||||
const branch = typeof status.current === 'string' ? status.current.trim() : '';
|
||||
return { directory, branch: branch && branch !== 'HEAD' ? branch : null };
|
||||
} catch {
|
||||
return { directory, branch: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBranchCache((prev) => {
|
||||
const next = new Map(prev);
|
||||
let changed = false;
|
||||
results.forEach(({ directory, branch }) => {
|
||||
const previous = next.get(directory);
|
||||
const fetchedAt = Date.now();
|
||||
if (!previous || previous.branch !== branch) {
|
||||
changed = true;
|
||||
}
|
||||
if (!previous || previous.fetchedAt !== fetchedAt || previous.branch !== branch) {
|
||||
next.set(directory, { branch, fetchedAt });
|
||||
}
|
||||
});
|
||||
|
||||
if (!changed && results.length > 0) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
branchCacheRef.current = next;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
void refreshBranches();
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
void refreshBranches();
|
||||
}, BRANCH_REFRESH_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(intervalId);
|
||||
};
|
||||
}, [candidateDirectories, git]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const validDirectories = new Set(candidateDirectories);
|
||||
setBranchCache((prev) => {
|
||||
let changed = false;
|
||||
const next = new Map<string, BranchCacheEntry>();
|
||||
prev.forEach((value, key) => {
|
||||
if (!validDirectories.has(key)) {
|
||||
changed = true;
|
||||
return;
|
||||
}
|
||||
next.set(key, value);
|
||||
});
|
||||
if (!changed) {
|
||||
return prev;
|
||||
}
|
||||
branchCacheRef.current = next;
|
||||
return next;
|
||||
});
|
||||
}, [candidateDirectories]);
|
||||
|
||||
const targets = React.useMemo(() => {
|
||||
const result: Array<{ directory: string; branch: string; remoteName?: string | null }> = [];
|
||||
candidateDirectories.forEach((directory) => {
|
||||
const cached = branchCache.get(directory);
|
||||
if (!cached?.branch) {
|
||||
return;
|
||||
}
|
||||
result.push({
|
||||
directory,
|
||||
branch: cached.branch,
|
||||
remoteName: null,
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}, [branchCache, candidateDirectories]);
|
||||
|
||||
React.useEffect(() => {
|
||||
syncBackgroundTargets({
|
||||
targets,
|
||||
github,
|
||||
githubAuthChecked,
|
||||
githubConnected: githubAuthStatus?.connected ?? null,
|
||||
});
|
||||
}, [github, githubAuthChecked, githubAuthStatus?.connected, syncBackgroundTargets, targets]);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2";
|
||||
|
||||
export type GlobalSessionRecord = Session & {
|
||||
project?: {
|
||||
id: string;
|
||||
name?: string;
|
||||
worktree?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const toNumber = (value: string | null): number | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const readResponseHeader = (response: unknown, header: string): string | null => {
|
||||
if (!response || typeof response !== "object") {
|
||||
return null;
|
||||
}
|
||||
const container = response as { headers?: unknown };
|
||||
const headers = container.headers;
|
||||
if (!headers || typeof headers !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const maybeGet = headers as { get?: (name: string) => string | null };
|
||||
if (typeof maybeGet.get === "function") {
|
||||
return maybeGet.get(header);
|
||||
}
|
||||
|
||||
const maybeRecord = headers as Record<string, unknown>;
|
||||
const direct = maybeRecord[header] ?? maybeRecord[header.toLowerCase()];
|
||||
return typeof direct === "string" ? direct : null;
|
||||
};
|
||||
|
||||
export const readNextCursor = (response: unknown): number | null => {
|
||||
return toNumber(readResponseHeader(response, "x-next-cursor"));
|
||||
};
|
||||
|
||||
export const isMissingGlobalSessionsEndpointError = (error: unknown): boolean => {
|
||||
if (!error || typeof error !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const value = error as {
|
||||
status?: number;
|
||||
response?: { status?: number };
|
||||
cause?: { status?: number; response?: { status?: number } };
|
||||
};
|
||||
|
||||
const status = value.status
|
||||
?? value.response?.status
|
||||
?? value.cause?.status
|
||||
?? value.cause?.response?.status;
|
||||
|
||||
return status === 404;
|
||||
};
|
||||
|
||||
export async function listGlobalSessionPages(
|
||||
apiClient: OpencodeClient,
|
||||
options: {
|
||||
archived: boolean;
|
||||
pageSize: number;
|
||||
onPage?: (sessions: GlobalSessionRecord[]) => void;
|
||||
},
|
||||
): Promise<GlobalSessionRecord[]> {
|
||||
const all: GlobalSessionRecord[] = [];
|
||||
let cursor: number | undefined;
|
||||
|
||||
while (true) {
|
||||
const response = await apiClient.experimental.session.list({
|
||||
archived: options.archived,
|
||||
limit: options.pageSize,
|
||||
...(cursor ? { cursor } : {}),
|
||||
});
|
||||
|
||||
const payload = Array.isArray(response.data) ? (response.data as GlobalSessionRecord[]) : [];
|
||||
if (payload.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
all.push(...payload);
|
||||
options.onPage?.(payload);
|
||||
|
||||
const nextCursor = toNumber(readResponseHeader(response, "x-next-cursor"));
|
||||
if (!nextCursor) {
|
||||
break;
|
||||
}
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return all;
|
||||
}
|
||||
@@ -12,9 +12,11 @@ import { triggerSessionStatusPoll } from "@/hooks/useServerSessionStatus";
|
||||
import type { ProjectEntry } from "@/lib/api/types";
|
||||
import { checkIsGitRepository } from "@/lib/gitApi";
|
||||
import { streamDebugEnabled } from "@/stores/utils/streamDebug";
|
||||
import { isMissingGlobalSessionsEndpointError, readNextCursor, type GlobalSessionRecord } from "./globalSessions";
|
||||
|
||||
interface SessionState {
|
||||
sessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
sessionsByDirectory: Map<string, Session[]>;
|
||||
currentSessionId: string | null;
|
||||
lastLoadedDirectory: string | null;
|
||||
@@ -31,6 +33,8 @@ interface SessionActions {
|
||||
createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise<Session | null>;
|
||||
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise<boolean>;
|
||||
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
archiveSession: (id: string) => Promise<boolean>;
|
||||
archiveSessions: (ids: string[], options?: { silent?: boolean }) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
|
||||
updateSessionTitle: (id: string, title: string) => Promise<void>;
|
||||
shareSession: (id: string) => Promise<Session | null>;
|
||||
unshareSession: (id: string) => Promise<Session | null>;
|
||||
@@ -97,24 +101,10 @@ type ProjectRepoCacheEntry = {
|
||||
isGitRepo: boolean;
|
||||
};
|
||||
|
||||
const PROJECT_SESSION_CACHE_TTL_MS = 30_000;
|
||||
const PROJECT_REPO_STATUS_CACHE_TTL_MS = 120_000;
|
||||
const projectSessionCache = new Map<string, ProjectSessionCacheEntry>();
|
||||
const projectRepoStatusCache = new Map<string, ProjectRepoCacheEntry>();
|
||||
|
||||
const getFreshProjectSessionCache = (projectPath: string): ProjectSessionResult | null => {
|
||||
const key = normalizePath(projectPath) ?? projectPath;
|
||||
const cached = projectSessionCache.get(key);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() - cached.cachedAt > PROJECT_SESSION_CACHE_TTL_MS) {
|
||||
projectSessionCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return cached.result;
|
||||
};
|
||||
|
||||
const setProjectSessionCache = (projectPath: string, result: ProjectSessionResult) => {
|
||||
const key = normalizePath(projectPath) ?? projectPath;
|
||||
projectSessionCache.set(key, { cachedAt: Date.now(), result });
|
||||
@@ -241,6 +231,21 @@ const deleteSessionOnServer = async (sessionId: string, directory?: string | nul
|
||||
return Boolean(response.data);
|
||||
};
|
||||
|
||||
const setSessionArchivedOnServer = async (
|
||||
sessionId: string,
|
||||
archivedAt: number,
|
||||
directory?: string | null,
|
||||
): Promise<Session | null> => {
|
||||
const apiClient = opencodeClient.getApiClient();
|
||||
const normalizedDirectory = normalizePath(directory ?? null);
|
||||
const response = await apiClient.session.update({
|
||||
sessionID: sessionId,
|
||||
...(normalizedDirectory ? { directory: normalizedDirectory } : {}),
|
||||
time: { archived: archivedAt },
|
||||
});
|
||||
return response.data ?? null;
|
||||
};
|
||||
|
||||
const normalizePath = (value?: string | null): string | null => {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
@@ -446,6 +451,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
(set, get) => ({
|
||||
|
||||
sessions: [],
|
||||
archivedSessions: [],
|
||||
sessionsByDirectory: new Map(),
|
||||
currentSessionId: null,
|
||||
lastLoadedDirectory: null,
|
||||
@@ -475,151 +481,6 @@ export const useSessionStore = create<SessionStore>()(
|
||||
activeProjectId: projectsStore.activeProjectId,
|
||||
});
|
||||
|
||||
const canonicalDirectoryCache = new Map<string, string>();
|
||||
|
||||
const resolveCanonicalDirectory = async (directory: string): Promise<string> => {
|
||||
const normalizedRequested = normalizePath(directory) ?? directory;
|
||||
const cacheKey = normalizedRequested;
|
||||
const cached = canonicalDirectoryCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await apiClient.path.get({ directory });
|
||||
const canonical = normalizePath((info.data as { directory?: string | null } | null)?.directory ?? null);
|
||||
const resolved = canonical ?? normalizedRequested;
|
||||
canonicalDirectoryCache.set(cacheKey, resolved);
|
||||
return resolved;
|
||||
} catch {
|
||||
canonicalDirectoryCache.set(cacheKey, normalizedRequested);
|
||||
return normalizedRequested;
|
||||
}
|
||||
};
|
||||
|
||||
const filterSessionsToDirectory = (
|
||||
sessions: Session[],
|
||||
directory: string,
|
||||
options?: { includeDescendants?: boolean; includeMissingDirectory?: boolean }
|
||||
): Session[] => {
|
||||
const normalized = normalizePath(directory);
|
||||
if (!normalized) {
|
||||
return sessions;
|
||||
}
|
||||
const includeDescendants = options?.includeDescendants === true;
|
||||
const prefix = includeDescendants ? `${normalized}/` : null;
|
||||
const includeMissingDirectory = options?.includeMissingDirectory === true;
|
||||
return sessions.filter((session) => {
|
||||
const sessionDir = normalizePath((session as { directory?: string | null }).directory ?? null);
|
||||
if (!sessionDir) return includeMissingDirectory;
|
||||
if (sessionDir === normalized) return true;
|
||||
if (prefix && sessionDir.startsWith(prefix)) return true;
|
||||
return false;
|
||||
});
|
||||
};
|
||||
|
||||
const assignRequestedDirectory = (
|
||||
sessions: Session[],
|
||||
requestedDirectory: string,
|
||||
canonicalDirectory?: string | null
|
||||
): Session[] => {
|
||||
const normalizedRequested = normalizePath(requestedDirectory);
|
||||
if (!normalizedRequested) {
|
||||
return sessions;
|
||||
}
|
||||
const normalizedCanonical = normalizePath(canonicalDirectory ?? null);
|
||||
if (!normalizedCanonical || normalizedCanonical === normalizedRequested) {
|
||||
return sessions.map((session) => {
|
||||
const sessionDir = normalizePath((session as { directory?: string | null }).directory ?? null);
|
||||
if (sessionDir) {
|
||||
return session;
|
||||
}
|
||||
return ({ ...session, directory: normalizedRequested } as Session);
|
||||
});
|
||||
}
|
||||
|
||||
const canonicalPrefix = normalizedCanonical === "/" ? "/" : `${normalizedCanonical}/`;
|
||||
const requestedPrefix = normalizedRequested === "/" ? "/" : `${normalizedRequested}/`;
|
||||
|
||||
return sessions.map((session) => {
|
||||
const sessionDir = normalizePath((session as { directory?: string | null }).directory ?? null);
|
||||
if (!sessionDir) {
|
||||
return ({ ...session, directory: normalizedRequested } as Session);
|
||||
}
|
||||
if (sessionDir === normalizedCanonical) {
|
||||
return ({ ...session, directory: normalizedRequested } as Session);
|
||||
}
|
||||
if (canonicalPrefix !== "/" && sessionDir.startsWith(canonicalPrefix)) {
|
||||
const suffix = sessionDir.slice(canonicalPrefix.length);
|
||||
return ({ ...session, directory: `${requestedPrefix}${suffix}` } as Session);
|
||||
}
|
||||
return session;
|
||||
});
|
||||
};
|
||||
|
||||
const fetchSessionsForDirectory = async (directoryParam?: string | null): Promise<Session[]> => {
|
||||
const requestedDirectory = normalizePath(directoryParam);
|
||||
if (!requestedDirectory) {
|
||||
try {
|
||||
const response = await apiClient.session.list(undefined);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
} catch (error) {
|
||||
console.debug("Failed to list sessions (global):", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalDirectory = await resolveCanonicalDirectory(requestedDirectory);
|
||||
|
||||
const listFromDirectoryScopedCall = async (): Promise<Session[]> => {
|
||||
const response = await apiClient.session.list({ directory: requestedDirectory });
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
};
|
||||
|
||||
let sessions: Session[] = [];
|
||||
let listError: unknown = null;
|
||||
let usedGlobalFallback = false;
|
||||
try {
|
||||
sessions = await listFromDirectoryScopedCall();
|
||||
} catch (error) {
|
||||
console.debug("Failed to list sessions for directory:", requestedDirectory, error);
|
||||
listError = error;
|
||||
sessions = [];
|
||||
}
|
||||
|
||||
// Some runtimes canonicalize directory paths (e.g. realpath). If the scoped call returns no results,
|
||||
// fall back to the global list and map canonical paths back to the requested directory.
|
||||
if (sessions.length === 0) {
|
||||
usedGlobalFallback = true;
|
||||
try {
|
||||
const globalResponse = await apiClient.session.list(undefined);
|
||||
const globalList = Array.isArray(globalResponse.data) ? globalResponse.data : [];
|
||||
sessions = filterSessionsToDirectory(globalList, canonicalDirectory, {
|
||||
includeDescendants,
|
||||
includeMissingDirectory: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.debug("Failed to list sessions (global fallback):", error);
|
||||
if (listError) {
|
||||
throw listError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = filterSessionsToDirectory(sessions, canonicalDirectory, {
|
||||
includeDescendants,
|
||||
includeMissingDirectory: !usedGlobalFallback,
|
||||
});
|
||||
vscodeDebugLog("fetchSessionsForDirectory", {
|
||||
requestedDirectory,
|
||||
canonicalDirectory,
|
||||
fetched: sessions.length,
|
||||
filtered: filtered.length,
|
||||
});
|
||||
return assignRequestedDirectory(filtered, requestedDirectory, canonicalDirectory);
|
||||
};
|
||||
|
||||
const normalizedFallback = normalizePath(directoryStore.currentDirectory ?? opencodeClient.getDirectory() ?? null);
|
||||
const activeProject = projectsStore.projects.find((project) => project.id === projectsStore.activeProjectId) ?? null;
|
||||
const activeProjectRoot = normalizePath(activeProject?.path ?? null);
|
||||
@@ -630,7 +491,26 @@ export const useSessionStore = create<SessionStore>()(
|
||||
? projectsStore.projects
|
||||
: (legacyRoot ? [{ id: 'legacy', path: legacyRoot }] : []);
|
||||
|
||||
const applyProjectResults = async (projectResults: ProjectSessionResult[]) => {
|
||||
const resolveSessionDirectory = (session: Session): string | null => {
|
||||
const direct = normalizePath((session as { directory?: string | null }).directory ?? null);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const projectWorktree = normalizePath((session as GlobalSessionRecord).project?.worktree ?? null);
|
||||
return projectWorktree;
|
||||
};
|
||||
|
||||
const matchesProjectDirectory = (sessionDirectory: string | null, projectDirectory: string): boolean => {
|
||||
if (!sessionDirectory) {
|
||||
return false;
|
||||
}
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
return true;
|
||||
}
|
||||
return includeDescendants && sessionDirectory.startsWith(`${projectDirectory}/`);
|
||||
};
|
||||
|
||||
const applyProjectResults = async (projectResults: ProjectSessionResult[], archivedSessions: Session[]) => {
|
||||
const sessionsByDirectory = new Map<string, Session[]>();
|
||||
projectResults.forEach((result) => {
|
||||
if (!result.projectPath) {
|
||||
@@ -751,6 +631,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
|
||||
set({
|
||||
sessions: mergedSessions,
|
||||
archivedSessions,
|
||||
sessionsByDirectory,
|
||||
currentSessionId: nextCurrentId,
|
||||
lastLoadedDirectory: activeDirectory ?? null,
|
||||
@@ -774,6 +655,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
}
|
||||
set({
|
||||
sessions: [],
|
||||
archivedSessions: [],
|
||||
sessionsByDirectory: new Map(),
|
||||
currentSessionId: null,
|
||||
lastLoadedDirectory: null,
|
||||
@@ -787,110 +669,134 @@ export const useSessionStore = create<SessionStore>()(
|
||||
|
||||
pruneProjectCaches(projectEntries.map((entry) => entry.path));
|
||||
|
||||
const activeProjectId = projectsStore.activeProjectId;
|
||||
const cachedProjectResults: ProjectSessionResult[] = [];
|
||||
projectEntries.forEach((project) => {
|
||||
const normalizedProject = normalizePath(project.path);
|
||||
if (!normalizedProject) {
|
||||
return;
|
||||
}
|
||||
const cached = getFreshProjectSessionCache(normalizedProject);
|
||||
if (cached) {
|
||||
cachedProjectResults.push(cached);
|
||||
}
|
||||
});
|
||||
const buildProjectResults = async (sourceSessions: Session[]): Promise<ProjectSessionResult[]> => {
|
||||
return Promise.all(
|
||||
projectEntries.map(async (project: Pick<ProjectEntry, 'id' | 'path'>) => {
|
||||
const normalizedProject = normalizePath(project.path);
|
||||
if (!normalizedProject) {
|
||||
return {
|
||||
projectId: project.id,
|
||||
projectPath: null,
|
||||
sessions: [],
|
||||
discoveredWorktrees: [],
|
||||
validPaths: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
const hasCachedActiveProject = cachedProjectResults.some(
|
||||
(result) => result.projectId === activeProjectId
|
||||
);
|
||||
|
||||
if (hasCachedActiveProject && isLatestRequest()) {
|
||||
await applyProjectResults(cachedProjectResults);
|
||||
}
|
||||
|
||||
const projectResults: ProjectSessionResult[] = await Promise.all(
|
||||
projectEntries.map(async (project: Pick<ProjectEntry, 'id' | 'path'>) => {
|
||||
const normalizedProject = normalizePath(project.path);
|
||||
if (!normalizedProject) {
|
||||
return {
|
||||
projectId: project.id,
|
||||
projectPath: null,
|
||||
sessions: [],
|
||||
discoveredWorktrees: [],
|
||||
validPaths: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
const cached = getFreshProjectSessionCache(normalizedProject);
|
||||
const isActiveProject = project.id === activeProjectId;
|
||||
if (cached && !isActiveProject) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const isGitRepo = await getProjectRepoStatus(normalizedProject);
|
||||
const parentSessions = await fetchSessionsForDirectory(normalizedProject || null);
|
||||
vscodeDebugLog("projectSessions", {
|
||||
projectId: project.id,
|
||||
projectPath: normalizedProject,
|
||||
isGitRepo,
|
||||
parentSessions: parentSessions.length,
|
||||
});
|
||||
|
||||
const subdirectorySessions: Session[] = [];
|
||||
let discoveredWorktrees: WorktreeMetadata[] = [];
|
||||
const validPaths = new Set<string>();
|
||||
validPaths.add(normalizedProject);
|
||||
|
||||
if (isGitRepo) {
|
||||
try {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
const managedWorktrees = await listProjectWorktrees({
|
||||
const isGitRepo = await getProjectRepoStatus(normalizedProject);
|
||||
let discoveredWorktrees: WorktreeMetadata[] = [];
|
||||
const validPaths = new Set<string>([normalizedProject]);
|
||||
if (isGitRepo) {
|
||||
discoveredWorktrees = await listProjectWorktrees({
|
||||
id: project.id,
|
||||
path: normalizedProject,
|
||||
}).catch(() => []);
|
||||
discoveredWorktrees = managedWorktrees;
|
||||
managedWorktrees.forEach((meta) => {
|
||||
discoveredWorktrees.forEach((meta) => {
|
||||
if (meta?.path) {
|
||||
candidates.add(normalizePath(meta.path) ?? meta.path);
|
||||
validPaths.add(normalizePath(meta.path) ?? meta.path);
|
||||
}
|
||||
});
|
||||
|
||||
candidates.forEach((candidate) => {
|
||||
const normalizedCandidate = normalizePath(candidate) ?? candidate;
|
||||
validPaths.add(normalizedCandidate);
|
||||
});
|
||||
|
||||
if (candidates.size > 0) {
|
||||
const results = await Promise.allSettled(
|
||||
Array.from(candidates).map((path) => fetchSessionsForDirectory(path))
|
||||
);
|
||||
results.forEach((result) => {
|
||||
if (result.status === "fulfilled" && Array.isArray(result.value)) {
|
||||
subdirectorySessions.push(...result.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
discoveredWorktrees = [];
|
||||
}
|
||||
|
||||
const mergedSessions = dedupeSessionsById(
|
||||
sourceSessions.filter((session) => {
|
||||
const sessionDirectory = resolveSessionDirectory(session);
|
||||
if (!sessionDirectory) {
|
||||
return false;
|
||||
}
|
||||
for (const projectPath of validPaths) {
|
||||
if (matchesProjectDirectory(sessionDirectory, projectPath)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
|
||||
const result: ProjectSessionResult = {
|
||||
projectId: project.id,
|
||||
projectPath: normalizedProject,
|
||||
sessions: mergedSessions,
|
||||
discoveredWorktrees,
|
||||
validPaths,
|
||||
};
|
||||
setProjectSessionCache(normalizedProject, result);
|
||||
return result;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const pageSize = 500;
|
||||
const firstPage = await apiClient.experimental.session.list({ limit: pageSize, archived: false });
|
||||
let liveSessions = dedupeSessionsById(Array.isArray(firstPage.data) ? firstPage.data as Session[] : []);
|
||||
let archivedSessions: Session[] = [];
|
||||
|
||||
const apply = async () => {
|
||||
if (!isLatestRequest()) {
|
||||
return;
|
||||
}
|
||||
const projectResults = await buildProjectResults(liveSessions);
|
||||
await applyProjectResults(projectResults, dedupeSessionsById(archivedSessions));
|
||||
};
|
||||
|
||||
await apply();
|
||||
|
||||
const backgroundLoad = async () => {
|
||||
let cursor = readNextCursor(firstPage) ?? undefined;
|
||||
while (cursor && isLatestRequest()) {
|
||||
const response = await apiClient.experimental.session.list({
|
||||
limit: pageSize,
|
||||
cursor,
|
||||
archived: false,
|
||||
});
|
||||
const page = Array.isArray(response.data) ? response.data as Session[] : [];
|
||||
if (page.length === 0) {
|
||||
break;
|
||||
}
|
||||
liveSessions = dedupeSessionsById([...liveSessions, ...page]);
|
||||
await apply();
|
||||
cursor = readNextCursor(response) ?? undefined;
|
||||
}
|
||||
|
||||
const mergedSessions = dedupeSessionsById([...parentSessions, ...subdirectorySessions]);
|
||||
let archivedCursor: number | undefined;
|
||||
while (isLatestRequest()) {
|
||||
const response = await apiClient.experimental.session.list({
|
||||
limit: pageSize,
|
||||
archived: true,
|
||||
...(archivedCursor ? { cursor: archivedCursor } : {}),
|
||||
});
|
||||
const page = Array.isArray(response.data)
|
||||
? (response.data as Session[]).filter((session) => Boolean(session.time?.archived))
|
||||
: [];
|
||||
if (page.length > 0) {
|
||||
archivedSessions = dedupeSessionsById([...archivedSessions, ...page]);
|
||||
await apply();
|
||||
}
|
||||
const next = readNextCursor(response);
|
||||
if (!next) {
|
||||
break;
|
||||
}
|
||||
archivedCursor = next;
|
||||
}
|
||||
};
|
||||
|
||||
const result: ProjectSessionResult = {
|
||||
projectId: project.id,
|
||||
projectPath: normalizedProject,
|
||||
sessions: mergedSessions,
|
||||
discoveredWorktrees,
|
||||
validPaths,
|
||||
};
|
||||
void backgroundLoad().catch((error) => {
|
||||
console.debug("Failed to load additional global sessions:", error);
|
||||
});
|
||||
|
||||
setProjectSessionCache(normalizedProject, result);
|
||||
return result;
|
||||
})
|
||||
);
|
||||
await applyProjectResults(projectResults);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (!isMissingGlobalSessionsEndpointError(error)) {
|
||||
throw error;
|
||||
}
|
||||
console.debug("Global session endpoint unavailable, using legacy loader");
|
||||
}
|
||||
|
||||
const fallbackResponse = await apiClient.session.list(undefined);
|
||||
const fallbackSessions = dedupeSessionsById(Array.isArray(fallbackResponse.data) ? fallbackResponse.data : []);
|
||||
const fallbackProjectResults = await buildProjectResults(fallbackSessions);
|
||||
await applyProjectResults(fallbackProjectResults, []);
|
||||
} catch (error) {
|
||||
if (!isLatestRequest()) {
|
||||
return;
|
||||
@@ -1057,7 +963,8 @@ export const useSessionStore = create<SessionStore>()(
|
||||
const metadata = get().worktreeMetadata.get(id);
|
||||
const metadataPath = typeof metadata?.path === 'string' ? metadata.path : null;
|
||||
const metadataProjectDirectory = typeof metadata?.projectDirectory === 'string' ? metadata.projectDirectory : null;
|
||||
const sessionDirectory = getSessionDirectory(get().sessions, id);
|
||||
const snapshot = get();
|
||||
const sessionDirectory = getSessionDirectory([...snapshot.sessions, ...snapshot.archivedSessions], id);
|
||||
const requestDirectory = normalizePath(metadataProjectDirectory)
|
||||
?? normalizePath(sessionDirectory)
|
||||
?? normalizePath(opencodeClient.getDirectory() ?? null)
|
||||
@@ -1091,6 +998,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
let nextCurrentId: string | null = null;
|
||||
set((state) => {
|
||||
const filteredSessions = state.sessions.filter((s) => s.id !== id);
|
||||
const filteredArchivedSessions = state.archivedSessions.filter((s) => s.id !== id);
|
||||
nextCurrentId = state.currentSessionId === id ? null : state.currentSessionId;
|
||||
const nextMetadata = new Map(state.worktreeMetadata);
|
||||
nextMetadata.delete(id);
|
||||
@@ -1109,6 +1017,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
}
|
||||
return {
|
||||
sessions: filteredSessions,
|
||||
archivedSessions: filteredArchivedSessions,
|
||||
sessionsByDirectory: buildSessionsByDirectory(filteredSessions),
|
||||
currentSessionId: nextCurrentId,
|
||||
isLoading: false,
|
||||
@@ -1155,7 +1064,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
for (const id of uniqueIds) {
|
||||
try {
|
||||
const metadata = get().worktreeMetadata.get(id);
|
||||
const sessionDirectory = getSessionDirectory(get().sessions, id);
|
||||
const sessionDirectory = getSessionDirectory([...get().sessions, ...get().archivedSessions], id);
|
||||
const requestDirectory = normalizePath(metadata?.projectDirectory ?? null)
|
||||
?? normalizePath(sessionDirectory)
|
||||
?? normalizePath(opencodeClient.getDirectory() ?? null)
|
||||
@@ -1218,6 +1127,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
|
||||
set((state) => {
|
||||
const filteredSessions = state.sessions.filter((session) => !deletedSet.has(session.id));
|
||||
const filteredArchivedSessions = state.archivedSessions.filter((session) => !deletedSet.has(session.id));
|
||||
if (state.currentSessionId && deletedSet.has(state.currentSessionId)) {
|
||||
nextCurrentId = null;
|
||||
} else {
|
||||
@@ -1261,6 +1171,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
|
||||
return {
|
||||
sessions: filteredSessions,
|
||||
archivedSessions: filteredArchivedSessions,
|
||||
sessionsByDirectory: buildSessionsByDirectory(filteredSessions),
|
||||
currentSessionId: nextCurrentId,
|
||||
...(silent ? {} : { isLoading: false, error: errorMessage }),
|
||||
@@ -1276,6 +1187,88 @@ export const useSessionStore = create<SessionStore>()(
|
||||
return { deletedIds, failedIds };
|
||||
},
|
||||
|
||||
archiveSession: async (id: string) => {
|
||||
const { archivedIds, failedIds } = await get().archiveSessions([id]);
|
||||
return archivedIds.length === 1 && failedIds.length === 0;
|
||||
},
|
||||
|
||||
archiveSessions: async (ids: string[], options?: { silent?: boolean }) => {
|
||||
const uniqueIds = Array.from(new Set(ids.filter((id): id is string => typeof id === "string" && id.length > 0)));
|
||||
if (uniqueIds.length === 0) {
|
||||
return { archivedIds: [], failedIds: [] };
|
||||
}
|
||||
|
||||
const silent = options?.silent === true;
|
||||
if (!silent) {
|
||||
set({ isLoading: true, error: null });
|
||||
}
|
||||
|
||||
const archivedIds: string[] = [];
|
||||
const failedIds: string[] = [];
|
||||
|
||||
for (const id of uniqueIds) {
|
||||
try {
|
||||
const metadata = get().worktreeMetadata.get(id);
|
||||
const sessionDirectory = getSessionDirectory([...get().sessions, ...get().archivedSessions], id);
|
||||
const requestDirectory = normalizePath(metadata?.projectDirectory ?? null)
|
||||
?? normalizePath(sessionDirectory)
|
||||
?? normalizePath(opencodeClient.getDirectory() ?? null)
|
||||
?? null;
|
||||
const archived = await setSessionArchivedOnServer(id, Date.now(), requestDirectory);
|
||||
if (!archived) {
|
||||
failedIds.push(id);
|
||||
continue;
|
||||
}
|
||||
archivedIds.push(id);
|
||||
} catch {
|
||||
failedIds.push(id);
|
||||
}
|
||||
}
|
||||
|
||||
const archivedSet = new Set(archivedIds);
|
||||
let nextCurrentId: string | null = null;
|
||||
const errorMessage = failedIds.length > 0
|
||||
? (failedIds.length === uniqueIds.length ? "Failed to archive sessions" : "Failed to archive some sessions")
|
||||
: null;
|
||||
|
||||
set((state) => {
|
||||
if (archivedSet.size === 0) {
|
||||
return silent ? state : { ...state, isLoading: false, error: errorMessage };
|
||||
}
|
||||
|
||||
const archivedRows = state.sessions.filter((session) => archivedSet.has(session.id)).map((session) => ({
|
||||
...session,
|
||||
time: {
|
||||
...session.time,
|
||||
archived: Date.now(),
|
||||
},
|
||||
} as Session));
|
||||
|
||||
const remaining = state.sessions.filter((session) => !archivedSet.has(session.id));
|
||||
const nextArchivedSessions = dedupeSessionsById([...archivedRows, ...state.archivedSessions]);
|
||||
|
||||
if (state.currentSessionId && archivedSet.has(state.currentSessionId)) {
|
||||
nextCurrentId = remaining[0]?.id ?? null;
|
||||
} else {
|
||||
nextCurrentId = state.currentSessionId;
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: remaining,
|
||||
archivedSessions: nextArchivedSessions,
|
||||
sessionsByDirectory: buildSessionsByDirectory(remaining),
|
||||
currentSessionId: nextCurrentId,
|
||||
...(silent ? {} : { isLoading: false, error: errorMessage }),
|
||||
};
|
||||
});
|
||||
|
||||
if (!silent && archivedSet.size === 0) {
|
||||
set({ isLoading: false, error: errorMessage });
|
||||
}
|
||||
|
||||
return { archivedIds, failedIds };
|
||||
},
|
||||
|
||||
updateSessionTitle: async (id: string, title: string) => {
|
||||
try {
|
||||
const sessionDirectory = getSessionDirectory(get().sessions, id);
|
||||
@@ -1560,14 +1553,23 @@ export const useSessionStore = create<SessionStore>()(
|
||||
updateSession: (session: Session) => {
|
||||
set((state) => {
|
||||
const index = state.sessions.findIndex((s) => s.id === session.id);
|
||||
const archivedIndex = state.archivedSessions.findIndex((s) => s.id === session.id);
|
||||
const isArchived = Boolean(session.time?.archived);
|
||||
|
||||
const nextSessions = index === -1
|
||||
? [session, ...state.sessions]
|
||||
? (isArchived ? state.sessions : [session, ...state.sessions])
|
||||
: state.sessions.map((s) => (s.id === session.id ? session : s));
|
||||
|
||||
const deduped = dedupeSessionsById(nextSessions);
|
||||
const nextArchivedSessions = archivedIndex === -1
|
||||
? (isArchived ? [session, ...state.archivedSessions] : state.archivedSessions)
|
||||
: state.archivedSessions.map((s) => (s.id === session.id ? session : s));
|
||||
|
||||
const deduped = dedupeSessionsById(nextSessions.filter((item) => !item.time?.archived));
|
||||
const dedupedArchived = dedupeSessionsById(nextArchivedSessions.filter((item) => Boolean(item.time?.archived)));
|
||||
|
||||
return {
|
||||
sessions: deduped,
|
||||
archivedSessions: dedupedArchived,
|
||||
sessionsByDirectory: buildSessionsByDirectory(deduped),
|
||||
};
|
||||
});
|
||||
@@ -1579,11 +1581,13 @@ export const useSessionStore = create<SessionStore>()(
|
||||
}
|
||||
|
||||
set((state) => {
|
||||
const target = state.sessions.find((session) => session.id === sessionId) as { directory?: string | null } | undefined;
|
||||
const target = [...state.sessions, ...state.archivedSessions]
|
||||
.find((session) => session.id === sessionId) as { directory?: string | null } | undefined;
|
||||
const directory = normalizePath(target?.directory ?? null);
|
||||
|
||||
const filteredSessions = state.sessions.filter((session) => session.id !== sessionId);
|
||||
if (filteredSessions.length === state.sessions.length) {
|
||||
const filteredArchivedSessions = state.archivedSessions.filter((session) => session.id !== sessionId);
|
||||
if (filteredSessions.length === state.sessions.length && filteredArchivedSessions.length === state.archivedSessions.length) {
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -1598,6 +1602,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
|
||||
return {
|
||||
sessions: filteredSessions,
|
||||
archivedSessions: filteredArchivedSessions,
|
||||
sessionsByDirectory: buildSessionsByDirectory(filteredSessions),
|
||||
currentSessionId: nextCurrentId,
|
||||
worktreeMetadata: nextMetadata,
|
||||
@@ -1611,6 +1616,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
partialize: (state) => ({
|
||||
currentSessionId: state.currentSessionId,
|
||||
sessions: state.sessions,
|
||||
archivedSessions: state.archivedSessions,
|
||||
lastLoadedDirectory: state.lastLoadedDirectory,
|
||||
webUICreatedSessions: Array.from(state.webUICreatedSessions),
|
||||
worktreeMetadata: Array.from(state.worktreeMetadata.entries()),
|
||||
@@ -1628,6 +1634,9 @@ export const useSessionStore = create<SessionStore>()(
|
||||
const persistedSessions = Array.isArray(persistedState.sessions)
|
||||
? (persistedState.sessions as Session[])
|
||||
: currentState.sessions;
|
||||
const persistedArchivedSessions = Array.isArray(persistedState.archivedSessions)
|
||||
? (persistedState.archivedSessions as Session[])
|
||||
: currentState.archivedSessions;
|
||||
|
||||
const persistedCurrentSessionId =
|
||||
typeof persistedState.currentSessionId === "string" || persistedState.currentSessionId === null
|
||||
@@ -1657,11 +1666,13 @@ export const useSessionStore = create<SessionStore>()(
|
||||
: currentState.lastLoadedDirectory ?? null;
|
||||
|
||||
const mergedSessions = dedupeSessionsById(persistedSessions);
|
||||
const mergedArchivedSessions = dedupeSessionsById(persistedArchivedSessions);
|
||||
|
||||
const mergedResult = {
|
||||
...currentState,
|
||||
...persistedState,
|
||||
sessions: mergedSessions,
|
||||
archivedSessions: mergedArchivedSessions,
|
||||
sessionsByDirectory: buildSessionsByDirectory(mergedSessions),
|
||||
currentSessionId: persistedCurrentSessionId,
|
||||
webUICreatedSessions: new Set(webUiSessionsArray),
|
||||
|
||||
@@ -137,6 +137,7 @@ export interface VoiceState {
|
||||
export interface SessionStore {
|
||||
|
||||
sessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
sessionsByDirectory: Map<string, Session[]>;
|
||||
currentSessionId: string | null;
|
||||
lastLoadedDirectory: string | null;
|
||||
@@ -221,6 +222,8 @@ export interface SessionStore {
|
||||
|
||||
deleteSession: (id: string, options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string }) => Promise<boolean>;
|
||||
deleteSessions: (ids: string[], options?: { archiveWorktree?: boolean; deleteRemoteBranch?: boolean; deleteLocalBranch?: boolean; remoteName?: string; silent?: boolean }) => Promise<{ deletedIds: string[]; failedIds: string[] }>;
|
||||
archiveSession: (id: string) => Promise<boolean>;
|
||||
archiveSessions: (ids: string[], options?: { silent?: boolean }) => Promise<{ archivedIds: string[]; failedIds: string[] }>;
|
||||
updateSessionTitle: (id: string, title: string) => Promise<void>;
|
||||
shareSession: (id: string) => Promise<Session | null>;
|
||||
unshareSession: (id: string) => Promise<Session | null>;
|
||||
|
||||
@@ -0,0 +1,562 @@
|
||||
import { create } from 'zustand';
|
||||
import type { GitHubPullRequestStatus, RuntimeAPIs } from '@/lib/api/types';
|
||||
|
||||
const PR_REVALIDATE_TTL_MS = 90_000;
|
||||
const PR_REVALIDATE_INTERVAL_MS = 15_000;
|
||||
const PR_DISCOVERY_INTERVAL_MS = 5 * 60_000;
|
||||
const PR_BOOTSTRAP_RETRY_DELAYS_MS = [2_000, 5_000] as const;
|
||||
const PR_OPEN_BUSY_INTERVAL_MS = 60_000;
|
||||
const PR_OPEN_DEFAULT_INTERVAL_MS = 2 * 60_000;
|
||||
const PR_OPEN_STABLE_INTERVAL_MS = 5 * 60_000;
|
||||
|
||||
const isTerminalPrState = (state: string | null | undefined): boolean => state === 'closed' || state === 'merged';
|
||||
const isPendingChecks = (status: GitHubPullRequestStatus | null): boolean => {
|
||||
const checks = status?.checks;
|
||||
if (!checks) {
|
||||
return false;
|
||||
}
|
||||
return checks.state === 'pending' || checks.pending > 0;
|
||||
};
|
||||
|
||||
export const getGitHubPrStatusKey = (directory: string, branch: string, remoteName?: string | null): string => {
|
||||
void remoteName;
|
||||
return `${directory}::${branch}`;
|
||||
};
|
||||
|
||||
type RefreshOptions = {
|
||||
force?: boolean;
|
||||
onlyExistingPr?: boolean;
|
||||
silent?: boolean;
|
||||
markInitialResolved?: boolean;
|
||||
};
|
||||
|
||||
type PrTrackingTarget = {
|
||||
directory: string;
|
||||
branch: string;
|
||||
remoteName?: string | null;
|
||||
};
|
||||
|
||||
type PrRuntimeParams = {
|
||||
directory: string;
|
||||
branch: string;
|
||||
remoteName: string | null;
|
||||
canShow: boolean;
|
||||
github?: RuntimeAPIs['github'];
|
||||
githubAuthChecked: boolean;
|
||||
githubConnected: boolean | null;
|
||||
};
|
||||
|
||||
type PrStatusEntry = {
|
||||
status: GitHubPullRequestStatus | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
isInitialStatusResolved: boolean;
|
||||
lastRefreshAt: number;
|
||||
lastDiscoveryPollAt: number;
|
||||
watchers: number;
|
||||
params: PrRuntimeParams | null;
|
||||
};
|
||||
|
||||
type GitHubPrStatusStore = {
|
||||
entries: Record<string, PrStatusEntry>;
|
||||
activeRequestCount: number;
|
||||
totalRequestCount: number;
|
||||
ensureEntry: (key: string) => void;
|
||||
setParams: (key: string, params: PrRuntimeParams) => void;
|
||||
startWatching: (key: string) => void;
|
||||
stopWatching: (key: string) => void;
|
||||
refresh: (key: string, options?: RefreshOptions) => Promise<void>;
|
||||
updateStatus: (key: string, updater: (prev: GitHubPullRequestStatus | null) => GitHubPullRequestStatus | null) => void;
|
||||
syncBackgroundTargets: (args: {
|
||||
targets: PrTrackingTarget[];
|
||||
github?: RuntimeAPIs['github'];
|
||||
githubAuthChecked: boolean;
|
||||
githubConnected: boolean | null;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const timers = new Map<string, number>();
|
||||
const bootstrapTimers = new Map<string, number[]>();
|
||||
const inFlightBySignature = new Set<string>();
|
||||
const lastRefreshBySignature = new Map<string, number>();
|
||||
const backgroundWatchingKeys = new Set<string>();
|
||||
|
||||
const getSignatureFromParams = (params: PrRuntimeParams | null | undefined): string | null => {
|
||||
if (!params?.directory || !params.branch) {
|
||||
return null;
|
||||
}
|
||||
return `${params.directory}::${params.branch}`;
|
||||
};
|
||||
|
||||
const getKeysBySignature = (entries: Record<string, PrStatusEntry>, signature: string): string[] => {
|
||||
return Object.entries(entries)
|
||||
.filter(([, entry]) => getSignatureFromParams(entry.params) === signature)
|
||||
.map(([key]) => key);
|
||||
};
|
||||
|
||||
const pickFetchParamsForSignature = (
|
||||
entries: Record<string, PrStatusEntry>,
|
||||
signature: string,
|
||||
preferredKey: string,
|
||||
): PrRuntimeParams | null => {
|
||||
const keys = getKeysBySignature(entries, signature);
|
||||
const candidates = keys
|
||||
.map((key) => entries[key])
|
||||
.filter((entry): entry is PrStatusEntry => Boolean(entry?.params))
|
||||
.map((entry) => entry.params)
|
||||
.filter((params): params is PrRuntimeParams => Boolean(params?.canShow && params.github?.prStatus));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const preferred = entries[preferredKey]?.params;
|
||||
if (
|
||||
preferred
|
||||
&& getSignatureFromParams(preferred) === signature
|
||||
&& preferred.canShow
|
||||
&& preferred.github?.prStatus
|
||||
) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
const withRemote = candidates.find((params) => Boolean(params.remoteName));
|
||||
if (withRemote) {
|
||||
return withRemote;
|
||||
}
|
||||
|
||||
return candidates[0] ?? null;
|
||||
};
|
||||
|
||||
const createEntry = (): PrStatusEntry => ({
|
||||
status: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
isInitialStatusResolved: false,
|
||||
lastRefreshAt: 0,
|
||||
lastDiscoveryPollAt: 0,
|
||||
watchers: 0,
|
||||
params: null,
|
||||
});
|
||||
|
||||
const mergeParams = (current: PrRuntimeParams | null, next: PrRuntimeParams): PrRuntimeParams => {
|
||||
if (!current) {
|
||||
return next;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
...next,
|
||||
remoteName: next.remoteName ?? current.remoteName ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
export const useGitHubPrStatusStore = create<GitHubPrStatusStore>((set, get) => ({
|
||||
entries: {},
|
||||
activeRequestCount: 0,
|
||||
totalRequestCount: 0,
|
||||
|
||||
ensureEntry: (key) => {
|
||||
set((state) => {
|
||||
if (state.entries[key]) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
entries: {
|
||||
...state.entries,
|
||||
[key]: createEntry(),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
setParams: (key, params) => {
|
||||
set((state) => {
|
||||
const current = state.entries[key] ?? createEntry();
|
||||
return {
|
||||
entries: {
|
||||
...state.entries,
|
||||
[key]: {
|
||||
...current,
|
||||
params: mergeParams(current.params, params),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
startWatching: (key) => {
|
||||
set((state) => {
|
||||
const current = state.entries[key] ?? createEntry();
|
||||
return {
|
||||
entries: {
|
||||
...state.entries,
|
||||
[key]: {
|
||||
...current,
|
||||
watchers: current.watchers + 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (timers.has(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const runBootstrapRefresh = (delayMs: number) => {
|
||||
const timerId = window.setTimeout(() => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
const entry = get().entries[key];
|
||||
if (!entry || entry.watchers <= 0) {
|
||||
return;
|
||||
}
|
||||
if (entry.status?.pr) {
|
||||
return;
|
||||
}
|
||||
void get().refresh(key, { force: true, silent: true, markInitialResolved: true });
|
||||
}, delayMs);
|
||||
const existing = bootstrapTimers.get(key) ?? [];
|
||||
existing.push(timerId);
|
||||
bootstrapTimers.set(key, existing);
|
||||
};
|
||||
|
||||
void get().refresh(key, { force: true, silent: true, markInitialResolved: true });
|
||||
PR_BOOTSTRAP_RETRY_DELAYS_MS.forEach((delay) => runBootstrapRefresh(delay));
|
||||
|
||||
const timerId = window.setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.visibilityState !== 'visible') {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = get().entries[key];
|
||||
if (!entry || entry.watchers <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasPr = Boolean(entry.status?.pr);
|
||||
if (!hasPr) {
|
||||
const now = Date.now();
|
||||
if (now - entry.lastDiscoveryPollAt < PR_DISCOVERY_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
set((state) => {
|
||||
const current = state.entries[key];
|
||||
if (!current) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
entries: {
|
||||
...state.entries,
|
||||
[key]: {
|
||||
...current,
|
||||
lastDiscoveryPollAt: now,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
void get().refresh(key, { force: true, silent: true, markInitialResolved: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (isTerminalPrState(entry.status?.pr?.state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - entry.lastRefreshAt;
|
||||
const nextInterval = isPendingChecks(entry.status)
|
||||
? PR_OPEN_BUSY_INTERVAL_MS
|
||||
: (entry.status?.checks && entry.status.checks.state !== 'pending'
|
||||
? PR_OPEN_STABLE_INTERVAL_MS
|
||||
: PR_OPEN_DEFAULT_INTERVAL_MS);
|
||||
if (elapsed < nextInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
void get().refresh(key, { force: true, onlyExistingPr: true, silent: true, markInitialResolved: true });
|
||||
}, PR_REVALIDATE_INTERVAL_MS);
|
||||
|
||||
timers.set(key, timerId);
|
||||
},
|
||||
|
||||
stopWatching: (key) => {
|
||||
set((state) => {
|
||||
const current = state.entries[key];
|
||||
if (!current) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const watchers = Math.max(0, current.watchers - 1);
|
||||
return {
|
||||
entries: {
|
||||
...state.entries,
|
||||
[key]: {
|
||||
...current,
|
||||
watchers,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const entry = get().entries[key];
|
||||
if (entry && entry.watchers > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = timers.get(key);
|
||||
if (typeof timerId === 'number') {
|
||||
window.clearInterval(timerId);
|
||||
}
|
||||
timers.delete(key);
|
||||
|
||||
const pendingBootstrapTimers = bootstrapTimers.get(key);
|
||||
if (pendingBootstrapTimers && pendingBootstrapTimers.length > 0) {
|
||||
pendingBootstrapTimers.forEach((id) => {
|
||||
window.clearTimeout(id);
|
||||
});
|
||||
}
|
||||
bootstrapTimers.delete(key);
|
||||
},
|
||||
|
||||
refresh: async (key, options) => {
|
||||
const state = get();
|
||||
const entry = state.entries[key];
|
||||
const signature = getSignatureFromParams(entry?.params);
|
||||
|
||||
if (!entry || !signature) {
|
||||
return;
|
||||
}
|
||||
const signatureKeys = getKeysBySignature(state.entries, signature);
|
||||
const hasExistingPr = signatureKeys.some((signatureKey) => Boolean(state.entries[signatureKey]?.status?.pr));
|
||||
if (options?.onlyExistingPr && !hasExistingPr) {
|
||||
return;
|
||||
}
|
||||
const lastRefreshAt = lastRefreshBySignature.get(signature) ?? 0;
|
||||
if (!options?.force && Date.now() - lastRefreshAt < PR_REVALIDATE_TTL_MS) {
|
||||
return;
|
||||
}
|
||||
if (inFlightBySignature.has(signature)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = pickFetchParamsForSignature(state.entries, signature, key);
|
||||
if (!params) {
|
||||
return;
|
||||
}
|
||||
|
||||
inFlightBySignature.add(signature);
|
||||
lastRefreshBySignature.set(signature, Date.now());
|
||||
|
||||
set((prev) => {
|
||||
const nextEntries = { ...prev.entries };
|
||||
signatureKeys.forEach((signatureKey) => {
|
||||
const current = nextEntries[signatureKey];
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
nextEntries[signatureKey] = {
|
||||
...current,
|
||||
lastRefreshAt: Date.now(),
|
||||
isLoading: options?.silent ? current.isLoading : true,
|
||||
error: null,
|
||||
};
|
||||
});
|
||||
return {
|
||||
entries: nextEntries,
|
||||
};
|
||||
});
|
||||
|
||||
if (params.githubAuthChecked && params.githubConnected === false) {
|
||||
set((prev) => {
|
||||
const nextEntries = { ...prev.entries };
|
||||
signatureKeys.forEach((signatureKey) => {
|
||||
const current = nextEntries[signatureKey];
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
nextEntries[signatureKey] = {
|
||||
...current,
|
||||
status: { connected: false },
|
||||
error: null,
|
||||
isLoading: options?.silent ? current.isLoading : false,
|
||||
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
|
||||
};
|
||||
});
|
||||
return {
|
||||
entries: nextEntries,
|
||||
};
|
||||
});
|
||||
inFlightBySignature.delete(signature);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params.github?.prStatus) {
|
||||
set((prev) => {
|
||||
const nextEntries = { ...prev.entries };
|
||||
signatureKeys.forEach((signatureKey) => {
|
||||
const current = nextEntries[signatureKey];
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
nextEntries[signatureKey] = {
|
||||
...current,
|
||||
status: null,
|
||||
error: 'GitHub runtime API unavailable',
|
||||
isLoading: options?.silent ? current.isLoading : false,
|
||||
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
|
||||
};
|
||||
});
|
||||
return {
|
||||
entries: nextEntries,
|
||||
};
|
||||
});
|
||||
inFlightBySignature.delete(signature);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
set((prev) => ({
|
||||
...prev,
|
||||
activeRequestCount: prev.activeRequestCount + 1,
|
||||
totalRequestCount: prev.totalRequestCount + 1,
|
||||
}));
|
||||
const next = await params.github.prStatus(params.directory, params.branch, params.remoteName ?? undefined);
|
||||
set((prev) => {
|
||||
const nextEntries = { ...prev.entries };
|
||||
signatureKeys.forEach((signatureKey) => {
|
||||
const current = nextEntries[signatureKey];
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prevPr = current.status?.pr;
|
||||
const nextPr = next.pr;
|
||||
const shouldCarryBody = Boolean(
|
||||
nextPr
|
||||
&& prevPr
|
||||
&& nextPr.number === prevPr.number
|
||||
&& (!nextPr.body || !nextPr.body.trim())
|
||||
&& typeof prevPr.body === 'string'
|
||||
&& prevPr.body.trim().length > 0,
|
||||
);
|
||||
|
||||
const status = shouldCarryBody && nextPr && prevPr?.body
|
||||
? {
|
||||
...next,
|
||||
pr: {
|
||||
...nextPr,
|
||||
body: prevPr.body,
|
||||
},
|
||||
}
|
||||
: next;
|
||||
|
||||
nextEntries[signatureKey] = {
|
||||
...current,
|
||||
status,
|
||||
error: null,
|
||||
isLoading: options?.silent ? current.isLoading : false,
|
||||
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
entries: nextEntries,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
set((prev) => {
|
||||
const nextEntries = { ...prev.entries };
|
||||
signatureKeys.forEach((signatureKey) => {
|
||||
const current = nextEntries[signatureKey];
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
nextEntries[signatureKey] = {
|
||||
...current,
|
||||
error: message || 'Failed to load PR status',
|
||||
isLoading: options?.silent ? current.isLoading : false,
|
||||
isInitialStatusResolved: options?.markInitialResolved === false ? current.isInitialStatusResolved : true,
|
||||
};
|
||||
});
|
||||
return {
|
||||
entries: nextEntries,
|
||||
};
|
||||
});
|
||||
} finally {
|
||||
inFlightBySignature.delete(signature);
|
||||
set((prev) => ({ ...prev, activeRequestCount: Math.max(0, prev.activeRequestCount - 1) }));
|
||||
}
|
||||
},
|
||||
|
||||
updateStatus: (key, updater) => {
|
||||
set((state) => {
|
||||
const current = state.entries[key] ?? createEntry();
|
||||
return {
|
||||
entries: {
|
||||
...state.entries,
|
||||
[key]: {
|
||||
...current,
|
||||
status: updater(current.status),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
syncBackgroundTargets: ({ targets, github, githubAuthChecked, githubConnected }) => {
|
||||
if (!github || targets.length === 0) {
|
||||
Array.from(backgroundWatchingKeys).forEach((key) => {
|
||||
get().stopWatching(key);
|
||||
backgroundWatchingKeys.delete(key);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const uniqueTargets = new Map<string, PrTrackingTarget>();
|
||||
targets.forEach((target) => {
|
||||
const directory = target.directory.trim();
|
||||
const branch = target.branch.trim();
|
||||
if (!directory || !branch) {
|
||||
return;
|
||||
}
|
||||
const key = getGitHubPrStatusKey(directory, branch, target.remoteName ?? null);
|
||||
if (!uniqueTargets.has(key)) {
|
||||
uniqueTargets.set(key, {
|
||||
directory,
|
||||
branch,
|
||||
remoteName: target.remoteName ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const nextKeys = new Set(uniqueTargets.keys());
|
||||
|
||||
Array.from(backgroundWatchingKeys).forEach((key) => {
|
||||
if (nextKeys.has(key)) {
|
||||
return;
|
||||
}
|
||||
get().stopWatching(key);
|
||||
backgroundWatchingKeys.delete(key);
|
||||
});
|
||||
|
||||
uniqueTargets.forEach((target, key) => {
|
||||
get().ensureEntry(key);
|
||||
get().setParams(key, {
|
||||
directory: target.directory,
|
||||
branch: target.branch,
|
||||
remoteName: target.remoteName ?? null,
|
||||
canShow: true,
|
||||
github,
|
||||
githubAuthChecked,
|
||||
githubConnected,
|
||||
});
|
||||
|
||||
if (!backgroundWatchingKeys.has(key)) {
|
||||
get().startWatching(key);
|
||||
backgroundWatchingKeys.add(key);
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type SessionDisplayMode = 'default' | 'minimal';
|
||||
|
||||
type SessionDisplayStore = {
|
||||
displayMode: SessionDisplayMode;
|
||||
setDisplayMode: (mode: SessionDisplayMode) => void;
|
||||
};
|
||||
|
||||
export const useSessionDisplayStore = create<SessionDisplayStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
displayMode: 'default',
|
||||
setDisplayMode: (mode) => set({ displayMode: mode }),
|
||||
}),
|
||||
{
|
||||
name: 'session-display-mode',
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -1,6 +1,8 @@
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import { getSafeStorage } from './utils/safeStorage';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { useDirectoryStore } from './useDirectoryStore';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
@@ -38,8 +40,77 @@ type SessionFoldersStore = SessionFoldersState & SessionFoldersActions;
|
||||
|
||||
const FOLDERS_STORAGE_KEY = 'oc.sessions.folders';
|
||||
const COLLAPSED_STORAGE_KEY = 'oc.sessions.folderCollapse';
|
||||
const SESSIONS_DIRECTORIES_PATH_SUFFIX = '.config/openchamber/sessions-directories.json';
|
||||
const DISK_WRITE_DEBOUNCE_MS = 250;
|
||||
const ARCHIVED_SCOPE_PREFIX = '__archived__:';
|
||||
|
||||
const safeStorage = getSafeStorage();
|
||||
let diskWriteTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let diskHydrated = false;
|
||||
let diskHydrationInFlight = false;
|
||||
|
||||
const getSessionsDirectoriesPath = (): string | null => {
|
||||
const directoryState = useDirectoryStore.getState();
|
||||
const homeDirectory = typeof directoryState.homeDirectory === 'string' && directoryState.homeDirectory.length > 0
|
||||
? directoryState.homeDirectory
|
||||
: (safeStorage.getItem('homeDirectory') || '');
|
||||
|
||||
if (!homeDirectory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `${homeDirectory.replace(/\/$/, '')}/${SESSIONS_DIRECTORIES_PATH_SUFFIX}`;
|
||||
};
|
||||
|
||||
const getParentDirectory = (path: string): string | null => {
|
||||
const index = path.lastIndexOf('/');
|
||||
if (index <= 0) {
|
||||
return null;
|
||||
}
|
||||
return path.slice(0, index);
|
||||
};
|
||||
|
||||
const schedulePersistToDisk = (foldersMap: SessionFoldersMap, collapsedFolderIds: Set<string>): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (diskWriteTimer) {
|
||||
clearTimeout(diskWriteTimer);
|
||||
}
|
||||
|
||||
const foldersSnapshot = JSON.parse(JSON.stringify(foldersMap)) as SessionFoldersMap;
|
||||
const collapsedSnapshot = Array.from(collapsedFolderIds);
|
||||
|
||||
diskWriteTimer = setTimeout(() => {
|
||||
diskWriteTimer = null;
|
||||
void (async () => {
|
||||
const runtimeFiles = getRegisteredRuntimeAPIs()?.files;
|
||||
if (!runtimeFiles?.writeFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = getSessionsDirectoriesPath();
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentDirectory = getParentDirectory(path);
|
||||
if (parentDirectory) {
|
||||
await runtimeFiles.createDirectory(parentDirectory).catch(() => undefined);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: 1,
|
||||
foldersMap: foldersSnapshot,
|
||||
collapsedFolderIds: collapsedSnapshot,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
await runtimeFiles.writeFile(path, JSON.stringify(payload, null, 2)).catch(() => undefined);
|
||||
})();
|
||||
}, DISK_WRITE_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const readPersistedFolders = (): SessionFoldersMap => {
|
||||
try {
|
||||
@@ -112,6 +183,12 @@ const persistCollapsed = (collapsedFolderIds: Set<string>): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const persistState = (foldersMap: SessionFoldersMap, collapsedFolderIds: Set<string>): void => {
|
||||
persistFolders(foldersMap);
|
||||
persistCollapsed(collapsedFolderIds);
|
||||
schedulePersistToDisk(foldersMap, collapsedFolderIds);
|
||||
};
|
||||
|
||||
const createFolderId = (): string => {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
return crypto.randomUUID();
|
||||
@@ -139,6 +216,14 @@ const syncCollapsedAfterFolderCleanup = (
|
||||
return nextCollapsed;
|
||||
};
|
||||
|
||||
const pruneEmptyArchivedFolders = (scopeKey: string, folders: SessionFolder[]): SessionFolder[] => {
|
||||
if (!scopeKey.startsWith(ARCHIVED_SCOPE_PREFIX)) {
|
||||
return folders;
|
||||
}
|
||||
|
||||
return folders.filter((folder) => folder.sessionIds.length > 0);
|
||||
};
|
||||
|
||||
// --- Store ---
|
||||
|
||||
export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
@@ -168,7 +253,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
[scopeKey]: [...scopeFolders, folder],
|
||||
};
|
||||
set({ foldersMap: nextMap });
|
||||
persistFolders(nextMap);
|
||||
persistState(nextMap, get().collapsedFolderIds);
|
||||
return folder;
|
||||
},
|
||||
|
||||
@@ -183,7 +268,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
);
|
||||
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
|
||||
set({ foldersMap: nextMap });
|
||||
persistFolders(nextMap);
|
||||
persistState(nextMap, get().collapsedFolderIds);
|
||||
},
|
||||
|
||||
deleteFolder: (scopeKey: string, folderId: string): void => {
|
||||
@@ -206,7 +291,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
const nextFolders = scopeFolders.filter((folder) => !idsToDelete.has(folder.id));
|
||||
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
|
||||
set({ foldersMap: nextMap });
|
||||
persistFolders(nextMap);
|
||||
persistState(nextMap, get().collapsedFolderIds);
|
||||
|
||||
// Clean up collapsed state for all deleted folders
|
||||
const collapsed = get().collapsedFolderIds;
|
||||
@@ -215,7 +300,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
const nextCollapsed = new Set(collapsed);
|
||||
idsToDelete.forEach((id) => nextCollapsed.delete(id));
|
||||
set({ collapsedFolderIds: nextCollapsed });
|
||||
persistCollapsed(nextCollapsed);
|
||||
persistState(nextMap, nextCollapsed);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -243,10 +328,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
set(nextCollapsed
|
||||
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
|
||||
: { foldersMap: nextMap });
|
||||
persistFolders(nextMap);
|
||||
if (nextCollapsed) {
|
||||
persistCollapsed(nextCollapsed);
|
||||
}
|
||||
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
|
||||
},
|
||||
|
||||
removeSessionFromFolder: (scopeKey: string, sessionId: string): void => {
|
||||
@@ -272,10 +354,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
set(nextCollapsed
|
||||
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
|
||||
: { foldersMap: nextMap });
|
||||
persistFolders(nextMap);
|
||||
if (nextCollapsed) {
|
||||
persistCollapsed(nextCollapsed);
|
||||
}
|
||||
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
|
||||
},
|
||||
|
||||
toggleFolderCollapse: (folderId: string): void => {
|
||||
@@ -287,7 +366,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
next.add(folderId);
|
||||
}
|
||||
set({ collapsedFolderIds: next });
|
||||
persistCollapsed(next);
|
||||
persistState(get().foldersMap, next);
|
||||
},
|
||||
|
||||
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>): void => {
|
||||
@@ -297,7 +376,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
if (!scopeFolders || scopeFolders.length === 0) return;
|
||||
|
||||
let changed = false;
|
||||
const nextFolders = scopeFolders.map((folder) => {
|
||||
const filteredFolders = scopeFolders.map((folder) => {
|
||||
const filtered = folder.sessionIds.filter((id) => existingSessionIds.has(id));
|
||||
if (filtered.length !== folder.sessionIds.length) {
|
||||
changed = true;
|
||||
@@ -306,6 +385,11 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
return folder;
|
||||
});
|
||||
|
||||
const nextFolders = pruneEmptyArchivedFolders(scopeKey, filteredFolders);
|
||||
if (nextFolders.length !== filteredFolders.length) {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
const nextMap: SessionFoldersMap = { ...current, [scopeKey]: nextFolders };
|
||||
const nextCollapsed = syncCollapsedAfterFolderCleanup(scopeFolders, nextFolders, get().collapsedFolderIds);
|
||||
@@ -313,10 +397,7 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
set(nextCollapsed
|
||||
? { foldersMap: nextMap, collapsedFolderIds: nextCollapsed }
|
||||
: { foldersMap: nextMap });
|
||||
persistFolders(nextMap);
|
||||
if (nextCollapsed) {
|
||||
persistCollapsed(nextCollapsed);
|
||||
}
|
||||
persistState(nextMap, nextCollapsed ?? get().collapsedFolderIds);
|
||||
},
|
||||
|
||||
getSessionFolderId: (scopeKey: string, sessionId: string): string | null => {
|
||||
@@ -334,3 +415,84 @@ export const useSessionFoldersStore = create<SessionFoldersStore>()(
|
||||
{ name: 'session-folders-store' },
|
||||
),
|
||||
);
|
||||
|
||||
const hydrateSessionFoldersFromDisk = async (): Promise<void> => {
|
||||
if (diskHydrated || diskHydrationInFlight || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const runtimeFiles = getRegisteredRuntimeAPIs()?.files;
|
||||
if (!runtimeFiles?.readFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = getSessionsDirectoriesPath();
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
diskHydrationInFlight = true;
|
||||
|
||||
const result = await runtimeFiles.readFile(path).catch(() => null);
|
||||
if (!result?.content) {
|
||||
diskHydrationInFlight = false;
|
||||
diskHydrated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(result.content) as {
|
||||
foldersMap?: SessionFoldersMap;
|
||||
collapsedFolderIds?: string[];
|
||||
};
|
||||
|
||||
const diskFolders = parsed?.foldersMap && typeof parsed.foldersMap === 'object'
|
||||
? parsed.foldersMap
|
||||
: {};
|
||||
const diskCollapsed = Array.isArray(parsed?.collapsedFolderIds)
|
||||
? new Set(parsed.collapsedFolderIds.filter((value): value is string => typeof value === 'string'))
|
||||
: new Set<string>();
|
||||
|
||||
const hasDiskData = Object.keys(diskFolders).length > 0 || diskCollapsed.size > 0;
|
||||
if (!hasDiskData) {
|
||||
return;
|
||||
}
|
||||
|
||||
useSessionFoldersStore.setState({
|
||||
foldersMap: diskFolders,
|
||||
collapsedFolderIds: diskCollapsed,
|
||||
});
|
||||
|
||||
persistFolders(diskFolders);
|
||||
persistCollapsed(diskCollapsed);
|
||||
} catch {
|
||||
// ignored
|
||||
} finally {
|
||||
diskHydrationInFlight = false;
|
||||
diskHydrated = true;
|
||||
}
|
||||
};
|
||||
|
||||
const bootstrapSessionFoldersDiskHydration = (): void => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 20;
|
||||
|
||||
const runAttempt = () => {
|
||||
attempts += 1;
|
||||
void hydrateSessionFoldersFromDisk();
|
||||
|
||||
if (diskHydrated || attempts >= maxAttempts) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(runAttempt, 500);
|
||||
};
|
||||
|
||||
runAttempt();
|
||||
};
|
||||
|
||||
bootstrapSessionFoldersDiskHydration();
|
||||
|
||||
@@ -83,6 +83,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
(set, get) => ({
|
||||
|
||||
sessions: [],
|
||||
archivedSessions: [],
|
||||
sessionsByDirectory: new Map(),
|
||||
currentSessionId: null,
|
||||
lastLoadedDirectory: null,
|
||||
@@ -280,6 +281,8 @@ export const useSessionStore = create<SessionStore>()(
|
||||
},
|
||||
deleteSession: (id: string, options) => useSessionManagementStore.getState().deleteSession(id, options),
|
||||
deleteSessions: (ids: string[], options) => useSessionManagementStore.getState().deleteSessions(ids, options),
|
||||
archiveSession: (id: string) => useSessionManagementStore.getState().archiveSession(id),
|
||||
archiveSessions: (ids: string[], options) => useSessionManagementStore.getState().archiveSessions(ids, options),
|
||||
updateSessionTitle: (id: string, title: string) => useSessionManagementStore.getState().updateSessionTitle(id, title),
|
||||
shareSession: (id: string) => useSessionManagementStore.getState().shareSession(id),
|
||||
unshareSession: (id: string) => useSessionManagementStore.getState().unshareSession(id),
|
||||
@@ -876,6 +879,7 @@ useSessionManagementStore.subscribe((state, prevState) => {
|
||||
|
||||
if (
|
||||
state.sessions === prevState.sessions &&
|
||||
state.archivedSessions === prevState.archivedSessions &&
|
||||
state.sessionsByDirectory === prevState.sessionsByDirectory &&
|
||||
state.currentSessionId === prevState.currentSessionId &&
|
||||
state.lastLoadedDirectory === prevState.lastLoadedDirectory &&
|
||||
@@ -893,6 +897,7 @@ useSessionManagementStore.subscribe((state, prevState) => {
|
||||
|
||||
useSessionStore.setState({
|
||||
sessions: state.sessions,
|
||||
archivedSessions: state.archivedSessions,
|
||||
sessionsByDirectory: state.sessionsByDirectory,
|
||||
currentSessionId: draftOpen ? null : state.currentSessionId,
|
||||
lastLoadedDirectory: state.lastLoadedDirectory,
|
||||
|
||||
Reference in New Issue
Block a user