Files
automaker/apps/app/src/components/session-manager.tsx
Cody Seibert fe9b26c49e feat: add delete session functionality with confirmation dialog
- Introduced a new DeleteSessionDialog component for confirming session deletions.
- Integrated the delete session dialog into the SessionManager component, allowing users to delete sessions with a confirmation prompt.
- Updated the UI to handle session deletion more intuitively, enhancing user experience.
- Refactored existing delete confirmation logic to utilize the new DeleteConfirmDialog component for consistency across the application.
2025-12-12 19:41:52 -05:00

580 lines
18 KiB
TypeScript

"use client";
import { useState, useEffect } from "react";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { HotkeyButton } from "@/components/ui/hotkey-button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Plus,
MessageSquare,
Archive,
Trash2,
Edit2,
Check,
X,
ArchiveRestore,
Loader2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { SessionListItem } from "@/types/electron";
import { useKeyboardShortcutsConfig } from "@/hooks/use-keyboard-shortcuts";
import { getElectronAPI } from "@/lib/electron";
import { DeleteSessionDialog } from "@/components/delete-session-dialog";
// Random session name generator
const adjectives = [
"Swift",
"Bright",
"Clever",
"Dynamic",
"Eager",
"Focused",
"Gentle",
"Happy",
"Inventive",
"Jolly",
"Keen",
"Lively",
"Mighty",
"Noble",
"Optimal",
"Peaceful",
"Quick",
"Radiant",
"Smart",
"Tranquil",
"Unique",
"Vibrant",
"Wise",
"Zealous",
];
const nouns = [
"Agent",
"Builder",
"Coder",
"Developer",
"Explorer",
"Forge",
"Garden",
"Helper",
"Innovator",
"Journey",
"Kernel",
"Lighthouse",
"Mission",
"Navigator",
"Oracle",
"Project",
"Quest",
"Runner",
"Spark",
"Task",
"Unicorn",
"Voyage",
"Workshop",
];
function generateRandomSessionName(): string {
const adjective = adjectives[Math.floor(Math.random() * adjectives.length)];
const noun = nouns[Math.floor(Math.random() * nouns.length)];
const number = Math.floor(Math.random() * 100);
return `${adjective} ${noun} ${number}`;
}
interface SessionManagerProps {
currentSessionId: string | null;
onSelectSession: (sessionId: string | null) => void;
projectPath: string;
isCurrentSessionThinking?: boolean;
onQuickCreateRef?: React.MutableRefObject<(() => Promise<void>) | null>;
}
export function SessionManager({
currentSessionId,
onSelectSession,
projectPath,
isCurrentSessionThinking = false,
onQuickCreateRef,
}: SessionManagerProps) {
const shortcuts = useKeyboardShortcutsConfig();
const [sessions, setSessions] = useState<SessionListItem[]>([]);
const [activeTab, setActiveTab] = useState<"active" | "archived">("active");
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingName, setEditingName] = useState("");
const [isCreating, setIsCreating] = useState(false);
const [newSessionName, setNewSessionName] = useState("");
const [runningSessions, setRunningSessions] = useState<Set<string>>(
new Set()
);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [sessionToDelete, setSessionToDelete] = useState<SessionListItem | null>(null);
// Check running state for all sessions
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
const api = getElectronAPI();
if (!api?.agent) return;
const runningIds = new Set<string>();
// Check each session's running state
for (const session of sessionList) {
try {
const result = await api.agent.getHistory(session.id);
if (result.success && result.isRunning) {
runningIds.add(session.id);
}
} catch (err) {
// Ignore errors for individual session checks
console.warn(
`[SessionManager] Failed to check running state for ${session.id}:`,
err
);
}
}
setRunningSessions(runningIds);
};
// Load sessions
const loadSessions = async () => {
const api = getElectronAPI();
if (!api?.sessions) return;
// Always load all sessions and filter client-side
const result = await api.sessions.list(true);
if (result.success && result.sessions) {
setSessions(result.sessions);
// Check running state for all sessions
await checkRunningSessions(result.sessions);
}
};
useEffect(() => {
loadSessions();
}, []);
// Periodically check running state for sessions (useful for detecting when agents finish)
useEffect(() => {
// Only poll if there are running sessions
if (runningSessions.size === 0 && !isCurrentSessionThinking) return;
const interval = setInterval(async () => {
if (sessions.length > 0) {
await checkRunningSessions(sessions);
}
}, 3000); // Check every 3 seconds
return () => clearInterval(interval);
}, [sessions, runningSessions.size, isCurrentSessionThinking]);
// Create new session with random name
const handleCreateSession = async () => {
const api = getElectronAPI();
if (!api?.sessions) return;
const sessionName = newSessionName.trim() || generateRandomSessionName();
const result = await api.sessions.create(
sessionName,
projectPath,
projectPath
);
if (result.success && result.session?.id) {
setNewSessionName("");
setIsCreating(false);
await loadSessions();
onSelectSession(result.session.id);
}
};
// Create new session directly with a random name (one-click)
const handleQuickCreateSession = async () => {
const api = getElectronAPI();
if (!api?.sessions) return;
const sessionName = generateRandomSessionName();
const result = await api.sessions.create(
sessionName,
projectPath,
projectPath
);
if (result.success && result.session?.id) {
await loadSessions();
onSelectSession(result.session.id);
}
};
// Expose the quick create function via ref for keyboard shortcuts
useEffect(() => {
if (onQuickCreateRef) {
onQuickCreateRef.current = handleQuickCreateSession;
}
return () => {
if (onQuickCreateRef) {
onQuickCreateRef.current = null;
}
};
}, [onQuickCreateRef, projectPath]);
// Rename session
const handleRenameSession = async (sessionId: string) => {
const api = getElectronAPI();
if (!editingName.trim() || !api?.sessions) return;
const result = await api.sessions.update(
sessionId,
editingName,
undefined
);
if (result.success) {
setEditingSessionId(null);
setEditingName("");
await loadSessions();
}
};
// Archive session
const handleArchiveSession = async (sessionId: string) => {
const api = getElectronAPI();
if (!api?.sessions) {
console.error("[SessionManager] Sessions API not available");
return;
}
try {
const result = await api.sessions.archive(sessionId);
if (result.success) {
// If the archived session was currently selected, deselect it
if (currentSessionId === sessionId) {
onSelectSession(null);
}
await loadSessions();
} else {
console.error("[SessionManager] Archive failed:", result.error);
}
} catch (error) {
console.error("[SessionManager] Archive error:", error);
}
};
// Unarchive session
const handleUnarchiveSession = async (sessionId: string) => {
const api = getElectronAPI();
if (!api?.sessions) {
console.error("[SessionManager] Sessions API not available");
return;
}
try {
const result = await api.sessions.unarchive(sessionId);
if (result.success) {
await loadSessions();
} else {
console.error("[SessionManager] Unarchive failed:", result.error);
}
} catch (error) {
console.error("[SessionManager] Unarchive error:", error);
}
};
// Open delete session dialog
const handleDeleteSession = (session: SessionListItem) => {
setSessionToDelete(session);
setIsDeleteDialogOpen(true);
};
// Confirm delete session
const confirmDeleteSession = async (sessionId: string) => {
const api = getElectronAPI();
if (!api?.sessions) return;
const result = await api.sessions.delete(sessionId);
if (result.success) {
await loadSessions();
if (currentSessionId === sessionId) {
// Switch to another session or create a new one
const activeSessionsList = sessions.filter((s) => !s.isArchived);
if (activeSessionsList.length > 0) {
onSelectSession(activeSessionsList[0].id);
}
}
}
setSessionToDelete(null);
};
const activeSessions = sessions.filter((s) => !s.isArchived);
const archivedSessions = sessions.filter((s) => s.isArchived);
const displayedSessions =
activeTab === "active" ? activeSessions : archivedSessions;
return (
<Card className="h-full flex flex-col">
<CardHeader className="pb-3">
<div className="flex items-center justify-between mb-4">
<CardTitle>Agent Sessions</CardTitle>
<HotkeyButton
variant="default"
size="sm"
onClick={() => {
// Switch to active tab if on archived tab
if (activeTab === "archived") {
setActiveTab("active");
}
handleQuickCreateSession();
}}
hotkey={shortcuts.newSession}
hotkeyActive={false}
data-testid="new-session-button"
title={`New Session (${shortcuts.newSession})`}
>
<Plus className="w-4 h-4 mr-1" />
New
</HotkeyButton>
</div>
<Tabs
value={activeTab}
onValueChange={(value) =>
setActiveTab(value as "active" | "archived")
}
className="w-full"
>
<TabsList className="w-full">
<TabsTrigger value="active" className="flex-1">
<MessageSquare className="w-4 h-4 mr-2" />
Active ({activeSessions.length})
</TabsTrigger>
<TabsTrigger value="archived" className="flex-1">
<Archive className="w-4 h-4 mr-2" />
Archived ({archivedSessions.length})
</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent
className="flex-1 overflow-y-auto space-y-2"
data-testid="session-list"
>
{/* Create new session */}
{isCreating && (
<div className="p-3 border rounded-lg bg-muted/50">
<div className="flex gap-2">
<Input
placeholder="Session name..."
value={newSessionName}
onChange={(e) => setNewSessionName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreateSession();
if (e.key === "Escape") {
setIsCreating(false);
setNewSessionName("");
}
}}
autoFocus
/>
<Button size="sm" onClick={handleCreateSession}>
<Check className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
setIsCreating(false);
setNewSessionName("");
}}
>
<X className="w-4 h-4" />
</Button>
</div>
</div>
)}
{/* Session list */}
{displayedSessions.map((session) => (
<div
key={session.id}
className={cn(
"p-3 border rounded-lg cursor-pointer transition-colors hover:bg-accent/50",
currentSessionId === session.id && "bg-primary/10 border-primary",
session.isArchived && "opacity-60"
)}
onClick={() => !session.isArchived && onSelectSession(session.id)}
data-testid={`session-item-${session.id}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
{editingSessionId === session.id ? (
<div className="flex gap-2 mb-2">
<Input
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRenameSession(session.id);
if (e.key === "Escape") {
setEditingSessionId(null);
setEditingName("");
}
}}
onClick={(e) => e.stopPropagation()}
autoFocus
className="h-7"
/>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleRenameSession(session.id);
}}
className="h-7"
>
<Check className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
setEditingSessionId(null);
setEditingName("");
}}
className="h-7"
>
<X className="w-3 h-3" />
</Button>
</div>
) : (
<>
<div className="flex items-center gap-2 mb-1">
{/* Show loading indicator if this session is running (either current session thinking or any session in runningSessions) */}
{(currentSessionId === session.id &&
isCurrentSessionThinking) ||
runningSessions.has(session.id) ? (
<Loader2 className="w-4 h-4 text-primary animate-spin shrink-0" />
) : (
<MessageSquare className="w-4 h-4 text-muted-foreground shrink-0" />
)}
<h3 className="font-medium truncate">{session.name}</h3>
{((currentSessionId === session.id &&
isCurrentSessionThinking) ||
runningSessions.has(session.id)) && (
<span className="text-xs text-primary bg-primary/10 px-2 py-0.5 rounded-full">
thinking...
</span>
)}
</div>
{session.preview && (
<p className="text-xs text-muted-foreground truncate">
{session.preview}
</p>
)}
<div className="flex items-center gap-2 mt-2">
<span className="text-xs text-muted-foreground">
{session.messageCount} messages
</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">
{new Date(session.updatedAt).toLocaleDateString()}
</span>
</div>
</>
)}
</div>
{/* Actions */}
{!session.isArchived && (
<div
className="flex gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditingSessionId(session.id);
setEditingName(session.name);
}}
className="h-7 w-7 p-0"
>
<Edit2 className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleArchiveSession(session.id)}
className="h-7 w-7 p-0"
data-testid={`archive-session-${session.id}`}
>
<Archive className="w-3 h-3" />
</Button>
</div>
)}
{session.isArchived && (
<div
className="flex gap-1"
onClick={(e) => e.stopPropagation()}
>
<Button
size="sm"
variant="ghost"
onClick={() => handleUnarchiveSession(session.id)}
className="h-7 w-7 p-0"
>
<ArchiveRestore className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDeleteSession(session)}
className="h-7 w-7 p-0 text-destructive"
data-testid={`delete-session-${session.id}`}
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
)}
</div>
</div>
))}
{displayedSessions.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="w-12 h-12 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{activeTab === "active"
? "No active sessions"
: "No archived sessions"}
</p>
<p className="text-xs">
{activeTab === "active"
? "Create your first session to get started"
: "Archive sessions to see them here"}
</p>
</div>
)}
</CardContent>
{/* Delete Session Confirmation Dialog */}
<DeleteSessionDialog
open={isDeleteDialogOpen}
onOpenChange={setIsDeleteDialogOpen}
session={sessionToDelete}
onConfirm={confirmDeleteSession}
/>
</Card>
);
}