Fix: Restore views properly, model selection for commit and pr and speed up some cli models with session resume (#801)

* Changes from fix/restoring-view

* feat: Add resume query safety checks and optimize store selectors

* feat: Improve session management and model normalization

* refactor: Extract prompt building logic and handle file path parsing for renames
This commit is contained in:
gsxdsm
2026-02-22 10:45:45 -08:00
committed by GitHub
parent 2f071a1ba3
commit 9305ecc242
26 changed files with 761 additions and 203 deletions

View File

@@ -1,6 +1,5 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useAppStore } from '@/store/app-store';
import type { PhaseModelEntry } from '@automaker/types';
import { useElectronAgent } from '@/hooks/use-electron-agent';
import { SessionManager } from '@/components/session-manager';
@@ -46,8 +45,6 @@ export function AgentView() {
return () => window.removeEventListener('resize', updateVisibility);
}, []);
const [modelSelection, setModelSelection] = useState<PhaseModelEntry>({ model: 'claude-sonnet' });
// Input ref for auto-focus
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -57,10 +54,12 @@ export function AgentView() {
const createSessionInFlightRef = useRef(false);
// Session management hook - scoped to current worktree
const { currentSessionId, handleSelectSession } = useAgentSession({
projectPath: currentProject?.path,
workingDirectory: effectiveWorkingDirectory,
});
// Also handles model selection persistence per session
const { currentSessionId, handleSelectSession, modelSelection, setModelSelection } =
useAgentSession({
projectPath: currentProject?.path,
workingDirectory: effectiveWorkingDirectory,
});
// Use the Electron agent hook (only if we have a session)
const {

View File

@@ -1,9 +1,14 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { createLogger } from '@automaker/utils/logger';
import type { PhaseModelEntry } from '@automaker/types';
import { useAppStore } from '@/store/app-store';
import { useShallow } from 'zustand/react/shallow';
const logger = createLogger('AgentSession');
// Default model selection when none is persisted
const DEFAULT_MODEL_SELECTION: PhaseModelEntry = { model: 'claude-sonnet' };
interface UseAgentSessionOptions {
projectPath: string | undefined;
workingDirectory?: string; // Current worktree path for per-worktree session persistence
@@ -12,14 +17,31 @@ interface UseAgentSessionOptions {
interface UseAgentSessionResult {
currentSessionId: string | null;
handleSelectSession: (sessionId: string | null) => void;
// Model selection persistence
modelSelection: PhaseModelEntry;
setModelSelection: (model: PhaseModelEntry) => void;
}
export function useAgentSession({
projectPath,
workingDirectory,
}: UseAgentSessionOptions): UseAgentSessionResult {
const { setLastSelectedSession, getLastSelectedSession } = useAppStore();
const {
setLastSelectedSession,
getLastSelectedSession,
setAgentModelForSession,
getAgentModelForSession,
} = useAppStore(
useShallow((state) => ({
setLastSelectedSession: state.setLastSelectedSession,
getLastSelectedSession: state.getLastSelectedSession,
setAgentModelForSession: state.setAgentModelForSession,
getAgentModelForSession: state.getAgentModelForSession,
}))
);
const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
const [modelSelection, setModelSelectionState] =
useState<PhaseModelEntry>(DEFAULT_MODEL_SELECTION);
// Track if initial session has been loaded
const initialSessionLoadedRef = useRef(false);
@@ -27,6 +49,22 @@ export function useAgentSession({
// Use workingDirectory as the persistence key so sessions are scoped per worktree
const persistenceKey = workingDirectory || projectPath;
/**
* Fetch persisted model for sessionId and update local state, or fall back to default.
*/
const restoreModelForSession = useCallback(
(sessionId: string) => {
const persistedModel = getAgentModelForSession(sessionId);
if (persistedModel) {
logger.debug('Restoring model selection for session:', sessionId, persistedModel);
setModelSelectionState(persistedModel);
} else {
setModelSelectionState(DEFAULT_MODEL_SELECTION);
}
},
[getAgentModelForSession]
);
// Handle session selection with persistence
const handleSelectSession = useCallback(
(sessionId: string | null) => {
@@ -35,16 +73,52 @@ export function useAgentSession({
if (persistenceKey) {
setLastSelectedSession(persistenceKey, sessionId);
}
// Restore model selection for this session if available
if (sessionId) {
restoreModelForSession(sessionId);
}
},
[persistenceKey, setLastSelectedSession]
[persistenceKey, setLastSelectedSession, restoreModelForSession]
);
// Wrapper for setModelSelection that also persists
const setModelSelection = useCallback(
(model: PhaseModelEntry) => {
setModelSelectionState(model);
// Persist model selection for current session.
// If currentSessionId is null (no active session), we only update local state
// and skip persistence — this is intentional because the model picker should be
// disabled (or hidden) in the UI whenever there is no active session, so this
// path is only reached if the UI allows selection before a session is established.
if (currentSessionId) {
setAgentModelForSession(currentSessionId, model);
}
},
[currentSessionId, setAgentModelForSession]
);
// Track the previous persistence key to detect actual changes
const prevPersistenceKeyRef = useRef(persistenceKey);
// Restore last selected session when switching to Agent view or when worktree changes
useEffect(() => {
if (!persistenceKey) {
// No project, reset
setCurrentSessionId(null);
// Detect if persistenceKey actually changed (worktree/project switch)
const persistenceKeyChanged = prevPersistenceKeyRef.current !== persistenceKey;
if (persistenceKeyChanged) {
// Reset state when switching worktree/project
prevPersistenceKeyRef.current = persistenceKey;
initialSessionLoadedRef.current = false;
setCurrentSessionId(null);
setModelSelectionState(DEFAULT_MODEL_SELECTION);
if (!persistenceKey) {
// No project, nothing to restore
return;
}
}
if (!persistenceKey) {
return;
}
@@ -54,19 +128,17 @@ export function useAgentSession({
const lastSessionId = getLastSelectedSession(persistenceKey);
if (lastSessionId) {
logger.info('Restoring last selected session:', lastSessionId);
logger.debug('Restoring last selected session:', lastSessionId);
setCurrentSessionId(lastSessionId);
// Also restore model selection for this session
restoreModelForSession(lastSessionId);
}
}, [persistenceKey, getLastSelectedSession]);
// Reset when worktree/project changes - clear current session and allow restore
useEffect(() => {
initialSessionLoadedRef.current = false;
setCurrentSessionId(null);
}, [persistenceKey]);
}, [persistenceKey, getLastSelectedSession, restoreModelForSession]);
return {
currentSessionId,
handleSelectSession,
modelSelection,
setModelSelection,
};
}

View File

@@ -116,7 +116,6 @@ export function BoardView() {
setPendingPlanApproval,
updateFeature,
batchUpdateFeatures,
getCurrentWorktree,
setCurrentWorktree,
getWorktrees,
setWorktrees,
@@ -135,7 +134,6 @@ export function BoardView() {
setPendingPlanApproval: state.setPendingPlanApproval,
updateFeature: state.updateFeature,
batchUpdateFeatures: state.batchUpdateFeatures,
getCurrentWorktree: state.getCurrentWorktree,
setCurrentWorktree: state.setCurrentWorktree,
getWorktrees: state.getWorktrees,
setWorktrees: state.setWorktrees,
@@ -444,9 +442,17 @@ export function BoardView() {
[batchResetBranchFeatures]
);
// Get current worktree info (path) for filtering features
// This needs to be before useBoardActions so we can pass currentWorktreeBranch
const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null;
const currentProjectPath = currentProject?.path;
// Get current worktree info (path/branch) for filtering features.
// Subscribe to the selected project's current worktree value directly so worktree
// switches trigger an immediate re-render and instant kanban/list re-filtering.
const currentWorktreeInfo = useAppStore(
useCallback(
(s) => (currentProjectPath ? (s.currentWorktreeByProject[currentProjectPath] ?? null) : null),
[currentProjectPath]
)
);
const currentWorktreePath = currentWorktreeInfo?.path ?? null;
// Select worktrees for the current project directly from the store.
@@ -455,7 +461,6 @@ export function BoardView() {
// object, causing unnecessary re-renders that cascaded into selectedWorktree →
// useAutoMode → refreshStatus → setAutoModeRunning → store update → re-render loop
// that could trigger React error #185 on initial project open).
const currentProjectPath = currentProject?.path;
const worktrees = useAppStore(
useCallback(
(s) =>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
Dialog,
DialogContent,
@@ -30,13 +30,17 @@ import {
ChevronDown,
ChevronRight,
Upload,
RefreshCw,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { resolveModelString } from '@automaker/model-resolver';
import { cn } from '@/lib/utils';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
import type { FileStatus, MergeStateInfo } from '@/types/electron';
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
@@ -206,6 +210,11 @@ export function CommitWorktreeDialog({
const [error, setError] = useState<string | null>(null);
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
// Commit message model override
const commitModelOverride = useModelOverride({ phase: 'commitMessageModel' });
const { effectiveModel: commitEffectiveModel, effectiveModelEntry: commitEffectiveModelEntry } =
commitModelOverride;
// File selection state
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState('');
@@ -532,6 +541,46 @@ export function CommitWorktreeDialog({
}
};
// Generate AI commit message
const generateCommitMessage = useCallback(async () => {
if (!worktree) return;
setIsGenerating(true);
try {
const resolvedCommitModel = resolveModelString(commitEffectiveModel);
const api = getHttpApiClient();
const result = await api.worktree.generateCommitMessage(
worktree.path,
resolvedCommitModel,
commitEffectiveModelEntry?.thinkingLevel,
commitEffectiveModelEntry?.providerId
);
if (result.success && result.message) {
setMessage(result.message);
} else {
console.warn('Failed to generate commit message:', result.error);
toast.error('Failed to generate commit message', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
console.warn('Error generating commit message:', err);
toast.error('Failed to generate commit message', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsGenerating(false);
}
}, [worktree, commitEffectiveModel, commitEffectiveModelEntry]);
// Keep a stable ref to generateCommitMessage so the open-dialog effect
// doesn't re-fire (and erase user edits) when the model override changes.
const generateCommitMessageRef = useRef(generateCommitMessage);
useEffect(() => {
generateCommitMessageRef.current = generateCommitMessage;
});
// Generate AI commit message when dialog opens (if enabled)
useEffect(() => {
if (open && worktree) {
@@ -543,45 +592,7 @@ export function CommitWorktreeDialog({
return;
}
setIsGenerating(true);
let cancelled = false;
const generateMessage = async () => {
try {
const api = getElectronAPI();
if (!api?.worktree?.generateCommitMessage) {
if (!cancelled) {
setIsGenerating(false);
}
return;
}
const result = await api.worktree.generateCommitMessage(worktree.path);
if (cancelled) return;
if (result.success && result.message) {
setMessage(result.message);
} else {
console.warn('Failed to generate commit message:', result.error);
setMessage('');
}
} catch (err) {
if (cancelled) return;
console.warn('Error generating commit message:', err);
setMessage('');
} finally {
if (!cancelled) {
setIsGenerating(false);
}
}
};
generateMessage();
return () => {
cancelled = true;
};
generateCommitMessageRef.current();
}
}, [open, worktree, enableAiCommitMessages]);
@@ -589,12 +600,12 @@ export function CommitWorktreeDialog({
const allSelected = selectedFiles.size === files.length && files.length > 0;
// Prevent the dialog from being dismissed while a push is in progress.
// Prevent the dialog from being dismissed while a push or generation is in progress.
// Overlay clicks and Escape key both route through onOpenChange(false); we
// intercept those here so the UI stays open until the push completes.
// intercept those here so the UI stays open until the operation completes.
const handleOpenChange = (nextOpen: boolean) => {
if (!nextOpen && isPushing) {
// Ignore close requests during an active push.
if (!nextOpen && (isLoading || isPushing || isGenerating)) {
// Ignore close requests during an active commit, push, or generation.
return;
}
onOpenChange(nextOpen);
@@ -813,15 +824,46 @@ export function CommitWorktreeDialog({
{/* Commit Message */}
<div className="grid gap-1.5">
<Label htmlFor="commit-message" className="flex items-center gap-2">
Commit Message
{isGenerating && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Sparkles className="w-3 h-3 animate-pulse" />
Generating...
</span>
)}
</Label>
<div className="flex items-center justify-between">
<Label htmlFor="commit-message" className="flex items-center gap-2">
Commit Message
{isGenerating && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Sparkles className="w-3 h-3 animate-pulse" />
Generating...
</span>
)}
</Label>
<div className="flex items-center gap-1">
{enableAiCommitMessages && (
<>
<Button
variant="ghost"
size="sm"
onClick={generateCommitMessage}
disabled={isGenerating || isLoading}
className="h-6 px-2 text-xs"
title="Regenerate commit message"
>
{isGenerating ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Regenerate
</Button>
<ModelOverrideTrigger
currentModelEntry={commitModelOverride.effectiveModelEntry}
onModelChange={commitModelOverride.setOverride}
phase="commitMessageModel"
isOverridden={commitModelOverride.isOverridden}
size="sm"
variant="icon"
/>
</>
)}
</div>
</div>
<Textarea
id="commit-message"
placeholder={

View File

@@ -26,6 +26,7 @@ import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useWorktreeBranches } from '@/hooks/queries';
import { ModelOverrideTrigger, useModelOverride } from '@/components/shared';
interface RemoteInfo {
name: string;
@@ -92,6 +93,9 @@ export function CreatePRDialog({
// Generate description state
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
// PR description model override
const prDescriptionModelOverride = useModelOverride({ phase: 'prDescriptionModel' });
// Use React Query for branch fetching - only enabled when dialog is open
const { data: branchesData, isLoading: isLoadingBranches } = useWorktreeBranches(
open ? worktree?.path : undefined,
@@ -306,7 +310,13 @@ export function CreatePRDialog({
resolvedRef !== baseBranch && resolvedRef.includes('/')
? resolvedRef.substring(resolvedRef.indexOf('/') + 1)
: resolvedRef;
const result = await api.worktree.generatePRDescription(worktree.path, branchNameForApi);
const result = await api.worktree.generatePRDescription(
worktree.path,
branchNameForApi,
prDescriptionModelOverride.effectiveModel,
prDescriptionModelOverride.effectiveModelEntry.thinkingLevel,
prDescriptionModelOverride.effectiveModelEntry.providerId
);
if (result.success) {
if (result.title) {
@@ -587,30 +597,40 @@ export function CreatePRDialog({
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="pr-title">PR Title</Label>
<Button
variant="ghost"
size="sm"
onClick={handleGenerateDescription}
disabled={isGeneratingDescription || isLoading}
className="h-6 px-2 text-xs"
title={
worktree.hasChanges
? 'Generate title and description from commits and uncommitted changes'
: 'Generate title and description from commits'
}
>
{isGeneratingDescription ? (
<>
<Spinner size="xs" className="mr-1" />
Generating...
</>
) : (
<>
<Sparkles className="w-3 h-3 mr-1" />
Generate with AI
</>
)}
</Button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={handleGenerateDescription}
disabled={isGeneratingDescription || isLoading}
className="h-6 px-2 text-xs"
title={
worktree.hasChanges
? 'Generate title and description from commits and uncommitted changes'
: 'Generate title and description from commits'
}
>
{isGeneratingDescription ? (
<>
<Spinner size="xs" className="mr-1" />
Generating...
</>
) : (
<>
<Sparkles className="w-3 h-3 mr-1" />
Generate with AI
</>
)}
</Button>
<ModelOverrideTrigger
currentModelEntry={prDescriptionModelOverride.effectiveModelEntry}
onModelChange={prDescriptionModelOverride.setOverride}
phase="prDescriptionModel"
isOverridden={prDescriptionModelOverride.isOverridden}
size="sm"
variant="icon"
/>
</div>
</div>
<Input
id="pr-title"