Comprehensive set of mobile and all improvements phase 1

This commit is contained in:
gsxdsm
2026-02-17 17:33:11 -08:00
parent 7fcf3c1e1f
commit cb44f8a717
36 changed files with 2037 additions and 304 deletions

View File

@@ -28,8 +28,13 @@ export default function App() {
if (savedPreference === 'true') {
return false;
}
// Only show splash once per session
if (sessionStorage.getItem('automaker-splash-shown')) {
// Only show splash once per browser session.
// Uses localStorage (not sessionStorage) so tab restores after discard
// don't replay the splash — sessionStorage is cleared when a tab is discarded.
// The flag is written on splash complete and cleared when the tab is fully closed
// (via the 'pagehide' + persisted=false event, which fires on true tab close but
// not on discard/background). This gives "once per actual session" semantics.
if (localStorage.getItem('automaker-splash-shown-session')) {
return false;
}
return true;
@@ -103,10 +108,25 @@ export default function App() {
useMobileOnlineManager();
const handleSplashComplete = useCallback(() => {
sessionStorage.setItem('automaker-splash-shown', 'true');
// Mark splash as shown for this session (survives tab discard/restore)
localStorage.setItem('automaker-splash-shown-session', 'true');
setShowSplash(false);
}, []);
// Clear the splash-shown flag when the tab is truly closed (not just discarded).
// `pagehide` with persisted=false fires on real navigation/close but NOT on discard,
// so discarded tabs that are restored skip the splash while true re-opens show it.
useEffect(() => {
const handlePageHide = (e: PageTransitionEvent) => {
if (!e.persisted) {
// Tab is being closed or navigating away (not going into bfcache)
localStorage.removeItem('automaker-splash-shown-session');
}
};
window.addEventListener('pagehide', handlePageHide);
return () => window.removeEventListener('pagehide', handlePageHide);
}, []);
return (
<TooltipProvider delayDuration={300}>
<RouterProvider router={router} />

View File

@@ -137,7 +137,7 @@ export function BoardHeader({
}, [isRefreshingBoard, onRefreshBoard]);
return (
<div className="flex items-center justify-between gap-5 p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center justify-between gap-5 px-4 py-2 sm:p-4 border-b border-border bg-glass backdrop-blur-md">
<div className="flex items-center gap-4">
<BoardSearchBar
searchQuery={searchQuery}

View File

@@ -3,6 +3,7 @@ import { ChevronDown, ChevronRight, Plus } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { useAppStore, formatShortcut } from '@/store/app-store';
import type { Feature } from '@/store/app-store';
import type { PipelineConfig, FeatureStatusWithPipeline } from '@automaker/types';
import { ListHeader } from './list-header';
@@ -134,7 +135,7 @@ const EmptyState = memo(function EmptyState({ onAddFeature }: { onAddFeature?: (
>
<p className="text-sm mb-4">No features to display</p>
{onAddFeature && (
<Button variant="outline" size="sm" onClick={onAddFeature}>
<Button variant="default" size="sm" onClick={onAddFeature}>
<Plus className="w-4 h-4 mr-2" />
Add Feature
</Button>
@@ -197,6 +198,10 @@ export const ListView = memo(function ListView({
// Track collapsed state for each status group
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
// Get the keyboard shortcut for adding features
const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts);
const addFeatureShortcut = keyboardShortcuts.addFeature || 'N';
// Generate status groups from columnFeaturesMap
const statusGroups = useMemo<StatusGroup[]>(() => {
const columns = getColumnsWithPipeline(pipelineConfig);
@@ -439,18 +444,21 @@ export const ListView = memo(function ListView({
})}
</div>
{/* Footer with Add Feature button */}
{/* Footer with Add Feature button, styled like board view */}
{onAddFeature && (
<div className="border-t border-border px-4 py-3">
<div className="border-t border-border px-4 py-2">
<Button
variant="outline"
variant="default"
size="sm"
onClick={onAddFeature}
className="w-full sm:w-auto"
className="w-full h-9 text-sm"
data-testid="list-view-add-feature"
>
<Plus className="w-4 h-4 mr-2" />
Add Feature
<span className="ml-auto pl-2 text-[10px] font-mono opacity-70 bg-black/20 px-1.5 py-0.5 rounded">
{formatShortcut(addFeatureShortcut, true)}
</span>
</Button>
</div>
)}

View File

@@ -0,0 +1,596 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Undo2,
FilePlus,
FileX,
FilePen,
FileText,
File,
ChevronDown,
ChevronRight,
AlertTriangle,
} 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 { cn } from '@/lib/utils';
import type { FileStatus } from '@/types/electron';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface DiscardWorktreeChangesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onDiscarded: () => void;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case 'A':
case '?':
return <FilePlus className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />;
case 'D':
return <FileX className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />;
case 'M':
case 'U':
return <FilePen className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />;
case 'R':
case 'C':
return <File className="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />;
default:
return <FileText className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />;
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'A':
return 'Added';
case '?':
return 'Untracked';
case 'D':
return 'Deleted';
case 'M':
return 'Modified';
case 'U':
return 'Updated';
case 'R':
return 'Renamed';
case 'C':
return 'Copied';
default:
return 'Changed';
}
};
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'A':
case '?':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'D':
return 'bg-red-500/20 text-red-400 border-red-500/30';
case 'M':
case 'U':
return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
case 'R':
case 'C':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
default:
return 'bg-muted text-muted-foreground border-border';
}
};
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
continue;
}
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
if (line.startsWith('@@')) {
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
if (currentHunk) {
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
return files;
}
function DiffLine({
type,
content,
lineNumber,
}: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}) {
const bgClass = {
context: 'bg-transparent',
addition: 'bg-green-500/10',
deletion: 'bg-red-500/10',
header: 'bg-blue-500/10',
};
const textClass = {
context: 'text-foreground-secondary',
addition: 'text-green-400',
deletion: 'text-red-400',
header: 'text-blue-400',
};
const prefix = {
context: ' ',
addition: '+',
deletion: '-',
header: '',
};
if (type === 'header') {
return (
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
{content}
</div>
);
}
return (
<div className={cn('flex font-mono text-xs', bgClass[type])}>
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
{lineNumber?.old ?? ''}
</span>
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
{lineNumber?.new ?? ''}
</span>
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
{prefix[type]}
</span>
<span className={cn('flex-1 px-1.5 whitespace-pre-wrap break-all', textClass[type])}>
{content || '\u00A0'}
</span>
</div>
);
}
export function DiscardWorktreeChangesDialog({
open,
onOpenChange,
worktree,
onDiscarded,
}: DiscardWorktreeChangesDialogProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// File selection state
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState('');
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [expandedFile, setExpandedFile] = useState<string | null>(null);
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
// Parse diffs
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
// Create a map of file path to parsed diff for quick lookup
const diffsByFile = useMemo(() => {
const map = new Map<string, ParsedFileDiff>();
for (const diff of parsedDiffs) {
map.set(diff.filePath, diff);
}
return map;
}, [parsedDiffs]);
// Load diffs when dialog opens
useEffect(() => {
if (open && worktree) {
setIsLoadingDiffs(true);
setFiles([]);
setDiffContent('');
setSelectedFiles(new Set());
setExpandedFile(null);
setError(null);
const loadDiffs = async () => {
try {
const api = getElectronAPI();
if (api?.git?.getDiffs) {
const result = await api.git.getDiffs(worktree.path);
if (result.success) {
const fileList = result.files ?? [];
setFiles(fileList);
setDiffContent(result.diff ?? '');
// Select all files by default
setSelectedFiles(new Set(fileList.map((f) => f.path)));
}
}
} catch (err) {
console.warn('Failed to load diffs for discard dialog:', err);
} finally {
setIsLoadingDiffs(false);
}
};
loadDiffs();
}
}, [open, worktree]);
const handleToggleFile = useCallback((filePath: string) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
}, []);
const handleToggleAll = useCallback(() => {
setSelectedFiles((prev) => {
if (prev.size === files.length) {
return new Set();
}
return new Set(files.map((f) => f.path));
});
}, [files]);
const handleFileClick = useCallback((filePath: string) => {
setExpandedFile((prev) => (prev === filePath ? null : filePath));
}, []);
const handleDiscard = async () => {
if (!worktree || selectedFiles.size === 0) return;
setIsLoading(true);
setError(null);
try {
const api = getHttpApiClient();
// Pass selected files if not all files are selected
const filesToDiscard =
selectedFiles.size === files.length ? undefined : Array.from(selectedFiles);
const result = await api.worktree.discardChanges(worktree.path, filesToDiscard);
if (result.success && result.result) {
if (result.result.discarded) {
const fileCount = filesToDiscard ? filesToDiscard.length : result.result.filesDiscarded;
toast.success('Changes discarded', {
description: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'} in ${worktree.branch}`,
});
onDiscarded();
onOpenChange(false);
} else {
toast.info('No changes to discard', {
description: result.result.message,
});
}
} else {
setError(result.error || 'Failed to discard changes');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to discard changes');
} finally {
setIsLoading(false);
}
};
if (!worktree) return null;
const allSelected = selectedFiles.size === files.length && files.length > 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Undo2 className="w-5 h-5 text-destructive" />
Discard Changes
</DialogTitle>
<DialogDescription>
Select which changes to discard in the{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree.
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
{/* File Selection */}
<div className="flex flex-col min-h-0">
<div className="flex items-center justify-between mb-1.5">
<Label className="text-sm font-medium flex items-center gap-2">
Files to discard
{isLoadingDiffs ? (
<Spinner size="sm" />
) : (
<span className="text-xs text-muted-foreground font-normal">
({selectedFiles.size}/{files.length} selected)
</span>
)}
</Label>
{files.length > 0 && (
<button
onClick={handleToggleAll}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{allSelected ? 'Deselect all' : 'Select all'}
</button>
)}
</div>
{isLoadingDiffs ? (
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
<Spinner size="sm" className="mr-2" />
<span className="text-sm">Loading changes...</span>
</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
<span className="text-sm">No changes detected</span>
</div>
) : (
<div className="border border-border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto scrollbar-visible">
{files.map((file) => {
const isChecked = selectedFiles.has(file.path);
const isExpanded = expandedFile === file.path;
const fileDiff = diffsByFile.get(file.path);
const additions = fileDiff
? fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
0
)
: 0;
const deletions = fileDiff
? fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
)
: 0;
return (
<div key={file.path} className="border-b border-border last:border-b-0">
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group',
isExpanded && 'bg-accent/30'
)}
>
{/* Checkbox */}
<Checkbox
checked={isChecked}
onCheckedChange={() => handleToggleFile(file.path)}
className="flex-shrink-0"
/>
{/* Clickable file row to show diff */}
<button
onClick={() => handleFileClick(file.path)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
{getFileIcon(file.status)}
<span className="text-xs font-mono truncate flex-1 text-foreground">
{file.path}
</span>
<span
className={cn(
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
getStatusBadgeColor(file.status)
)}
>
{getStatusLabel(file.status)}
</span>
{additions > 0 && (
<span className="text-[10px] text-green-400 flex-shrink-0">
+{additions}
</span>
)}
{deletions > 0 && (
<span className="text-[10px] text-red-400 flex-shrink-0">
-{deletions}
</span>
)}
</button>
</div>
{/* Expanded diff view */}
{isExpanded && fileDiff && (
<div className="bg-background border-t border-border max-h-[200px] overflow-y-auto scrollbar-visible">
{fileDiff.hunks.map((hunk, hunkIndex) => (
<div
key={hunkIndex}
className="border-b border-border-glass last:border-b-0"
>
{hunk.lines.map((line, lineIndex) => (
<DiffLine
key={lineIndex}
type={line.type}
content={line.content}
lineNumber={line.lineNumber}
/>
))}
</div>
))}
</div>
)}
{isExpanded && !fileDiff && (
<div className="px-4 py-3 text-xs text-muted-foreground bg-background border-t border-border">
{file.status === '?' ? (
<span>New file - diff preview not available</span>
) : file.status === 'D' ? (
<span>File deleted</span>
) : (
<span>Diff content not available</span>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Warning message */}
<div className="flex items-start gap-2 p-3 rounded-lg bg-destructive/10 border border-destructive/20">
<AlertTriangle className="w-4 h-4 text-destructive flex-shrink-0 mt-0.5" />
<p className="text-xs text-destructive">
This will permanently discard the selected changes. Staged changes will be unstaged,
modifications to tracked files will be reverted, and untracked files will be deleted.
This action cannot be undone.
</p>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDiscard}
disabled={isLoading || selectedFiles.size === 0}
>
{isLoading ? (
<>
<Spinner size="sm" className="mr-2" />
Discarding...
</>
) : (
<>
<Undo2 className="w-4 h-4 mr-2" />
Discard
{selectedFiles.size > 0 && selectedFiles.size < files.length
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
: selectedFiles.size > 0
? ` All (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
: ''}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -5,6 +5,7 @@ export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog';
export { DiscardWorktreeChangesDialog } from './discard-worktree-changes-dialog';
export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog';

View File

@@ -11,7 +11,7 @@ import {
import type { ReasoningEffort } from '@automaker/types';
import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/description-image-dropzone';
import { getElectronAPI } from '@/lib/electron';
import { isConnectionError, handleServerOffline } from '@/lib/http-api-client';
import { isConnectionError, handleServerOffline, getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useAutoMode } from '@/hooks/use-auto-mode';
import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
@@ -903,17 +903,40 @@ export function useBoardActions({
const handleUnarchiveFeature = useCallback(
(feature: Feature) => {
const updates = {
// Determine the branch to restore to:
// - If the feature had a branch assigned, keep it (preserves worktree context)
// - If no branch was assigned, it will show on the primary worktree
const featureBranch = feature.branchName;
// Check if the feature will be visible on the current worktree view
const willBeVisibleOnCurrentView = !featureBranch
? !currentWorktreeBranch ||
(projectPath ? isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch) : true)
: featureBranch === currentWorktreeBranch;
const updates: Partial<Feature> = {
status: 'verified' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.success('Feature restored', {
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
});
if (willBeVisibleOnCurrentView) {
toast.success('Feature restored', {
description: `Moved back to verified: ${truncateDescription(feature.description)}`,
});
} else {
toast.success('Feature restored', {
description: `Moved back to verified on branch "${featureBranch}": ${truncateDescription(feature.description)}`,
});
}
},
[updateFeature, persistFeatureUpdate]
[
updateFeature,
persistFeatureUpdate,
currentWorktreeBranch,
projectPath,
isPrimaryWorktreeBranch,
]
);
const handleViewOutput = useCallback(
@@ -1073,28 +1096,53 @@ export function useBoardActions({
const handleArchiveAllVerified = useCallback(async () => {
const verifiedFeatures = features.filter((f) => f.status === 'verified');
if (verifiedFeatures.length === 0) return;
// Optimistically update all features in the UI immediately
for (const feature of verifiedFeatures) {
const isRunning = runningAutoTasks.includes(feature.id);
if (isRunning) {
try {
await autoMode.stopFeature(feature.id);
} catch (error) {
logger.error('Error stopping feature before archive:', error);
updateFeature(feature.id, { status: 'completed' as const });
}
// Stop any running features in parallel (non-blocking for the UI)
const runningVerified = verifiedFeatures.filter((f) => runningAutoTasks.includes(f.id));
if (runningVerified.length > 0) {
await Promise.allSettled(
runningVerified.map((feature) =>
autoMode.stopFeature(feature.id).catch((error) => {
logger.error('Error stopping feature before archive:', error);
})
)
);
}
// Use bulk update API for a single server request instead of N individual calls
try {
if (currentProject) {
const api = getHttpApiClient();
const featureIds = verifiedFeatures.map((f) => f.id);
const result = await api.features.bulkUpdate(currentProject.path, featureIds, {
status: 'completed' as const,
});
if (result.success) {
// Refresh features from server to sync React Query cache
loadFeatures();
} else {
logger.error('Bulk archive failed:', result);
// Reload features to sync state with server
loadFeatures();
}
}
// Archive the feature by setting status to completed
const updates = {
status: 'completed' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
} catch (error) {
logger.error('Failed to bulk archive features:', error);
// Reload features to sync state with server on error
loadFeatures();
}
toast.success('All verified features archived', {
description: `Archived ${verifiedFeatures.length} feature(s).`,
});
}, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]);
}, [features, runningAutoTasks, autoMode, updateFeature, currentProject, loadFeatures]);
const handleDuplicateFeature = useCallback(
async (feature: Feature, asChild: boolean = false) => {

View File

@@ -28,10 +28,29 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
) => {
if (!currentProject) return;
// Capture previous cache snapshot for rollback on error
const previousFeatures = queryClient.getQueryData<Feature[]>(
queryKeys.features.all(currentProject.path)
);
// Optimistically update React Query cache for immediate board refresh
// This ensures status changes (e.g., restoring archived features) are reflected immediately
queryClient.setQueryData<Feature[]>(
queryKeys.features.all(currentProject.path),
(existing) => {
if (!existing) return existing;
return existing.map((f) => (f.id === featureId ? { ...f, ...updates } : f));
}
);
try {
const api = getElectronAPI();
if (!api.features) {
logger.error('Features API not available');
// Rollback optimistic update since we can't persist
if (previousFeatures) {
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
}
return;
}
@@ -51,6 +70,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
if (result.success && result.feature) {
const updatedFeature = result.feature as Feature;
updateFeature(updatedFeature.id, updatedFeature as Partial<Feature>);
// Update cache with server-confirmed feature before invalidating
queryClient.setQueryData<Feature[]>(
queryKeys.features.all(currentProject.path),
(features) => {
@@ -66,9 +86,23 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
});
} else if (!result.success) {
logger.error('API features.update failed', result);
// Rollback optimistic update on failure
if (previousFeatures) {
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
}
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
}
} catch (error) {
logger.error('Failed to persist feature update:', error);
// Rollback optimistic update on error
if (previousFeatures) {
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
}
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
}
},
[currentProject, updateFeature, queryClient]

View File

@@ -318,7 +318,7 @@ export function KanbanBoard({
return (
<div
className={cn(
'flex-1 overflow-x-auto px-5 pt-4 pb-4 relative',
'flex-1 overflow-x-auto px-5 pt-2 sm:pt-4 pb-1 sm:pb-4 relative',
'transition-opacity duration-200',
className
)}

View File

@@ -14,12 +14,7 @@ import type {
TestRunnerOutputEvent,
TestRunnerCompletedEvent,
} from '@/types/electron';
import type {
WorktreePanelProps,
WorktreeInfo,
TestSessionInfo,
BranchSwitchConflictInfo,
} from './types';
import type { WorktreePanelProps, WorktreeInfo, TestSessionInfo } from './types';
import {
useWorktrees,
useDevServers,
@@ -36,10 +31,13 @@ import {
WorktreeDropdown,
} from './components';
import { useAppStore } from '@/store/app-store';
import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import {
ViewWorktreeChangesDialog,
PushToRemoteDialog,
MergeWorktreeDialog,
DiscardWorktreeChangesDialog,
} from '../dialogs';
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
import { Undo2 } from 'lucide-react';
import { getElectronAPI } from '@/lib/electron';
/** Threshold for switching from tabs to dropdown layout (number of worktrees) */
@@ -471,30 +469,9 @@ export function WorktreePanel({
setDiscardChangesDialogOpen(true);
}, []);
const handleConfirmDiscardChanges = useCallback(async () => {
if (!discardChangesWorktree) return;
try {
const api = getHttpApiClient();
const result = await api.worktree.discardChanges(discardChangesWorktree.path);
if (result.success) {
toast.success('Changes discarded', {
description: `Discarded changes in ${discardChangesWorktree.branch}`,
});
// Refresh worktrees to update the changes status
fetchWorktrees({ silent: true });
} else {
toast.error('Failed to discard changes', {
description: result.error || 'Unknown error',
});
}
} catch (error) {
toast.error('Failed to discard changes', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
}, [discardChangesWorktree, fetchWorktrees]);
const handleDiscardCompleted = useCallback(() => {
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle opening the log panel for a specific worktree
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
@@ -679,17 +656,12 @@ export function WorktreePanel({
projectPath={projectPath}
/>
{/* Discard Changes Confirmation Dialog */}
<ConfirmDialog
{/* Discard Changes Dialog */}
<DiscardWorktreeChangesDialog
open={discardChangesDialogOpen}
onOpenChange={setDiscardChangesDialogOpen}
onConfirm={handleConfirmDiscardChanges}
title="Discard Changes"
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
icon={Undo2}
iconClassName="text-destructive"
confirmText="Discard Changes"
confirmVariant="destructive"
worktree={discardChangesWorktree}
onDiscarded={handleDiscardCompleted}
/>
{/* Dev Server Logs Panel */}
@@ -1015,17 +987,12 @@ export function WorktreePanel({
projectPath={projectPath}
/>
{/* Discard Changes Confirmation Dialog */}
<ConfirmDialog
{/* Discard Changes Dialog */}
<DiscardWorktreeChangesDialog
open={discardChangesDialogOpen}
onOpenChange={setDiscardChangesDialogOpen}
onConfirm={handleConfirmDiscardChanges}
title="Discard Changes"
description={`Are you sure you want to discard all changes in "${discardChangesWorktree?.branch}"? This will reset staged changes, discard modifications to tracked files, and remove untracked files. This action cannot be undone.`}
icon={Undo2}
iconClassName="text-destructive"
confirmText="Discard Changes"
confirmVariant="destructive"
worktree={discardChangesWorktree}
onDiscarded={handleDiscardCompleted}
/>
{/* Dev Server Logs Panel */}

View File

@@ -21,8 +21,6 @@ const SPECIAL_KEYS = {
const CTRL_KEYS = {
'Ctrl+C': '\x03', // Interrupt / SIGINT
'Ctrl+Z': '\x1a', // Suspend / SIGTSTP
'Ctrl+D': '\x04', // EOF
'Ctrl+L': '\x0c', // Clear screen
'Ctrl+A': '\x01', // Move to beginning of line
'Ctrl+B': '\x02', // Move cursor back (tmux prefix)
} as const;
@@ -34,7 +32,7 @@ const ARROW_KEYS = {
left: '\x1b[D',
} as const;
interface MobileTerminalControlsProps {
interface MobileTerminalShortcutsProps {
/** Callback to send input data to the terminal WebSocket */
onSendInput: (data: string) => void;
/** Whether the terminal is connected and ready */
@@ -42,14 +40,17 @@ interface MobileTerminalControlsProps {
}
/**
* Mobile quick controls bar for terminal interaction on touch devices.
* Mobile shortcuts bar for terminal interaction on touch devices.
* Provides special keys (Escape, Tab, Ctrl+C, etc.) and arrow keys that are
* typically unavailable on mobile virtual keyboards.
*
* Anchored at the top of the terminal panel, above the terminal content.
* Can be collapsed to a minimal toggle to maximize terminal space.
*/
export function MobileTerminalControls({ onSendInput, isConnected }: MobileTerminalControlsProps) {
export function MobileTerminalShortcuts({
onSendInput,
isConnected,
}: MobileTerminalShortcutsProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
// Track repeat interval for arrow key long-press
@@ -108,10 +109,10 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
<button
className="flex items-center gap-1 px-4 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors touch-manipulation"
onClick={() => setIsCollapsed(false)}
title="Show quick controls"
title="Show shortcuts"
>
<ChevronDown className="h-3.5 w-3.5" />
<span>Controls</span>
<span>Shortcuts</span>
</button>
</div>
);
@@ -123,7 +124,7 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
<button
className="p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors shrink-0 touch-manipulation"
onClick={() => setIsCollapsed(true)}
title="Hide quick controls"
title="Hide shortcuts"
>
<ChevronUp className="h-4 w-4" />
</button>
@@ -132,12 +133,12 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
<div className="w-px h-6 bg-border shrink-0" />
{/* Special keys */}
<ControlButton
<ShortcutButton
label="Esc"
onPress={() => sendKey(SPECIAL_KEYS.escape)}
disabled={!isConnected}
/>
<ControlButton
<ShortcutButton
label="Tab"
onPress={() => sendKey(SPECIAL_KEYS.tab)}
disabled={!isConnected}
@@ -147,31 +148,19 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
<div className="w-px h-6 bg-border shrink-0" />
{/* Common Ctrl shortcuts */}
<ControlButton
<ShortcutButton
label="^C"
title="Ctrl+C (Interrupt)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+C'])}
disabled={!isConnected}
/>
<ControlButton
<ShortcutButton
label="^Z"
title="Ctrl+Z (Suspend)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+Z'])}
disabled={!isConnected}
/>
<ControlButton
label="^D"
title="Ctrl+D (EOF)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+D'])}
disabled={!isConnected}
/>
<ControlButton
label="^L"
title="Ctrl+L (Clear)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+L'])}
disabled={!isConnected}
/>
<ControlButton
<ShortcutButton
label="^B"
title="Ctrl+B (Back/tmux prefix)"
onPress={() => sendKey(CTRL_KEYS['Ctrl+B'])}
@@ -181,26 +170,6 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
{/* Separator */}
<div className="w-px h-6 bg-border shrink-0" />
{/* Navigation keys */}
<ControlButton
label="Del"
onPress={() => sendKey(SPECIAL_KEYS.delete)}
disabled={!isConnected}
/>
<ControlButton
label="Home"
onPress={() => sendKey(SPECIAL_KEYS.home)}
disabled={!isConnected}
/>
<ControlButton
label="End"
onPress={() => sendKey(SPECIAL_KEYS.end)}
disabled={!isConnected}
/>
{/* Separator */}
<div className="w-px h-6 bg-border shrink-0" />
{/* Arrow keys with long-press repeat */}
<ArrowButton
direction="left"
@@ -226,14 +195,34 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
onRelease={handleArrowRelease}
disabled={!isConnected}
/>
{/* Separator */}
<div className="w-px h-6 bg-border shrink-0" />
{/* Navigation keys */}
<ShortcutButton
label="Del"
onPress={() => sendKey(SPECIAL_KEYS.delete)}
disabled={!isConnected}
/>
<ShortcutButton
label="Home"
onPress={() => sendKey(SPECIAL_KEYS.home)}
disabled={!isConnected}
/>
<ShortcutButton
label="End"
onPress={() => sendKey(SPECIAL_KEYS.end)}
disabled={!isConnected}
/>
</div>
);
}
/**
* Individual control button for special keys and shortcuts.
* Individual shortcut button for special keys.
*/
function ControlButton({
function ShortcutButton({
label,
title,
onPress,

View File

@@ -0,0 +1,147 @@
import { useCallback } from 'react';
import { cn } from '@/lib/utils';
export type StickyModifier = 'ctrl' | 'alt' | null;
interface StickyModifierKeysProps {
/** Currently active sticky modifier (null = none) */
activeModifier: StickyModifier;
/** Callback when a modifier is toggled */
onModifierChange: (modifier: StickyModifier) => void;
/** Whether the terminal is connected */
isConnected: boolean;
}
/**
* Sticky modifier keys (Ctrl, Alt) for the terminal toolbar.
*
* "Sticky" means: tap a modifier to activate it, then the next key pressed
* in the terminal will be sent with that modifier applied. After the modified
* key is sent, the sticky modifier automatically deactivates.
*
* - Ctrl: Sends the control code (character code & 0x1f)
* - Alt: Sends escape prefix (\x1b) before the character
*
* Tapping an already-active modifier deactivates it (toggle behavior).
*/
export function StickyModifierKeys({
activeModifier,
onModifierChange,
isConnected,
}: StickyModifierKeysProps) {
const toggleCtrl = useCallback(() => {
onModifierChange(activeModifier === 'ctrl' ? null : 'ctrl');
}, [activeModifier, onModifierChange]);
const toggleAlt = useCallback(() => {
onModifierChange(activeModifier === 'alt' ? null : 'alt');
}, [activeModifier, onModifierChange]);
return (
<div className="flex items-center gap-1 shrink-0">
<ModifierButton
label="Ctrl"
isActive={activeModifier === 'ctrl'}
onPress={toggleCtrl}
disabled={!isConnected}
title="Sticky Ctrl tap to activate, then press a key (e.g. Ctrl+C)"
/>
<ModifierButton
label="Alt"
isActive={activeModifier === 'alt'}
onPress={toggleAlt}
disabled={!isConnected}
title="Sticky Alt tap to activate, then press a key (e.g. Alt+D)"
/>
</div>
);
}
/**
* Individual modifier toggle button with active state styling.
*/
function ModifierButton({
label,
isActive,
onPress,
disabled = false,
title,
}: {
label: string;
isActive: boolean;
onPress: () => void;
disabled?: boolean;
title?: string;
}) {
return (
<button
className={cn(
'px-2 py-1 rounded-md text-xs font-medium shrink-0 select-none transition-all min-w-[36px] min-h-[28px] flex items-center justify-center',
'touch-manipulation border',
isActive
? 'bg-brand-500 text-white border-brand-500 shadow-sm shadow-brand-500/25'
: 'bg-muted/80 text-foreground hover:bg-accent border-transparent',
disabled && 'opacity-40 pointer-events-none'
)}
onPointerDown={(e) => {
e.preventDefault(); // Prevent focus stealing from terminal
onPress();
}}
title={title}
disabled={disabled}
aria-pressed={isActive}
role="switch"
>
{label}
</button>
);
}
/**
* Apply a sticky modifier to raw terminal input data.
*
* For Ctrl: converts printable ASCII characters to their control-code equivalent.
* e.g. 'c' → \x03 (Ctrl+C), 'a' → \x01 (Ctrl+A)
*
* For Alt: prepends the escape character (\x1b) before the data.
* e.g. 'd' → \x1bd (Alt+D)
*
* Returns null if the modifier cannot be applied (non-ASCII, etc.)
*/
export function applyStickyModifier(data: string, modifier: StickyModifier): string | null {
if (!modifier || !data) return null;
if (modifier === 'ctrl') {
// Only apply Ctrl to single printable ASCII characters (a-z, A-Z, and some specials)
if (data.length === 1) {
const code = data.charCodeAt(0);
// Letters a-z or A-Z: Ctrl sends code & 0x1f
if ((code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a)) {
return String.fromCharCode(code & 0x1f);
}
// Special Ctrl combinations
// Ctrl+[ = Escape (0x1b)
if (code === 0x5b) return '\x1b';
// Ctrl+\ = 0x1c
if (code === 0x5c) return '\x1c';
// Ctrl+] = 0x1d
if (code === 0x5d) return '\x1d';
// Ctrl+^ = 0x1e
if (code === 0x5e) return '\x1e';
// Ctrl+_ = 0x1f
if (code === 0x5f) return '\x1f';
// Ctrl+Space or Ctrl+@ = 0x00 (NUL)
if (code === 0x20 || code === 0x40) return '\x00';
}
return null;
}
if (modifier === 'alt') {
// Alt sends ESC prefix followed by the character
return '\x1b' + data;
}
return null;
}

View File

@@ -53,7 +53,12 @@ import { getElectronAPI } from '@/lib/electron';
import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client';
import { useIsMobile } from '@/hooks/use-media-query';
import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize';
import { MobileTerminalControls } from './mobile-terminal-controls';
import { MobileTerminalShortcuts } from './mobile-terminal-shortcuts';
import {
StickyModifierKeys,
applyStickyModifier,
type StickyModifier,
} from './sticky-modifier-keys';
const logger = createLogger('Terminal');
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
@@ -158,6 +163,10 @@ export function TerminalPanel({
const showSearchRef = useRef(false);
const [isAtBottom, setIsAtBottom] = useState(true);
// Sticky modifier key state (Ctrl or Alt) for the terminal toolbar
const [stickyModifier, setStickyModifier] = useState<StickyModifier>(null);
const stickyModifierRef = useRef<StickyModifier>(null);
const [connectionStatus, setConnectionStatus] = useState<
'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'auth_failed'
>('connecting');
@@ -166,7 +175,7 @@ export function TerminalPanel({
const INITIAL_RECONNECT_DELAY = 1000;
const [processExitCode, setProcessExitCode] = useState<number | null>(null);
// Detect mobile viewport for quick controls
// Detect mobile viewport for shortcuts bar
const isMobile = useIsMobile();
// Track virtual keyboard height on mobile to prevent overlap
@@ -354,7 +363,13 @@ export function TerminalPanel({
}
}, []);
// Send raw input to terminal via WebSocket (used by mobile quick controls)
// Handle sticky modifier toggle and keep ref in sync
const handleStickyModifierChange = useCallback((modifier: StickyModifier) => {
setStickyModifier(modifier);
stickyModifierRef.current = modifier;
}, []);
// Send raw input to terminal via WebSocket (used by mobile shortcuts bar)
const sendTerminalInput = useCallback((data: string) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'input', data }));
@@ -1207,10 +1222,24 @@ export function TerminalPanel({
connect();
// Handle terminal input
// Handle terminal input - apply sticky modifier if active
const dataHandler = terminal.onData((data) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({ type: 'input', data }));
const modifier = stickyModifierRef.current;
if (modifier) {
const modified = applyStickyModifier(data, modifier);
if (modified !== null) {
wsRef.current.send(JSON.stringify({ type: 'input', data: modified }));
} else {
// Could not apply modifier (e.g. non-ASCII input), send as-is
wsRef.current.send(JSON.stringify({ type: 'input', data }));
}
// Clear sticky modifier after one key press (one-shot behavior)
stickyModifierRef.current = null;
setStickyModifier(null);
} else {
wsRef.current.send(JSON.stringify({ type: 'input', data }));
}
}
});
@@ -2037,6 +2066,15 @@ export function TerminalPanel({
<div className="w-px h-3 mx-0.5 bg-border" />
{/* Sticky modifier keys (Ctrl, Alt) */}
<StickyModifierKeys
activeModifier={stickyModifier}
onModifierChange={handleStickyModifierChange}
isConnected={connectionStatus === 'connected'}
/>
<div className="w-px h-3 mx-0.5 bg-border" />
{/* Split/close buttons */}
<Button
variant="ghost"
@@ -2157,9 +2195,9 @@ export function TerminalPanel({
</div>
)}
{/* Mobile quick controls - special keys and arrow keys for touch devices */}
{/* Mobile shortcuts bar - special keys and arrow keys for touch devices */}
{isMobile && (
<MobileTerminalControls
<MobileTerminalShortcuts
onSendInput={sendTerminalInput}
isConnected={connectionStatus === 'connected'}
/>

View File

@@ -23,6 +23,7 @@
import { useEffect } from 'react';
import { focusManager, onlineManager } from '@tanstack/react-query';
import { isMobileDevice } from '@/lib/mobile-detect';
import { queryClient } from '@/lib/query-client';
/**
* Grace period (ms) after the app becomes visible before allowing refetches.
@@ -108,9 +109,19 @@ export function useMobileOnlineManager(): void {
// App was backgrounded for a long time.
// Briefly mark as offline to prevent all queries from refetching at once,
// then restore online status after a delay so queries refetch gradually.
//
// IMPORTANT: When online is restored, invalidate all stale queries.
// This fixes a race condition where WebSocket reconnects immediately
// and fires invalidations during the offline window — those invalidations
// are silently dropped by React Query because it thinks we're offline.
// By invalidating stale queries after going online, we catch any updates
// that were missed during the offline grace period.
onlineManager.setOnline(false);
setTimeout(() => {
onlineManager.setOnline(true);
// Re-invalidate all stale queries to catch any WebSocket events
// that were dropped during the offline grace period
queryClient.invalidateQueries({ stale: true });
}, 2000);
}
}

View File

@@ -2572,8 +2572,8 @@ function createMockWorktreeAPI(): WorktreeAPI {
};
},
discardChanges: async (worktreePath: string) => {
console.log('[Mock] Discarding changes:', { worktreePath });
discardChanges: async (worktreePath: string, files?: string[]) => {
console.log('[Mock] Discarding changes:', { worktreePath, files });
return {
success: true,
result: {

View File

@@ -692,6 +692,10 @@ export class HttpApiClient implements ElectronAPI {
private eventCallbacks: Map<EventType, Set<EventCallback>> = new Map();
private reconnectTimer: NodeJS.Timeout | null = null;
private isConnecting = false;
/** Consecutive reconnect failure count for exponential backoff */
private reconnectAttempts = 0;
/** Visibility change handler reference for cleanup */
private visibilityHandler: (() => void) | null = null;
constructor() {
this.serverUrl = getServerUrl();
@@ -709,6 +713,27 @@ export class HttpApiClient implements ElectronAPI {
this.connectWebSocket();
});
}
// OPTIMIZATION: Reconnect WebSocket immediately when tab becomes visible
// This eliminates the reconnection delay after tab discard/background
this.visibilityHandler = () => {
if (document.visibilityState === 'visible') {
// If WebSocket is disconnected, reconnect immediately
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
logger.info('Tab became visible - attempting immediate WebSocket reconnect');
// Clear any pending reconnect timer
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.reconnectAttempts = 0; // Reset backoff on visibility change
this.connectWebSocket();
}
}
};
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.visibilityHandler);
}
}
/**
@@ -832,6 +857,7 @@ export class HttpApiClient implements ElectronAPI {
this.ws.onopen = () => {
logger.info('WebSocket connected');
this.isConnecting = false;
this.reconnectAttempts = 0; // Reset backoff on successful connection
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
@@ -863,12 +889,27 @@ export class HttpApiClient implements ElectronAPI {
logger.info('WebSocket disconnected');
this.isConnecting = false;
this.ws = null;
// Attempt to reconnect after 5 seconds
// OPTIMIZATION: Exponential backoff instead of fixed 5-second delay
// First attempt: immediate (0ms), then 500ms → 1s → 2s → 5s max
if (!this.reconnectTimer) {
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
const backoffDelays = [0, 500, 1000, 2000, 5000];
const delayMs =
backoffDelays[Math.min(this.reconnectAttempts, backoffDelays.length - 1)] ?? 5000;
this.reconnectAttempts++;
if (delayMs === 0) {
// Immediate reconnect on first attempt
this.connectWebSocket();
}, 5000);
} else {
logger.info(
`WebSocket reconnecting in ${delayMs}ms (attempt ${this.reconnectAttempts})`
);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.connectWebSocket();
}, delayMs);
}
}
};
@@ -2147,8 +2188,8 @@ export class HttpApiClient implements ElectronAPI {
this.httpDelete('/api/worktree/init-script', { projectPath }),
runInitScript: (projectPath: string, worktreePath: string, branch: string) =>
this.post('/api/worktree/run-init-script', { projectPath, worktreePath, branch }),
discardChanges: (worktreePath: string) =>
this.post('/api/worktree/discard-changes', { worktreePath }),
discardChanges: (worktreePath: string, files?: string[]) =>
this.post('/api/worktree/discard-changes', { worktreePath, files }),
onInitScriptEvent: (
callback: (event: {
type: 'worktree:init-started' | 'worktree:init-output' | 'worktree:init-completed';

View File

@@ -10,7 +10,7 @@
* blank screens, reloads, and battery drain on flaky mobile connections.
*/
import { QueryClient } from '@tanstack/react-query';
import { QueryClient, keepPreviousData } from '@tanstack/react-query';
import { toast } from 'sonner';
import { createLogger } from '@automaker/utils/logger';
import { isConnectionError, handleServerOffline } from './http-api-client';
@@ -63,10 +63,10 @@ export const STALE_TIMES = {
* and component unmounts, preventing blank screens on re-mount.
*/
export const GC_TIMES = {
/** Default garbage collection time */
DEFAULT: isMobileDevice ? 15 * 60 * 1000 : 5 * 60 * 1000, // 15 min on mobile, 5 min desktop
/** Default garbage collection time - must exceed persist maxAge for cache to survive tab discard */
DEFAULT: isMobileDevice ? 15 * 60 * 1000 : 10 * 60 * 1000, // 15 min on mobile, 10 min desktop
/** Extended for expensive queries */
EXTENDED: isMobileDevice ? 30 * 60 * 1000 : 10 * 60 * 1000, // 30 min on mobile, 10 min desktop
EXTENDED: isMobileDevice ? 30 * 60 * 1000 : 15 * 60 * 1000, // 30 min on mobile, 15 min desktop
} as const;
/**
@@ -143,13 +143,14 @@ export const queryClient = new QueryClient({
// invalidation handles real-time updates; polling handles the rest.
refetchOnWindowFocus: !isMobileDevice,
refetchOnReconnect: true,
// On mobile, only refetch on mount if data is stale (not always).
// On mobile, only refetch on mount if data is stale (true = refetch only when stale).
// On desktop, always refetch on mount for freshest data ('always' = refetch even if fresh).
// This prevents unnecessary network requests when navigating between
// routes, which was causing blank screen flickers on mobile.
refetchOnMount: isMobileDevice ? true : true,
refetchOnMount: isMobileDevice ? true : 'always',
// Keep previous data visible while refetching to prevent blank flashes.
// This is especially important on mobile where network is slower.
placeholderData: isMobileDevice ? (previousData: unknown) => previousData : undefined,
placeholderData: isMobileDevice ? keepPreviousData : undefined,
},
mutations: {
onError: handleMutationError,

View File

@@ -0,0 +1,133 @@
/**
* React Query Cache Persistence
*
* Persists the React Query cache to IndexedDB so that after a tab discard
* or page reload, the user sees cached data instantly while fresh data
* loads in the background.
*
* Uses @tanstack/react-query-persist-client with idb-keyval for IndexedDB storage.
* Cached data is treated as stale on restore and silently refetched.
*/
import { get, set, del } from 'idb-keyval';
import type { PersistedClient, Persister } from '@tanstack/react-query-persist-client';
import { createLogger } from '@automaker/utils/logger';
const logger = createLogger('QueryPersist');
const IDB_KEY = 'automaker-react-query-cache';
/**
* Maximum age of persisted cache before it's discarded (24 hours).
* After this time, the cache is considered too old and will be removed.
*/
export const PERSIST_MAX_AGE_MS = 24 * 60 * 60 * 1000;
/**
* Throttle time for persisting cache to IndexedDB.
* Prevents excessive writes during rapid query updates.
*/
export const PERSIST_THROTTLE_MS = 2000;
/**
* Query key prefixes that should NOT be persisted.
* Auth-related and volatile data should always be fetched fresh.
*/
const EXCLUDED_QUERY_KEY_PREFIXES = ['auth', 'health', 'wsToken', 'sandbox'];
/**
* Check if a query key should be excluded from persistence
*/
function shouldExcludeQuery(queryKey: readonly unknown[]): boolean {
if (queryKey.length === 0) return false;
const firstKey = String(queryKey[0]);
return EXCLUDED_QUERY_KEY_PREFIXES.some((prefix) => firstKey.startsWith(prefix));
}
/**
* Check whether there is a recent enough React Query cache in IndexedDB
* to consider the app "warm" (i.e., safe to skip blocking on the server
* health check and show the UI immediately).
*
* Returns true only if:
* 1. The cache exists and is recent (within maxAgeMs)
* 2. The cache buster matches the current build hash
*
* If the buster doesn't match, PersistQueryClientProvider will wipe the
* cache on restore — so we must NOT skip the server wait in that case,
* otherwise the board renders with empty queries and no data.
*
* This is a read-only probe — it does not restore the cache (that is
* handled by PersistQueryClientProvider automatically).
*/
export async function hasWarmIDBCache(
currentBuster: string,
maxAgeMs = PERSIST_MAX_AGE_MS
): Promise<boolean> {
try {
const client = await get<PersistedClient>(IDB_KEY);
if (!client) return false;
// PersistedClient stores a `timestamp` (ms) when it was last persisted
const age = Date.now() - (client.timestamp ?? 0);
if (age >= maxAgeMs) return false;
// If the buster doesn't match, PersistQueryClientProvider will wipe the cache.
// Treat this as a cold start — we need fresh data from the server.
if (currentBuster && client.buster !== currentBuster) return false;
return true;
} catch {
return false;
}
}
/**
* Create an IndexedDB-based persister for React Query.
*
* This persister:
* - Stores the full query cache in IndexedDB under a single key
* - Filters out auth/health queries that shouldn't be persisted
* - Handles errors gracefully (cache persistence is best-effort)
*/
export function createIDBPersister(): Persister {
return {
persistClient: async (client: PersistedClient) => {
try {
// Filter out excluded queries before persisting
const filteredClient: PersistedClient = {
...client,
clientState: {
...client.clientState,
queries: client.clientState.queries.filter(
(query) => !shouldExcludeQuery(query.queryKey)
),
// Don't persist mutations (they should be re-triggered, not replayed)
mutations: [],
},
};
await set(IDB_KEY, filteredClient);
} catch (error) {
logger.warn('Failed to persist query cache to IndexedDB:', error);
}
},
restoreClient: async () => {
try {
const client = await get<PersistedClient>(IDB_KEY);
if (client) {
logger.info('Restored React Query cache from IndexedDB');
}
return client ?? undefined;
} catch (error) {
logger.warn('Failed to restore query cache from IndexedDB:', error);
return undefined;
}
},
removeClient: async () => {
try {
await del(IDB_KEY);
} catch (error) {
logger.warn('Failed to remove query cache from IndexedDB:', error);
}
},
};
}

View File

@@ -1,6 +1,6 @@
import { createRootRoute, Outlet, useLocation, useNavigate } from '@tanstack/react-router';
import { useEffect, useState, useCallback, useDeferredValue, useRef } from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { createLogger } from '@automaker/utils/logger';
import { Sidebar } from '@/components/layout/sidebar';
@@ -26,10 +26,17 @@ import {
} from '@/lib/http-api-client';
import {
hydrateStoreFromSettings,
parseLocalStorageSettings,
signalMigrationComplete,
performSettingsMigration,
} from '@/hooks/use-settings-migration';
import { queryClient } from '@/lib/query-client';
import {
createIDBPersister,
hasWarmIDBCache,
PERSIST_MAX_AGE_MS,
PERSIST_THROTTLE_MS,
} from '@/lib/query-persist';
import { Toaster } from 'sonner';
import { ThemeOption, themeOptions } from '@/config/theme-options';
import { SandboxRiskDialog } from '@/components/dialogs/sandbox-risk-dialog';
@@ -38,6 +45,8 @@ import { LoadingState } from '@/components/ui/loading-state';
import { useProjectSettingsLoader } from '@/hooks/use-project-settings-loader';
import { useIsCompact } from '@/hooks/use-media-query';
import type { Project } from '@/lib/electron';
import type { GlobalSettings } from '@automaker/types';
import { syncUICache, restoreFromUICache } from '@/store/ui-cache-store';
const logger = createLogger('RootLayout');
const IS_DEV = import.meta.env.DEV;
@@ -49,6 +58,28 @@ const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
const AUTO_OPEN_HISTORY_INDEX = 0;
const SINGLE_PROJECT_COUNT = 1;
const DEFAULT_LAST_OPENED_TIME_MS = 0;
// IndexedDB persister for React Query cache (survives tab discard)
const idbPersister = createIDBPersister();
/** Options for PersistQueryClientProvider */
const persistOptions = {
persister: idbPersister,
maxAge: PERSIST_MAX_AGE_MS,
// Throttle IndexedDB writes to prevent excessive I/O on every query state change.
// Without this, every query update triggers an IndexedDB write — especially costly on mobile.
throttleTime: PERSIST_THROTTLE_MS,
// Build hash injected by Vite — same hash used by swCacheBuster for the SW CACHE_NAME.
// When the app is rebuilt, this changes and both the IDB query cache and SW cache
// are invalidated together, preventing stale data from surviving a deployment.
// In dev mode this is a stable hash of the package version so the cache persists
// across hot reloads.
buster: typeof __APP_BUILD_HASH__ !== 'undefined' ? __APP_BUILD_HASH__ : '',
dehydrateOptions: {
shouldDehydrateQuery: (query: { state: { status: string } }) =>
query.state.status === 'success',
},
};
const AUTO_OPEN_STATUS = {
idle: 'idle',
opening: 'opening',
@@ -265,6 +296,21 @@ function RootLayoutContent() {
setIsMounted(true);
}, []);
// Sync critical UI state to the persistent UI cache store
// This keeps the cache up-to-date so tab discard recovery is instant
useEffect(() => {
const unsubscribe = useAppStore.subscribe((state) => {
syncUICache({
currentProject: state.currentProject,
sidebarOpen: state.sidebarOpen,
sidebarStyle: state.sidebarStyle,
worktreePanelCollapsed: state.worktreePanelCollapsed,
collapsedNavSections: state.collapsedNavSections,
});
});
return unsubscribe;
}, []);
// Check sandbox environment only after user is authenticated, setup is complete, and settings are loaded
useEffect(() => {
// Skip if already decided
@@ -391,6 +437,11 @@ function RootLayoutContent() {
// Initialize authentication
// - Electron mode: Uses API key from IPC (header-based auth)
// - Web mode: Uses HTTP-only session cookie
//
// Optimizations applied:
// 1. Instant hydration from localStorage settings cache (optimistic)
// 2. Parallelized server checks: verifySession + fetchSettings fire together
// 3. Server settings reconcile in background after optimistic render
useEffect(() => {
// Prevent concurrent auth checks
if (authCheckRunning.current) {
@@ -401,40 +452,171 @@ function RootLayoutContent() {
authCheckRunning.current = true;
try {
// OPTIMIZATION: Restore UI layout from the UI cache store immediately.
// This gives instant visual continuity (sidebar state, nav sections, etc.)
// before server settings arrive. Will be reconciled by hydrateStoreFromSettings().
restoreFromUICache((state) => useAppStore.setState(state));
// OPTIMIZATION: Immediately hydrate from localStorage settings cache
// This gives the user an instant UI while server data loads in the background
const cachedSettings = parseLocalStorageSettings();
let optimisticallyHydrated = false;
if (cachedSettings && cachedSettings.projects && cachedSettings.projects.length > 0) {
logger.info('[FAST_HYDRATE] Optimistically hydrating from localStorage cache');
hydrateStoreFromSettings(cachedSettings as GlobalSettings);
optimisticallyHydrated = true;
}
// Initialize API key for Electron mode
await initApiKey();
// OPTIMIZATION: Skip blocking on server health check when both caches are warm.
//
// On a normal cold start, we must wait for the server to be ready before
// making auth/settings requests. But on a tab restore or page reload, the
// server is almost certainly already running — waiting up to ~12s for health
// check retries just shows a blank loading screen when the user has data cached.
//
// When BOTH of these are true:
// 1. localStorage settings cache has valid project data (optimisticallyHydrated)
// 2. IndexedDB React Query cache exists and is recent (< 24h old)
//
// ...we mark auth as complete immediately with the cached data, then verify
// the session in the background. If the session turns out to be invalid, the
// 401 handler in http-api-client.ts will fire automaker:logged-out and redirect.
// If the server isn't reachable, automaker:server-offline will redirect to /login.
//
// This turns tab-restore from: blank screen → 1-3s wait → board
// into: board renders instantly → silent background verify
// Pass the current buster so hasWarmIDBCache can verify the cache is still
// valid for this build. If the buster changed (new deployment or dev restart),
// PersistQueryClientProvider will wipe the IDB cache — we must not treat
// it as warm in that case or we'll render the board with empty queries.
const currentBuster = typeof __APP_BUILD_HASH__ !== 'undefined' ? __APP_BUILD_HASH__ : '';
const idbWarm = optimisticallyHydrated && (await hasWarmIDBCache(currentBuster));
if (idbWarm) {
logger.info('[FAST_HYDRATE] Warm caches detected — marking auth complete optimistically');
signalMigrationComplete();
useAuthStore.getState().setAuthState({
isAuthenticated: true,
authChecked: true,
settingsLoaded: true,
});
// Verify session + fetch fresh settings in the background.
// The UI is already rendered; this reconciles any stale data.
void (async () => {
try {
const serverReady = await waitForServerReady();
if (!serverReady) {
// Server is down — the server-offline event handler in __root will redirect
handleServerOffline();
return;
}
const api = getHttpApiClient();
const [sessionValid, settingsResult] = await Promise.all([
verifySession().catch(() => false),
api.settings.getGlobal().catch(() => ({ success: false, settings: null }) as const),
]);
if (!sessionValid) {
// Session expired while user was away — log them out
logger.warn('[FAST_HYDRATE] Background verify: session invalid, logging out');
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
return;
}
if (settingsResult.success && settingsResult.settings) {
const { settings: finalSettings } = await performSettingsMigration(
settingsResult.settings as unknown as Parameters<
typeof performSettingsMigration
>[0]
);
hydrateStoreFromSettings(finalSettings);
logger.info('[FAST_HYDRATE] Background reconcile complete');
}
} catch (error) {
logger.warn(
'[FAST_HYDRATE] Background verify failed (server may be restarting):',
error
);
}
})();
return; // Auth is done — foreground initAuth exits here
}
// Cold start path: server not yet confirmed running, wait for it
const serverReady = await waitForServerReady();
if (!serverReady) {
handleServerOffline();
return;
}
// 1. Verify session (Single Request, ALL modes)
let isValid = false;
try {
isValid = await verifySession();
} catch (error) {
logger.warn('Session verification failed (likely network/server issue):', error);
isValid = false;
}
// OPTIMIZATION: Fire verifySession and fetchSettings in parallel
// instead of waiting for session verification before fetching settings
const api = getHttpApiClient();
const [sessionValid, settingsResult] = await Promise.all([
verifySession().catch((error) => {
logger.warn('Session verification failed (likely network/server issue):', error);
return false;
}),
api.settings.getGlobal().catch((error) => {
logger.warn('Settings fetch failed during parallel init:', error);
return { success: false, settings: null } as const;
}),
]);
if (isValid) {
// 2. Load settings (and hydrate stores) before marking auth as checked.
// This prevents useSettingsSync from pushing default/empty state to the server
// when the backend is still starting up or temporarily unavailable.
const api = getHttpApiClient();
if (sessionValid) {
// Settings were fetched in parallel - use them directly
if (settingsResult.success && settingsResult.settings) {
const { settings: finalSettings, migrated } = await performSettingsMigration(
settingsResult.settings as unknown as Parameters<typeof performSettingsMigration>[0]
);
if (migrated) {
logger.info('Settings migration from localStorage completed');
}
// Hydrate store with the final settings (reconcile with optimistic data)
hydrateStoreFromSettings(finalSettings);
// CRITICAL: Wait for React to render the hydrated state before
// signaling completion. Zustand updates are synchronous, but React
// hasn't necessarily re-rendered yet. This prevents race conditions
// where useSettingsSync reads state before the UI has updated.
await new Promise((resolve) => setTimeout(resolve, 0));
// Signal that settings hydration is complete FIRST.
signalMigrationComplete();
// Now mark auth as checked AND settings as loaded.
useAuthStore.getState().setAuthState({
isAuthenticated: true,
authChecked: true,
settingsLoaded: true,
});
return;
}
// Settings weren't available in parallel response - retry with backoff
try {
const maxAttempts = 8;
const maxAttempts = 6;
const baseDelayMs = 250;
let lastError: unknown = null;
let lastError: unknown = settingsResult;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const delayMs = Math.min(1500, baseDelayMs * attempt);
logger.warn(
`Settings not ready (attempt ${attempt}/${maxAttempts}); retrying in ${delayMs}ms...`,
lastError
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
try {
const settingsResult = await api.settings.getGlobal();
if (settingsResult.success && settingsResult.settings) {
const retryResult = await api.settings.getGlobal();
if (retryResult.success && retryResult.settings) {
const { settings: finalSettings, migrated } = await performSettingsMigration(
settingsResult.settings as unknown as Parameters<
retryResult.settings as unknown as Parameters<
typeof performSettingsMigration
>[0]
);
@@ -443,25 +625,10 @@ function RootLayoutContent() {
logger.info('Settings migration from localStorage completed');
}
// Hydrate store with the final settings (merged if migration occurred)
hydrateStoreFromSettings(finalSettings);
// CRITICAL: Wait for React to render the hydrated state before
// signaling completion. Zustand updates are synchronous, but React
// hasn't necessarily re-rendered yet. This prevents race conditions
// where useSettingsSync reads state before the UI has updated.
await new Promise((resolve) => setTimeout(resolve, 0));
// Signal that settings hydration is complete FIRST.
// This ensures useSettingsSync's waitForMigrationComplete() will resolve
// immediately when it starts after auth state change, preventing it from
// syncing default empty state to the server.
signalMigrationComplete();
// Now mark auth as checked AND settings as loaded.
// The settingsLoaded flag ensures useSettingsSync won't start syncing
// until settings have been properly hydrated, even if authChecked was
// set earlier by login-view.
useAuthStore.getState().setAuthState({
isAuthenticated: true,
authChecked: true,
@@ -471,24 +638,29 @@ function RootLayoutContent() {
return;
}
lastError = settingsResult;
lastError = retryResult;
} catch (error) {
lastError = error;
}
const delayMs = Math.min(1500, baseDelayMs * attempt);
logger.warn(
`Settings not ready (attempt ${attempt}/${maxAttempts}); retrying in ${delayMs}ms...`,
lastError
);
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
throw lastError ?? new Error('Failed to load settings');
} catch (error) {
logger.error('Failed to fetch settings after valid session:', error);
// If optimistically hydrated, allow the user to continue with cached data
if (optimisticallyHydrated) {
logger.info('[FAST_HYDRATE] Using optimistic cache as fallback (server unavailable)');
signalMigrationComplete();
useAuthStore.getState().setAuthState({
isAuthenticated: true,
authChecked: true,
settingsLoaded: true,
});
return;
}
// If we can't load settings, we must NOT start syncing defaults to the server.
// Treat as not authenticated for now (backend likely unavailable) and unblock sync hook.
useAuthStore.getState().setAuthState({ isAuthenticated: false, authChecked: true });
signalMigrationComplete();
if (location.pathname !== '/logged-out' && location.pathname !== '/login') {
@@ -892,14 +1064,14 @@ function RootLayout() {
const shouldShowDevtools = IS_DEV && showQueryDevtools && !isCompact;
return (
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider client={queryClient} persistOptions={persistOptions}>
<FileBrowserProvider>
<RootLayoutContent />
</FileBrowserProvider>
{shouldShowDevtools && (
<ReactQueryDevtools initialIsOpen={false} buttonPosition="bottom-right" />
)}
</QueryClientProvider>
</PersistQueryClientProvider>
);
}

View File

@@ -0,0 +1,6 @@
import { createLazyFileRoute } from '@tanstack/react-router';
import { BoardView } from '@/components/views/board-view';
export const Route = createLazyFileRoute('/board')({
component: BoardView,
});

View File

@@ -1,6 +1,9 @@
import { createFileRoute } from '@tanstack/react-router';
import { BoardView } from '@/components/views/board-view';
export const Route = createFileRoute('/board')({
component: BoardView,
});
// Component is lazy-loaded via board.lazy.tsx for code splitting.
// Board is the most-visited landing route, but lazy loading still benefits
// initial load because the board component and its dependencies are only
// downloaded when the user actually navigates to /board (vs being bundled
// into the entry chunk). TanStack Router's autoCodeSplitting handles the
// dynamic import automatically when a .lazy.tsx file exists.
export const Route = createFileRoute('/board')({});

View File

@@ -0,0 +1,6 @@
import { createLazyFileRoute } from '@tanstack/react-router';
import { GraphViewPage } from '@/components/views/graph-view-page';
export const Route = createLazyFileRoute('/graph')({
component: GraphViewPage,
});

View File

@@ -1,6 +1,4 @@
import { createFileRoute } from '@tanstack/react-router';
import { GraphViewPage } from '@/components/views/graph-view-page';
export const Route = createFileRoute('/graph')({
component: GraphViewPage,
});
// Component is lazy-loaded via graph.lazy.tsx for code splitting
export const Route = createFileRoute('/graph')({});

View File

@@ -0,0 +1,6 @@
import { createLazyFileRoute } from '@tanstack/react-router';
import { SpecView } from '@/components/views/spec-view';
export const Route = createLazyFileRoute('/spec')({
component: SpecView,
});

View File

@@ -1,6 +1,4 @@
import { createFileRoute } from '@tanstack/react-router';
import { SpecView } from '@/components/views/spec-view';
export const Route = createFileRoute('/spec')({
component: SpecView,
});
// Component is lazy-loaded via spec.lazy.tsx for code splitting
export const Route = createFileRoute('/spec')({});

View File

@@ -0,0 +1,11 @@
import { createLazyFileRoute, useSearch } from '@tanstack/react-router';
import { TerminalView } from '@/components/views/terminal-view';
export const Route = createLazyFileRoute('/terminal')({
component: RouteComponent,
});
function RouteComponent() {
const { cwd, branch, mode, nonce } = useSearch({ from: '/terminal' });
return <TerminalView initialCwd={cwd} initialBranch={branch} initialMode={mode} nonce={nonce} />;
}

View File

@@ -1,5 +1,4 @@
import { createFileRoute } from '@tanstack/react-router';
import { TerminalView } from '@/components/views/terminal-view';
import { z } from 'zod';
const terminalSearchSchema = z.object({
@@ -9,12 +8,7 @@ const terminalSearchSchema = z.object({
nonce: z.coerce.number().optional(),
});
// Component is lazy-loaded via terminal.lazy.tsx for code splitting
export const Route = createFileRoute('/terminal')({
validateSearch: terminalSearchSchema,
component: RouteComponent,
});
function RouteComponent() {
const { cwd, branch, mode, nonce } = Route.useSearch();
return <TerminalView initialCwd={cwd} initialBranch={branch} initialMode={mode} nonce={nonce} />;
}

View File

@@ -0,0 +1,123 @@
/**
* UI Cache Store - Persisted UI State for Instant Restore
*
* This lightweight Zustand store persists critical UI state to localStorage
* so that after a tab discard, the user sees their previous UI configuration
* instantly without waiting for the server.
*
* This is NOT a replacement for the app-store or the API-first settings sync.
* It's a fast cache layer that provides instant visual continuity during:
* - Tab discard recovery
* - Page reloads
* - App restarts
*
* The app-store remains the source of truth. This cache is reconciled
* when server settings are loaded (hydrateStoreFromSettings overwrites everything).
*
* Only stores UI-visual state that affects what the user sees immediately:
* - Selected project ID (to restore board context)
* - Sidebar state (open/closed, style)
* - View preferences (board view mode, collapsed sections)
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UICacheState {
/** ID of the currently selected project */
cachedProjectId: string | null;
/** Whether sidebar is open */
cachedSidebarOpen: boolean;
/** Sidebar style (unified or discord) */
cachedSidebarStyle: 'unified' | 'discord';
/** Whether worktree panel is collapsed */
cachedWorktreePanelCollapsed: boolean;
/** Collapsed nav sections */
cachedCollapsedNavSections: Record<string, boolean>;
}
interface UICacheActions {
/** Update the cached UI state from the main app store */
updateFromAppStore: (state: Partial<UICacheState>) => void;
}
const STORE_NAME = 'automaker-ui-cache';
export const useUICacheStore = create<UICacheState & UICacheActions>()(
persist(
(set) => ({
cachedProjectId: null,
cachedSidebarOpen: true,
cachedSidebarStyle: 'unified',
cachedWorktreePanelCollapsed: false,
cachedCollapsedNavSections: {},
updateFromAppStore: (state) => set(state),
}),
{
name: STORE_NAME,
version: 1,
partialize: (state) => ({
cachedProjectId: state.cachedProjectId,
cachedSidebarOpen: state.cachedSidebarOpen,
cachedSidebarStyle: state.cachedSidebarStyle,
cachedWorktreePanelCollapsed: state.cachedWorktreePanelCollapsed,
cachedCollapsedNavSections: state.cachedCollapsedNavSections,
}),
}
)
);
/**
* 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.
*
* This is intentionally a function (not a hook) so it can be called
* from store subscriptions without React.
*/
export function syncUICache(appState: {
currentProject?: { id: string } | null;
sidebarOpen?: boolean;
sidebarStyle?: 'unified' | 'discord';
worktreePanelCollapsed?: boolean;
collapsedNavSections?: Record<string, boolean>;
}): void {
useUICacheStore.getState().updateFromAppStore({
cachedProjectId: appState.currentProject?.id ?? null,
cachedSidebarOpen: appState.sidebarOpen ?? true,
cachedSidebarStyle: appState.sidebarStyle ?? 'unified',
cachedWorktreePanelCollapsed: appState.worktreePanelCollapsed ?? false,
cachedCollapsedNavSections: appState.collapsedNavSections ?? {},
});
}
/**
* Restore cached UI state into the main app store.
* Call this early during initialization — before server settings arrive —
* so the user sees their previous UI layout instantly on tab discard recovery
* or page reload, instead of a flash of default state.
*
* This is reconciled later when hydrateStoreFromSettings() overwrites
* the app store with authoritative server data.
*
* @param appStoreSetState - The setState function from the app store (avoids circular import)
*/
export function restoreFromUICache(
appStoreSetState: (state: Record<string, unknown>) => void
): boolean {
const cache = useUICacheStore.getState();
// Only restore if we have meaningful cached data (not just defaults)
if (cache.cachedProjectId === null) {
return false;
}
appStoreSetState({
sidebarOpen: cache.cachedSidebarOpen,
sidebarStyle: cache.cachedSidebarStyle,
worktreePanelCollapsed: cache.cachedWorktreePanelCollapsed,
collapsedNavSections: cache.cachedCollapsedNavSections,
});
return true;
}

View File

@@ -1304,8 +1304,11 @@ export interface WorktreeAPI {
}) => void
) => () => void;
// Discard changes for a worktree
discardChanges: (worktreePath: string) => Promise<{
// Discard changes for a worktree (optionally only specific files)
discardChanges: (
worktreePath: string,
files?: string[]
) => Promise<{
success: boolean;
result?: {
discarded: boolean;

View File

@@ -12,3 +12,4 @@ interface ImportMeta {
// Global constants defined in vite.config.mts
declare const __APP_VERSION__: string;
declare const __APP_BUILD_HASH__: string;