mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
refactor: restructure project to monorepo with apps directory
This commit is contained in:
532
apps/app/src/components/session-manager.tsx
Normal file
532
apps/app/src/components/session-manager.tsx
Normal file
@@ -0,0 +1,532 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
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, TabsContent } 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 { useAppStore } from "@/store/app-store";
|
||||
|
||||
// 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()
|
||||
);
|
||||
|
||||
// Check running state for all sessions
|
||||
const checkRunningSessions = async (sessionList: SessionListItem[]) => {
|
||||
if (!window.electronAPI?.agent) return;
|
||||
|
||||
const runningIds = new Set<string>();
|
||||
|
||||
// Check each session's running state
|
||||
for (const session of sessionList) {
|
||||
try {
|
||||
const result = await window.electronAPI.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 () => {
|
||||
if (!window.electronAPI?.sessions) return;
|
||||
|
||||
// Always load all sessions and filter client-side
|
||||
const result = await window.electronAPI.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 () => {
|
||||
if (!window.electronAPI?.sessions) return;
|
||||
|
||||
const sessionName = newSessionName.trim() || generateRandomSessionName();
|
||||
|
||||
const result = await window.electronAPI.sessions.create(
|
||||
sessionName,
|
||||
projectPath,
|
||||
projectPath
|
||||
);
|
||||
|
||||
if (result.success && result.sessionId) {
|
||||
setNewSessionName("");
|
||||
setIsCreating(false);
|
||||
await loadSessions();
|
||||
onSelectSession(result.sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
// Create new session directly with a random name (one-click)
|
||||
const handleQuickCreateSession = async () => {
|
||||
if (!window.electronAPI?.sessions) return;
|
||||
|
||||
const sessionName = generateRandomSessionName();
|
||||
|
||||
const result = await window.electronAPI.sessions.create(
|
||||
sessionName,
|
||||
projectPath,
|
||||
projectPath
|
||||
);
|
||||
|
||||
if (result.success && result.sessionId) {
|
||||
await loadSessions();
|
||||
onSelectSession(result.sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
// 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) => {
|
||||
if (!editingName.trim() || !window.electronAPI?.sessions) return;
|
||||
|
||||
const result = await window.electronAPI.sessions.update(
|
||||
sessionId,
|
||||
editingName,
|
||||
undefined
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
setEditingSessionId(null);
|
||||
setEditingName("");
|
||||
await loadSessions();
|
||||
}
|
||||
};
|
||||
|
||||
// Archive session
|
||||
const handleArchiveSession = async (sessionId: string) => {
|
||||
if (!window.electronAPI?.sessions) return;
|
||||
|
||||
const result = await window.electronAPI.sessions.archive(sessionId);
|
||||
if (result.success) {
|
||||
// If the archived session was currently selected, deselect it
|
||||
if (currentSessionId === sessionId) {
|
||||
onSelectSession(null);
|
||||
}
|
||||
await loadSessions();
|
||||
}
|
||||
};
|
||||
|
||||
// Unarchive session
|
||||
const handleUnarchiveSession = async (sessionId: string) => {
|
||||
if (!window.electronAPI?.sessions) return;
|
||||
|
||||
const result = await window.electronAPI.sessions.unarchive(sessionId);
|
||||
if (result.success) {
|
||||
await loadSessions();
|
||||
}
|
||||
};
|
||||
|
||||
// Delete session
|
||||
const handleDeleteSession = async (sessionId: string) => {
|
||||
if (!window.electronAPI?.sessions) return;
|
||||
if (!confirm("Are you sure you want to delete this session?")) return;
|
||||
|
||||
const result = await window.electronAPI.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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
{activeTab === "active" && (
|
||||
<HotkeyButton
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={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.id)}
|
||||
className="h-7 w-7 p-0 text-destructive"
|
||||
>
|
||||
<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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user