mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 10:23:07 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -109,6 +109,7 @@ const SETTINGS_FIELDS_TO_SYNC = [
|
||||
'projectHistory',
|
||||
'projectHistoryIndex',
|
||||
'lastSelectedSessionByProject',
|
||||
'agentModelBySession',
|
||||
'currentWorktreeByProject',
|
||||
// Codex CLI Settings
|
||||
'codexAutoLoadAgents',
|
||||
@@ -173,6 +174,17 @@ function getSettingsFieldValue(
|
||||
}
|
||||
return persistedSettings;
|
||||
}
|
||||
if (field === 'agentModelBySession') {
|
||||
// Cap to the 50 most-recently-inserted session entries to prevent unbounded growth.
|
||||
// agentModelBySession grows by one entry per agent session — without pruning this
|
||||
// will bloat settings.json, every debounced sync payload, and the localStorage cache.
|
||||
const map = appState.agentModelBySession as Record<string, unknown>;
|
||||
const MAX_ENTRIES = 50;
|
||||
const entries = Object.entries(map);
|
||||
if (entries.length <= MAX_ENTRIES) return map;
|
||||
// Keep the last MAX_ENTRIES entries (insertion-order approximation for recency)
|
||||
return Object.fromEntries(entries.slice(-MAX_ENTRIES));
|
||||
}
|
||||
return appState[field as keyof typeof appState];
|
||||
}
|
||||
|
||||
@@ -806,6 +818,13 @@ export async function refreshSettingsFromServer(): Promise<boolean> {
|
||||
projectHistory: serverSettings.projectHistory,
|
||||
projectHistoryIndex: serverSettings.projectHistoryIndex,
|
||||
lastSelectedSessionByProject: serverSettings.lastSelectedSessionByProject,
|
||||
agentModelBySession: serverSettings.agentModelBySession
|
||||
? Object.fromEntries(
|
||||
Object.entries(serverSettings.agentModelBySession as Record<string, unknown>).map(
|
||||
([sessionId, entry]) => [sessionId, migratePhaseModelEntry(entry)]
|
||||
)
|
||||
)
|
||||
: currentAppState.agentModelBySession,
|
||||
// Sanitize: only restore entries with path === null (main branch).
|
||||
// Non-null paths may reference deleted worktrees, causing crash loops.
|
||||
currentWorktreeByProject: sanitizeWorktreeByProject(
|
||||
|
||||
@@ -2204,10 +2204,32 @@ export class HttpApiClient implements ElectronAPI {
|
||||
}),
|
||||
commit: (worktreePath: string, message: string, files?: string[]) =>
|
||||
this.post('/api/worktree/commit', { worktreePath, message, files }),
|
||||
generateCommitMessage: (worktreePath: string) =>
|
||||
this.post('/api/worktree/generate-commit-message', { worktreePath }),
|
||||
generatePRDescription: (worktreePath: string, baseBranch?: string) =>
|
||||
this.post('/api/worktree/generate-pr-description', { worktreePath, baseBranch }),
|
||||
generateCommitMessage: (
|
||||
worktreePath: string,
|
||||
model?: string,
|
||||
thinkingLevel?: string,
|
||||
providerId?: string
|
||||
) =>
|
||||
this.post('/api/worktree/generate-commit-message', {
|
||||
worktreePath,
|
||||
model,
|
||||
thinkingLevel,
|
||||
providerId,
|
||||
}),
|
||||
generatePRDescription: (
|
||||
worktreePath: string,
|
||||
baseBranch?: string,
|
||||
model?: string,
|
||||
thinkingLevel?: string,
|
||||
providerId?: string
|
||||
) =>
|
||||
this.post('/api/worktree/generate-pr-description', {
|
||||
worktreePath,
|
||||
baseBranch,
|
||||
model,
|
||||
thinkingLevel,
|
||||
providerId,
|
||||
}),
|
||||
push: (worktreePath: string, force?: boolean, remote?: string, autoResolve?: boolean) =>
|
||||
this.post('/api/worktree/push', { worktreePath, force, remote, autoResolve }),
|
||||
sync: (worktreePath: string, remote?: string) =>
|
||||
|
||||
@@ -3,10 +3,14 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Drop currentWorktreeByProject entries with non-null paths.
|
||||
* Non-null paths reference worktree directories that may have been deleted,
|
||||
* and restoring them causes crash loops (board renders invalid worktree
|
||||
* -> error boundary reloads -> restores same stale path).
|
||||
* Validate and sanitize currentWorktreeByProject entries.
|
||||
*
|
||||
* Keeps all valid entries (both main branch and feature worktrees).
|
||||
* The validation against actual worktrees happens in use-worktrees.ts
|
||||
* which resets to main branch if the selected worktree no longer exists.
|
||||
*
|
||||
* Only drops entries with invalid structure (not an object, missing/invalid
|
||||
* path or branch).
|
||||
*/
|
||||
export function sanitizeWorktreeByProject(
|
||||
raw: Record<string, { path: string | null; branch: string }> | undefined
|
||||
@@ -14,11 +18,13 @@ export function sanitizeWorktreeByProject(
|
||||
if (!raw) return {};
|
||||
const sanitized: Record<string, { path: string | null; branch: string }> = {};
|
||||
for (const [projectPath, worktree] of Object.entries(raw)) {
|
||||
// Only validate structure - keep both null (main) and non-null (worktree) paths
|
||||
// Runtime validation in use-worktrees.ts handles deleted worktrees
|
||||
if (
|
||||
typeof worktree === 'object' &&
|
||||
worktree !== null &&
|
||||
'path' in worktree &&
|
||||
worktree.path === null
|
||||
typeof worktree.branch === 'string' &&
|
||||
(worktree.path === null || typeof worktree.path === 'string')
|
||||
) {
|
||||
sanitized[projectPath] = worktree;
|
||||
}
|
||||
|
||||
@@ -275,6 +275,7 @@ const initialState: AppState = {
|
||||
collapsedNavSections: cachedUI.collapsedNavSections,
|
||||
mobileSidebarHidden: false,
|
||||
lastSelectedSessionByProject: {},
|
||||
agentModelBySession: {},
|
||||
theme: getStoredTheme() || 'dark',
|
||||
fontFamilySans: getStoredFontSans(),
|
||||
fontFamilyMono: getStoredFontMono(),
|
||||
@@ -962,11 +963,15 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
),
|
||||
})),
|
||||
deleteChatSession: (sessionId) =>
|
||||
set((state) => ({
|
||||
chatSessions: state.chatSessions.filter((s) => s.id !== sessionId),
|
||||
currentChatSession:
|
||||
state.currentChatSession?.id === sessionId ? null : state.currentChatSession,
|
||||
})),
|
||||
set((state) => {
|
||||
const { [sessionId]: _removed, ...remainingAgentModels } = state.agentModelBySession;
|
||||
return {
|
||||
chatSessions: state.chatSessions.filter((s) => s.id !== sessionId),
|
||||
currentChatSession:
|
||||
state.currentChatSession?.id === sessionId ? null : state.currentChatSession,
|
||||
agentModelBySession: remainingAgentModels,
|
||||
};
|
||||
}),
|
||||
setChatHistoryOpen: (open) => set({ chatHistoryOpen: open }),
|
||||
toggleChatHistory: () => set((state) => ({ chatHistoryOpen: !state.chatHistoryOpen })),
|
||||
|
||||
@@ -1598,6 +1603,16 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
})),
|
||||
getLastSelectedSession: (projectPath) => get().lastSelectedSessionByProject[projectPath] ?? null,
|
||||
|
||||
// Agent model selection actions
|
||||
setAgentModelForSession: (sessionId, model) =>
|
||||
set((state) => ({
|
||||
agentModelBySession: {
|
||||
...state.agentModelBySession,
|
||||
[sessionId]: model,
|
||||
},
|
||||
})),
|
||||
getAgentModelForSession: (sessionId) => get().agentModelBySession[sessionId] ?? null,
|
||||
|
||||
// Board Background actions
|
||||
setBoardBackground: (projectPath, imagePath) =>
|
||||
set((state) => ({
|
||||
|
||||
@@ -83,6 +83,8 @@ export interface AppState {
|
||||
|
||||
// Agent Session state (per-project, keyed by project path)
|
||||
lastSelectedSessionByProject: Record<string, string>; // projectPath -> sessionId
|
||||
// Agent model selection (per-session, keyed by sessionId)
|
||||
agentModelBySession: Record<string, PhaseModelEntry>; // sessionId -> model selection
|
||||
|
||||
// Theme
|
||||
theme: ThemeMode;
|
||||
@@ -669,6 +671,9 @@ export interface AppActions {
|
||||
// Agent Session actions
|
||||
setLastSelectedSession: (projectPath: string, sessionId: string | null) => void;
|
||||
getLastSelectedSession: (projectPath: string) => string | null;
|
||||
// Agent model selection actions
|
||||
setAgentModelForSession: (sessionId: string, model: PhaseModelEntry) => void;
|
||||
getAgentModelForSession: (sessionId: string) => PhaseModelEntry | null;
|
||||
|
||||
// Board Background actions
|
||||
setBoardBackground: (projectPath: string, imagePath: string | null) => void;
|
||||
|
||||
@@ -81,6 +81,38 @@ export const useUICacheStore = create<UICacheState & UICacheActions>()(
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Check whether an unknown value is a valid cached worktree entry.
|
||||
* Accepts objects with a non-empty string branch and a path that is null or a string.
|
||||
*/
|
||||
function isValidCachedWorktreeEntry(
|
||||
worktree: unknown
|
||||
): worktree is { path: string | null; branch: string } {
|
||||
return (
|
||||
typeof worktree === 'object' &&
|
||||
worktree !== null &&
|
||||
typeof (worktree as Record<string, unknown>).branch === 'string' &&
|
||||
((worktree as Record<string, unknown>).branch as string).trim().length > 0 &&
|
||||
((worktree as Record<string, unknown>).path === null ||
|
||||
typeof (worktree as Record<string, unknown>).path === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a raw worktree map, discarding entries that fail structural validation.
|
||||
*/
|
||||
function sanitizeCachedWorktreeByProject(
|
||||
raw: Record<string, unknown>
|
||||
): Record<string, { path: string | null; branch: string }> {
|
||||
const sanitized: Record<string, { path: string | null; branch: string }> = {};
|
||||
for (const [key, worktree] of Object.entries(raw)) {
|
||||
if (isValidCachedWorktreeEntry(worktree)) {
|
||||
sanitized[key] = worktree;
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync critical UI state from the main app store to the UI cache.
|
||||
* Call this whenever the app store changes to keep the cache up to date.
|
||||
@@ -114,24 +146,14 @@ export function syncUICache(appState: {
|
||||
update.cachedCollapsedNavSections = appState.collapsedNavSections;
|
||||
}
|
||||
if ('currentWorktreeByProject' in appState && appState.currentWorktreeByProject) {
|
||||
// Sanitize on write: only persist entries where path is null (main branch).
|
||||
// Non-null paths point to worktree directories on disk that may be deleted
|
||||
// while the app is not running. Persisting stale paths can cause crash loops
|
||||
// on restore (the board renders with an invalid selection, the error boundary
|
||||
// reloads, which restores the same bad cache). This mirrors the sanitization
|
||||
// in restoreFromUICache() for defense-in-depth.
|
||||
const sanitized: Record<string, { path: string | null; branch: string }> = {};
|
||||
for (const [projectPath, worktree] of Object.entries(appState.currentWorktreeByProject)) {
|
||||
if (
|
||||
typeof worktree === 'object' &&
|
||||
worktree !== null &&
|
||||
'path' in worktree &&
|
||||
worktree.path === null
|
||||
) {
|
||||
sanitized[projectPath] = worktree;
|
||||
}
|
||||
}
|
||||
update.cachedCurrentWorktreeByProject = sanitized;
|
||||
// Persist all valid worktree selections (both main branch and feature worktrees).
|
||||
// Validation against actual worktrees happens at restore time in:
|
||||
// 1. restoreFromUICache() - early restore with validation
|
||||
// 2. use-worktrees.ts - runtime validation that resets to main if deleted
|
||||
// This allows users to have their feature worktree selection persist across refreshes.
|
||||
update.cachedCurrentWorktreeByProject = sanitizeCachedWorktreeByProject(
|
||||
appState.currentWorktreeByProject as Record<string, unknown>
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(update).length > 0) {
|
||||
@@ -178,33 +200,18 @@ export function restoreFromUICache(
|
||||
// Restore last selected worktree per project so the board doesn't
|
||||
// reset to main branch after PWA memory eviction or tab discard.
|
||||
//
|
||||
// IMPORTANT: Only restore entries where path is null (main branch selection).
|
||||
// Non-null paths point to worktree directories on disk that may have been
|
||||
// deleted while the PWA was evicted. Restoring a stale worktree path causes
|
||||
// the board to render with an invalid selection, and if the server can't
|
||||
// validate it fast enough, the app enters an unrecoverable crash loop
|
||||
// (the error boundary reloads, which restores the same bad cache).
|
||||
// Main branch (path=null) is always valid and safe to restore.
|
||||
// Restore all valid worktree selections (both main branch and feature worktrees).
|
||||
// The validation effect in use-worktrees.ts will handle resetting to main branch
|
||||
// if the cached worktree no longer exists when worktree data loads.
|
||||
if (
|
||||
cache.cachedCurrentWorktreeByProject &&
|
||||
Object.keys(cache.cachedCurrentWorktreeByProject).length > 0
|
||||
) {
|
||||
const sanitized: Record<string, { path: string | null; branch: string }> = {};
|
||||
for (const [projectPath, worktree] of Object.entries(cache.cachedCurrentWorktreeByProject)) {
|
||||
if (
|
||||
typeof worktree === 'object' &&
|
||||
worktree !== null &&
|
||||
'path' in worktree &&
|
||||
worktree.path === null
|
||||
) {
|
||||
// Main branch selection — always safe to restore
|
||||
sanitized[projectPath] = worktree;
|
||||
}
|
||||
// Non-null paths are dropped; the app will re-discover actual worktrees
|
||||
// from the server and the validation effect in use-worktrees will handle
|
||||
// resetting to main if the cached worktree no longer exists.
|
||||
// Null/malformed entries are also dropped to prevent crashes.
|
||||
}
|
||||
// Validate structure only - keep both null (main) and non-null (worktree) paths
|
||||
// Runtime validation in use-worktrees.ts handles deleted worktrees gracefully
|
||||
const sanitized = sanitizeCachedWorktreeByProject(
|
||||
cache.cachedCurrentWorktreeByProject as Record<string, unknown>
|
||||
);
|
||||
if (Object.keys(sanitized).length > 0) {
|
||||
stateUpdate.currentWorktreeByProject = sanitized;
|
||||
}
|
||||
|
||||
12
apps/ui/src/types/electron.d.ts
vendored
12
apps/ui/src/types/electron.d.ts
vendored
@@ -959,7 +959,12 @@ export interface WorktreeAPI {
|
||||
}>;
|
||||
|
||||
// Generate an AI commit message from git diff
|
||||
generateCommitMessage: (worktreePath: string) => Promise<{
|
||||
generateCommitMessage: (
|
||||
worktreePath: string,
|
||||
model?: string,
|
||||
thinkingLevel?: string,
|
||||
providerId?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
@@ -968,7 +973,10 @@ export interface WorktreeAPI {
|
||||
// Generate an AI PR title and description from branch diff
|
||||
generatePRDescription: (
|
||||
worktreePath: string,
|
||||
baseBranch?: string
|
||||
baseBranch?: string,
|
||||
model?: string,
|
||||
thinkingLevel?: string,
|
||||
providerId?: string
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
title?: string;
|
||||
|
||||
Reference in New Issue
Block a user