Initial commit: restructure to flat layout with ui/ and web/ at root
This commit is contained in:
582
ui/src/components/session/BranchPickerDialog.tsx
Normal file
582
ui/src/components/session/BranchPickerDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
314
ui/src/components/session/DirectoryAutocomplete.tsx
Normal file
314
ui/src/components/session/DirectoryAutocomplete.tsx
Normal 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';
|
||||
362
ui/src/components/session/DirectoryExplorerDialog.tsx
Normal file
362
ui/src/components/session/DirectoryExplorerDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
1065
ui/src/components/session/DirectoryTree.tsx
Normal file
1065
ui/src/components/session/DirectoryTree.tsx
Normal file
File diff suppressed because it is too large
Load Diff
618
ui/src/components/session/GitHubIntegrationDialog.tsx
Normal file
618
ui/src/components/session/GitHubIntegrationDialog.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
742
ui/src/components/session/GitHubIssuePickerDialog.tsx
Normal file
742
ui/src/components/session/GitHubIssuePickerDialog.tsx
Normal 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 what’s 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 aren’t 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-<number>-<slug>)</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>
|
||||
);
|
||||
}
|
||||
489
ui/src/components/session/GitHubPrPickerDialog.tsx
Normal file
489
ui/src/components/session/GitHubPrPickerDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1768
ui/src/components/session/NewWorktreeDialog.tsx
Normal file
1768
ui/src/components/session/NewWorktreeDialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
360
ui/src/components/session/ProjectNotesTodoPanel.tsx
Normal file
360
ui/src/components/session/ProjectNotesTodoPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
789
ui/src/components/session/SessionDialogs.tsx
Normal file
789
ui/src/components/session/SessionDialogs.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
325
ui/src/components/session/SessionFolderItem.tsx
Normal file
325
ui/src/components/session/SessionFolderItem.tsx
Normal 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;
|
||||
1103
ui/src/components/session/SessionSidebar.tsx
Normal file
1103
ui/src/components/session/SessionSidebar.tsx
Normal file
File diff suppressed because it is too large
Load Diff
113
ui/src/components/session/sidebar/ConfirmDialogs.tsx
Normal file
113
ui/src/components/session/sidebar/ConfirmDialogs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
ui/src/components/session/sidebar/DOCUMENTATION.md
Normal file
46
ui/src/components/session/sidebar/DOCUMENTATION.md
Normal 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).
|
||||
580
ui/src/components/session/sidebar/SessionGroupSection.tsx
Normal file
580
ui/src/components/session/sidebar/SessionGroupSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
498
ui/src/components/session/sidebar/SessionNodeItem.tsx
Normal file
498
ui/src/components/session/sidebar/SessionNodeItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
440
ui/src/components/session/sidebar/SidebarHeader.tsx
Normal file
440
ui/src/components/session/sidebar/SidebarHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
ui/src/components/session/sidebar/SidebarProjectsList.tsx
Normal file
208
ui/src/components/session/sidebar/SidebarProjectsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
import { dedupeSessionsById, getArchivedScopeKey, isSessionRelatedToProject, normalizePath, resolveArchivedFolderName } from '../utils';
|
||||
|
||||
export type ProjectForArchivedFolders = {
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
type FolderEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
sessionIds: string[];
|
||||
};
|
||||
|
||||
type Args = {
|
||||
normalizedProjects: ProjectForArchivedFolders[];
|
||||
sessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
isVSCode: boolean;
|
||||
isSessionsLoading: boolean;
|
||||
foldersMap: Record<string, FolderEntry[]>;
|
||||
createFolder: (scopeKey: string, name: string, parentId?: string | null) => FolderEntry;
|
||||
addSessionToFolder: (scopeKey: string, folderId: string, sessionId: string) => void;
|
||||
cleanupSessions: (scopeKey: string, existingSessionIds: Set<string>) => void;
|
||||
};
|
||||
|
||||
const getArchivedSessionsForProject = (
|
||||
project: ProjectForArchivedFolders,
|
||||
params: Pick<Args, 'sessions' | 'archivedSessions' | 'availableWorktreesByProject' | 'isVSCode'>,
|
||||
): Session[] => {
|
||||
const worktreesForProject = params.isVSCode ? [] : (params.availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||
const validDirectories = new Set<string>([
|
||||
project.normalizedPath,
|
||||
...worktreesForProject
|
||||
.map((meta) => normalizePath(meta.path) ?? meta.path)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
]);
|
||||
|
||||
const collect = (input: Session[]): Session[] => input.filter((session) =>
|
||||
isSessionRelatedToProject(session, project.normalizedPath, validDirectories),
|
||||
);
|
||||
|
||||
const archived = collect(params.archivedSessions);
|
||||
const unassignedLive = params.sessions.filter((session) => {
|
||||
if (session.time?.archived) {
|
||||
return false;
|
||||
}
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (sessionDirectory) {
|
||||
return false;
|
||||
}
|
||||
return isSessionRelatedToProject(session, project.normalizedPath, validDirectories);
|
||||
});
|
||||
|
||||
return dedupeSessionsById([...archived, ...unassignedLive]);
|
||||
};
|
||||
|
||||
export const useArchivedAutoFolders = (args: Args): void => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
isVSCode,
|
||||
isSessionsLoading,
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
cleanupSessions,
|
||||
} = args;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSessionsLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
normalizedProjects.forEach((project) => {
|
||||
const scopeKey = getArchivedScopeKey(project.normalizedPath);
|
||||
const projectArchivedSessions = getArchivedSessionsForProject(project, {
|
||||
sessions,
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
isVSCode,
|
||||
});
|
||||
const sessionIds = new Set(projectArchivedSessions.map((session) => session.id));
|
||||
|
||||
const existingFolders = foldersMap[scopeKey] ?? [];
|
||||
const folderByName = new Map(existingFolders.map((folder) => [folder.name.toLowerCase(), folder]));
|
||||
|
||||
projectArchivedSessions.forEach((session) => {
|
||||
const folderName = resolveArchivedFolderName(session, project.normalizedPath);
|
||||
const key = folderName.toLowerCase();
|
||||
let folder = folderByName.get(key);
|
||||
if (!folder) {
|
||||
folder = createFolder(scopeKey, folderName);
|
||||
folderByName.set(key, folder);
|
||||
}
|
||||
|
||||
if (!folder.sessionIds.includes(session.id)) {
|
||||
addSessionToFolder(scopeKey, folder.id, session.id);
|
||||
}
|
||||
});
|
||||
|
||||
cleanupSessions(scopeKey, sessionIds);
|
||||
});
|
||||
}, [
|
||||
normalizedProjects,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
availableWorktreesByProject,
|
||||
isVSCode,
|
||||
isSessionsLoading,
|
||||
foldersMap,
|
||||
createFolder,
|
||||
addSessionToFolder,
|
||||
cleanupSessions,
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { opencodeClient } from '@/lib/opencode/client';
|
||||
import { normalizePath } from '../utils';
|
||||
|
||||
type ProjectLike = { path: string };
|
||||
|
||||
type Args = {
|
||||
sortedSessions: Session[];
|
||||
projects: ProjectLike[];
|
||||
directoryStatus: Map<string, 'unknown' | 'exists' | 'missing'>;
|
||||
setDirectoryStatus: React.Dispatch<React.SetStateAction<Map<string, 'unknown' | 'exists' | 'missing'>>>;
|
||||
};
|
||||
|
||||
export const useDirectoryStatusProbe = ({
|
||||
sortedSessions,
|
||||
projects,
|
||||
directoryStatus,
|
||||
setDirectoryStatus,
|
||||
}: Args): void => {
|
||||
const directoryStatusRef = React.useRef<Map<string, 'unknown' | 'exists' | 'missing'>>(new Map());
|
||||
const checkingDirectories = React.useRef<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
directoryStatusRef.current = directoryStatus;
|
||||
}, [directoryStatus]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const directories = new Set<string>();
|
||||
sortedSessions.forEach((session) => {
|
||||
const dir = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (dir) {
|
||||
directories.add(dir);
|
||||
}
|
||||
});
|
||||
projects.forEach((project) => {
|
||||
const normalized = normalizePath(project.path);
|
||||
if (normalized) {
|
||||
directories.add(normalized);
|
||||
}
|
||||
});
|
||||
|
||||
directories.forEach((directory) => {
|
||||
const known = directoryStatusRef.current.get(directory);
|
||||
if ((known && known !== 'unknown') || checkingDirectories.current.has(directory)) {
|
||||
return;
|
||||
}
|
||||
checkingDirectories.current.add(directory);
|
||||
opencodeClient
|
||||
.listLocalDirectory(directory)
|
||||
.then(() => {
|
||||
setDirectoryStatus((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.get(directory) === 'exists') {
|
||||
return prev;
|
||||
}
|
||||
next.set(directory, 'exists');
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(async () => {
|
||||
const looksLikeSdkWorktree =
|
||||
directory.includes('/opencode/worktree/') ||
|
||||
directory.includes('/.opencode/data/worktree/') ||
|
||||
directory.includes('/.local/share/opencode/worktree/');
|
||||
|
||||
if (looksLikeSdkWorktree) {
|
||||
const ok = await opencodeClient.probeDirectory(directory).catch(() => false);
|
||||
if (ok) {
|
||||
setDirectoryStatus((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.get(directory) === 'exists') {
|
||||
return prev;
|
||||
}
|
||||
next.set(directory, 'exists');
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDirectoryStatus((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (next.get(directory) === 'missing') {
|
||||
return prev;
|
||||
}
|
||||
next.set(directory, 'missing');
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
checkingDirectories.current.delete(directory);
|
||||
});
|
||||
});
|
||||
}, [sortedSessions, projects, setDirectoryStatus]);
|
||||
};
|
||||
33
ui/src/components/session/sidebar/hooks/useGroupOrdering.ts
Normal file
33
ui/src/components/session/sidebar/hooks/useGroupOrdering.ts
Normal 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 };
|
||||
};
|
||||
105
ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts
Normal file
105
ui/src/components/session/sidebar/hooks/useProjectRepoStatus.ts
Normal 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]);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import { dedupeSessionsById, isSessionRelatedToProject, normalizePath } from '../utils';
|
||||
|
||||
type WorktreeMeta = { path: string };
|
||||
|
||||
type Args = {
|
||||
isVSCode: boolean;
|
||||
sessions: Session[];
|
||||
archivedSessions: Session[];
|
||||
sessionsByDirectory: Map<string, Session[]>;
|
||||
getSessionsByDirectory: (directory: string) => Session[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMeta[]>;
|
||||
};
|
||||
|
||||
export const useProjectSessionLists = (args: Args) => {
|
||||
const {
|
||||
isVSCode,
|
||||
sessions,
|
||||
archivedSessions,
|
||||
sessionsByDirectory,
|
||||
getSessionsByDirectory,
|
||||
availableWorktreesByProject,
|
||||
} = args;
|
||||
|
||||
const getSessionsForProject = React.useCallback(
|
||||
(project: { normalizedPath: string }) => {
|
||||
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||
const directories = [
|
||||
project.normalizedPath,
|
||||
...worktreesForProject
|
||||
.map((meta) => normalizePath(meta.path) ?? meta.path)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const collected: Session[] = [];
|
||||
|
||||
directories.forEach((directory) => {
|
||||
const sessionsForDirectory = sessionsByDirectory.get(directory) ?? getSessionsByDirectory(directory);
|
||||
sessionsForDirectory.forEach((session) => {
|
||||
if (seen.has(session.id)) {
|
||||
return;
|
||||
}
|
||||
seen.add(session.id);
|
||||
collected.push(session);
|
||||
});
|
||||
});
|
||||
|
||||
return collected;
|
||||
},
|
||||
[availableWorktreesByProject, getSessionsByDirectory, isVSCode, sessionsByDirectory],
|
||||
);
|
||||
|
||||
const getArchivedSessionsForProject = React.useCallback(
|
||||
(project: { normalizedPath: string }) => {
|
||||
const worktreesForProject = isVSCode ? [] : (availableWorktreesByProject.get(project.normalizedPath) ?? []);
|
||||
const validDirectories = new Set<string>([
|
||||
project.normalizedPath,
|
||||
...worktreesForProject
|
||||
.map((meta) => normalizePath(meta.path) ?? meta.path)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
]);
|
||||
|
||||
const collect = (input: Session[]): Session[] => input.filter((session) =>
|
||||
isSessionRelatedToProject(session, project.normalizedPath, validDirectories),
|
||||
);
|
||||
|
||||
const archived = collect(archivedSessions);
|
||||
const unassignedLive = sessions.filter((session) => {
|
||||
if (session.time?.archived) {
|
||||
return false;
|
||||
}
|
||||
const sessionDirectory = normalizePath((session as Session & { directory?: string | null }).directory ?? null);
|
||||
if (sessionDirectory) {
|
||||
return false;
|
||||
}
|
||||
const projectWorktree = normalizePath((session as Session & { project?: { worktree?: string | null } | null }).project?.worktree ?? null);
|
||||
if (!projectWorktree) {
|
||||
return false;
|
||||
}
|
||||
return projectWorktree === project.normalizedPath || projectWorktree.startsWith(`${project.normalizedPath}/`);
|
||||
});
|
||||
|
||||
return dedupeSessionsById([...archived, ...unassignedLive]);
|
||||
},
|
||||
[archivedSessions, availableWorktreesByProject, isVSCode, sessions],
|
||||
);
|
||||
|
||||
return {
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroup, SessionNode } from '../types';
|
||||
import { normalizePath } from '../utils';
|
||||
|
||||
type ProjectSection = {
|
||||
project: { id: string; normalizedPath: string };
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
type Args = {
|
||||
projectSections: ProjectSection[];
|
||||
activeProjectId: string | null;
|
||||
activeSessionByProject: Map<string, string>;
|
||||
setActiveSessionByProject: React.Dispatch<React.SetStateAction<Map<string, string>>>;
|
||||
currentSessionId: string | null;
|
||||
handleSessionSelect: (sessionId: string, sessionDirectory: string | null, isMissingDirectory: boolean, projectId?: string | null) => void;
|
||||
isVSCode: boolean;
|
||||
newSessionDraftOpen: boolean;
|
||||
mobileVariant: boolean;
|
||||
openNewSessionDraft: (options?: { directoryOverride?: string | null }) => void;
|
||||
setActiveMainTab: (tab: 'chat' | 'plan' | 'git' | 'diff' | 'terminal' | 'files') => void;
|
||||
setSessionSwitcherOpen: (open: boolean) => void;
|
||||
sessions: Session[];
|
||||
worktreeMetadata: Map<string, { path?: string | null }>;
|
||||
};
|
||||
|
||||
export const useProjectSessionSelection = (args: Args): { currentSessionDirectory: string | null } => {
|
||||
const {
|
||||
projectSections,
|
||||
activeProjectId,
|
||||
activeSessionByProject,
|
||||
setActiveSessionByProject,
|
||||
currentSessionId,
|
||||
handleSessionSelect,
|
||||
isVSCode,
|
||||
newSessionDraftOpen,
|
||||
mobileVariant,
|
||||
openNewSessionDraft,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
sessions,
|
||||
worktreeMetadata,
|
||||
} = args;
|
||||
|
||||
const projectSessionMeta = React.useMemo(() => {
|
||||
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
|
||||
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
|
||||
|
||||
const visitNodes = (
|
||||
projectId: string,
|
||||
projectRoot: string,
|
||||
fallbackDirectory: string | null,
|
||||
nodes: SessionNode[],
|
||||
) => {
|
||||
if (!metaByProject.has(projectId)) {
|
||||
metaByProject.set(projectId, new Map());
|
||||
}
|
||||
const projectMap = metaByProject.get(projectId)!;
|
||||
nodes.forEach((node) => {
|
||||
const sessionDirectory = normalizePath(
|
||||
node.worktree?.path
|
||||
?? (node.session as Session & { directory?: string | null }).directory
|
||||
?? fallbackDirectory
|
||||
?? projectRoot,
|
||||
);
|
||||
projectMap.set(node.session.id, { directory: sessionDirectory });
|
||||
if (!firstSessionByProject.has(projectId)) {
|
||||
firstSessionByProject.set(projectId, { id: node.session.id, directory: sessionDirectory });
|
||||
}
|
||||
if (node.children.length > 0) {
|
||||
visitNodes(projectId, projectRoot, sessionDirectory, node.children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
projectSections.forEach((section) => {
|
||||
section.groups.forEach((group) => {
|
||||
visitNodes(section.project.id, section.project.normalizedPath, group.directory, group.sessions);
|
||||
});
|
||||
});
|
||||
|
||||
return { metaByProject, firstSessionByProject };
|
||||
}, [projectSections]);
|
||||
|
||||
const previousActiveProjectRef = React.useRef<string | null>(null);
|
||||
const lastSeenActiveProjectRef = React.useRef<string | null>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!activeProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousSeenProjectId = lastSeenActiveProjectRef.current;
|
||||
const isProjectSwitch = Boolean(previousSeenProjectId && previousSeenProjectId !== activeProjectId);
|
||||
lastSeenActiveProjectRef.current = activeProjectId;
|
||||
|
||||
if (newSessionDraftOpen && (isVSCode || !isProjectSwitch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (previousActiveProjectRef.current === activeProjectId) {
|
||||
return;
|
||||
}
|
||||
const section = projectSections.find((item) => item.project.id === activeProjectId);
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
previousActiveProjectRef.current = activeProjectId;
|
||||
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
|
||||
|
||||
if (currentSessionId && projectMap && projectMap.has(currentSessionId)) {
|
||||
setActiveSessionByProject((prev) => {
|
||||
if (prev.get(activeProjectId) === currentSessionId) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(activeProjectId, currentSessionId);
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectMap || projectMap.size === 0) {
|
||||
setActiveMainTab('chat');
|
||||
if (mobileVariant) {
|
||||
setSessionSwitcherOpen(false);
|
||||
}
|
||||
openNewSessionDraft({ directoryOverride: section.project.normalizedPath });
|
||||
return;
|
||||
}
|
||||
|
||||
const rememberedSessionId = activeSessionByProject.get(activeProjectId);
|
||||
const remembered = rememberedSessionId && projectMap.has(rememberedSessionId)
|
||||
? rememberedSessionId
|
||||
: null;
|
||||
const fallback = projectSessionMeta.firstSessionByProject.get(activeProjectId)?.id ?? null;
|
||||
const targetSessionId = remembered ?? fallback;
|
||||
if (!targetSessionId || targetSessionId === currentSessionId) {
|
||||
return;
|
||||
}
|
||||
const targetDirectory = projectMap.get(targetSessionId)?.directory ?? null;
|
||||
handleSessionSelect(targetSessionId, targetDirectory, false, activeProjectId);
|
||||
}, [
|
||||
activeProjectId,
|
||||
activeSessionByProject,
|
||||
currentSessionId,
|
||||
handleSessionSelect,
|
||||
isVSCode,
|
||||
newSessionDraftOpen,
|
||||
mobileVariant,
|
||||
openNewSessionDraft,
|
||||
projectSections,
|
||||
projectSessionMeta,
|
||||
setActiveMainTab,
|
||||
setSessionSwitcherOpen,
|
||||
setActiveSessionByProject,
|
||||
]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeProjectId || !currentSessionId) {
|
||||
return;
|
||||
}
|
||||
const projectMap = projectSessionMeta.metaByProject.get(activeProjectId);
|
||||
if (!projectMap || !projectMap.has(currentSessionId)) {
|
||||
return;
|
||||
}
|
||||
setActiveSessionByProject((prev) => {
|
||||
if (prev.get(activeProjectId) === currentSessionId) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Map(prev);
|
||||
next.set(activeProjectId, currentSessionId);
|
||||
return next;
|
||||
});
|
||||
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
|
||||
|
||||
const currentSessionDirectory = React.useMemo(() => {
|
||||
if (!currentSessionId) {
|
||||
return null;
|
||||
}
|
||||
const metadataPath = worktreeMetadata.get(currentSessionId)?.path;
|
||||
if (metadataPath) {
|
||||
return normalizePath(metadataPath) ?? metadataPath;
|
||||
}
|
||||
const activeSession = sessions.find((session) => session.id === currentSessionId);
|
||||
if (!activeSession) {
|
||||
return null;
|
||||
}
|
||||
return normalizePath((activeSession as Session & { directory?: string | null }).directory ?? null);
|
||||
}, [currentSessionId, sessions, worktreeMetadata]);
|
||||
|
||||
return { currentSessionDirectory };
|
||||
};
|
||||
239
ui/src/components/session/sidebar/hooks/useSessionActions.ts
Normal file
239
ui/src/components/session/sidebar/hooks/useSessionActions.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
]);
|
||||
};
|
||||
237
ui/src/components/session/sidebar/hooks/useSessionGrouping.ts
Normal file
237
ui/src/components/session/sidebar/hooks/useSessionGrouping.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
113
ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts
Normal file
113
ui/src/components/session/sidebar/hooks/useSessionPrefetch.ts
Normal 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 = [];
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
type Args = {
|
||||
isSessionSearchOpen: boolean;
|
||||
setIsSessionSearchOpen: (open: boolean) => void;
|
||||
sessionSearchInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
sessionSearchContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export const useSessionSearchEffects = ({
|
||||
isSessionSearchOpen,
|
||||
setIsSessionSearchOpen,
|
||||
sessionSearchInputRef,
|
||||
sessionSearchContainerRef,
|
||||
}: Args): void => {
|
||||
React.useEffect(() => {
|
||||
if (!isSessionSearchOpen || typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const raf = window.requestAnimationFrame(() => {
|
||||
sessionSearchInputRef.current?.focus();
|
||||
sessionSearchInputRef.current?.select();
|
||||
});
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [isSessionSearchOpen, sessionSearchInputRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isSessionSearchOpen || typeof document === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!sessionSearchContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!sessionSearchContainerRef.current.contains(event.target as Node)) {
|
||||
setIsSessionSearchOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [isSessionSearchOpen, setIsSessionSearchOpen, sessionSearchContainerRef]);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
import type { SessionGroup, SessionNode, GroupSearchData } from '../types';
|
||||
import { dedupeSessionsById, normalizePath } from '../utils';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
type ProjectItem = {
|
||||
id: string;
|
||||
path: string;
|
||||
label?: string;
|
||||
normalizedPath: string;
|
||||
};
|
||||
|
||||
type ProjectSection = {
|
||||
project: ProjectItem;
|
||||
groups: SessionGroup[];
|
||||
};
|
||||
|
||||
type Args = {
|
||||
normalizedProjects: ProjectItem[];
|
||||
activeProjectId: string | null;
|
||||
getSessionsForProject: (project: { normalizedPath: string }) => Session[];
|
||||
getArchivedSessionsForProject: (project: { normalizedPath: string }) => Session[];
|
||||
availableWorktreesByProject: Map<string, WorktreeMetadata[]>;
|
||||
projectRepoStatus: Map<string, boolean | null>;
|
||||
projectRootBranches: Map<string, string | null>;
|
||||
lastRepoStatus: boolean;
|
||||
buildGroupedSessions: (
|
||||
sessions: Session[],
|
||||
projectRoot: string,
|
||||
availableWorktrees: WorktreeMetadata[],
|
||||
rootBranch: string | null,
|
||||
isRepo: boolean,
|
||||
) => SessionGroup[];
|
||||
hasSessionSearchQuery: boolean;
|
||||
normalizedSessionSearchQuery: string;
|
||||
filterSessionNodesForSearch: (nodes: SessionNode[], query: string) => SessionNode[];
|
||||
buildGroupSearchText: (group: SessionGroup) => string;
|
||||
getFoldersForScope: (scopeKey: string) => Array<{ name: string }>;
|
||||
};
|
||||
|
||||
export const useSessionSidebarSections = (args: Args) => {
|
||||
const {
|
||||
normalizedProjects,
|
||||
activeProjectId,
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
availableWorktreesByProject,
|
||||
projectRepoStatus,
|
||||
projectRootBranches,
|
||||
lastRepoStatus,
|
||||
buildGroupedSessions,
|
||||
hasSessionSearchQuery,
|
||||
normalizedSessionSearchQuery,
|
||||
filterSessionNodesForSearch,
|
||||
buildGroupSearchText,
|
||||
getFoldersForScope,
|
||||
} = args;
|
||||
|
||||
const projectSections = React.useMemo<ProjectSection[]>(() => {
|
||||
return normalizedProjects.map((project) => {
|
||||
const projectSessions = dedupeSessionsById([
|
||||
...getSessionsForProject(project),
|
||||
...getArchivedSessionsForProject(project),
|
||||
]);
|
||||
const worktreesForProject = availableWorktreesByProject.get(project.normalizedPath) ?? [];
|
||||
const isRepo = projectRepoStatus.has(project.id)
|
||||
? Boolean(projectRepoStatus.get(project.id))
|
||||
: lastRepoStatus;
|
||||
const groups = buildGroupedSessions(
|
||||
projectSessions,
|
||||
project.normalizedPath,
|
||||
worktreesForProject,
|
||||
projectRootBranches.get(project.id) ?? null,
|
||||
isRepo,
|
||||
);
|
||||
return { project, groups };
|
||||
});
|
||||
}, [
|
||||
normalizedProjects,
|
||||
getSessionsForProject,
|
||||
getArchivedSessionsForProject,
|
||||
availableWorktreesByProject,
|
||||
projectRepoStatus,
|
||||
lastRepoStatus,
|
||||
buildGroupedSessions,
|
||||
projectRootBranches,
|
||||
]);
|
||||
|
||||
const visibleProjectSections = React.useMemo(() => {
|
||||
if (projectSections.length === 0) {
|
||||
return projectSections;
|
||||
}
|
||||
const active = projectSections.find((section) => section.project.id === activeProjectId);
|
||||
return active ? [active] : [projectSections[0]];
|
||||
}, [projectSections, activeProjectId]);
|
||||
|
||||
const groupSearchDataByGroup = React.useMemo(() => {
|
||||
const result = new WeakMap<SessionGroup, GroupSearchData>();
|
||||
if (!hasSessionSearchQuery) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const countNodes = (nodes: SessionNode[]): number => nodes.reduce((total, node) => total + 1 + countNodes(node.children), 0);
|
||||
|
||||
visibleProjectSections.forEach((section) => {
|
||||
section.groups.forEach((group) => {
|
||||
const filteredNodes = filterSessionNodesForSearch(group.sessions, normalizedSessionSearchQuery);
|
||||
const matchedSessionCount = countNodes(filteredNodes);
|
||||
const groupMatches = buildGroupSearchText(group).includes(normalizedSessionSearchQuery);
|
||||
const scopeKey = normalizePath(group.directory ?? null);
|
||||
const folderNameMatchCount = scopeKey
|
||||
? getFoldersForScope(scopeKey).filter((folder) => folder.name.toLowerCase().includes(normalizedSessionSearchQuery)).length
|
||||
: 0;
|
||||
|
||||
result.set(group, {
|
||||
filteredNodes,
|
||||
matchedSessionCount,
|
||||
folderNameMatchCount,
|
||||
groupMatches,
|
||||
hasMatch: groupMatches || matchedSessionCount > 0 || folderNameMatchCount > 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [
|
||||
hasSessionSearchQuery,
|
||||
visibleProjectSections,
|
||||
filterSessionNodesForSearch,
|
||||
normalizedSessionSearchQuery,
|
||||
buildGroupSearchText,
|
||||
getFoldersForScope,
|
||||
]);
|
||||
|
||||
const searchableProjectSections = React.useMemo(() => {
|
||||
if (!hasSessionSearchQuery) {
|
||||
return visibleProjectSections;
|
||||
}
|
||||
|
||||
return visibleProjectSections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
groups: section.groups.filter((group) => groupSearchDataByGroup.get(group)?.hasMatch === true),
|
||||
}))
|
||||
.filter((section) => section.groups.length > 0);
|
||||
}, [hasSessionSearchQuery, visibleProjectSections, groupSearchDataByGroup]);
|
||||
|
||||
const sectionsForRender = hasSessionSearchQuery ? searchableProjectSections : visibleProjectSections;
|
||||
|
||||
const searchMatchCount = React.useMemo(() => {
|
||||
if (!hasSessionSearchQuery) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return sectionsForRender.reduce((total, section) => {
|
||||
return total + section.groups.reduce((groupTotal, group) => {
|
||||
const data = groupSearchDataByGroup.get(group);
|
||||
if (!data) {
|
||||
return groupTotal;
|
||||
}
|
||||
const metadataMatches = data.folderNameMatchCount + (data.groupMatches ? 1 : 0);
|
||||
return groupTotal + data.matchedSessionCount + metadataMatches;
|
||||
}, 0);
|
||||
}, 0);
|
||||
}, [hasSessionSearchQuery, sectionsForRender, groupSearchDataByGroup]);
|
||||
|
||||
return {
|
||||
projectSections,
|
||||
visibleProjectSections,
|
||||
groupSearchDataByGroup,
|
||||
searchableProjectSections,
|
||||
sectionsForRender,
|
||||
searchMatchCount,
|
||||
};
|
||||
};
|
||||
167
ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts
Normal file
167
ui/src/components/session/sidebar/hooks/useSidebarPersistence.ts
Normal 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 };
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
134
ui/src/components/session/sidebar/sessionFolderDnd.tsx
Normal file
134
ui/src/components/session/sidebar/sessionFolderDnd.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
336
ui/src/components/session/sidebar/sortableItems.tsx
Normal file
336
ui/src/components/session/sidebar/sortableItems.tsx
Normal 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);
|
||||
36
ui/src/components/session/sidebar/types.ts
Normal file
36
ui/src/components/session/sidebar/types.ts
Normal 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;
|
||||
};
|
||||
242
ui/src/components/session/sidebar/utils.tsx
Normal file
242
ui/src/components/session/sidebar/utils.tsx
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user