Merge remote-tracking branch 'origin/main' into feat/extend-models-support

This commit is contained in:
Kacper
2025-12-10 15:42:49 +01:00
11 changed files with 263 additions and 382 deletions

View File

@@ -67,6 +67,29 @@ export function SessionManager({
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 () => {
@@ -76,6 +99,8 @@ export function SessionManager({
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);
}
};
@@ -83,6 +108,20 @@ export function SessionManager({
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;
@@ -328,13 +367,14 @@ export function SessionManager({
) : (
<>
<div className="flex items-center gap-2 mb-1">
{currentSessionId === session.id && isCurrentSessionThinking ? (
{/* 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 && (
{((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>

View File

@@ -317,7 +317,7 @@ export function DescriptionImageDropZone({
<button
type="button"
onClick={handleBrowseClick}
className="text-blue-500 hover:text-blue-400 underline"
className="text-primary hover:text-primary/80 underline"
disabled={disabled || isProcessing}
>
browse

View File

@@ -22,6 +22,7 @@ import {
import { cn } from "@/lib/utils";
import { useElectronAgent } from "@/hooks/use-electron-agent";
import { SessionManager } from "@/components/session-manager";
import { Markdown } from "@/components/ui/markdown";
import type { ImageAttachment } from "@/store/app-store";
import {
useKeyboardShortcuts,
@@ -30,7 +31,7 @@ import {
} from "@/hooks/use-keyboard-shortcuts";
export function AgentView() {
const { currentProject } = useAppStore();
const { currentProject, setLastSelectedSession, getLastSelectedSession } = useAppStore();
const [input, setInput] = useState("");
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([]);
const [showImageDropZone, setShowImageDropZone] = useState(false);
@@ -39,6 +40,9 @@ export function AgentView() {
const [showSessionManager, setShowSessionManager] = useState(true);
const [isDragOver, setIsDragOver] = useState(false);
// Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false);
// Scroll management for auto-scroll
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [isUserAtBottom, setIsUserAtBottom] = useState(true);
@@ -66,6 +70,40 @@ export function AgentView() {
},
});
// Handle session selection with persistence
const handleSelectSession = useCallback((sessionId: string | null) => {
setCurrentSessionId(sessionId);
// Persist the selection for this project
if (currentProject?.path) {
setLastSelectedSession(currentProject.path, sessionId);
}
}, [currentProject?.path, setLastSelectedSession]);
// Restore last selected session when switching to Agent view or when project changes
useEffect(() => {
if (!currentProject?.path) {
// No project, reset
setCurrentSessionId(null);
initialSessionLoadedRef.current = false;
return;
}
// Only restore once per project
if (initialSessionLoadedRef.current) return;
initialSessionLoadedRef.current = true;
const lastSessionId = getLastSelectedSession(currentProject.path);
if (lastSessionId) {
console.log("[AgentView] Restoring last selected session:", lastSessionId);
setCurrentSessionId(lastSessionId);
}
}, [currentProject?.path, getLastSelectedSession]);
// Reset initialSessionLoadedRef when project changes
useEffect(() => {
initialSessionLoadedRef.current = false;
}, [currentProject?.path]);
const handleSend = useCallback(async () => {
if ((!input.trim() && selectedImages.length === 0) || isProcessing) return;
@@ -441,7 +479,7 @@ export function AgentView() {
<div className="w-80 border-r flex-shrink-0">
<SessionManager
currentSessionId={currentSessionId}
onSelectSession={setCurrentSessionId}
onSelectSession={handleSelectSession}
projectPath={currentProject.path}
isCurrentSessionThinking={isProcessing}
onQuickCreateRef={quickCreateSessionRef}
@@ -559,9 +597,13 @@ export function AgentView() {
)}
>
<CardContent className="p-3">
<p className="text-sm whitespace-pre-wrap">
{message.content}
</p>
{message.role === "assistant" ? (
<Markdown className="text-sm">{message.content}</Markdown>
) : (
<p className="text-sm whitespace-pre-wrap">
{message.content}
</p>
)}
<p
className={cn(
"text-xs mt-2",

View File

@@ -230,6 +230,10 @@ export function BoardView() {
const [showEditAdvancedOptions, setShowEditAdvancedOptions] = useState(false);
const [showSuggestionsDialog, setShowSuggestionsDialog] = useState(false);
const [suggestionsCount, setSuggestionsCount] = useState(0);
const [featureSuggestions, setFeatureSuggestions] = useState<
import("@/lib/electron").FeatureSuggestion[]
>([]);
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
// Make current project available globally for modal
useEffect(() => {
@@ -241,7 +245,7 @@ export function BoardView() {
};
}, [currentProject]);
// Listen for suggestions events to update count
// Listen for suggestions events to update count (persists even when dialog is closed)
useEffect(() => {
const api = getElectronAPI();
if (!api?.suggestions) return;
@@ -249,6 +253,10 @@ export function BoardView() {
const unsubscribe = api.suggestions.onEvent((event) => {
if (event.type === "suggestions_complete" && event.suggestions) {
setSuggestionsCount(event.suggestions.length);
setFeatureSuggestions(event.suggestions);
setIsGeneratingSuggestions(false);
} else if (event.type === "suggestions_error") {
setIsGeneratingSuggestions(false);
}
});
@@ -2567,9 +2575,15 @@ export function BoardView() {
open={showSuggestionsDialog}
onClose={() => {
setShowSuggestionsDialog(false);
// Clear the count when dialog is closed (suggestions were either imported or dismissed)
}}
projectPath={currentProject.path}
suggestions={featureSuggestions}
setSuggestions={(suggestions) => {
setFeatureSuggestions(suggestions);
setSuggestionsCount(suggestions.length);
}}
isGenerating={isGeneratingSuggestions}
setIsGenerating={setIsGeneratingSuggestions}
/>
</div>
);

View File

@@ -28,16 +28,23 @@ interface FeatureSuggestionsDialogProps {
open: boolean;
onClose: () => void;
projectPath: string;
// Props to persist state across dialog open/close
suggestions: FeatureSuggestion[];
setSuggestions: (suggestions: FeatureSuggestion[]) => void;
isGenerating: boolean;
setIsGenerating: (generating: boolean) => void;
}
export function FeatureSuggestionsDialog({
open,
onClose,
projectPath,
suggestions,
setSuggestions,
isGenerating,
setIsGenerating,
}: FeatureSuggestionsDialogProps) {
const [isGenerating, setIsGenerating] = useState(false);
const [progress, setProgress] = useState<string[]>([]);
const [suggestions, setSuggestions] = useState<FeatureSuggestion[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [isImporting, setIsImporting] = useState(false);
@@ -46,6 +53,13 @@ export function FeatureSuggestionsDialog({
const { features, setFeatures } = useAppStore();
// Initialize selectedIds when suggestions change
useEffect(() => {
if (suggestions.length > 0 && selectedIds.size === 0) {
setSelectedIds(new Set(suggestions.map((s) => s.id)));
}
}, [suggestions, selectedIds.size]);
// Auto-scroll progress when new content arrives
useEffect(() => {
if (autoScrollRef.current && scrollRef.current && isGenerating) {
@@ -53,7 +67,7 @@ export function FeatureSuggestionsDialog({
}
}, [progress, isGenerating]);
// Listen for suggestion events
// Listen for suggestion events when dialog is open
useEffect(() => {
if (!open) return;
@@ -85,7 +99,7 @@ export function FeatureSuggestionsDialog({
return () => {
unsubscribe();
};
}, [open]);
}, [open, setSuggestions, setIsGenerating]);
// Start generating suggestions
const handleGenerate = useCallback(async () => {
@@ -111,7 +125,7 @@ export function FeatureSuggestionsDialog({
toast.error("Failed to start generation");
setIsGenerating(false);
}
}, [projectPath]);
}, [projectPath, setIsGenerating, setSuggestions]);
// Stop generating
const handleStop = useCallback(async () => {
@@ -125,7 +139,7 @@ export function FeatureSuggestionsDialog({
} catch (error) {
console.error("Failed to stop generation:", error);
}
}, []);
}, [setIsGenerating]);
// Toggle suggestion selection
const toggleSelection = useCallback((id: string) => {
@@ -198,6 +212,12 @@ export function FeatureSuggestionsDialog({
setFeatures(updatedFeatures);
toast.success(`Imported ${newFeatures.length} features to backlog!`);
// Clear suggestions after importing
setSuggestions([]);
setSelectedIds(new Set());
setProgress([]);
onClose();
} catch (error) {
console.error("Failed to import features:", error);
@@ -205,7 +225,7 @@ export function FeatureSuggestionsDialog({
} finally {
setIsImporting(false);
}
}, [selectedIds, suggestions, features, setFeatures, projectPath, onClose]);
}, [selectedIds, suggestions, features, setFeatures, setSuggestions, projectPath, onClose]);
// Handle scroll to detect if user scrolled up
const handleScroll = () => {

View File

@@ -137,8 +137,7 @@ export function useElectronAgent({
let mounted = true;
const initialize = async () => {
// Reset state when switching sessions
setIsProcessing(false);
// Reset error state when switching sessions
setError(null);
try {
@@ -154,13 +153,23 @@ export function useElectronAgent({
console.log("[useElectronAgent] Loaded", result.messages.length, "messages");
setMessages(result.messages);
setIsConnected(true);
// Check if the agent is currently running for this session
const historyResult = await window.electronAPI.agent.getHistory(sessionId);
if (mounted && historyResult.success) {
const isRunning = historyResult.isRunning || false;
console.log("[useElectronAgent] Session running state:", isRunning);
setIsProcessing(isRunning);
}
} else {
setError(result.error || "Failed to start session");
setIsProcessing(false);
}
} catch (err) {
if (!mounted) return;
console.error("[useElectronAgent] Failed to initialize:", err);
setError(err instanceof Error ? err.message : "Failed to initialize");
setIsProcessing(false);
}
};

View File

@@ -123,7 +123,7 @@ export const ACTION_SHORTCUTS: Record<string, string> = {
addFeature: "N", // N for New feature
addContextFile: "F", // F for File (add context file)
startNext: "G", // G for Grab (start next features from backlog)
newSession: "W", // W for new session (in agent view)
newSession: "N", // N for New session (in agent view)
openProject: "O", // O for Open project (navigate to welcome view)
projectPicker: "P", // P for Project picker
cyclePrevProject: "Q", // Q for previous project (cycle back through MRU)

View File

@@ -156,6 +156,9 @@ export interface AppState {
currentView: ViewMode;
sidebarOpen: boolean;
// Agent Session state (per-project, keyed by project path)
lastSelectedSessionByProject: Record<string, string>; // projectPath -> sessionId
// Theme
theme: ThemeMode;
@@ -308,6 +311,10 @@ export interface AppActions {
setIsAnalyzing: (analyzing: boolean) => void;
clearAnalysis: () => void;
// Agent Session actions
setLastSelectedSession: (projectPath: string, sessionId: string | null) => void;
getLastSelectedSession: (projectPath: string) => string | null;
// Reset
reset: () => void;
}
@@ -374,6 +381,7 @@ const initialState: AppState = {
projectHistoryIndex: -1,
currentView: "welcome",
sidebarOpen: true,
lastSelectedSessionByProject: {},
theme: "dark",
features: [],
appSpec: "",
@@ -532,17 +540,33 @@ export const useAppStore = create<AppState & AppActions>()(
cyclePrevProject: () => {
const { projectHistory, projectHistoryIndex, projects } = get();
if (projectHistory.length <= 1) return; // Need at least 2 projects to cycle
// Move to the next index (going back in history = higher index)
const newIndex = (projectHistoryIndex + 1) % projectHistory.length;
const targetProjectId = projectHistory[newIndex];
// Filter history to only include valid projects
const validHistory = projectHistory.filter((id) =>
projects.some((p) => p.id === id)
);
if (validHistory.length <= 1) return; // Need at least 2 valid projects to cycle
// Find current position in valid history
const currentProjectId = get().currentProject?.id;
let currentIndex = currentProjectId
? validHistory.indexOf(currentProjectId)
: projectHistoryIndex;
// If current project not found in valid history, start from 0
if (currentIndex === -1) currentIndex = 0;
// Move to the next index (going back in history = higher index), wrapping around
const newIndex = (currentIndex + 1) % validHistory.length;
const targetProjectId = validHistory[newIndex];
const targetProject = projects.find((p) => p.id === targetProjectId);
if (targetProject) {
// Update the index but don't modify history order when cycling
// Update history to only include valid projects and set new index
set({
currentProject: targetProject,
projectHistory: validHistory,
projectHistoryIndex: newIndex,
currentView: "board"
});
@@ -551,19 +575,35 @@ export const useAppStore = create<AppState & AppActions>()(
cycleNextProject: () => {
const { projectHistory, projectHistoryIndex, projects } = get();
if (projectHistory.length <= 1) return; // Need at least 2 projects to cycle
// Move to the previous index (going forward = lower index, wrapping around)
const newIndex = projectHistoryIndex <= 0
? projectHistory.length - 1
: projectHistoryIndex - 1;
const targetProjectId = projectHistory[newIndex];
// Filter history to only include valid projects
const validHistory = projectHistory.filter((id) =>
projects.some((p) => p.id === id)
);
if (validHistory.length <= 1) return; // Need at least 2 valid projects to cycle
// Find current position in valid history
const currentProjectId = get().currentProject?.id;
let currentIndex = currentProjectId
? validHistory.indexOf(currentProjectId)
: projectHistoryIndex;
// If current project not found in valid history, start from 0
if (currentIndex === -1) currentIndex = 0;
// Move to the previous index (going forward = lower index), wrapping around
const newIndex = currentIndex <= 0
? validHistory.length - 1
: currentIndex - 1;
const targetProjectId = validHistory[newIndex];
const targetProject = projects.find((p) => p.id === targetProjectId);
if (targetProject) {
// Update the index but don't modify history order when cycling
// Update history to only include valid projects and set new index
set({
currentProject: targetProject,
projectHistory: validHistory,
projectHistoryIndex: newIndex,
currentView: "board"
});
@@ -869,6 +909,26 @@ export const useAppStore = create<AppState & AppActions>()(
setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }),
clearAnalysis: () => set({ projectAnalysis: null }),
// Agent Session actions
setLastSelectedSession: (projectPath, sessionId) => {
const current = get().lastSelectedSessionByProject;
if (sessionId === null) {
// Remove the entry for this project
const { [projectPath]: _, ...rest } = current;
set({ lastSelectedSessionByProject: rest });
} else {
set({
lastSelectedSessionByProject: {
...current,
[projectPath]: sessionId,
},
});
}
},
getLastSelectedSession: (projectPath) => {
return get().lastSelectedSessionByProject[projectPath] || null;
},
// Reset
reset: () => set(initialState),
}),
@@ -892,6 +952,7 @@ export const useAppStore = create<AppState & AppActions>()(
useWorktrees: state.useWorktrees,
showProfilesOnly: state.showProfilesOnly,
aiProfiles: state.aiProfiles,
lastSelectedSessionByProject: state.lastSelectedSessionByProject,
}),
}
)