Initial commit: restructure to flat layout with ui/ and web/ at root

This commit is contained in:
2026-03-12 21:33:50 +08:00
commit decba25a08
1708 changed files with 199890 additions and 0 deletions

View File

@@ -0,0 +1,582 @@
import * as React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from '@/components/ui';
import {
RiCheckLine,
RiCloseLine,
RiDeleteBinLine,
RiGitBranchLine,
RiLoader4Line,
RiPencilLine,
RiSearchLine,
RiSplitCellsHorizontal,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { deleteGitBranch, getGitBranches, git, renameBranch } from '@/lib/gitApi';
import type { GitBranch, GitWorktreeInfo } from '@/lib/api/types';
import type { WorktreeMetadata } from '@/types/worktree';
import { createWorktreeWithDefaults } from '@/lib/worktrees/worktreeCreate';
import { getRootBranch } from '@/lib/worktrees/worktreeStatus';
import { getWorktreeSetupCommands } from '@/lib/openchamberConfig';
import { sessionEvents } from '@/lib/sessionEvents';
import { useSessionStore } from '@/stores/useSessionStore';
export interface BranchPickerProject {
id: string;
path: string;
normalizedPath: string;
label?: string;
}
interface BranchPickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
project: BranchPickerProject | null;
}
const displayProjectName = (project: BranchPickerProject): string =>
project.label || project.normalizedPath.split('/').pop() || project.normalizedPath;
const normalizeBranchName = (value: string | null | undefined): string => {
return String(value || '')
.trim()
.replace(/^refs\/heads\//, '')
.replace(/^heads\//, '')
.replace(/^remotes\//, '');
};
const normalizePath = (value: string | null | undefined): string => {
const raw = String(value || '').trim().replace(/\\/g, '/');
if (!raw) {
return '';
}
if (raw === '/') {
return '/';
}
return raw.length > 1 ? raw.replace(/\/+$/, '') : raw;
};
export function BranchPickerDialog({ open, onOpenChange, project }: BranchPickerDialogProps) {
const sessions = useSessionStore((state) => state.sessions);
const [searchQuery, setSearchQuery] = React.useState('');
const [branches, setBranches] = React.useState<GitBranch | null>(null);
const [worktrees, setWorktrees] = React.useState<GitWorktreeInfo[]>([]);
const [rootBranchName, setRootBranchName] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [creatingWorktreeBranch, setCreatingWorktreeBranch] = React.useState<string | null>(null);
const [deletingBranch, setDeletingBranch] = React.useState<string | null>(null);
const [confirmingDelete, setConfirmingDelete] = React.useState<string | null>(null);
const [forceDeleteBranch, setForceDeleteBranch] = React.useState<string | null>(null);
const [editingBranch, setEditingBranch] = React.useState<string | null>(null);
const [editValue, setEditValue] = React.useState('');
const [renamingBranchKey, setRenamingBranchKey] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
if (!project) return;
setLoading(true);
setError(null);
try {
const [b, w, rootBranch] = await Promise.all([
getGitBranches(project.path),
git.worktree.list(project.path),
getRootBranch(project.path).catch(() => null),
]);
setBranches(b);
setWorktrees(w);
setRootBranchName(rootBranch);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load');
setBranches(null);
setWorktrees([]);
setRootBranchName(null);
} finally {
setLoading(false);
}
}, [project]);
React.useEffect(() => {
if (!open) {
setSearchQuery('');
setConfirmingDelete(null);
setForceDeleteBranch(null);
setEditingBranch(null);
setEditValue('');
setRenamingBranchKey(null);
setCreatingWorktreeBranch(null);
return;
}
void refresh();
}, [open, refresh]);
const filterBranches = (list: string[], query: string): string[] => {
if (!query.trim()) return list;
const lower = query.toLowerCase();
return list.filter((b) => b.toLowerCase().includes(lower));
};
const beginRename = React.useCallback((branchName: string) => {
setEditingBranch(branchName);
setEditValue(branchName);
}, []);
const cancelRename = React.useCallback(() => {
setEditingBranch(null);
setEditValue('');
setRenamingBranchKey(null);
}, []);
const cancelDelete = React.useCallback(() => {
setConfirmingDelete(null);
setForceDeleteBranch(null);
}, []);
const commitRename = React.useCallback(async (oldName: string) => {
if (!project) return;
const newName = editValue.trim();
if (!newName || newName === oldName) {
cancelRename();
return;
}
setRenamingBranchKey(oldName);
try {
const result = await renameBranch(project.path, oldName, newName);
if (!result?.success) {
throw new Error('Rename rejected');
}
await refresh();
cancelRename();
toast.success('Branch renamed', { description: `${oldName} -> ${newName}` });
} catch (err) {
toast.error('Failed to rename branch', {
description: err instanceof Error ? err.message : 'Rename failed',
});
setRenamingBranchKey(null);
}
}, [project, editValue, refresh, cancelRename]);
const handleDeleteBranch = React.useCallback(async (branchName: string) => {
if (!project) return;
setDeletingBranch(branchName);
try {
const force = forceDeleteBranch === branchName;
const result = await deleteGitBranch(project.path, { branch: branchName, force });
if (!result?.success) {
throw new Error('Delete rejected');
}
await refresh();
toast.success('Branch deleted', { description: branchName });
setConfirmingDelete(null);
setForceDeleteBranch(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Delete failed';
// If branch isn't merged, prompt for force delete on next confirm.
if (/not fully merged/i.test(message) && forceDeleteBranch !== branchName) {
setForceDeleteBranch(branchName);
toast.error('Branch not merged', {
description: 'Confirm again to force delete',
});
} else {
toast.error('Failed to delete branch', { description: message });
}
} finally {
setDeletingBranch(null);
}
}, [project, refresh, forceDeleteBranch]);
const handleCreateWorktreeForBranch = React.useCallback(async (branchName: string) => {
if (!project) {
return;
}
setCreatingWorktreeBranch(branchName);
try {
const setupCommands = await getWorktreeSetupCommands({
id: project.id,
path: project.path,
});
await createWorktreeWithDefaults(
{
id: project.id,
path: project.path,
},
{
preferredName: branchName,
mode: 'existing',
existingBranch: branchName,
branchName,
worktreeName: branchName,
setupCommands,
}
);
await refresh();
toast.success('Worktree created', { description: branchName });
} catch (err) {
toast.error('Failed to create worktree', {
description: err instanceof Error ? err.message : 'Create worktree failed',
});
} finally {
setCreatingWorktreeBranch(null);
}
}, [project, refresh]);
const handleRemoveWorktree = React.useCallback((worktree: GitWorktreeInfo | null) => {
if (!project || !worktree) {
return;
}
const normalizedWorktreePath = normalizePath(worktree.path);
const directSessions = sessions.filter((session) => {
const sessionPath = normalizePath(session.directory ?? null);
return Boolean(sessionPath) && sessionPath === normalizedWorktreePath;
});
const directSessionIds = new Set(directSessions.map((session) => session.id));
const findSubsessions = (parentIds: Set<string>): typeof sessions => {
const subsessions = sessions.filter((session) => {
const parentID = (session as { parentID?: string | null }).parentID;
if (!parentID) {
return false;
}
return parentIds.has(parentID);
});
if (subsessions.length === 0) {
return [];
}
const subsessionIds = new Set(subsessions.map((session) => session.id));
return [...subsessions, ...findSubsessions(subsessionIds)];
};
const allSubsessions = findSubsessions(directSessionIds);
const seenIds = new Set<string>();
const allSessions = [...directSessions, ...allSubsessions].filter((session) => {
if (seenIds.has(session.id)) {
return false;
}
seenIds.add(session.id);
return true;
});
const normalizedBranch = normalizeBranchName(worktree.branch);
const worktreeMetadata: WorktreeMetadata = {
source: 'sdk',
name: worktree.name,
path: worktree.path,
projectDirectory: project.path,
branch: normalizedBranch,
label: normalizedBranch || worktree.name,
};
sessionEvents.requestDelete({
sessions: allSessions,
mode: 'worktree',
worktree: worktreeMetadata,
});
}, [project, sessions]);
const worktreeByBranch = new Map<string, GitWorktreeInfo>();
for (const worktree of worktrees) {
const branchName = normalizeBranchName(worktree.branch);
if (branchName && !worktreeByBranch.has(branchName)) {
worktreeByBranch.set(branchName, worktree);
}
}
const normalizedRootBranch = normalizeBranchName(rootBranchName);
const allBranches = branches?.all || [];
const filteredBranches = filterBranches(allBranches, searchQuery);
const localBranches = filteredBranches.filter((b) => !b.startsWith('remotes/'));
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col overflow-hidden gap-3">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiGitBranchLine className="h-5 w-5" />
Manage Branches
</DialogTitle>
<DialogDescription>
{project ? `Local branches for ${displayProjectName(project)}` : 'Select a project'}
</DialogDescription>
</DialogHeader>
<div className="relative flex-shrink-0">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search branches..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="space-y-1">
{!project ? (
<div className="text-center py-8 text-muted-foreground">No project selected</div>
) : loading ? (
<div className="px-2 py-2 text-muted-foreground text-sm">Loading branches...</div>
) : error ? (
<div className="px-2 py-2 text-destructive text-sm">{error}</div>
) : localBranches.length === 0 ? (
<div className="px-2 py-2 text-muted-foreground text-sm">
{searchQuery ? 'No matching branches' : 'No branches found'}
</div>
) : (
localBranches.map((branchName) => {
const details = branches?.branches[branchName];
const normalizedBranchName = normalizeBranchName(branchName);
const isCurrent = Boolean(details?.current);
const isDeleting = deletingBranch === branchName;
const isRenaming = renamingBranchKey === branchName;
const attachedWorktree = worktreeByBranch.get(normalizedBranchName) ?? null;
const hasAttachedWorktree = Boolean(attachedWorktree);
const isProjectRootBranch = Boolean(
normalizedBranchName &&
normalizedRootBranch &&
normalizedBranchName === normalizedRootBranch
);
const isEditing = editingBranch === branchName;
const isConfirming = confirmingDelete === branchName;
const isForceDelete = forceDeleteBranch === branchName;
const isCreatingWorktree = creatingWorktreeBranch === branchName;
const disableCreateWorktree = Boolean(
hasAttachedWorktree || isCreatingWorktree || isDeleting || isRenaming || isEditing
);
const disableDelete = Boolean(
isCurrent || isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
);
const disableRename = Boolean(
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch
);
const disableWorktreeDelete = Boolean(
isDeleting || isRenaming || isEditing || isCreatingWorktree || isProjectRootBranch || !attachedWorktree
);
return (
<div
key={branchName}
className="flex items-center gap-2 px-2.5 py-1.5 hover:bg-interactive-hover/30 rounded-md overflow-hidden"
>
<RiGitBranchLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0 overflow-hidden">
<div className="flex items-center gap-1.5 min-w-0">
{isEditing ? (
<form
className="flex w-full items-center min-w-0"
onSubmit={(event) => {
event.preventDefault();
void commitRename(branchName);
}}
>
<input
value={editValue}
onChange={(event) => setEditValue(event.target.value)}
className="flex-1 min-w-0 h-5 bg-transparent text-sm leading-none outline-none placeholder:text-muted-foreground"
autoFocus
placeholder="Rename branch"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault();
cancelRename();
}
if (event.key === 'Enter') {
event.preventDefault();
void commitRename(branchName);
}
}}
/>
</form>
) : (
<span className={cn('text-sm truncate', isCurrent && 'font-medium text-primary')}>
{branchName}
</span>
)}
{isCurrent && (
<span className="text-xs bg-primary/10 text-primary px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
HEAD
</span>
)}
{hasAttachedWorktree && !isEditing && (
<span className="text-xs bg-muted/40 text-muted-foreground px-1.5 py-0.5 rounded flex-shrink-0 whitespace-nowrap">
worktree
</span>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{details?.commit ? (
<span className="font-mono">{details.commit.slice(0, 7)}</span>
) : null}
{typeof details?.ahead === 'number' && details.ahead > 0 ? (
<span className="text-[color:var(--status-success)]">{details.ahead}</span>
) : null}
{typeof details?.behind === 'number' && details.behind > 0 ? (
<span className="text-[color:var(--status-warning)]">{details.behind}</span>
) : null}
</div>
</div>
{!isEditing && !isConfirming ? (
<div className="flex items-center gap-1 flex-shrink-0">
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => void handleCreateWorktreeForBranch(branchName)}
disabled={disableCreateWorktree}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
aria-label="Create worktree"
>
{isCreatingWorktree ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiSplitCellsHorizontal className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="left">
{hasAttachedWorktree ? 'Worktree already exists' : 'Create worktree'}
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => beginRename(branchName)}
disabled={disableRename}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
aria-label="Rename"
>
<RiPencilLine className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="left">
{isProjectRootBranch ? 'Rename disabled for root branch' : 'Rename'}
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={700}>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => {
if (hasAttachedWorktree) {
handleRemoveWorktree(attachedWorktree);
return;
}
setConfirmingDelete(branchName);
}}
disabled={hasAttachedWorktree ? disableWorktreeDelete : disableDelete}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors disabled:opacity-50"
aria-label={hasAttachedWorktree ? 'Delete worktree' : 'Delete'}
>
{isDeleting ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiDeleteBinLine className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="left">
{hasAttachedWorktree
? isProjectRootBranch
? 'Delete worktree (root branch protected)'
: 'Delete worktree'
: isCurrent
? 'Delete (current branch)'
: isProjectRootBranch
? 'Delete disabled for root branch'
: 'Delete'}
</TooltipContent>
</Tooltip>
</div>
) : null}
{isEditing ? (
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={() => void commitRename(branchName)}
disabled={isRenaming}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
aria-label="Confirm rename"
>
{isRenaming ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiCheckLine className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={cancelRename}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Cancel rename"
>
<RiCloseLine className="h-4 w-4" />
</button>
</div>
) : null}
{!isEditing && isConfirming && !hasAttachedWorktree ? (
<div className="flex items-center gap-1 flex-shrink-0">
<span className={cn(
'text-xs mr-1',
isForceDelete ? 'text-destructive' : 'text-muted-foreground'
)}>
{isForceDelete ? 'Force delete?' : 'Delete?'}
</span>
<button
type="button"
onClick={() => void handleDeleteBranch(branchName)}
disabled={isDeleting}
className={cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors disabled:opacity-50',
isForceDelete
? 'bg-destructive/10 text-destructive hover:bg-destructive/15'
: 'hover:bg-destructive/10 text-muted-foreground hover:text-destructive'
)}
aria-label="Confirm delete"
>
{isDeleting ? (
<RiLoader4Line className="h-4 w-4 animate-spin" />
) : (
<RiCheckLine className="h-4 w-4" />
)}
</button>
<button
type="button"
onClick={cancelDelete}
className="inline-flex h-7 w-7 items-center justify-center rounded-md hover:bg-interactive-hover/40 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Cancel delete"
>
<RiCloseLine className="h-4 w-4" />
</button>
</div>
) : null}
</div>
);
})
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,314 @@
import React from 'react';
import { RiFolderLine, RiRefreshLine } from '@remixicon/react';
import { cn } from '@/lib/utils';
import { opencodeClient, type FilesystemEntry } from '@/lib/opencode/client';
import { useDebouncedValue } from '@/hooks/useDebouncedValue';
interface DirectoryAutocompleteProps {
inputValue: string;
homeDirectory: string | null;
onSelectSuggestion: (path: string) => void;
visible: boolean;
onClose: () => void;
showHidden: boolean;
}
export interface DirectoryAutocompleteHandle {
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => boolean;
}
export const DirectoryAutocomplete = React.forwardRef<DirectoryAutocompleteHandle, DirectoryAutocompleteProps>(({
inputValue,
homeDirectory,
onSelectSuggestion,
visible,
onClose,
showHidden,
}, ref) => {
const [suggestions, setSuggestions] = React.useState<FilesystemEntry[]>([]);
const [loading, setLoading] = React.useState(false);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
// Fuzzy matching score - returns null if no match, higher score = better match
const fuzzyScore = React.useCallback((query: string, candidate: string): number | null => {
const q = query.trim().toLowerCase();
if (!q) {
return 0;
}
const c = candidate.toLowerCase();
let score = 0;
let lastIndex = -1;
let consecutive = 0;
for (let i = 0; i < q.length; i += 1) {
const ch = q[i];
if (!ch || ch === ' ') {
continue;
}
const idx = c.indexOf(ch, lastIndex + 1);
if (idx === -1) {
return null; // Character not found - no match
}
const gap = idx - lastIndex - 1;
if (gap === 0) {
consecutive += 1;
} else {
consecutive = 0;
}
score += 10; // Base score per matched char
score += Math.max(0, 18 - idx); // Bonus for early matches
score -= Math.max(0, gap); // Penalty for gaps
// Bonus for match at start or after separator
if (idx === 0) {
score += 12;
} else {
const prev = c[idx - 1];
if (prev === '/' || prev === '_' || prev === '-' || prev === '.' || prev === ' ') {
score += 10;
}
}
score += consecutive > 0 ? 12 : 0; // Bonus for consecutive matches
lastIndex = idx;
}
score += Math.max(0, 24 - Math.round(c.length / 3)); // Shorter names score higher
return score;
}, []);
// Expand ~ to home directory
const expandPath = React.useCallback((path: string): string => {
if (path.startsWith('~') && homeDirectory) {
return path.replace(/^~/, homeDirectory);
}
return path;
}, [homeDirectory]);
// Get the directory part of the path for listing
const getParentDir = React.useCallback((path: string): string => {
const expanded = expandPath(path);
// If ends with /, list that directory
if (expanded.endsWith('/')) {
return expanded;
}
// Otherwise, get parent directory
const lastSlash = expanded.lastIndexOf('/');
if (lastSlash === -1) return '';
if (lastSlash === 0) return '/';
return expanded.substring(0, lastSlash + 1);
}, [expandPath]);
// Get the partial name being typed (for filtering)
const getPartialName = React.useCallback((path: string): string => {
const expanded = expandPath(path);
if (expanded.endsWith('/')) return '';
const lastSlash = expanded.lastIndexOf('/');
if (lastSlash === -1) return expanded;
return expanded.substring(lastSlash + 1);
}, [expandPath]);
const debouncedInputValue = useDebouncedValue(inputValue, 150);
// Fetch directory suggestions
React.useEffect(() => {
if (!visible || !debouncedInputValue) {
setSuggestions([]);
return;
}
const parentDir = getParentDir(debouncedInputValue);
const partialName = getPartialName(debouncedInputValue).toLowerCase();
if (!parentDir) {
setSuggestions([]);
return;
}
let cancelled = false;
setLoading(true);
opencodeClient.listLocalDirectory(parentDir)
.then((entries) => {
if (cancelled) return;
// Filter to directories only, respect hidden setting
const directories = entries.filter((entry) => {
if (!entry.isDirectory) return false;
if (!showHidden && entry.name.startsWith('.')) return false;
return true;
});
// Apply fuzzy matching and sort by score
const scored = partialName
? directories
.map((entry) => {
const score = fuzzyScore(partialName, entry.name);
return score !== null ? { entry, score } : null;
})
.filter((item): item is { entry: FilesystemEntry; score: number } => item !== null)
.sort((a, b) => b.score - a.score || a.entry.name.localeCompare(b.entry.name))
.map((item) => item.entry)
: directories.sort((a, b) => a.name.localeCompare(b.name));
setSuggestions(scored.slice(0, 10)); // Limit suggestions
setSelectedIndex(0);
})
.catch(() => {
if (!cancelled) {
setSuggestions([]);
}
})
.finally(() => {
if (!cancelled) {
setLoading(false);
}
});
return () => {
cancelled = true;
};
}, [visible, debouncedInputValue, getParentDir, getPartialName, showHidden, fuzzyScore]);
// Scroll selected item into view
React.useEffect(() => {
itemRefs.current[selectedIndex]?.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
});
}, [selectedIndex]);
// Handle outside click
React.useEffect(() => {
if (!visible) return;
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node | null;
if (!target || !containerRef.current) return;
if (containerRef.current.contains(target)) return;
onClose();
};
document.addEventListener('pointerdown', handlePointerDown, true);
return () => {
document.removeEventListener('pointerdown', handlePointerDown, true);
};
}, [visible, onClose]);
const handleSelectSuggestion = React.useCallback((entry: FilesystemEntry) => {
// Append the selected directory name to current path, with trailing slash
const path = entry.path.endsWith('/') ? entry.path : entry.path + '/';
onSelectSuggestion(path);
}, [onSelectSuggestion]);
// Expose key handler to parent
React.useImperativeHandle(ref, () => ({
handleKeyDown: (e: React.KeyboardEvent<HTMLInputElement>): boolean => {
if (!visible || suggestions.length === 0) {
return false;
}
const total = suggestions.length;
if (e.key === 'Tab') {
e.preventDefault();
if (e.shiftKey) {
// Shift+Tab: previous suggestion
setSelectedIndex((prev) => (prev - 1 + total) % total);
} else {
// Tab: next suggestion or select if only one
if (total === 1) {
const selected = suggestions[0];
if (selected) {
handleSelectSuggestion(selected);
}
} else {
setSelectedIndex((prev) => (prev + 1) % total);
}
}
return true;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedIndex((prev) => (prev + 1) % total);
return true;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedIndex((prev) => (prev - 1 + total) % total);
return true;
}
if (e.key === 'Enter') {
e.preventDefault();
// Select current item and close autocomplete
const safeIndex = ((selectedIndex % total) + total) % total;
const selected = suggestions[safeIndex];
if (selected) {
handleSelectSuggestion(selected);
}
onClose();
return true; // Consume the event, don't let parent confirm yet
}
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return true;
}
return false;
}
}), [visible, suggestions, selectedIndex, handleSelectSuggestion, onClose]);
if (!visible || (suggestions.length === 0 && !loading)) {
return null;
}
return (
<div
ref={containerRef}
className="absolute z-[100] w-full max-h-48 bg-background border border-border rounded-lg shadow-none top-full mt-1 left-0 flex flex-col overflow-hidden"
>
{loading ? (
<div className="flex items-center justify-center py-3">
<RiRefreshLine className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="overflow-y-auto py-1">
{suggestions.map((entry, index) => {
const isSelected = selectedIndex === index;
return (
<div
key={entry.path}
ref={(el) => { itemRefs.current[index] = el; }}
className={cn(
"flex items-center gap-2 px-3 py-1.5 cursor-pointer typography-ui-label",
isSelected && "bg-interactive-selection"
)}
onClick={() => { handleSelectSuggestion(entry); onClose(); }}
onMouseEnter={() => setSelectedIndex(index)}
>
<RiFolderLine className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="truncate">{entry.name}</span>
</div>
);
})}
</div>
)}
<div className="px-3 py-1.5 border-t typography-meta text-muted-foreground bg-sidebar/50">
Tab cycle navigate Enter select
</div>
</div>
);
});
DirectoryAutocomplete.displayName = 'DirectoryAutocomplete';

View File

@@ -0,0 +1,362 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DirectoryTree } from './DirectoryTree';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { cn, formatPathForDisplay } from '@/lib/utils';
import { toast } from '@/components/ui';
import {
RiCheckboxBlankLine,
RiCheckboxLine,
} from '@remixicon/react';
import { useDeviceInfo } from '@/lib/device';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { DirectoryAutocomplete, type DirectoryAutocompleteHandle } from './DirectoryAutocomplete';
import {
setDirectoryShowHidden,
useDirectoryShowHidden,
} from '@/lib/directoryShowHidden';
interface DirectoryExplorerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const DirectoryExplorerDialog: React.FC<DirectoryExplorerDialogProps> = ({
open,
onOpenChange,
}) => {
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
const { addProject, getActiveProject } = useProjectsStore();
const [pendingPath, setPendingPath] = React.useState<string | null>(null);
const [pathInputValue, setPathInputValue] = React.useState('');
const [hasUserSelection, setHasUserSelection] = React.useState(false);
const [isConfirming, setIsConfirming] = React.useState(false);
const showHidden = useDirectoryShowHidden();
const { isDesktop, requestAccess, startAccessing } = useFileSystemAccess();
const { isMobile } = useDeviceInfo();
const [autocompleteVisible, setAutocompleteVisible] = React.useState(false);
const autocompleteRef = React.useRef<DirectoryAutocompleteHandle>(null);
// Helper to format path for display
const formatPath = React.useCallback((path: string | null) => {
if (!path) return '';
return formatPathForDisplay(path, homeDirectory);
}, [homeDirectory]);
// Reset state when dialog opens
React.useEffect(() => {
if (open) {
setHasUserSelection(false);
setIsConfirming(false);
setAutocompleteVisible(false);
// Initialize with active project or current directory
const activeProject = getActiveProject();
const initialPath = activeProject?.path || currentDirectory || homeDirectory || '';
setPendingPath(initialPath);
setPathInputValue(formatPath(initialPath));
}
}, [open, currentDirectory, homeDirectory, formatPath, getActiveProject]);
// Set initial pending path to home when ready (only if not yet selected)
React.useEffect(() => {
if (!open || hasUserSelection || pendingPath) {
return;
}
if (homeDirectory && isHomeReady) {
setPendingPath(homeDirectory);
setHasUserSelection(true);
setPathInputValue('~');
}
}, [open, hasUserSelection, pendingPath, homeDirectory, isHomeReady]);
const handleClose = React.useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
const finalizeSelection = React.useCallback(async (targetPath: string) => {
if (!targetPath || isConfirming) {
return;
}
setIsConfirming(true);
try {
let resolvedPath = targetPath;
let projectId: string | undefined;
if (isDesktop) {
const accessResult = await requestAccess(targetPath);
if (!accessResult.success) {
toast.error('Unable to access directory', {
description: accessResult.error || 'Desktop denied directory access.',
});
return;
}
resolvedPath = accessResult.path ?? targetPath;
projectId = accessResult.projectId;
const startResult = await startAccessing(resolvedPath);
if (!startResult.success) {
toast.error('Failed to open directory', {
description: startResult.error || 'Desktop could not grant file access.',
});
return;
}
}
const added = addProject(resolvedPath, { id: projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory path.',
});
return;
}
handleClose();
} catch (error) {
toast.error('Failed to select directory', {
description: error instanceof Error ? error.message : 'Unknown error occurred.',
});
} finally {
setIsConfirming(false);
}
}, [
addProject,
handleClose,
isDesktop,
requestAccess,
startAccessing,
isConfirming,
]);
const handleConfirm = React.useCallback(async () => {
const pathToUse = pathInputValue.trim() || pendingPath;
if (!pathToUse) {
return;
}
await finalizeSelection(pathToUse);
}, [finalizeSelection, pathInputValue, pendingPath]);
const handleSelectPath = React.useCallback((path: string) => {
setPendingPath(path);
setHasUserSelection(true);
setPathInputValue(formatPath(path));
}, [formatPath]);
const handleDoubleClickPath = React.useCallback(async (path: string) => {
setPendingPath(path);
setHasUserSelection(true);
setPathInputValue(formatPath(path));
await finalizeSelection(path);
}, [finalizeSelection, formatPath]);
const handlePathInputChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setPathInputValue(value);
setHasUserSelection(true);
// Show autocomplete when typing a path
setAutocompleteVisible(value.startsWith('/') || value.startsWith('~'));
// Update pending path if it looks like a valid path
if (value.startsWith('/') || value.startsWith('~')) {
// Expand ~ to home directory
const expandedPath = value.startsWith('~') && homeDirectory
? value.replace(/^~/, homeDirectory)
: value;
setPendingPath(expandedPath);
}
}, [homeDirectory]);
const handlePathInputKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
// Let autocomplete handle the key first if visible
if (autocompleteRef.current?.handleKeyDown(e)) {
return;
}
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}, [handleConfirm]);
const handleAutocompleteSuggestion = React.useCallback((path: string) => {
setPendingPath(path);
setHasUserSelection(true);
setPathInputValue(formatPath(path));
// Keep autocomplete open to allow further drilling down
}, [formatPath]);
const handleAutocompleteClose = React.useCallback(() => {
setAutocompleteVisible(false);
}, []);
const toggleShowHidden = React.useCallback(() => {
setDirectoryShowHidden(!showHidden);
}, [showHidden]);
const showHiddenToggle = (
<button
type="button"
onClick={toggleShowHidden}
className="flex items-center gap-2 px-2 py-1 rounded-lg hover:bg-interactive-hover/40 transition-colors typography-meta text-muted-foreground flex-shrink-0"
>
{showHidden ? (
<RiCheckboxLine className="h-4 w-4 text-primary" />
) : (
<RiCheckboxBlankLine className="h-4 w-4" />
)}
Show hidden
</button>
);
const dialogHeader = (
<DialogHeader className="flex-shrink-0 px-4 pb-2 pt-[calc(var(--oc-safe-area-top,0px)+0.5rem)] sm:px-0 sm:pb-3 sm:pt-0">
<DialogTitle>Add project directory</DialogTitle>
<div className="hidden sm:flex sm:items-center sm:justify-between sm:gap-4">
<DialogDescription className="flex-1">
Choose a folder to add as a project.
</DialogDescription>
{showHiddenToggle}
</div>
</DialogHeader>
);
const pathInputSection = (
<div className="relative">
<Input
value={pathInputValue}
onChange={handlePathInputChange}
onKeyDown={handlePathInputKeyDown}
placeholder="Enter path or select from tree..."
className="font-mono typography-meta"
spellCheck={false}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
/>
<DirectoryAutocomplete
ref={autocompleteRef}
inputValue={pathInputValue}
homeDirectory={homeDirectory}
onSelectSuggestion={handleAutocompleteSuggestion}
visible={autocompleteVisible}
onClose={handleAutocompleteClose}
showHidden={showHidden}
/>
</div>
);
const treeSection = (
<div className="flex-1 min-h-0 rounded-xl border border-border/40 bg-sidebar/70 overflow-hidden flex flex-col">
<DirectoryTree
variant="inline"
currentPath={pendingPath ?? currentDirectory}
onSelectPath={handleSelectPath}
onDoubleClickPath={handleDoubleClickPath}
className="flex-1 min-h-0 sm:min-h-[280px] sm:max-h-[380px]"
selectionBehavior="deferred"
showHidden={showHidden}
rootDirectory={isHomeReady ? homeDirectory : null}
isRootReady={isHomeReady}
/>
</div>
);
// Mobile: use flex layout where tree takes remaining space
const mobileContent = (
<div className="flex flex-col gap-3 h-full">
<div className="flex-shrink-0">{pathInputSection}</div>
<div className="flex-shrink-0 flex items-center justify-end">
{showHiddenToggle}
</div>
<div className="flex-1 min-h-0 rounded-xl border border-border/40 bg-sidebar/70 overflow-hidden flex flex-col">
<DirectoryTree
variant="inline"
currentPath={pendingPath ?? currentDirectory}
onSelectPath={handleSelectPath}
onDoubleClickPath={handleDoubleClickPath}
className="flex-1 min-h-0"
selectionBehavior="deferred"
showHidden={showHidden}
rootDirectory={isHomeReady ? homeDirectory : null}
isRootReady={isHomeReady}
alwaysShowActions
/>
</div>
</div>
);
const desktopContent = (
<div className="flex-1 min-h-0 overflow-hidden flex flex-col gap-3">
{pathInputSection}
{treeSection}
</div>
);
const renderActionButtons = () => (
<>
<Button
variant="ghost"
onClick={handleClose}
disabled={isConfirming}
className="flex-1 sm:flex-none sm:w-auto"
>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={isConfirming || !hasUserSelection || (!pendingPath && !pathInputValue.trim())}
className="flex-1 sm:flex-none sm:w-auto sm:min-w-[140px]"
>
{isConfirming ? 'Adding...' : 'Add Project'}
</Button>
</>
);
if (isMobile) {
return (
<MobileOverlayPanel
open={open}
onClose={() => onOpenChange(false)}
title="Add project directory"
className="max-w-full"
contentMaxHeightClassName="max-h-[min(70vh,520px)] h-[min(70vh,520px)]"
footer={<div className="flex flex-row gap-2">{renderActionButtons()}</div>}
>
{mobileContent}
</MobileOverlayPanel>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className={cn(
'flex w-full max-w-[min(560px,100vw)] max-h-[calc(100vh-32px)] flex-col gap-0 overflow-hidden p-0 sm:max-h-[80vh] sm:max-w-xl sm:p-6'
)}
onOpenAutoFocus={(e) => {
// Prevent auto-focus on input to avoid text selection
e.preventDefault();
}}
>
{dialogHeader}
{desktopContent}
<DialogFooter
className="sticky bottom-0 flex w-full flex-shrink-0 flex-row gap-2 border-t border-border/40 bg-sidebar px-4 py-3 sm:static sm:justify-end sm:border-0 sm:bg-transparent sm:px-0 sm:pt-4 sm:pb-0"
>
{renderActionButtons()}
</DialogFooter>
</DialogContent>
</Dialog>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,618 @@
import * as React from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
RiGithubLine,
RiLoader4Line,
RiSearchLine,
RiErrorWarningLine,
RiCheckLine,
RiGitPullRequestLine,
RiGitBranchLine,
RiCloseLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { validateWorktreeCreate } from '@/lib/worktrees/worktreeManager';
import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import type {
GitHubIssue,
GitHubIssueSummary,
GitHubPullRequestSummary,
} from '@/lib/api/types';
import type { ProjectRef } from '@/lib/worktrees/worktreeManager';
type GitHubTab = 'issues' | 'prs';
interface GitHubIntegrationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (result: {
type: 'issue' | 'pr';
item: GitHubIssue | GitHubPullRequestSummary;
includeDiff?: boolean;
} | null) => void;
}
interface ValidationResult {
isValid: boolean;
error: string | null;
}
export function GitHubIntegrationDialog({
open,
onOpenChange,
onSelect,
}: GitHubIntegrationDialogProps) {
const isMobile = useUIStore((state) => state.isMobile);
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null;
const projectRef: ProjectRef | null = React.useMemo(() => {
if (projectDirectory && activeProject) {
return { id: activeProject.id, path: projectDirectory };
}
return null;
}, [activeProject, projectDirectory]);
// State
const [activeTab, setActiveTab] = React.useState<GitHubTab>('issues');
const [searchQuery, setSearchQuery] = React.useState('');
const [issues, setIssues] = React.useState<GitHubIssueSummary[]>([]);
const [prs, setPrs] = React.useState<GitHubPullRequestSummary[]>([]);
const [loading, setLoading] = React.useState(false);
const [loadingMore, setLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [selectedIssue, setSelectedIssue] = React.useState<GitHubIssue | null>(null);
const [selectedPr, setSelectedPr] = React.useState<GitHubPullRequestSummary | null>(null);
const [includeDiff, setIncludeDiff] = React.useState(false);
const [validations, setValidations] = React.useState<Map<string, ValidationResult>>(new Map());
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(false);
// Load GitHub data
const loadData = React.useCallback(async () => {
if (!projectDirectory || !github) return;
if (githubAuthChecked && githubAuthStatus?.connected === false) return;
setLoading(true);
setError(null);
setPage(1);
setHasMore(false);
try {
if (activeTab === 'issues' && github.issuesList) {
const result = await github.issuesList(projectDirectory, { page: 1 });
if (result.connected === false) {
setError('GitHub not connected');
setIssues([]);
} else {
setIssues(result.issues ?? []);
setPage(result.page ?? 1);
setHasMore(Boolean(result.hasMore));
}
} else if (activeTab === 'prs' && github.prsList) {
const result = await github.prsList(projectDirectory, { page: 1 });
if (result.connected === false) {
setError('GitHub not connected');
setPrs([]);
} else {
setPrs(result.prs ?? []);
setPage(result.page ?? 1);
setHasMore(Boolean(result.hasMore));
}
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
}, [projectDirectory, github, githubAuthChecked, githubAuthStatus, activeTab]);
// Load more data
const loadMore = React.useCallback(async () => {
if (!projectDirectory || !github) return;
if (loading || loadingMore) return;
if (!hasMore) return;
setLoadingMore(true);
try {
const nextPage = page + 1;
if (activeTab === 'issues' && github.issuesList) {
const result = await github.issuesList(projectDirectory, { page: nextPage });
if (result.connected !== false) {
setIssues(prev => [...prev, ...(result.issues ?? [])]);
setPage(result.page ?? nextPage);
setHasMore(Boolean(result.hasMore));
}
} else if (activeTab === 'prs' && github.prsList) {
const result = await github.prsList(projectDirectory, { page: nextPage });
if (result.connected !== false) {
setPrs(prev => [...prev, ...(result.prs ?? [])]);
setPage(result.page ?? nextPage);
setHasMore(Boolean(result.hasMore));
}
}
} catch {
// Silently fail on load more errors
} finally {
setLoadingMore(false);
}
}, [projectDirectory, github, activeTab, page, hasMore, loading, loadingMore]);
// Reset state when dialog opens/closes
React.useEffect(() => {
if (!open) {
setActiveTab('issues');
setSearchQuery('');
setIssues([]);
setPrs([]);
setSelectedIssue(null);
setSelectedPr(null);
setIncludeDiff(false);
setError(null);
setValidations(new Map());
setPage(1);
setHasMore(false);
return;
}
void loadData();
}, [open, loadData]);
// Validate branches for worktree creation
const validateBranch = React.useCallback(async (branchName: string) => {
if (!projectRef || !branchName) return;
// Check cache first
if (validations.has(branchName)) return;
try {
const result = await validateWorktreeCreate(projectRef, {
mode: 'new',
branchName,
worktreeName: branchName,
});
const isBlocked = result.errors.some(
(entry) => entry.code === 'branch_in_use' || entry.code === 'branch_exists'
);
setValidations(prev => new Map(prev).set(branchName, {
isValid: !isBlocked,
error: isBlocked ? 'Branch is already checked out in a worktree' : null,
}));
} catch {
setValidations(prev => new Map(prev).set(branchName, {
isValid: false,
error: 'Validation failed',
}));
}
}, [projectRef, validations]);
// Validate PR branches when loaded
React.useEffect(() => {
if (!open || activeTab !== 'prs') return;
prs.forEach(pr => {
if (pr.head) {
void validateBranch(pr.head);
}
});
}, [open, activeTab, prs, validateBranch]);
// Filtered results
const filteredIssues = React.useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return issues;
return issues.filter(issue => {
if (String(issue.number) === q.replace(/^#/, '')) return true;
return issue.title.toLowerCase().includes(q);
});
}, [issues, searchQuery]);
const filteredPrs = React.useMemo(() => {
const q = searchQuery.trim().toLowerCase();
if (!q) return prs;
return prs.filter(pr => {
if (String(pr.number) === q.replace(/^#/, '')) return true;
return pr.title.toLowerCase().includes(q);
});
}, [prs, searchQuery]);
// GitHub connection check
const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true;
const openGitHubSettings = () => {
setSettingsPage('github');
setSettingsDialogOpen(true);
};
// Handle selection
const handleSelectIssue = (issue: GitHubIssueSummary) => {
setSelectedIssue(issue as GitHubIssue);
setSelectedPr(null);
};
const handleSelectPr = (pr: GitHubPullRequestSummary) => {
setSelectedPr(pr);
setSelectedIssue(null);
};
const handleConfirm = () => {
if (selectedIssue) {
onSelect({
type: 'issue',
item: selectedIssue,
});
} else if (selectedPr) {
onSelect({
type: 'pr',
item: selectedPr,
includeDiff,
});
}
onOpenChange(false);
};
const handleClear = () => {
setSelectedIssue(null);
setSelectedPr(null);
setIncludeDiff(false);
};
// Check if selection is valid
const canConfirm = selectedIssue || (selectedPr && validations.get(selectedPr.head ?? '')?.isValid !== false);
// Check if PR is blocked
const isPrBlocked = (pr: GitHubPullRequestSummary): boolean => {
if (!pr.head) return true;
const validation = validations.get(pr.head);
return validation?.isValid === false;
};
// Content for the dialog (shared between mobile and desktop)
const dialogContent = (
<>
{!isGitHubConnected ? (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-4">
<RiGithubLine className="h-12 w-12 text-muted-foreground" />
<div className="text-center">
<p className="typography-ui-label text-foreground">Connect to GitHub</p>
<p className="typography-small text-muted-foreground mt-1">
Link issues or pull requests to auto-fill worktree details
</p>
</div>
<Button onClick={openGitHubSettings} size="sm">Connect GitHub</Button>
</div>
) : (
<>
{/* Search */}
<div className="relative mt-2">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={activeTab === 'issues' ? "Search issues or enter #123..." : "Search PRs or enter #456..."}
className="h-8 pl-9"
/>
</div>
{/* List Content */}
<div className="mt-2 h-[300px] overflow-hidden">
<div className="h-full overflow-y-auto">
{/* Loading */}
{loading && (
<div className="flex items-center justify-center h-full">
<RiLoader4Line className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{/* Error */}
{error && (
<div className="flex items-center justify-center h-full">
<div className="flex items-center gap-2 p-2 rounded-md bg-destructive/10 text-destructive">
<RiErrorWarningLine className="h-4 w-4" />
<span className="typography-small">{error}</span>
</div>
</div>
)}
{/* Issues List */}
{!loading && !error && activeTab === 'issues' && (
<div className="space-y-0.5 min-h-full">
{filteredIssues.length > 0 ? (
filteredIssues.map(issue => (
<button
key={issue.number}
onClick={() => handleSelectIssue(issue)}
className={cn(
'w-full text-left px-2 py-1.5 rounded transition-colors',
selectedIssue?.number === issue.number
? 'bg-interactive-selection text-interactive-selection-foreground'
: 'hover:bg-interactive-hover'
)}
>
<div className="flex items-start gap-2">
<span className="text-muted-foreground shrink-0 typography-micro">#{issue.number}</span>
<span className="typography-small line-clamp-2">{issue.title}</span>
</div>
</button>
))
) : (
<div className="flex items-center justify-center h-[300px] text-center typography-small text-muted-foreground">
No issues found
</div>
)}
{hasMore && !loadingMore && (
<div className="flex justify-center pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => void loadMore()}
className="h-7 text-xs"
>
Load more
</Button>
</div>
)}
{loadingMore && (
<div className="flex items-center justify-center py-2">
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
{/* PRs List */}
{!loading && !error && activeTab === 'prs' && (
<div className="space-y-0.5 min-h-full">
{filteredPrs.length > 0 ? (
filteredPrs.map(pr => {
const blocked = isPrBlocked(pr);
const validation = pr.head ? validations.get(pr.head) : undefined;
return (
<button
key={pr.number}
onClick={() => !blocked && handleSelectPr(pr)}
disabled={blocked}
className={cn(
'w-full text-left px-2 py-1.5 rounded transition-colors',
selectedPr?.number === pr.number
? 'bg-interactive-selection text-interactive-selection-foreground'
: blocked
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-interactive-hover'
)}
>
<div className="flex items-start gap-2">
<span className="text-muted-foreground shrink-0 typography-micro">#{pr.number}</span>
<div className="min-w-0 flex-1">
<span className="typography-small line-clamp-1">{pr.title}</span>
<div className="flex items-center gap-2 mt-0.5">
<span className="typography-micro text-muted-foreground">
{pr.head} {pr.base}
</span>
{blocked && validation?.error && (
<span className="typography-micro text-destructive">
{validation.error}
</span>
)}
</div>
</div>
</div>
</button>
);
})
) : (
<div className="flex items-center justify-center h-[300px] text-center typography-small text-muted-foreground">
No pull requests found
</div>
)}
{hasMore && !loadingMore && (
<div className="flex justify-center pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => void loadMore()}
className="h-7 text-xs"
>
Load more
</Button>
</div>
)}
{loadingMore && (
<div className="flex items-center justify-center py-2">
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
)}
</div>
)}
</div>
</div>
</>
)}
</>
);
// Footer content
const footerContent = (
<div className={cn(
'w-full',
isMobile ? 'flex flex-col gap-2' : 'flex flex-row items-center'
)}>
{/* Left side: Selected Item / Checkbox */}
<div className={cn(
'flex items-center gap-4',
isMobile ? 'w-full justify-center order-1' : 'flex-1'
)}>
{/* Selected Issue/PR display - hidden on mobile (shown in header instead) */}
{!isMobile && (selectedIssue || selectedPr) && (
<div className="flex items-center gap-2 px-2 h-8 rounded-md bg-muted/50 border border-border/50">
<RiCheckLine className="h-3.5 w-3.5 text-status-success shrink-0" />
<span className="typography-small truncate max-w-[150px]">
{selectedIssue ? `Issue #${selectedIssue.number}` : `PR #${selectedPr?.number}`}
</span>
<button
onClick={handleClear}
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
</div>
)}
{/* Include Diff Checkbox - only show when PR tab is active and PR is selected */}
{activeTab === 'prs' && selectedPr && (
<label className="flex items-center gap-2 cursor-pointer h-8">
<Checkbox
checked={includeDiff}
onChange={(checked) => setIncludeDiff(checked)}
ariaLabel="Include PR diff in session context"
/>
<span className="typography-small text-foreground">
Include PR diff
</span>
</label>
)}
</div>
{/* Right side: Buttons */}
<div className={cn(
'flex gap-2',
isMobile ? 'w-full order-2' : 'justify-end'
)}>
<Button
variant="outline"
size="sm"
onClick={() => onOpenChange(false)}
className={cn(isMobile && 'flex-1')}
>
Cancel
</Button>
<Button
size="sm"
onClick={handleConfirm}
disabled={!canConfirm}
className={cn(isMobile && 'flex-1')}
>
Select
</Button>
</div>
</div>
);
return (
<>
{isMobile ? (
<MobileOverlayPanel
open={open}
title="Select from GitHub"
onClose={() => onOpenChange(false)}
footer={!isGitHubConnected ? undefined : footerContent}
renderHeader={(closeButton) => (
<div className="flex flex-col gap-2 px-3 py-2 border-b border-border/40">
<div className="flex items-center justify-between">
<h2 className="typography-ui-label font-semibold text-foreground">Select from GitHub</h2>
{closeButton}
</div>
{/* Tabs - using SortableTabsStrip */}
<div className="w-full">
<SortableTabsStrip
items={[
{ id: 'issues', label: 'Issues', icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'prs', label: 'Pull Requests', icon: <RiGitPullRequestLine className="h-3.5 w-3.5" /> },
]}
activeId={activeTab}
onSelect={(id) => {
setActiveTab(id as GitHubTab);
setSearchQuery('');
}}
variant="active-pill"
layoutMode="fit"
/>
</div>
{/* Selected Item Inline Display */}
{(selectedIssue || selectedPr) && (
<div className="flex items-center gap-2 px-2 py-1 rounded-md bg-muted/50 border border-border/50">
<RiCheckLine className="h-3.5 w-3.5 text-status-success shrink-0" />
<span className="typography-small truncate flex-1">
{selectedIssue ? `Issue #${selectedIssue.number}` : `PR #${selectedPr?.number}`}
</span>
<button
onClick={handleClear}
className="text-muted-foreground hover:text-foreground shrink-0 p-0.5 rounded hover:bg-muted transition-colors"
>
<RiCloseLine className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
)}
>
{dialogContent}
</MobileOverlayPanel>
) : (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<DialogTitle className="flex items-center gap-2 shrink-0">
<RiGithubLine className="h-5 w-5" />
Select from GitHub
</DialogTitle>
{/* Tabs - using SortableTabsStrip */}
<div className="w-[220px]">
<SortableTabsStrip
items={[
{ id: 'issues', label: 'Issues', icon: <RiGitBranchLine className="h-3.5 w-3.5" /> },
{ id: 'prs', label: 'Pull Requests', icon: <RiGitPullRequestLine className="h-3.5 w-3.5" /> },
]}
activeId={activeTab}
onSelect={(id) => {
setActiveTab(id as GitHubTab);
setSearchQuery('');
}}
variant="active-pill"
layoutMode="fit"
/>
</div>
</div>
</DialogHeader>
{dialogContent}
{/* Footer */}
<DialogFooter className="mt-1">
{footerContent}
</DialogFooter>
</DialogContent>
</Dialog>
)}
</>
);
}

View File

@@ -0,0 +1,742 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { toast } from '@/components/ui';
import {
RiCheckboxBlankLine,
RiCheckboxLine,
RiExternalLinkLine,
RiGithubLine,
RiLoader4Line,
RiSearchLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useMessageStore } from '@/stores/messageStore';
import { useContextStore } from '@/stores/contextStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { opencodeClient } from '@/lib/opencode/client';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
import type { GitHubIssue, GitHubIssueComment, GitHubIssuesListResult, GitHubIssueSummary } from '@/lib/api/types';
const parseIssueNumber = (value: string): number | null => {
const trimmed = value.trim();
if (!trimmed) return null;
const urlMatch = trimmed.match(/\/issues\/(\d+)(?:\b|\/|$)/i);
if (urlMatch) {
const parsed = Number(urlMatch[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
const hashMatch = trimmed.match(/^#?(\d+)$/);
if (hashMatch) {
const parsed = Number(hashMatch[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
return null;
};
const buildIssueContextText = (args: {
repo: GitHubIssuesListResult['repo'] | undefined;
issue: GitHubIssue;
comments: GitHubIssueComment[];
}) => {
const payload = {
repo: args.repo ?? null,
issue: args.issue,
comments: args.comments,
};
return `GitHub issue context (JSON)\n${JSON.stringify(payload, null, 2)}`;
};
export function GitHubIssuePickerDialog({
open,
onOpenChange,
mode = 'createSession',
onSelect,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
mode?: 'createSession' | 'select';
onSelect?: (issue: { number: number; title: string; url: string; contextText: string; author?: { login: string; avatarUrl?: string } }) => void;
}) {
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const isMobile = useUIStore((state) => state.isMobile);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null;
const [query, setQuery] = React.useState('');
const [createInWorktree, setCreateInWorktree] = React.useState(false);
const [result, setResult] = React.useState<GitHubIssuesListResult | null>(null);
const [issues, setIssues] = React.useState<GitHubIssueSummary[]>([]);
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(false);
const [startingIssueNumber, setStartingIssueNumber] = React.useState<number | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
if (!projectDirectory) {
setResult(null);
setError('No active project');
return;
}
if (githubAuthChecked && githubAuthStatus?.connected === false) {
setResult({ connected: false });
setIssues([]);
setHasMore(false);
setPage(1);
setError(null);
return;
}
if (!github?.issuesList) {
setResult(null);
setError('GitHub runtime API unavailable');
return;
}
setIsLoading(true);
setError(null);
try {
const next = await github.issuesList(projectDirectory, { page: 1 });
setResult(next);
setIssues(next.issues ?? []);
setPage(next.page ?? 1);
setHasMore(Boolean(next.hasMore));
if (next.connected === false) {
setError(null);
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsLoading(false);
}
}, [github, githubAuthChecked, githubAuthStatus, projectDirectory]);
const loadMore = React.useCallback(async () => {
if (!projectDirectory) return;
if (!github?.issuesList) return;
if (isLoadingMore || isLoading) return;
if (!hasMore) return;
setIsLoadingMore(true);
try {
const nextPage = page + 1;
const next = await github.issuesList(projectDirectory, { page: nextPage });
setResult(next);
setIssues((prev) => [...prev, ...(next.issues ?? [])]);
setPage(next.page ?? nextPage);
setHasMore(Boolean(next.hasMore));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load more issues', { description: message });
} finally {
setIsLoadingMore(false);
}
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]);
React.useEffect(() => {
if (!open) {
setQuery('');
setCreateInWorktree(false);
setStartingIssueNumber(null);
setError(null);
setResult(null);
setIssues([]);
setPage(1);
setHasMore(false);
setIsLoading(false);
return;
}
void refresh();
}, [open, refresh]);
React.useEffect(() => {
if (!open) return;
if (githubAuthChecked && githubAuthStatus?.connected === false) {
setResult({ connected: false });
setIssues([]);
setHasMore(false);
setPage(1);
setError(null);
}
}, [githubAuthChecked, githubAuthStatus, open]);
const connected = githubAuthChecked ? result?.connected !== false : true;
const repoUrl = result?.repo?.url ?? null;
const openGitHubSettings = React.useCallback(() => {
setSettingsPage('github');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return issues;
return issues.filter((issue) => {
if (String(issue.number) === q.replace(/^#/, '')) return true;
return issue.title.toLowerCase().includes(q);
});
}, [issues, query]);
const directNumber = React.useMemo(() => parseIssueNumber(query), [query]);
const resolveDefaultAgentName = React.useCallback((): string | undefined => {
const configState = useConfigStore.getState();
const visibleAgents = configState.getVisibleAgents();
if (configState.settingsDefaultAgent) {
const settingsAgent = visibleAgents.find((a) => a.name === configState.settingsDefaultAgent);
if (settingsAgent) {
return settingsAgent.name;
}
}
return (
visibleAgents.find((agent) => agent.name === 'build')?.name ||
visibleAgents[0]?.name
);
}, []);
const resolveDefaultModelSelection = React.useCallback((): { providerID: string; modelID: string } | null => {
const configState = useConfigStore.getState();
const settingsDefaultModel = configState.settingsDefaultModel;
if (!settingsDefaultModel) {
return null;
}
const parts = settingsDefaultModel.split('/');
if (parts.length !== 2) {
return null;
}
const [providerID, modelID] = parts;
if (!providerID || !modelID) {
return null;
}
const modelMetadata = configState.getModelMetadata(providerID, modelID);
if (!modelMetadata) {
return null;
}
return { providerID, modelID };
}, []);
const resolveDefaultVariant = React.useCallback((providerID: string, modelID: string): string | undefined => {
const configState = useConfigStore.getState();
const settingsDefaultVariant = configState.settingsDefaultVariant;
if (!settingsDefaultVariant) {
return undefined;
}
const provider = configState.providers.find((p) => p.id === providerID);
const model = provider?.models.find((m: Record<string, unknown>) => (m as { id?: string }).id === modelID) as
| { variants?: Record<string, unknown> }
| undefined;
const variants = model?.variants;
if (!variants) {
return undefined;
}
if (!Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) {
return undefined;
}
return settingsDefaultVariant;
}, []);
const startSession = React.useCallback(async (issueNumber: number) => {
if (mode === 'select') {
// In select mode, fetch full issue details and return via onSelect
if (!projectDirectory) {
toast.error('No active project');
return;
}
if (!github?.issueGet || !github?.issueComments) {
toast.error('GitHub runtime API unavailable');
return;
}
if (startingIssueNumber) return;
setStartingIssueNumber(issueNumber);
try {
const issueRes = await github.issueGet(projectDirectory, issueNumber);
if (issueRes.connected === false) {
toast.error('GitHub not connected');
return;
}
if (!issueRes.repo) {
toast.error('Repo not resolvable', {
description: 'origin remote must be a GitHub URL',
});
return;
}
const issue = issueRes.issue;
if (!issue) {
toast.error('Issue not found');
return;
}
const commentsRes = await github.issueComments(projectDirectory, issueNumber);
if (commentsRes.connected === false) {
toast.error('GitHub not connected');
return;
}
const comments = commentsRes.comments ?? [];
// Build full context text like in createSession mode
const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments });
if (onSelect) {
onSelect({
number: issue.number,
title: issue.title,
url: issue.url,
contextText,
author: issue.author ? {
login: issue.author.login,
avatarUrl: issue.author.avatarUrl,
} : undefined,
});
}
onOpenChange(false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load issue details', { description: message });
} finally {
setStartingIssueNumber(null);
}
return;
}
if (!projectDirectory) {
toast.error('No active project');
return;
}
if (!github?.issueGet || !github?.issueComments) {
toast.error('GitHub runtime API unavailable');
return;
}
if (startingIssueNumber) return;
setStartingIssueNumber(issueNumber);
try {
const issueRes = await github.issueGet(projectDirectory, issueNumber);
if (issueRes.connected === false) {
toast.error('GitHub not connected');
return;
}
if (!issueRes.repo) {
toast.error('Repo not resolvable', {
description: 'origin remote must be a GitHub URL',
});
return;
}
const issue = issueRes.issue;
if (!issue) {
toast.error('Issue not found');
return;
}
const commentsRes = await github.issueComments(projectDirectory, issueNumber);
if (commentsRes.connected === false) {
toast.error('GitHub not connected');
return;
}
const comments = commentsRes.comments ?? [];
const sessionTitle = `#${issue.number} ${issue.title}`.trim();
const sessionId = await (async () => {
if (createInWorktree) {
const preferred = `issue-${issue.number}-${generateBranchSlug()}`;
const created = await createWorktreeSessionForNewBranch(
projectDirectory,
preferred
);
if (!created?.id) {
throw new Error('Failed to create worktree session');
}
return created.id;
}
const session = await useSessionStore.getState().createSession(sessionTitle, projectDirectory, null);
if (!session?.id) {
throw new Error('Failed to create session');
}
return session.id;
})();
// Ensure worktree-based sessions also get the issue title.
void useSessionStore.getState().updateSessionTitle(sessionId, sessionTitle).catch(() => undefined);
try {
useSessionStore.getState().initializeNewOpenChamberSession(sessionId, useConfigStore.getState().agents);
} catch {
// ignore
}
// Close modal immediately after session exists (don't wait for message send).
onOpenChange(false);
const configState = useConfigStore.getState();
const lastUsedProvider = useMessageStore.getState().lastUsedProvider;
const defaultModel = resolveDefaultModelSelection();
const providerID = defaultModel?.providerID || configState.currentProviderId || lastUsedProvider?.providerID;
const modelID = defaultModel?.modelID || configState.currentModelId || lastUsedProvider?.modelID;
const agentName = resolveDefaultAgentName() || configState.currentAgentName || undefined;
if (!providerID || !modelID) {
toast.error('No model selected');
return;
}
const variant = resolveDefaultVariant(providerID, modelID);
try {
useContextStore.getState().saveSessionModelSelection(sessionId, providerID, modelID);
} catch {
// ignore
}
if (agentName) {
try {
configState.setAgent(agentName);
} catch {
// ignore
}
try {
useContextStore.getState().saveSessionAgentSelection(sessionId, agentName);
} catch {
// ignore
}
try {
useContextStore.getState().saveAgentModelForSession(sessionId, agentName, providerID, modelID);
} catch {
// ignore
}
if (variant !== undefined) {
try {
configState.setCurrentVariant(variant);
} catch {
// ignore
}
try {
useContextStore.getState().saveAgentModelVariantForSession(sessionId, agentName, providerID, modelID, variant);
} catch {
// ignore
}
}
}
const visiblePromptText = 'Review this issue using the provided issue context: title, body, labels, assignees, comments, metadata.';
const instructionsText = `Review this issue using the provided issue context.
Process:
- First classify the issue type (bug / feature request / question/support / refactor / ops) and state it as: Type: <one label>.
- Gather any needed repository context (code, config, docs) to validate assumptions.
- After gathering, if anything is still unclear or cannot be verified, do not speculate—state whats missing and ask targeted questions.
Output rules:
- Compact output; pick ONE template below and omit the others.
- No emojis. No code snippets. No fenced blocks.
- Short inline code identifiers allowed.
- Reference evidence with file paths and line ranges when applicable; if exact lines arent available, cite the file and say “approx” + why.
- Keep the entire response under ~300 words.
Templates (choose one):
Bug:
- Summary (1-2 sentences)
- Likely cause (max 2)
- Repro/diagnostics needed (max 3)
- Fix approach (max 4 steps)
- Verification (max 3)
Feature:
- Summary (1-2 sentences)
- Requirements (max 4)
- Unknowns/questions (max 4)
- Proposed plan (max 5 steps)
- Verification (max 3)
Question/Support:
- Summary (1-2 sentences)
- Answer/guidance (max 6 lines)
- Missing info (max 4)
Do not implement changes until I confirm; end with: “Next actions: <1 sentence>”.`;
const contextText = buildIssueContextText({ repo: issueRes.repo, issue, comments });
void opencodeClient.sendMessage({
id: sessionId,
providerID,
modelID,
agent: agentName,
variant,
text: visiblePromptText,
additionalParts: [
{ text: instructionsText, synthetic: true },
{ text: contextText, synthetic: true },
],
}).catch((e) => {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to send issue context', {
description: message,
});
});
toast.success('Session created from issue');
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to start session', { description: message });
} finally {
setStartingIssueNumber(null);
}
}, [createInWorktree, github, mode, onOpenChange, onSelect, projectDirectory, resolveDefaultAgentName, resolveDefaultModelSelection, resolveDefaultVariant, startingIssueNumber]);
const title = mode === 'select' ? 'Link GitHub Issue' : 'New Session From GitHub Issue';
const description = mode === 'select'
? 'Select an issue to link to this session.'
: 'Seeds a new session with hidden issue context (title/body/labels/comments).';
const content = (
<>
<div className="relative mt-2">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by title or #123, or paste issue URL"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
/>
</div>
<div className={cn(isMobile ? 'min-h-0 mt-2' : 'flex-1 overflow-y-auto mt-2')}>
{!projectDirectory ? (
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
) : null}
{!github ? (
<div className="text-center text-muted-foreground py-8">GitHub runtime API unavailable.</div>
) : null}
{isLoading ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading issues...
</div>
) : null}
{connected === false ? (
<div className="text-center text-muted-foreground py-8 space-y-3">
<div>GitHub not connected. Connect your GitHub account in settings.</div>
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={openGitHubSettings}>
Open settings
</Button>
</div>
</div>
) : null}
{error ? (
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
) : null}
{directNumber && projectDirectory && github && connected ? (
<div
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
startingIssueNumber === directNumber && 'bg-interactive-selection/30'
)}
onClick={() => void startSession(directNumber)}
>
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">#</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
Use issue #{directNumber}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueNumber === directNumber ? (
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
) : null}
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{query ? 'No issues found' : 'No open issues found'}</div>
) : null}
{filtered.map((issue) => (
<div
key={issue.number}
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
startingIssueNumber === issue.number && 'bg-interactive-selection/30'
)}
onClick={() => void startSession(issue.number)}
>
<span className="typography-meta text-muted-foreground w-12 text-right flex-shrink-0">
#{issue.number}
</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
{issue.title}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{startingIssueNumber === issue.number ? (
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className="hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
aria-label="Open in GitHub"
>
<RiExternalLinkLine className="h-4 w-4" />
</a>
)}
</div>
</div>
))}
{hasMore && connected && projectDirectory && github ? (
<div className="py-2 flex justify-center">
<button
type="button"
onClick={() => void loadMore()}
disabled={isLoadingMore || Boolean(startingIssueNumber)}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
(isLoadingMore || Boolean(startingIssueNumber)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
)}
>
{isLoadingMore ? (
<span className="inline-flex items-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading...
</span>
) : (
'Load more'
)}
</button>
</div>
) : null}
</div>
{mode !== 'select' && (
<div className="mt-4 p-3 bg-muted/30 rounded-lg">
<p className="typography-meta text-muted-foreground font-medium mb-2">Actions</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-2">
<div
className="flex items-center gap-2 cursor-pointer"
role="button"
tabIndex={0}
aria-pressed={createInWorktree}
onClick={() => setCreateInWorktree((v) => !v)}
onKeyDown={(e) => {
if (e.key === ' ' || e.key === 'Enter') {
e.preventDefault();
setCreateInWorktree((v) => !v);
}
}}
>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setCreateInWorktree((v) => !v);
}}
aria-label="Toggle worktree"
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
>
{createInWorktree ? (
<RiCheckboxLine className="h-4 w-4 text-primary" />
) : (
<RiCheckboxBlankLine className="h-4 w-4" />
)}
</button>
<span className="typography-meta text-muted-foreground">Create in worktree</span>
<span className="typography-meta text-muted-foreground/70 hidden sm:inline">(issue-&lt;number&gt;-&lt;slug&gt;)</span>
</div>
<div className="hidden sm:block sm:flex-1" />
<div className="flex items-center gap-2">
{repoUrl ? (
<Button variant="outline" size="sm" asChild>
<a href={repoUrl} target="_blank" rel="noopener noreferrer">
<RiExternalLinkLine className="size-4" />
Open Repo
</a>
</Button>
) : null}
<Button variant="outline" size="sm" onClick={refresh} disabled={isLoading || Boolean(startingIssueNumber)}>
Refresh
</Button>
</div>
</div>
</div>
)}
</>
);
if (isMobile) {
return (
<MobileOverlayPanel
open={open}
title={title}
onClose={() => onOpenChange(false)}
renderHeader={(closeButton) => (
<div className="flex flex-col gap-1.5 px-3 py-2 border-b border-border/40">
<div className="flex items-center justify-between">
<h2 className="typography-ui-label font-semibold text-foreground">{title}</h2>
{closeButton}
</div>
<p className="typography-small text-muted-foreground">{description}</p>
</div>
)}
>
{content}
</MobileOverlayPanel>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiGithubLine className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,489 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { toast } from '@/components/ui';
import {
RiGithubLine,
RiLoader4Line,
RiSearchLine,
RiExternalLinkLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import type { GitHubPullRequestContextResult, GitHubPullRequestSummary, GitHubPullRequestsListResult } from '@/lib/api/types';
const parsePrNumber = (value: string): number | null => {
const trimmed = value.trim();
if (!trimmed) return null;
const urlMatch = trimmed.match(/\/pull\/(\d+)(?:\b|\/|$)/i);
if (urlMatch) {
const parsed = Number(urlMatch[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
const hashMatch = trimmed.match(/^#?(\d+)$/);
if (hashMatch) {
const parsed = Number(hashMatch[1]);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
return null;
};
const buildPullRequestContextText = (payload: GitHubPullRequestContextResult) => {
return `GitHub pull request context (JSON)\n${JSON.stringify(payload, null, 2)}`;
};
const PR_REVIEW_INSTRUCTIONS = `Before reporting issues:
- First identify the PR intent (what it's trying to achieve) from title/body/diff, then evaluate whether the implementation matches that intent; call out missing pieces, incorrect behavior vs intent, and scope creep.
- Gather any needed repository context (code, config, docs) to validate assumptions.
- No speculation: if something is unclear or cannot be verified, say what's missing and ask for it instead of guessing.
Output rules:
- Start with a 1-2 sentence summary.
- Provide a single concise PR review comment.
- No emojis. No code snippets. No fenced blocks.
- Short inline code identifiers allowed, but no snippets or fenced blocks.
- Reference evidence with file paths and line ranges (e.g., path/to/file.ts:120-138). If exact lines aren't available, cite the file and say "approx" + why.
- Keep the entire comment under ~300 words.
Report:
- Must-fix issues (blocking)-brief why and a one-line action each.
- Nice-to-have improvements (optional)-brief why and a one-line action each.
Quality & safety (general):
- Call out correctness risks, edge cases, performance regressions, security/privacy concerns, and backwards-compatibility risks.
- Call out missing tests/verification steps and suggest the minimal validation needed.
- Note readability/maintainability issues when they materially affect future changes.
Applicability (only if relevant):
- If changes affect multiple components/targets/environments (e.g., client/server, OSs, deployments), state what is affected vs not, and why.
Architecture:
- Call out breakages, missing implementations across modules/targets, boundary violations, and cross-cutting concerns (errors, logging/observability, accessibility).
Precedence:
- If local precedent conflicts with best practices, state it and suggest a follow-up task.
Do not implement changes until I confirm; end with a short "Next actions" sentence describing the recommended plan.
Format exactly:
Must-fix:
- <issue> - <brief why> - <file:line-range> - Action: <one-line action>
Nice-to-have:
- <issue> - <brief why> - <file:line-range> - Action: <one-line action>
If no issues, write:
Must-fix:
- None
Nice-to-have:
- None`;
export function GitHubPrPickerDialog({
open,
onOpenChange,
onSelect,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect?: (pr: {
number: number;
title: string;
url: string;
head: string;
base: string;
includeDiff: boolean;
instructionsText: string;
contextText: string;
author?: { login: string; avatarUrl?: string };
}) => void;
}) {
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSettingsPage = useUIStore((state) => state.setSettingsPage);
const isMobile = useUIStore((state) => state.isMobile);
const activeProject = useProjectsStore((state) => state.getActiveProject());
const projectDirectory = activeProject?.path ?? null;
const [query, setQuery] = React.useState('');
const [includeDiff, setIncludeDiff] = React.useState(false);
const [result, setResult] = React.useState<GitHubPullRequestsListResult | null>(null);
const [prs, setPrs] = React.useState<GitHubPullRequestSummary[]>([]);
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(false);
const [loadingPrNumber, setLoadingPrNumber] = React.useState<number | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
if (!projectDirectory) {
setResult(null);
setError('No active project');
return;
}
if (githubAuthChecked && githubAuthStatus?.connected === false) {
setResult({ connected: false });
setPrs([]);
setHasMore(false);
setPage(1);
setError(null);
return;
}
if (!github?.prsList) {
setResult(null);
setError('GitHub runtime API unavailable');
return;
}
setIsLoading(true);
setError(null);
try {
const next = await github.prsList(projectDirectory, { page: 1 });
setResult(next);
setPrs(next.prs ?? []);
setPage(next.page ?? 1);
setHasMore(Boolean(next.hasMore));
if (next.connected === false) {
setError(null);
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsLoading(false);
}
}, [github, githubAuthChecked, githubAuthStatus, projectDirectory]);
const loadMore = React.useCallback(async () => {
if (!projectDirectory) return;
if (!github?.prsList) return;
if (isLoadingMore || isLoading) return;
if (!hasMore) return;
setIsLoadingMore(true);
try {
const nextPage = page + 1;
const next = await github.prsList(projectDirectory, { page: nextPage });
setResult(next);
setPrs((prev) => [...prev, ...(next.prs ?? [])]);
setPage(next.page ?? nextPage);
setHasMore(Boolean(next.hasMore));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load more pull requests', { description: message });
} finally {
setIsLoadingMore(false);
}
}, [github, hasMore, isLoading, isLoadingMore, page, projectDirectory]);
React.useEffect(() => {
if (!open) {
setQuery('');
setIncludeDiff(false);
setLoadingPrNumber(null);
setError(null);
setResult(null);
setPrs([]);
setPage(1);
setHasMore(false);
setIsLoading(false);
return;
}
void refresh();
}, [open, refresh]);
React.useEffect(() => {
if (!open) return;
if (githubAuthChecked && githubAuthStatus?.connected === false) {
setResult({ connected: false });
setPrs([]);
setHasMore(false);
setPage(1);
setError(null);
}
}, [githubAuthChecked, githubAuthStatus, open]);
const connected = githubAuthChecked ? result?.connected !== false : true;
const openGitHubSettings = React.useCallback(() => {
setSettingsPage('github');
setSettingsDialogOpen(true);
}, [setSettingsDialogOpen, setSettingsPage]);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return prs;
return prs.filter((pr) => {
if (String(pr.number) === q.replace(/^#/, '')) return true;
return pr.title.toLowerCase().includes(q);
});
}, [prs, query]);
const directNumber = React.useMemo(() => parsePrNumber(query), [query]);
const attachPr = React.useCallback(async (prNumber: number) => {
if (!projectDirectory) {
toast.error('No active project');
return;
}
if (!github?.prContext) {
toast.error('GitHub runtime API unavailable');
return;
}
if (loadingPrNumber) return;
setLoadingPrNumber(prNumber);
try {
const context = await github.prContext(projectDirectory, prNumber, {
includeDiff,
includeCheckDetails: false,
});
if (context.connected === false) {
toast.error('GitHub not connected');
return;
}
if (!context.pr) {
toast.error('Pull request not found');
return;
}
if (!context.repo) {
toast.error('Repo not resolvable', {
description: 'origin remote must be a GitHub URL',
});
return;
}
if (onSelect) {
onSelect({
number: context.pr.number,
title: context.pr.title,
url: context.pr.url,
head: context.pr.head,
base: context.pr.base,
includeDiff,
instructionsText: PR_REVIEW_INSTRUCTIONS,
contextText: buildPullRequestContextText(context),
author: context.pr.author
? {
login: context.pr.author.login,
avatarUrl: context.pr.author.avatarUrl,
}
: undefined,
});
}
onOpenChange(false);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
toast.error('Failed to load pull request details', { description: message });
} finally {
setLoadingPrNumber(null);
}
}, [github, includeDiff, loadingPrNumber, onOpenChange, onSelect, projectDirectory]);
const title = 'Link GitHub Pull Request';
const description = 'Select a pull request to attach review context to this message.';
const content = (
<>
<div className="mt-2 flex items-center gap-3">
<div className="relative flex-1 min-w-0">
<RiSearchLine className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search by title or #123, or paste pull request URL"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9 w-full"
/>
</div>
<button
type="button"
onClick={() => setIncludeDiff((prev) => !prev)}
className="h-9 shrink-0 flex items-center gap-1 text-left"
aria-pressed={includeDiff}
aria-label="Include PR diff in attached context"
>
<Checkbox
checked={includeDiff}
onChange={(checked) => setIncludeDiff(checked)}
ariaLabel="Include PR diff in attached context"
className="size-6"
iconClassName="size-5"
/>
<span className="typography-small text-muted-foreground whitespace-nowrap">Include PR diff</span>
</button>
</div>
<div className={cn(isMobile ? 'min-h-0' : 'flex-1 overflow-y-auto')}>
{!projectDirectory ? (
<div className="text-center text-muted-foreground py-8">No active project selected.</div>
) : null}
{!github ? (
<div className="text-center text-muted-foreground py-8">GitHub runtime API unavailable.</div>
) : null}
{isLoading ? (
<div className="text-center text-muted-foreground py-8 flex items-center justify-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading pull requests...
</div>
) : null}
{connected === false ? (
<div className="text-center text-muted-foreground py-8 space-y-3">
<div>GitHub not connected. Connect your GitHub account in settings.</div>
<div className="flex justify-center">
<Button variant="outline" size="sm" onClick={openGitHubSettings}>
Open settings
</Button>
</div>
</div>
) : null}
{error ? (
<div className="text-center text-muted-foreground py-8 break-words">{error}</div>
) : null}
{directNumber && projectDirectory && github && connected ? (
<div
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
loadingPrNumber === directNumber && 'bg-interactive-selection/30'
)}
onClick={() => void attachPr(directNumber)}
>
<span className="typography-meta text-muted-foreground w-5 text-right flex-shrink-0">#</span>
<p className="flex-1 min-w-0 typography-small text-foreground truncate ml-0.5">
Use pull request #{directNumber}
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{loadingPrNumber === directNumber ? (
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
) : null}
</div>
</div>
) : null}
{filtered.length === 0 && !isLoading && connected && github && projectDirectory ? (
<div className="text-center text-muted-foreground py-8">{query ? 'No pull requests found' : 'No open pull requests found'}</div>
) : null}
{filtered.map((pr) => (
<div
key={pr.number}
className={cn(
'group flex items-center gap-2 py-1.5 hover:bg-interactive-hover/30 rounded transition-colors cursor-pointer',
loadingPrNumber === pr.number && 'bg-interactive-selection/30'
)}
onClick={() => void attachPr(pr.number)}
>
<div className="flex-1 min-w-0 ml-0.5">
<p className="typography-small text-foreground truncate">
<span className="text-muted-foreground mr-1">#{pr.number}</span>
{pr.title}
</p>
<p className="typography-meta text-muted-foreground truncate">{pr.head} {pr.base}</p>
</div>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
{loadingPrNumber === pr.number ? (
<RiLoader4Line className="h-4 w-4 animate-spin text-muted-foreground" />
) : (
<a
href={pr.url}
target="_blank"
rel="noopener noreferrer"
className="hidden group-hover:flex h-5 w-5 items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
onClick={(e) => e.stopPropagation()}
aria-label="Open in GitHub"
>
<RiExternalLinkLine className="h-4 w-4" />
</a>
)}
</div>
</div>
))}
{hasMore && connected && projectDirectory && github ? (
<div className="py-2 flex justify-center">
<button
type="button"
onClick={() => void loadMore()}
disabled={isLoadingMore || Boolean(loadingPrNumber)}
className={cn(
'typography-meta text-muted-foreground hover:text-foreground transition-colors underline underline-offset-4',
(isLoadingMore || Boolean(loadingPrNumber)) && 'opacity-50 cursor-not-allowed hover:text-muted-foreground'
)}
>
{isLoadingMore ? (
<span className="inline-flex items-center gap-2">
<RiLoader4Line className="h-4 w-4 animate-spin" />
Loading...
</span>
) : (
'Load more'
)}
</button>
</div>
) : null}
</div>
</>
);
if (isMobile) {
return (
<MobileOverlayPanel
open={open}
title={title}
onClose={() => onOpenChange(false)}
renderHeader={(closeButton) => (
<div className="flex flex-col gap-1.5 px-3 py-2 border-b border-border/40">
<div className="flex items-center justify-between">
<h2 className="typography-ui-label font-semibold text-foreground">{title}</h2>
{closeButton}
</div>
<p className="typography-small text-muted-foreground">{description}</p>
</div>
)}
>
{content}
</MobileOverlayPanel>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[70vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<RiGithubLine className="h-5 w-5" />
{title}
</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
{content}
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,360 @@
import React from 'react';
import { RiAddLine, RiDeleteBinLine, RiSendPlaneLine } from '@remixicon/react';
import { toast } from '@/components/ui';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
getProjectNotesAndTodos,
OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH,
OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH,
saveProjectNotesAndTodos,
type OpenChamberProjectTodoItem,
type ProjectRef,
} from '@/lib/openchamberConfig';
import { useUIStore } from '@/stores/useUIStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { createWorktreeOnly } from '@/lib/worktreeSessionCreator';
import { cn } from '@/lib/utils';
interface ProjectNotesTodoPanelProps {
projectRef: ProjectRef | null;
canCreateWorktree?: boolean;
onActionComplete?: () => void;
className?: string;
}
const createTodoId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `todo_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};
export const ProjectNotesTodoPanel: React.FC<ProjectNotesTodoPanelProps> = ({
projectRef,
canCreateWorktree = false,
onActionComplete,
className,
}) => {
const [isLoading, setIsLoading] = React.useState(false);
const [notes, setNotes] = React.useState('');
const [todos, setTodos] = React.useState<OpenChamberProjectTodoItem[]>([]);
const [newTodoText, setNewTodoText] = React.useState('');
const [sendingTodoId, setSendingTodoId] = React.useState<string | null>(null);
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const openNewSessionDraft = useSessionStore((state) => state.openNewSessionDraft);
const setPendingInputText = useSessionStore((state) => state.setPendingInputText);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
const setSessionSwitcherOpen = useUIStore((state) => state.setSessionSwitcherOpen);
const persistProjectData = React.useCallback(
async (nextNotes: string, nextTodos: OpenChamberProjectTodoItem[]) => {
if (!projectRef) {
return false;
}
const saved = await saveProjectNotesAndTodos(projectRef, {
notes: nextNotes,
todos: nextTodos,
});
if (!saved) {
toast.error('Failed to save project notes');
}
return saved;
},
[projectRef]
);
React.useEffect(() => {
if (!projectRef) {
setNotes('');
setTodos([]);
setNewTodoText('');
return;
}
let cancelled = false;
setIsLoading(true);
(async () => {
try {
const data = await getProjectNotesAndTodos(projectRef);
if (cancelled) {
return;
}
setNotes(data.notes);
setTodos(data.todos);
setNewTodoText('');
} catch {
if (!cancelled) {
toast.error('Failed to load project notes');
setNotes('');
setTodos([]);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [projectRef]);
const handleNotesBlur = React.useCallback(() => {
void persistProjectData(notes, todos);
}, [notes, persistProjectData, todos]);
const handleAddTodo = React.useCallback(() => {
const trimmed = newTodoText.trim();
if (!trimmed) {
return;
}
const nextTodos = [
...todos,
{
id: createTodoId(),
text: trimmed.slice(0, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH),
completed: false,
createdAt: Date.now(),
},
];
setTodos(nextTodos);
setNewTodoText('');
void persistProjectData(notes, nextTodos);
}, [newTodoText, notes, persistProjectData, todos]);
const handleToggleTodo = React.useCallback(
(id: string, completed: boolean) => {
const nextTodos = todos.map((todo) => (todo.id === id ? { ...todo, completed } : todo));
setTodos(nextTodos);
void persistProjectData(notes, nextTodos);
},
[notes, persistProjectData, todos]
);
const handleDeleteTodo = React.useCallback(
(id: string) => {
const nextTodos = todos.filter((todo) => todo.id !== id);
setTodos(nextTodos);
void persistProjectData(notes, nextTodos);
},
[notes, persistProjectData, todos]
);
const handleClearCompletedTodos = React.useCallback(() => {
const nextTodos = todos.filter((todo) => !todo.completed);
if (nextTodos.length === todos.length) {
return;
}
setTodos(nextTodos);
void persistProjectData(notes, nextTodos);
}, [notes, persistProjectData, todos]);
const todoInputValue = newTodoText.slice(0, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH);
const completedTodoCount = todos.reduce((count, todo) => count + (todo.completed ? 1 : 0), 0);
const routeToChat = React.useCallback(() => {
setActiveMainTab('chat');
setSessionSwitcherOpen(false);
}, [setActiveMainTab, setSessionSwitcherOpen]);
const handleSendToNewSession = React.useCallback(
(todoText: string) => {
if (!projectRef) {
return;
}
routeToChat();
openNewSessionDraft({
directoryOverride: projectRef.path,
initialPrompt: todoText,
});
toast.success('Todo sent to new session');
onActionComplete?.();
},
[onActionComplete, openNewSessionDraft, projectRef, routeToChat]
);
const handleSendToCurrentSession = React.useCallback(
(todoText: string) => {
if (!currentSessionId) {
toast.error('No active session selected');
return;
}
routeToChat();
setPendingInputText(todoText, 'append');
toast.success('Todo sent to current session');
onActionComplete?.();
},
[currentSessionId, onActionComplete, routeToChat, setPendingInputText]
);
const handleSendToNewWorktreeSession = React.useCallback(
async (todoId: string, todoText: string) => {
if (!projectRef) {
return;
}
if (!canCreateWorktree) {
toast.error('Worktree actions are only available for Git repositories');
return;
}
setSendingTodoId(todoId);
try {
const newWorktreePath = await createWorktreeOnly();
if (!newWorktreePath) {
return;
}
routeToChat();
openNewSessionDraft({
directoryOverride: newWorktreePath,
initialPrompt: todoText,
});
toast.success('Todo sent to new worktree session');
onActionComplete?.();
} finally {
setSendingTodoId(null);
}
},
[canCreateWorktree, onActionComplete, openNewSessionDraft, projectRef, routeToChat]
);
if (!projectRef) {
return (
<div className={cn('w-full min-w-0 p-3', className)}>
<p className="typography-meta text-muted-foreground">Select a project to add notes and todos.</p>
</div>
);
}
return (
<div className={cn('w-full min-w-0 space-y-3 p-3', className)}>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<h3 className="typography-ui-label font-semibold text-foreground">Quick notes</h3>
<span className="typography-meta text-muted-foreground">{notes.length}/{OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH}</span>
</div>
<Textarea
value={notes}
onChange={(event) => setNotes(event.target.value.slice(0, OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH))}
onBlur={handleNotesBlur}
placeholder="Capture context, reminders, or links"
className="min-h-24 resize-none"
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="typography-ui-label font-semibold text-foreground">Todo</h3>
<div className="flex items-center gap-2">
<span className="typography-meta text-muted-foreground">{todos.length} item{todos.length === 1 ? '' : 's'}</span>
<button
type="button"
onClick={handleClearCompletedTodos}
disabled={isLoading || completedTodoCount === 0}
className="typography-meta rounded-md px-1.5 py-0.5 text-muted-foreground hover:bg-interactive-hover/50 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
>
Clear completed
</button>
</div>
</div>
<div className="flex items-center gap-1.5">
<Input
value={todoInputValue}
onChange={(event) => setNewTodoText(event.target.value.slice(0, OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH))}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleAddTodo();
}
}}
placeholder="Add a todo"
disabled={isLoading}
className="h-8"
/>
<button
type="button"
onClick={handleAddTodo}
disabled={isLoading || todoInputValue.trim().length === 0}
className="inline-flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md border border-border/70 text-muted-foreground hover:text-foreground hover:bg-interactive-hover/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Add todo"
>
<RiAddLine className="h-4 w-4" />
</button>
</div>
<div className="max-h-56 overflow-y-auto rounded-lg border border-border/60 bg-background/40">
{todos.length === 0 ? (
<p className="px-3 py-3 typography-meta text-muted-foreground">No todos yet. Add a small checklist for this project.</p>
) : (
<ul className="divide-y divide-border/50">
{todos.map((todo) => (
<li key={todo.id} className="flex items-center gap-1.5 px-2.5 py-1.5">
<Checkbox
checked={todo.completed}
onChange={(checked) => handleToggleTodo(todo.id, checked)}
ariaLabel={`Mark "${todo.text}" complete`}
/>
<span
className={cn(
'min-w-0 flex-1 typography-ui-label text-foreground',
todo.completed && 'text-muted-foreground line-through'
)}
title={todo.text}
>
{todo.text}
</span>
<button
type="button"
onClick={() => handleDeleteTodo(todo.id)}
className="inline-flex h-7 w-7 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={`Delete "${todo.text}"`}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
disabled={sendingTodoId === todo.id}
className="inline-flex h-7 w-7 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 disabled:cursor-not-allowed disabled:opacity-50"
aria-label={`Send "${todo.text}"`}
>
<RiSendPlaneLine className="h-3.5 w-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onClick={() => handleSendToCurrentSession(todo.text)}>
Send to current session
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleSendToNewSession(todo.text)}>
Send to new session
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => void handleSendToNewWorktreeSession(todo.id, todo.text)}
disabled={!canCreateWorktree}
>
Send to new worktree session
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,789 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { RiCheckboxBlankLine, RiCheckboxLine, RiDeleteBinLine, RiGitBranchLine } from '@remixicon/react';
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
import { DirectoryExplorerDialog } from './DirectoryExplorerDialog';
import { cn, formatPathForDisplay } from '@/lib/utils';
import type { Session } from '@opencode-ai/sdk/v2';
import type { WorktreeMetadata } from '@/types/worktree';
import { getWorktreeStatus } from '@/lib/worktrees/worktreeStatus';
import { removeProjectWorktree } from '@/lib/worktrees/worktreeManager';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useUIStore } from '@/stores/useUIStore';
import { useFileSystemAccess } from '@/hooks/useFileSystemAccess';
import { isDesktopLocalOriginActive, isTauriShell } from '@/lib/desktop';
import { useDeviceInfo } from '@/lib/device';
import { sessionEvents } from '@/lib/sessionEvents';
const renderToastDescription = (text?: string) =>
text ? <span className="text-foreground/80 dark:text-foreground/70">{text}</span> : undefined;
const normalizeProjectDirectory = (path: string | null | undefined): string => {
if (!path) {
return '';
}
const replaced = path.replace(/\\/g, '/');
if (replaced === '/') {
return '/';
}
return replaced.replace(/\/+$/, '');
};
type DeleteDialogState = {
sessions: Session[];
dateLabel?: string;
mode: 'session' | 'worktree';
worktree?: WorktreeMetadata | null;
};
export const SessionDialogs: React.FC = () => {
const [isDirectoryDialogOpen, setIsDirectoryDialogOpen] = React.useState(false);
const [hasShownInitialDirectoryPrompt, setHasShownInitialDirectoryPrompt] = React.useState(false);
const [deleteDialog, setDeleteDialog] = React.useState<DeleteDialogState | null>(null);
const [deleteDialogSummaries, setDeleteDialogSummaries] = React.useState<Array<{ session: Session; metadata: WorktreeMetadata }>>([]);
const [deleteDialogShouldRemoveRemote, setDeleteDialogShouldRemoveRemote] = React.useState(false);
const [deleteDialogShouldDeleteLocalBranch, setDeleteDialogShouldDeleteLocalBranch] = React.useState(false);
const [isProcessingDelete, setIsProcessingDelete] = React.useState(false);
const [hasCompletedDirtyCheck, setHasCompletedDirtyCheck] = React.useState(false);
const [dirtyWorktreePaths, setDirtyWorktreePaths] = React.useState<Set<string>>(new Set());
const {
deleteSession,
deleteSessions,
archiveSession,
archiveSessions,
loadSessions,
getWorktreeMetadata,
} = useSessionStore();
const showDeletionDialog = useUIStore((state) => state.showDeletionDialog);
const setShowDeletionDialog = useUIStore((state) => state.setShowDeletionDialog);
const { currentDirectory, homeDirectory, isHomeReady } = useDirectoryStore();
const { projects, addProject, activeProjectId } = useProjectsStore();
const { requestAccess, startAccessing } = useFileSystemAccess();
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
const useMobileOverlay = isMobile || isTablet || hasTouchInput;
const projectDirectory = React.useMemo(() => {
const targetProject = activeProjectId
? projects.find((project) => project.id === activeProjectId) ?? null
: null;
const targetPath = targetProject?.path ?? currentDirectory;
return normalizeProjectDirectory(targetPath);
}, [activeProjectId, currentDirectory, projects]);
const getProjectRefForWorktree = React.useCallback((worktree: WorktreeMetadata) => {
const normalized = normalizeProjectDirectory(worktree.projectDirectory);
const fallbackPath = normalized || projectDirectory;
const match = projects.find((project) => normalizeProjectDirectory(project.path) === fallbackPath) ?? null;
return { id: match?.id ?? `path:${fallbackPath}`, path: fallbackPath };
}, [projectDirectory, projects]);
const hasDirtyWorktrees = hasCompletedDirtyCheck && dirtyWorktreePaths.size > 0;
const canRemoveRemoteBranches = React.useMemo(
() => {
const targetWorktree = deleteDialog?.worktree;
if (targetWorktree && typeof targetWorktree.branch === 'string' && targetWorktree.branch.trim().length > 0) {
return true;
}
return (
deleteDialogSummaries.length > 0 &&
deleteDialogSummaries.every(({ metadata }) => typeof metadata.branch === 'string' && metadata.branch.trim().length > 0)
);
},
[deleteDialog?.worktree, deleteDialogSummaries],
);
const isWorktreeDelete = deleteDialog?.mode === 'worktree';
const shouldArchiveWorktree = isWorktreeDelete;
const removeRemoteOptionDisabled =
isProcessingDelete || !isWorktreeDelete || !canRemoveRemoteBranches;
const deleteLocalOptionDisabled = isProcessingDelete || !isWorktreeDelete;
React.useEffect(() => {
loadSessions();
}, [loadSessions, currentDirectory]);
const projectsKey = React.useMemo(
() => projects.map((project) => `${project.id}:${project.path}`).join('|'),
[projects],
);
const lastProjectsKeyRef = React.useRef(projectsKey);
React.useEffect(() => {
if (projectsKey === lastProjectsKeyRef.current) {
return;
}
lastProjectsKeyRef.current = projectsKey;
loadSessions();
}, [loadSessions, projectsKey]);
React.useEffect(() => {
if (hasShownInitialDirectoryPrompt || !isHomeReady || projects.length > 0) {
return;
}
setHasShownInitialDirectoryPrompt(true);
if (isTauriShell() && isDesktopLocalOriginActive()) {
requestAccess('')
.then(async (result) => {
if (!result.success || !result.path) {
if (result.error && result.error !== 'Directory selection cancelled') {
toast.error('Failed to select directory', {
description: result.error,
});
}
return;
}
const accessResult = await startAccessing(result.path);
if (!accessResult.success) {
toast.error('Failed to open directory', {
description: accessResult.error || 'Desktop could not grant file access.',
});
return;
}
const added = addProject(result.path, { id: result.projectId });
if (!added) {
toast.error('Failed to add project', {
description: 'Please select a valid directory path.',
});
}
})
.catch((error) => {
console.error('Desktop: Error selecting directory:', error);
toast.error('Failed to select directory');
});
return;
}
setIsDirectoryDialogOpen(true);
}, [
addProject,
hasShownInitialDirectoryPrompt,
isHomeReady,
projects.length,
requestAccess,
startAccessing,
]);
const openDeleteDialog = React.useCallback((payload: { sessions: Session[]; dateLabel?: string; mode?: 'session' | 'worktree'; worktree?: WorktreeMetadata | null }) => {
setDeleteDialog({
sessions: payload.sessions,
dateLabel: payload.dateLabel,
mode: payload.mode ?? 'session',
worktree: payload.worktree ?? null,
});
}, []);
const closeDeleteDialog = React.useCallback(() => {
setDeleteDialog(null);
setDeleteDialogSummaries([]);
setDeleteDialogShouldRemoveRemote(false);
setDeleteDialogShouldDeleteLocalBranch(false);
setIsProcessingDelete(false);
setHasCompletedDirtyCheck(false);
setDirtyWorktreePaths(new Set());
}, []);
const deleteSessionsWithoutDialog = React.useCallback(async (payload: { sessions: Session[]; dateLabel?: string }) => {
if (payload.sessions.length === 0) {
return;
}
if (payload.sessions.length === 1) {
const target = payload.sessions[0];
const success = await deleteSession(target.id);
if (success) {
toast.success('Session deleted');
} else {
toast.error('Failed to delete session');
}
return;
}
const ids = payload.sessions.map((session) => session.id);
const { deletedIds, failedIds } = await deleteSessions(ids);
if (deletedIds.length > 0) {
const successDescription = failedIds.length > 0
? `${failedIds.length} session${failedIds.length === 1 ? '' : 's'} could not be deleted.`
: payload.dateLabel
? `Removed all sessions from ${payload.dateLabel}.`
: undefined;
toast.success(`Deleted ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, {
description: renderToastDescription(successDescription),
});
}
if (failedIds.length > 0) {
toast.error(`Failed to delete ${failedIds.length} session${failedIds.length === 1 ? '' : 's'}`, {
description: renderToastDescription('Please try again in a moment.'),
});
}
}, [deleteSession, deleteSessions]);
React.useEffect(() => {
return sessionEvents.onDeleteRequest((payload) => {
if (!showDeletionDialog && (payload.mode ?? 'session') === 'session') {
void deleteSessionsWithoutDialog(payload);
return;
}
openDeleteDialog(payload);
});
}, [openDeleteDialog, showDeletionDialog, deleteSessionsWithoutDialog]);
React.useEffect(() => {
return sessionEvents.onDirectoryRequest(() => {
setIsDirectoryDialogOpen(true);
});
}, []);
React.useEffect(() => {
if (!deleteDialog) {
setDeleteDialogSummaries([]);
setDeleteDialogShouldRemoveRemote(false);
setDeleteDialogShouldDeleteLocalBranch(false);
setHasCompletedDirtyCheck(false);
setDirtyWorktreePaths(new Set());
return;
}
const summaries = deleteDialog.sessions
.map((session) => {
const metadata = getWorktreeMetadata(session.id);
return metadata ? { session, metadata } : null;
})
.filter((entry): entry is { session: Session; metadata: WorktreeMetadata } => Boolean(entry));
setDeleteDialogSummaries(summaries);
setDeleteDialogShouldRemoveRemote(false);
setHasCompletedDirtyCheck(false);
setDirtyWorktreePaths(new Set());
const metadataByPath = new Map<string, WorktreeMetadata>();
if (deleteDialog.worktree?.path) {
metadataByPath.set(normalizeProjectDirectory(deleteDialog.worktree.path), deleteDialog.worktree);
}
summaries.forEach(({ metadata }) => {
if (metadata.path) {
metadataByPath.set(normalizeProjectDirectory(metadata.path), metadata);
}
});
if (metadataByPath.size === 0) {
setHasCompletedDirtyCheck(true);
return;
}
let cancelled = false;
(async () => {
const statusByPath = new Map<string, WorktreeMetadata['status']>();
const nextDirtyPaths = new Set<string>();
await Promise.all(
Array.from(metadataByPath.entries()).map(async ([pathKey, metadata]) => {
try {
const status = await getWorktreeStatus(metadata.path);
statusByPath.set(pathKey, status);
if (status?.isDirty) {
nextDirtyPaths.add(pathKey);
}
} catch {
if (metadata.status) {
statusByPath.set(pathKey, metadata.status);
if (metadata.status.isDirty) {
nextDirtyPaths.add(pathKey);
}
}
}
})
).catch((error) => {
console.warn('Failed to inspect worktree status before deletion:', error);
});
if (cancelled) {
return;
}
setDirtyWorktreePaths(nextDirtyPaths);
setHasCompletedDirtyCheck(true);
setDeleteDialog((prev) => {
if (!prev?.worktree?.path) {
return prev;
}
const pathKey = normalizeProjectDirectory(prev.worktree.path);
const nextStatus = statusByPath.get(pathKey);
if (!nextStatus) {
return prev;
}
const prevStatus = prev.worktree.status;
if (
prevStatus?.isDirty === nextStatus.isDirty &&
prevStatus?.ahead === nextStatus.ahead &&
prevStatus?.behind === nextStatus.behind &&
prevStatus?.upstream === nextStatus.upstream
) {
return prev;
}
return {
...prev,
worktree: {
...prev.worktree,
status: nextStatus,
},
};
});
setDeleteDialogSummaries((prev) =>
prev.map((entry) => {
const pathKey = normalizeProjectDirectory(entry.metadata.path);
const nextStatus = statusByPath.get(pathKey);
if (!nextStatus) {
return entry;
}
return {
session: entry.session,
metadata: { ...entry.metadata, status: nextStatus },
};
})
);
})();
return () => {
cancelled = true;
};
}, [deleteDialog, getWorktreeMetadata]);
React.useEffect(() => {
if (!canRemoveRemoteBranches) {
setDeleteDialogShouldRemoveRemote(false);
}
}, [canRemoveRemoteBranches]);
const removeSelectedWorktree = React.useCallback(async (
worktree: WorktreeMetadata,
deleteLocalBranch: boolean
): Promise<boolean> => {
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
try {
await removeProjectWorktree(
getProjectRefForWorktree(worktree),
worktree,
{ deleteRemoteBranch: shouldRemoveRemote, deleteLocalBranch }
);
return true;
} catch (error) {
toast.error('Failed to remove worktree', {
description: renderToastDescription(error instanceof Error ? error.message : 'Please try again.'),
});
return false;
}
}, [canRemoveRemoteBranches, deleteDialogShouldRemoveRemote, getProjectRefForWorktree]);
const handleConfirmDelete = React.useCallback(async () => {
if (!deleteDialog) {
return;
}
setIsProcessingDelete(true);
try {
const shouldArchive = shouldArchiveWorktree;
const removeRemoteBranch = shouldArchive && deleteDialogShouldRemoveRemote;
const deleteLocalBranch = shouldArchive && deleteDialogShouldDeleteLocalBranch;
if (deleteDialog.sessions.length === 0 && isWorktreeDelete && deleteDialog.worktree) {
const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
if (!removed) {
closeDeleteDialog();
return;
}
const shouldRemoveRemote = deleteDialogShouldRemoveRemote && canRemoveRemoteBranches;
const archiveNote = shouldRemoveRemote ? 'Worktree and remote branch removed.' : 'Worktree removed.';
toast.success('Worktree removed', {
description: renderToastDescription(archiveNote),
});
closeDeleteDialog();
loadSessions();
return;
}
if (deleteDialog.sessions.length === 1) {
const target = deleteDialog.sessions[0];
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(isWorktreeDelete ? 'Failed to archive session' : 'Failed to delete session');
setIsProcessingDelete(false);
return;
}
const archiveNote = !isWorktreeDelete && shouldArchive
? removeRemoteBranch
? 'Worktree and remote branch removed.'
: 'Attached worktree archived.'
: undefined;
toast.success(isWorktreeDelete ? 'Session archived' : 'Session deleted', {
description: renderToastDescription(archiveNote),
action: {
label: 'OK',
onClick: () => { },
},
});
} else {
const ids = deleteDialog.sessions.map((session) => session.id);
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.
// Use same projectRef logic as the no-sessions path.
const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
if (removed) {
await loadSessions();
}
}
if (deletedIds.length > 0) {
const archiveNote = !isWorktreeDelete && shouldArchive
? removeRemoteBranch
? 'Archived worktrees and removed remote branches.'
: 'Attached worktrees archived.'
: undefined;
const successDescription =
failedIds.length > 0
? `${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(`${isWorktreeDelete ? 'Archived' : 'Deleted'} ${deletedIds.length} session${deletedIds.length === 1 ? '' : 's'}`, {
description: renderToastDescription(combinedDescription || undefined),
action: {
label: 'OK',
onClick: () => { },
},
});
}
if (failedIds.length > 0) {
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) {
setIsProcessingDelete(false);
return;
}
}
}
if (isWorktreeDelete && deleteDialog.sessions.length === 1 && deleteDialog.worktree) {
const removed = await removeSelectedWorktree(deleteDialog.worktree, deleteLocalBranch);
if (removed) {
await loadSessions();
}
}
closeDeleteDialog();
} finally {
setIsProcessingDelete(false);
}
}, [
deleteDialog,
deleteDialogShouldRemoveRemote,
deleteDialogShouldDeleteLocalBranch,
deleteSession,
deleteSessions,
archiveSession,
archiveSessions,
closeDeleteDialog,
shouldArchiveWorktree,
isWorktreeDelete,
canRemoveRemoteBranches,
removeSelectedWorktree,
loadSessions,
]);
const targetWorktree = deleteDialog?.worktree ?? deleteDialogSummaries[0]?.metadata ?? null;
const deleteDialogDescription = deleteDialog
? deleteDialog.mode === 'worktree'
? deleteDialog.sessions.length === 0
? 'This removes the selected worktree.'
: `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}` : ''
}.`
: '';
const deleteDialogBody = deleteDialog ? (
<div className={cn(isWorktreeDelete ? 'space-y-3' : 'space-y-2')}>
{deleteDialog.sessions.length > 0 && (
<div className={cn(
isWorktreeDelete ? 'rounded-lg bg-muted/30 p-3' : 'space-y-1.5 rounded-xl border border-border/40 bg-sidebar/60 p-3'
)}>
{isWorktreeDelete && (
<div className="flex items-center gap-2">
<span className="typography-meta font-medium text-foreground">
{deleteDialog.sessions.length === 1 ? 'Linked session' : 'Linked sessions'}
</span>
<span className="typography-micro text-muted-foreground/70">
{deleteDialog.sessions.length}
</span>
</div>
)}
<ul className={cn(isWorktreeDelete ? 'mt-2 space-y-1' : 'space-y-0.5')}>
{deleteDialog.sessions.slice(0, 5).map((session) => (
<li
key={session.id}
className={cn(
isWorktreeDelete
? 'flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm text-muted-foreground'
: 'typography-micro text-muted-foreground/80'
)}
>
<span className={cn(!isWorktreeDelete && 'hidden')}>
</span>
<span className="truncate">
{session.title || 'Untitled Session'}
</span>
</li>
))}
{deleteDialog.sessions.length > 5 && (
<li className={cn(
isWorktreeDelete
? 'px-2.5 py-1 text-xs text-muted-foreground/70'
: 'typography-micro text-muted-foreground/70'
)}>
+{deleteDialog.sessions.length - 5} more
</li>
)}
</ul>
</div>
)}
{isWorktreeDelete ? (
<div className="space-y-2 rounded-lg bg-muted/30 p-3">
<div className="flex items-center gap-2">
<RiGitBranchLine className="h-4 w-4 text-muted-foreground" />
<span className="typography-meta font-medium text-foreground">Worktree</span>
{targetWorktree?.label ? (
<span className="typography-micro text-muted-foreground/70">{targetWorktree.label}</span>
) : null}
</div>
<p className="typography-micro text-muted-foreground/80 break-all">
{targetWorktree ? formatPathForDisplay(targetWorktree.path, homeDirectory) : 'Worktree path unavailable.'}
</p>
{hasDirtyWorktrees && (
<p className="typography-micro text-status-warning">Uncommitted changes will be discarded.</p>
)}
</div>
) : (
<div className="rounded-xl border border-border/40 bg-sidebar/60 p-3">
<p className="typography-meta text-muted-foreground/80">
Worktree directories stay intact. Subsessions linked to the selected sessions will also be removed.
</p>
</div>
)}
</div>
) : null;
const deleteRemoteBranchAction = isWorktreeDelete ? (
canRemoveRemoteBranches ? (
<button
type="button"
onClick={() => {
if (removeRemoteOptionDisabled) {
return;
}
setDeleteDialogShouldRemoveRemote((prev) => !prev);
}}
disabled={removeRemoteOptionDisabled}
className={cn(
'flex items-center gap-2 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors',
removeRemoteOptionDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
>
{deleteDialogShouldRemoveRemote ? (
<RiCheckboxLine className="size-4 text-primary" />
) : (
<RiCheckboxBlankLine className="size-4" />
)}
Delete remote branch
</button>
) : (
<span className="text-xs text-muted-foreground/70">Remote branch info unavailable</span>
)
) : null;
const deleteLocalBranchAction = isWorktreeDelete ? (
<button
type="button"
onClick={() => {
if (deleteLocalOptionDisabled) {
return;
}
setDeleteDialogShouldDeleteLocalBranch((prev) => !prev);
}}
disabled={deleteLocalOptionDisabled}
className={cn(
'flex items-center gap-2 rounded-md px-2 py-1 text-sm text-muted-foreground transition-colors',
deleteLocalOptionDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
)}
>
{deleteDialogShouldDeleteLocalBranch ? (
<RiCheckboxLine className="size-4 text-primary" />
) : (
<RiCheckboxBlankLine className="size-4" />
)}
Delete local branch
</button>
) : null;
const deleteDialogActions = isWorktreeDelete ? (
<div className="flex w-full items-center justify-between gap-3">
<div className="flex flex-col items-start gap-1">
{deleteLocalBranchAction}
{deleteRemoteBranchAction}
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isProcessingDelete}>
{isProcessingDelete ? 'Deleting…' : 'Delete worktree'}
</Button>
</div>
</div>
) : (
<div className="flex w-full items-center justify-between gap-3">
<button
type="button"
onClick={() => setShowDeletionDialog(!showDeletionDialog)}
className="inline-flex items-center gap-1.5 typography-meta 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="size-4 text-primary" /> : <RiCheckboxBlankLine className="size-4" />}
Never ask
</button>
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={closeDeleteDialog} disabled={isProcessingDelete}>
Cancel
</Button>
<Button variant="destructive" onClick={handleConfirmDelete} disabled={isProcessingDelete}>
{isProcessingDelete
? 'Deleting…'
: deleteDialog?.sessions.length === 1
? 'Delete session'
: 'Delete sessions'}
</Button>
</div>
</div>
);
const deleteDialogTitle = isWorktreeDelete
? 'Delete worktree'
: deleteDialog?.sessions.length === 1
? 'Delete session'
: 'Delete sessions';
return (
<>
{useMobileOverlay ? (
<MobileOverlayPanel
open={Boolean(deleteDialog)}
onClose={() => {
if (isProcessingDelete) {
return;
}
closeDeleteDialog();
}}
title={deleteDialogTitle}
footer={<div className="flex justify-end gap-2">{deleteDialogActions}</div>}
>
<div className="space-y-2 pb-2">
{deleteDialogDescription && (
<p className="typography-meta text-muted-foreground/80">{deleteDialogDescription}</p>
)}
{deleteDialogBody}
</div>
</MobileOverlayPanel>
) : (
<Dialog
open={Boolean(deleteDialog)}
onOpenChange={(open) => {
if (!open) {
if (isProcessingDelete) {
return;
}
closeDeleteDialog();
}
}}
>
<DialogContent
className={cn(
isWorktreeDelete
? 'max-w-xl max-h-[70vh] flex flex-col overflow-hidden gap-3'
: 'max-w-[min(520px,100vw-2rem)] space-y-2 pb-2'
)}
>
<DialogHeader>
<DialogTitle className={cn(isWorktreeDelete && 'flex items-center gap-2')}>
{isWorktreeDelete && <RiDeleteBinLine className="h-5 w-5" />}
{deleteDialogTitle}
</DialogTitle>
{deleteDialogDescription && <DialogDescription>{deleteDialogDescription}</DialogDescription>}
</DialogHeader>
<div className={cn(isWorktreeDelete && 'flex-1 min-h-0 overflow-y-auto space-y-2')}>
{deleteDialogBody}
</div>
<DialogFooter className="mt-2 gap-2 pt-1 pb-1">{deleteDialogActions}</DialogFooter>
</DialogContent>
</Dialog>
)}
<DirectoryExplorerDialog
open={isDirectoryDialogOpen}
onOpenChange={setIsDirectoryDialogOpen}
/>
</>
);
};

View File

@@ -0,0 +1,325 @@
import React from 'react';
import {
RiFolderLine,
RiFolderOpenLine,
RiArrowRightSLine,
RiArrowDownSLine,
RiPencilAiLine,
RiDeleteBinLine,
RiCheckLine,
RiCloseLine,
RiAddLine,
RiFolderAddLine,
} from '@remixicon/react';
import { cn } from '@/lib/utils';
import type { SessionFolder } from '@/stores/useSessionFoldersStore';
interface SessionFolderItemProps<TSessionNode> {
folder: SessionFolder;
sessions: TSessionNode[];
/** Sub-folders that belong directly to this folder */
subFolderItems?: React.ReactNode;
isCollapsed: boolean;
onToggle: () => void;
onRename: (name: string) => void;
onDelete: () => void;
renderSessionNode: (
node: TSessionNode,
depth?: number,
groupDir?: string | null,
projectId?: string | null,
archivedBucket?: boolean,
) => React.ReactNode;
groupDirectory?: string | null;
projectId?: string | null;
mobileVariant?: boolean;
isRenaming?: boolean;
renameDraft?: string;
onRenameDraftChange?: (value: string) => void;
onRenameSave?: () => void;
onRenameCancel?: () => void;
/** Ref callback from useDroppable attach to folder header to make it a drop zone */
droppableRef?: (node: HTMLElement | null) => void;
/** Whether a draggable session is currently hovering over this folder */
isDropTarget?: boolean;
/** Create a new session scoped to this folder */
onNewSession?: () => void;
/** Create a new sub-folder inside this folder */
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,>({
folder,
sessions,
subFolderItems,
isCollapsed,
onToggle,
onRename,
onDelete,
renderSessionNode,
groupDirectory,
projectId,
mobileVariant = false,
isRenaming = false,
renameDraft = '',
onRenameDraftChange,
onRenameSave,
onRenameCancel,
droppableRef,
isDropTarget = false,
onNewSession,
onNewSubFolder,
depth = 0,
hideActions = false,
archivedBucket = false,
}: SessionFolderItemProps<TSessionNode>) => {
const [localRenaming, setLocalRenaming] = React.useState(false);
const [localDraft, setLocalDraft] = React.useState('');
const inputRef = React.useRef<HTMLInputElement | null>(null);
const renaming = isRenaming || localRenaming;
const draft = isRenaming ? renameDraft : localDraft;
const handleStartRename = React.useCallback(() => {
setLocalDraft(folder.name);
setLocalRenaming(true);
}, [folder.name]);
const handleSaveRename = React.useCallback(() => {
const trimmed = draft.trim();
if (trimmed && trimmed !== folder.name) {
onRename(trimmed);
}
if (isRenaming && onRenameSave) {
onRenameSave();
}
setLocalRenaming(false);
setLocalDraft('');
}, [draft, folder.name, isRenaming, onRename, onRenameSave]);
const handleCancelRename = React.useCallback(() => {
if (isRenaming && onRenameCancel) {
onRenameCancel();
}
setLocalRenaming(false);
setLocalDraft('');
}, [isRenaming, onRenameCancel]);
const handleDraftChange = React.useCallback(
(value: string) => {
if (isRenaming && onRenameDraftChange) {
onRenameDraftChange(value);
} else {
setLocalDraft(value);
}
},
[isRenaming, onRenameDraftChange],
);
// Auto-focus rename when externally triggered
React.useEffect(() => {
if (!isRenaming) return;
const focusInput = () => {
const input = inputRef.current;
if (!input) return;
input.focus();
input.select();
};
const frameId = requestAnimationFrame(focusInput);
const timeoutId = window.setTimeout(focusInput, 0);
return () => {
cancelAnimationFrame(frameId);
window.clearTimeout(timeoutId);
};
}, [isRenaming]);
const FolderIcon = isCollapsed ? RiFolderLine : RiFolderOpenLine;
const isSubFolder = depth > 0;
return (
<div className={cn('oc-folder', isSubFolder && 'ml-3')}>
{/* Folder header also acts as a drop zone when droppableRef is provided */}
<div
ref={droppableRef}
className={cn(
'group/folder flex items-center justify-between gap-1.5 py-1 min-w-0 rounded-sm',
'hover:bg-interactive-hover/50 cursor-pointer',
isDropTarget && 'bg-primary/10 ring-1 ring-inset ring-primary/30',
)}
onClick={renaming ? undefined : onToggle}
role={renaming ? undefined : 'button'}
tabIndex={renaming ? undefined : 0}
onKeyDown={
renaming
? undefined
: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggle();
}
}
}
aria-label={isCollapsed ? `Expand folder ${folder.name}` : `Collapse folder ${folder.name}`}
>
<div className="min-w-0 flex items-center gap-1.5 pl-1.5 flex-1">
<FolderIcon className={cn('h-3.5 w-3.5 flex-shrink-0', isDropTarget ? 'text-primary' : 'text-muted-foreground')} />
{renaming ? (
<form
className="flex min-w-0 flex-1 items-center gap-1"
data-keyboard-avoid="true"
onSubmit={(event) => {
event.preventDefault();
handleSaveRename();
}}
>
<input
ref={inputRef}
value={draft}
onChange={(event) => handleDraftChange(event.target.value)}
className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground"
autoFocus
placeholder="Folder name"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.stopPropagation();
handleCancelRename();
return;
}
if (event.key === ' ' || event.key === 'Enter') {
event.stopPropagation();
}
}}
/>
<button
type="submit"
className="shrink-0 text-muted-foreground hover:text-foreground"
onClick={(event) => event.stopPropagation()}
>
<RiCheckLine className="size-4" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleCancelRename();
}}
className="shrink-0 text-muted-foreground hover:text-foreground"
>
<RiCloseLine className="size-4" />
</button>
</form>
) : (
<div className="min-w-0 flex items-center gap-1.5 flex-1">
<span className={cn('typography-ui-label font-semibold truncate', isDropTarget ? 'text-primary' : 'text-muted-foreground')}>
{folder.name}
</span>
<span className="typography-micro text-muted-foreground/70 flex-shrink-0">
{sessions.length}
</span>
{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>
)}
</div>
{/* Action buttons */}
{!renaming && !hideActions ? (
<div className="flex items-center gap-0.5 px-0.5">
<div
className={cn(
'flex items-center gap-0.5 transition-opacity',
mobileVariant ? 'opacity-100' : 'opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100',
)}
>
{onNewSession ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onNewSession();
}}
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 ${folder.name}`}
title="New session"
>
<RiAddLine className="h-3.5 w-3.5" />
</button>
) : null}
{/* Only allow sub-folders at depth 0 (one level deep max) */}
{onNewSubFolder && depth === 0 ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onNewSubFolder();
}}
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 sub-folder in ${folder.name}`}
title="New sub-folder"
>
<RiFolderAddLine className="h-3.5 w-3.5" />
</button>
) : null}
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleStartRename();
}}
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={`Rename folder ${folder.name}`}
>
<RiPencilAiLine className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
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 folder ${folder.name}`}
>
<RiDeleteBinLine className="h-3.5 w-3.5" />
</button>
</div>
</div>
) : null}
</div>
{/* Folder body */}
{!isCollapsed ? (
<div className="pb-1 pl-2">
{/* Sub-folders first */}
{subFolderItems}
{/* Then sessions */}
{sessions.length > 0 ? (
sessions.map((node) =>
renderSessionNode(node, 0, groupDirectory ?? null, projectId ?? null, archivedBucket),
)
) : !subFolderItems ? (
<div className="py-1 pl-1.5 text-left typography-micro text-muted-foreground/70">
Empty folder
</div>
) : null}
</div>
) : null}
</div>
);
};
export const SessionFolderItem = React.memo(SessionFolderItemBase) as <TSessionNode>(
props: SessionFolderItemProps<TSessionNode>,
) => React.ReactElement;

File diff suppressed because it is too large Load Diff

View File

@@ -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>
);
}

View File

@@ -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).

View File

@@ -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.mergeableState === 'blocked' || prIndicator.mergeableState === 'dirty'
? 'Conflicts or blocked'
: (prIndicator.mergeableState === 'clean' || prIndicator.canMerge === true ? 'Mergeable' : 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
]);
};

View File

@@ -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]);
};

View File

@@ -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 };
};

View File

@@ -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]);
};

View File

@@ -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,
};
};

View File

@@ -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 };
};

View File

@@ -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,
};
};

View File

@@ -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,
]);
};

View File

@@ -0,0 +1,237 @@
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) ?? [],
}];
// Calculate activity info for each worktree to determine sorting priority
const worktreeActivityInfo = new Map<string, { hasActiveSession: boolean; lastUpdatedAt: number }>();
availableWorktrees.forEach((meta) => {
const directory = normalizePath(meta.path) ?? meta.path;
const sessionsInWorktree = groupedNodes.get(directory) ?? [];
const hasActiveSession = sessionsInWorktree.length > 0;
// Calculate the latest update time among all sessions in this worktree
const lastUpdatedAt = sessionsInWorktree.reduce((max, node) => {
const updatedAt = Number(node.session.time?.updated ?? node.session.time?.created ?? 0);
if (!Number.isFinite(updatedAt)) {
return max;
}
return Math.max(max, updatedAt);
}, 0);
worktreeActivityInfo.set(directory, { hasActiveSession, lastUpdatedAt });
});
// Sort worktrees: active first (by last updated desc), then inactive (by label asc)
const sortedWorktrees = [...availableWorktrees].sort((a, b) => {
const aDir = normalizePath(a.path) ?? a.path;
const bDir = normalizePath(b.path) ?? b.path;
const aInfo = worktreeActivityInfo.get(aDir) ?? { hasActiveSession: false, lastUpdatedAt: 0 };
const bInfo = worktreeActivityInfo.get(bDir) ?? { hasActiveSession: false, lastUpdatedAt: 0 };
// First priority: active status (active first)
if (aInfo.hasActiveSession !== bInfo.hasActiveSession) {
return aInfo.hasActiveSession ? -1 : 1;
}
// Second priority: for active worktrees, sort by last updated (desc)
if (aInfo.hasActiveSession && bInfo.hasActiveSession) {
return bInfo.lastUpdatedAt - aInfo.lastUpdatedAt;
}
// Third priority: for inactive worktrees, sort by label (asc)
const aLabel = (a.label || a.branch || a.name || a.path || '').toLowerCase();
const bLabel = (b.label || b.branch || b.name || b.path || '').toLowerCase();
return aLabel.localeCompare(bLabel);
});
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,
};
};

View File

@@ -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 = [];
};
}, []);
};

View File

@@ -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]);
};

View File

@@ -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,
};
};

View File

@@ -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 };
};

View File

@@ -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;
};

View File

@@ -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>
);
};

View File

@@ -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);

View File

@@ -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;
};

View File

@@ -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;
};