mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-16 21:53:07 +00:00
feat: Mobile improvements and Add selective file staging and improve branch switching
This commit is contained in:
@@ -15,9 +15,10 @@ const execAsync = promisify(exec);
|
||||
export function createCommitHandler() {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { worktreePath, message } = req.body as {
|
||||
const { worktreePath, message, files } = req.body as {
|
||||
worktreePath: string;
|
||||
message: string;
|
||||
files?: string[];
|
||||
};
|
||||
|
||||
if (!worktreePath || !message) {
|
||||
@@ -44,8 +45,19 @@ export function createCommitHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stage all changes
|
||||
await execAsync('git add -A', { cwd: worktreePath });
|
||||
// Stage changes - either specific files or all changes
|
||||
if (files && files.length > 0) {
|
||||
// Reset any previously staged changes first
|
||||
await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => {
|
||||
// Ignore errors from reset (e.g., if nothing is staged)
|
||||
});
|
||||
// Stage only the selected files
|
||||
const escapedFiles = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(' ');
|
||||
await execAsync(`git add ${escapedFiles}`, { cwd: worktreePath });
|
||||
} else {
|
||||
// Stage all changes (original behavior)
|
||||
await execAsync('git add -A', { cwd: worktreePath });
|
||||
}
|
||||
|
||||
// Create commit
|
||||
await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, {
|
||||
|
||||
@@ -92,6 +92,9 @@ export function createListBranchesHandler() {
|
||||
// Skip HEAD pointers like "origin/HEAD"
|
||||
if (cleanName.includes('/HEAD')) return;
|
||||
|
||||
// Skip bare remote names without a branch (e.g. "origin" by itself)
|
||||
if (!cleanName.includes('/')) return;
|
||||
|
||||
// Only add remote branches if a branch with the exact same name isn't already
|
||||
// in the list. This avoids duplicates if a local branch is named like a remote one.
|
||||
// Note: We intentionally include remote branches even when a local branch with the
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* POST /switch-branch endpoint - Switch to an existing branch
|
||||
*
|
||||
* Simple branch switching.
|
||||
* If there are uncommitted changes, the switch will fail and
|
||||
* the user should commit first.
|
||||
* Handles branch switching with automatic stash/reapply of local changes.
|
||||
* If there are uncommitted changes, they are stashed before switching and
|
||||
* reapplied after. If the stash pop results in merge conflicts, returns
|
||||
* a special response code so the UI can create a conflict resolution task.
|
||||
*
|
||||
* For remote branches (e.g., "origin/feature"), automatically creates a
|
||||
* local tracking branch and checks it out.
|
||||
*
|
||||
* Also fetches the latest remote refs after switching.
|
||||
*
|
||||
* Note: Git repository validation (isGitRepo, hasCommits) is handled by
|
||||
* the requireValidWorktree middleware in index.ts
|
||||
@@ -16,14 +22,14 @@ import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
function isUntrackedLine(line: string): boolean {
|
||||
return line.startsWith('?? ');
|
||||
}
|
||||
|
||||
function isExcludedWorktreeLine(line: string): boolean {
|
||||
return line.includes('.worktrees/') || line.endsWith('.worktrees');
|
||||
}
|
||||
|
||||
function isUntrackedLine(line: string): boolean {
|
||||
return line.startsWith('?? ');
|
||||
}
|
||||
|
||||
function isBlockingChangeLine(line: string): boolean {
|
||||
if (!line.trim()) return false;
|
||||
if (isExcludedWorktreeLine(line)) return false;
|
||||
@@ -46,18 +52,130 @@ async function hasUncommittedChanges(cwd: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of uncommitted changes for user feedback
|
||||
* Excludes .worktrees/ directory
|
||||
* Check if there are any changes at all (including untracked) that should be stashed
|
||||
*/
|
||||
async function getChangesSummary(cwd: string): Promise<string> {
|
||||
async function hasAnyChanges(cwd: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git status --short', { cwd });
|
||||
const lines = stdout.trim().split('\n').filter(isBlockingChangeLine);
|
||||
if (lines.length === 0) return '';
|
||||
if (lines.length <= 5) return lines.join(', ');
|
||||
return `${lines.slice(0, 5).join(', ')} and ${lines.length - 5} more files`;
|
||||
const { stdout } = await execAsync('git status --porcelain', { cwd });
|
||||
const lines = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
if (!line.trim()) return false;
|
||||
if (isExcludedWorktreeLine(line)) return false;
|
||||
return true;
|
||||
});
|
||||
return lines.length > 0;
|
||||
} catch {
|
||||
return 'unknown changes';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stash all local changes (including untracked files)
|
||||
* Returns true if a stash was created, false if there was nothing to stash
|
||||
*/
|
||||
async function stashChanges(cwd: string, message: string): Promise<boolean> {
|
||||
try {
|
||||
// Get stash count before
|
||||
const { stdout: beforeCount } = await execAsync('git stash list', { cwd });
|
||||
const countBefore = beforeCount
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((l) => l.trim()).length;
|
||||
|
||||
// Stash including untracked files
|
||||
await execAsync(`git stash push --include-untracked -m "${message}"`, { cwd });
|
||||
|
||||
// Get stash count after to verify something was stashed
|
||||
const { stdout: afterCount } = await execAsync('git stash list', { cwd });
|
||||
const countAfter = afterCount
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((l) => l.trim()).length;
|
||||
|
||||
return countAfter > countBefore;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the most recent stash entry
|
||||
* Returns an object indicating success and whether there were conflicts
|
||||
*/
|
||||
async function popStash(
|
||||
cwd: string
|
||||
): Promise<{ success: boolean; hasConflicts: boolean; error?: string }> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync('git stash pop', { cwd });
|
||||
const output = `${stdout}\n${stderr}`;
|
||||
// Check for conflict markers in the output
|
||||
if (output.includes('CONFLICT') || output.includes('Merge conflict')) {
|
||||
return { success: false, hasConflicts: true };
|
||||
}
|
||||
return { success: true, hasConflicts: false };
|
||||
} catch (error) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
if (errorMsg.includes('CONFLICT') || errorMsg.includes('Merge conflict')) {
|
||||
return { success: false, hasConflicts: true, error: errorMsg };
|
||||
}
|
||||
return { success: false, hasConflicts: false, error: errorMsg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch latest from all remotes (silently, with timeout)
|
||||
*/
|
||||
async function fetchRemotes(cwd: string): Promise<void> {
|
||||
try {
|
||||
await execAsync('git fetch --all --quiet', {
|
||||
cwd,
|
||||
timeout: 15000, // 15 second timeout
|
||||
});
|
||||
} catch {
|
||||
// Ignore fetch errors - we may be offline
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a remote branch name like "origin/feature-branch" into its parts
|
||||
*/
|
||||
function parseRemoteBranch(branchName: string): { remote: string; branch: string } | null {
|
||||
const slashIndex = branchName.indexOf('/');
|
||||
if (slashIndex === -1) return null;
|
||||
return {
|
||||
remote: branchName.substring(0, slashIndex),
|
||||
branch: branchName.substring(slashIndex + 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a branch name refers to a remote branch
|
||||
*/
|
||||
async function isRemoteBranch(cwd: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execAsync('git branch -r --format="%(refname:short)"', { cwd });
|
||||
const remoteBranches = stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((b) => b.trim().replace(/^['"]|['"]$/g, ''))
|
||||
.filter((b) => b);
|
||||
return remoteBranches.includes(branchName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a local branch already exists
|
||||
*/
|
||||
async function localBranchExists(cwd: string, branchName: string): Promise<boolean> {
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify "refs/heads/${branchName}"`, { cwd });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,53 +209,133 @@ export function createSwitchBranchHandler() {
|
||||
});
|
||||
const previousBranch = currentBranchOutput.trim();
|
||||
|
||||
if (previousBranch === branchName) {
|
||||
// Determine the actual target branch name for checkout
|
||||
let targetBranch = branchName;
|
||||
let isRemote = false;
|
||||
|
||||
// Check if this is a remote branch (e.g., "origin/feature-branch")
|
||||
if (await isRemoteBranch(worktreePath, branchName)) {
|
||||
isRemote = true;
|
||||
const parsed = parseRemoteBranch(branchName);
|
||||
if (parsed) {
|
||||
// If a local branch with the same name already exists, just switch to it
|
||||
if (await localBranchExists(worktreePath, parsed.branch)) {
|
||||
targetBranch = parsed.branch;
|
||||
} else {
|
||||
// Will create a local tracking branch from the remote
|
||||
targetBranch = parsed.branch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (previousBranch === targetBranch) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch,
|
||||
currentBranch: branchName,
|
||||
message: `Already on branch '${branchName}'`,
|
||||
currentBranch: targetBranch,
|
||||
message: `Already on branch '${targetBranch}'`,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if branch exists
|
||||
// Check if target branch exists (locally or as remote ref)
|
||||
if (!isRemote) {
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify "${branchName}"`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Branch '${branchName}' does not exist`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Stash local changes if any exist
|
||||
const hadChanges = await hasAnyChanges(worktreePath);
|
||||
let didStash = false;
|
||||
|
||||
if (hadChanges) {
|
||||
const stashMessage = `automaker-branch-switch: ${previousBranch} → ${targetBranch}`;
|
||||
didStash = await stashChanges(worktreePath, stashMessage);
|
||||
}
|
||||
|
||||
try {
|
||||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
} catch {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Branch '${branchName}' does not exist`,
|
||||
});
|
||||
return;
|
||||
// Switch to the target branch
|
||||
if (isRemote) {
|
||||
const parsed = parseRemoteBranch(branchName);
|
||||
if (parsed) {
|
||||
if (await localBranchExists(worktreePath, parsed.branch)) {
|
||||
// Local branch exists, just checkout
|
||||
await execAsync(`git checkout "${parsed.branch}"`, { cwd: worktreePath });
|
||||
} else {
|
||||
// Create local tracking branch from remote
|
||||
await execAsync(`git checkout -b "${parsed.branch}" "${branchName}"`, {
|
||||
cwd: worktreePath,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await execAsync(`git checkout "${targetBranch}"`, { cwd: worktreePath });
|
||||
}
|
||||
|
||||
// Fetch latest from remotes after switching
|
||||
await fetchRemotes(worktreePath);
|
||||
|
||||
// Reapply stashed changes if we stashed earlier
|
||||
let hasConflicts = false;
|
||||
let conflictMessage = '';
|
||||
|
||||
if (didStash) {
|
||||
const popResult = await popStash(worktreePath);
|
||||
if (popResult.hasConflicts) {
|
||||
hasConflicts = true;
|
||||
conflictMessage = `Switched to branch '${targetBranch}' but merge conflicts occurred when reapplying your local changes. Please resolve the conflicts.`;
|
||||
} else if (!popResult.success) {
|
||||
// Stash pop failed for a non-conflict reason - the stash is still there
|
||||
conflictMessage = `Switched to branch '${targetBranch}' but failed to reapply stashed changes: ${popResult.error}. Your changes are still in the stash.`;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasConflicts) {
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch,
|
||||
currentBranch: targetBranch,
|
||||
message: conflictMessage,
|
||||
hasConflicts: true,
|
||||
stashedChanges: true,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const stashNote = didStash ? ' (local changes stashed and reapplied)' : '';
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch,
|
||||
currentBranch: targetBranch,
|
||||
message: `Switched to branch '${targetBranch}'${stashNote}`,
|
||||
hasConflicts: false,
|
||||
stashedChanges: didStash,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (checkoutError) {
|
||||
// If checkout failed and we stashed, try to restore the stash
|
||||
if (didStash) {
|
||||
try {
|
||||
await popStash(worktreePath);
|
||||
} catch {
|
||||
// Ignore errors restoring stash - it's still in the stash list
|
||||
}
|
||||
}
|
||||
throw checkoutError;
|
||||
}
|
||||
|
||||
// Check for uncommitted changes
|
||||
if (await hasUncommittedChanges(worktreePath)) {
|
||||
const summary = await getChangesSummary(worktreePath);
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: `Cannot switch branches: you have uncommitted changes (${summary}). Please commit your changes first.`,
|
||||
code: 'UNCOMMITTED_CHANGES',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Switch to the target branch
|
||||
await execAsync(`git checkout "${branchName}"`, { cwd: worktreePath });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result: {
|
||||
previousBranch,
|
||||
currentBranch: branchName,
|
||||
message: `Switched to branch '${branchName}'`,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Switch branch failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
|
||||
@@ -387,8 +387,10 @@ export class AutoLoopCoordinator {
|
||||
const projectId = settings.projects?.find((p) => p.path === projectPath)?.id;
|
||||
const autoModeByWorktree = settings.autoModeByWorktree;
|
||||
if (projectId && autoModeByWorktree && typeof autoModeByWorktree === 'object') {
|
||||
const normalizedBranch =
|
||||
branchName === null || branchName === 'main' ? '__main__' : branchName;
|
||||
// branchName is already normalized to null for the primary branch by callers
|
||||
// (e.g., checkWorktreeCapacity, startAutoLoopForProject), so we only
|
||||
// need to convert null to '__main__' for the worktree key lookup
|
||||
const normalizedBranch = branchName === null ? '__main__' : branchName;
|
||||
const worktreeId = `${projectId}::${normalizedBranch}`;
|
||||
if (
|
||||
worktreeId in autoModeByWorktree &&
|
||||
|
||||
@@ -4,18 +4,84 @@
|
||||
<meta charset="UTF-8" />
|
||||
<title>Automaker - Autonomous AI Development Studio</title>
|
||||
<meta name="description" content="Build software autonomously with AI agents" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"
|
||||
/>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||
<!-- PWA -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Automaker" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<link rel="apple-touch-icon" href="/logo_larger.png" />
|
||||
<!-- Performance: Preload critical assets with fetchpriority for faster First Contentful Paint -->
|
||||
<link rel="preload" href="/logo.png" as="image" fetchpriority="high" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/automaker.svg"
|
||||
as="image"
|
||||
type="image/svg+xml"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<!-- Critical inline styles: prevent white/wrong-color flash on mobile PWA cold start -->
|
||||
<!-- These styles are applied before any external CSS loads, eliminating FOUC -->
|
||||
<style>
|
||||
html {
|
||||
background-color: #09090b;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
html:not([class]) {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
html.light,
|
||||
html.cream,
|
||||
html.solarizedlight,
|
||||
html.github,
|
||||
html.paper,
|
||||
html.rose,
|
||||
html.mint,
|
||||
html.lavender,
|
||||
html.sand,
|
||||
html.sky,
|
||||
html.peach,
|
||||
html.snow,
|
||||
html.sepia,
|
||||
html.gruvboxlight,
|
||||
html.nordlight,
|
||||
html.blossom,
|
||||
html.ayu-light,
|
||||
html.onelight,
|
||||
html.bluloco,
|
||||
html.feather {
|
||||
background-color: #fff;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
#app {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
// Prevent theme flash - apply stored theme before React hydrates
|
||||
(function () {
|
||||
try {
|
||||
const stored = localStorage.getItem('automaker-storage');
|
||||
var stored = localStorage.getItem('automaker-storage');
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored);
|
||||
const theme = data.state?.theme;
|
||||
var data = JSON.parse(stored);
|
||||
var theme = data.state?.theme;
|
||||
if (theme && theme !== 'system' && theme !== 'light') {
|
||||
// Apply the actual theme class (dark, retro, dracula, nord, etc.)
|
||||
document.documentElement.classList.add(theme);
|
||||
} else if (
|
||||
theme === 'system' &&
|
||||
|
||||
44
apps/ui/public/manifest.json
Normal file
44
apps/ui/public/manifest.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "Automaker - Autonomous AI Development Studio",
|
||||
"short_name": "Automaker",
|
||||
"description": "Build software autonomously with AI agents",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#09090b",
|
||||
"theme_color": "#09090b",
|
||||
"orientation": "any",
|
||||
"scope": "/",
|
||||
"id": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/logo.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/logo_larger.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/automaker.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"categories": ["developer", "productivity", "utilities"],
|
||||
"lang": "en-US",
|
||||
"dir": "ltr",
|
||||
"launch_handler": {
|
||||
"client_mode": "focus-existing"
|
||||
},
|
||||
"handle_links": "preferred",
|
||||
"edge_side_panel": {
|
||||
"preferred_width": 480
|
||||
},
|
||||
"prefer_related_applications": false,
|
||||
"display_override": ["standalone", "minimal-ui"]
|
||||
}
|
||||
373
apps/ui/public/sw.js
Normal file
373
apps/ui/public/sw.js
Normal file
@@ -0,0 +1,373 @@
|
||||
// Automaker Service Worker - Optimized for mobile PWA loading performance
|
||||
const CACHE_NAME = 'automaker-v3';
|
||||
|
||||
// Separate cache for immutable hashed assets (long-lived)
|
||||
const IMMUTABLE_CACHE = 'automaker-immutable-v2';
|
||||
|
||||
// Separate cache for API responses (short-lived, stale-while-revalidate on mobile)
|
||||
const API_CACHE = 'automaker-api-v1';
|
||||
|
||||
// Assets to cache on install (app shell for instant loading)
|
||||
const SHELL_ASSETS = [
|
||||
'/',
|
||||
'/manifest.json',
|
||||
'/logo.png',
|
||||
'/logo_larger.png',
|
||||
'/automaker.svg',
|
||||
'/favicon.ico',
|
||||
];
|
||||
|
||||
// Whether mobile caching is enabled (set via message from main thread)
|
||||
let mobileMode = false;
|
||||
|
||||
// API endpoints that are safe to serve from stale cache on mobile.
|
||||
// These are GET-only, read-heavy endpoints where showing slightly stale data
|
||||
// is far better than a blank screen or reload on flaky mobile connections.
|
||||
const CACHEABLE_API_PATTERNS = [
|
||||
'/api/features',
|
||||
'/api/settings',
|
||||
'/api/models',
|
||||
'/api/usage',
|
||||
'/api/worktrees',
|
||||
'/api/github',
|
||||
'/api/cli',
|
||||
'/api/sessions',
|
||||
'/api/running-agents',
|
||||
'/api/pipeline',
|
||||
'/api/workspace',
|
||||
'/api/spec',
|
||||
];
|
||||
|
||||
// Max age for API cache entries (5 minutes).
|
||||
// After this, even mobile will require a network fetch.
|
||||
const API_CACHE_MAX_AGE = 5 * 60 * 1000;
|
||||
|
||||
// Maximum entries in API cache to prevent unbounded growth
|
||||
const API_CACHE_MAX_ENTRIES = 100;
|
||||
|
||||
/**
|
||||
* Check if an API request is safe to cache (read-only data endpoints)
|
||||
*/
|
||||
function isCacheableApiRequest(url) {
|
||||
const path = url.pathname;
|
||||
if (!path.startsWith('/api/')) return false;
|
||||
return CACHEABLE_API_PATTERNS.some((pattern) => path.startsWith(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cached API response is still fresh enough to use
|
||||
*/
|
||||
function isApiCacheFresh(response) {
|
||||
const cachedAt = response.headers.get('x-sw-cached-at');
|
||||
if (!cachedAt) return false;
|
||||
return Date.now() - parseInt(cachedAt, 10) < API_CACHE_MAX_AGE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone a response and add a timestamp header for cache freshness tracking
|
||||
*/
|
||||
async function addCacheTimestamp(response) {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set('x-sw-cached-at', String(Date.now()));
|
||||
const body = await response.clone().blob();
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('install', (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.addAll(SHELL_ASSETS);
|
||||
})
|
||||
);
|
||||
// Activate immediately without waiting for existing clients
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
// Remove old caches (both regular and immutable)
|
||||
const validCaches = new Set([CACHE_NAME, IMMUTABLE_CACHE, API_CACHE]);
|
||||
event.waitUntil(
|
||||
Promise.all([
|
||||
// Clean old caches
|
||||
caches.keys().then((cacheNames) => {
|
||||
return Promise.all(
|
||||
cacheNames.filter((name) => !validCaches.has(name)).map((name) => caches.delete(name))
|
||||
);
|
||||
}),
|
||||
// Enable Navigation Preload for faster navigation responses on mobile.
|
||||
// When enabled, the browser fires the navigation fetch in parallel with
|
||||
// service worker boot, eliminating the SW startup delay (~50-200ms on mobile).
|
||||
self.registration.navigationPreload && self.registration.navigationPreload.enable(),
|
||||
])
|
||||
);
|
||||
// Take control of all clients immediately
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
/**
|
||||
* Determine if a URL points to an immutable hashed asset.
|
||||
* Vite produces filenames like /assets/index-D3f1k2.js or /assets/style-Ab12Cd.css
|
||||
* These contain content hashes and are safe to cache permanently.
|
||||
*/
|
||||
function isImmutableAsset(url) {
|
||||
const path = url.pathname;
|
||||
// Match Vite's hashed asset pattern: /assets/<name>-<hash>.<ext>
|
||||
if (path.startsWith('/assets/') && /\-[A-Za-z0-9_-]{6,}\.\w+$/.test(path)) {
|
||||
return true;
|
||||
}
|
||||
// Font files are immutable (woff2, woff, ttf, otf)
|
||||
if (/\.(woff2?|ttf|otf)$/.test(path)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a URL points to a static asset that benefits from stale-while-revalidate
|
||||
*/
|
||||
function isStaticAsset(url) {
|
||||
const path = url.pathname;
|
||||
return /\.(png|jpg|jpeg|gif|svg|ico|webp|mp3|wav)$/.test(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a request is for a navigation (HTML page)
|
||||
*/
|
||||
function isNavigationRequest(request) {
|
||||
return (
|
||||
request.mode === 'navigate' ||
|
||||
(request.method === 'GET' && request.headers.get('accept')?.includes('text/html'))
|
||||
);
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// Only handle GET requests
|
||||
if (event.request.method !== 'GET') return;
|
||||
|
||||
const url = new URL(event.request.url);
|
||||
|
||||
// Skip cross-origin requests
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// Strategy 5 (mobile only): Stale-while-revalidate for cacheable API requests.
|
||||
// On mobile, flaky connections cause blank screens and reloads. By serving
|
||||
// cached API responses immediately and refreshing in the background, we ensure
|
||||
// the UI always has data to render, even on slow or interrupted connections.
|
||||
// The main thread's React Query layer handles the eventual fresh data via its
|
||||
// own refetching mechanism, so the user sees updates within seconds.
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
if (mobileMode && isCacheableApiRequest(url)) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
const cache = await caches.open(API_CACHE);
|
||||
const cachedResponse = await cache.match(event.request);
|
||||
|
||||
// Start network fetch in background regardless
|
||||
const fetchPromise = fetch(event.request)
|
||||
.then(async (networkResponse) => {
|
||||
if (networkResponse.ok) {
|
||||
// Store with timestamp for freshness checking
|
||||
const timestampedResponse = await addCacheTimestamp(networkResponse);
|
||||
cache.put(event.request, timestampedResponse);
|
||||
}
|
||||
return networkResponse;
|
||||
})
|
||||
.catch((err) => {
|
||||
// Network failed - if we have cache, that's fine (returned below)
|
||||
// If no cache, propagate the error
|
||||
if (cachedResponse) return null;
|
||||
throw err;
|
||||
});
|
||||
|
||||
// If we have a fresh-enough cached response, return it immediately
|
||||
if (cachedResponse && isApiCacheFresh(cachedResponse)) {
|
||||
// Return cached data instantly - network update happens in background
|
||||
return cachedResponse;
|
||||
}
|
||||
|
||||
// If we have a stale cached response but network is slow, race them:
|
||||
// Return whichever resolves first (cached immediately vs network)
|
||||
if (cachedResponse) {
|
||||
// Give network a brief window (2s) to respond, otherwise use stale cache
|
||||
const networkResult = await Promise.race([
|
||||
fetchPromise,
|
||||
new Promise((resolve) => setTimeout(() => resolve(null), 2000)),
|
||||
]);
|
||||
return networkResult || cachedResponse;
|
||||
}
|
||||
|
||||
// No cache at all - must wait for network
|
||||
return fetchPromise;
|
||||
})()
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Non-mobile or non-cacheable API: skip SW, let browser handle normally
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 1: Cache-first for immutable hashed assets (JS/CSS bundles, fonts)
|
||||
// These files contain content hashes in their names - they never change.
|
||||
if (isImmutableAsset(url)) {
|
||||
event.respondWith(
|
||||
caches.open(IMMUTABLE_CACHE).then((cache) => {
|
||||
return cache.match(event.request).then((cachedResponse) => {
|
||||
if (cachedResponse) {
|
||||
return cachedResponse;
|
||||
}
|
||||
return fetch(event.request).then((networkResponse) => {
|
||||
if (networkResponse.ok) {
|
||||
cache.put(event.request, networkResponse.clone());
|
||||
}
|
||||
return networkResponse;
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 2: Stale-while-revalidate for static assets (images, audio)
|
||||
// Serve cached version immediately, update cache in background.
|
||||
if (isStaticAsset(url)) {
|
||||
event.respondWith(
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
return cache.match(event.request).then((cachedResponse) => {
|
||||
const fetchPromise = fetch(event.request)
|
||||
.then((networkResponse) => {
|
||||
if (networkResponse.ok && networkResponse.type === 'basic') {
|
||||
cache.put(event.request, networkResponse.clone());
|
||||
}
|
||||
return networkResponse;
|
||||
})
|
||||
.catch(() => cachedResponse);
|
||||
|
||||
// Return cached version immediately, or wait for network
|
||||
return cachedResponse || fetchPromise;
|
||||
});
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 3: Network-first for navigation requests (HTML)
|
||||
// Uses Navigation Preload when available - the browser fires the network request
|
||||
// in parallel with SW startup, eliminating the ~50-200ms SW boot delay on mobile.
|
||||
// Falls back to regular fetch when Navigation Preload is not supported.
|
||||
if (isNavigationRequest(event.request)) {
|
||||
event.respondWith(
|
||||
(async () => {
|
||||
try {
|
||||
// Use the preloaded response if available (fired during SW boot)
|
||||
// This is the key mobile performance win - no waiting for SW to start
|
||||
const preloadResponse = event.preloadResponse && (await event.preloadResponse);
|
||||
if (preloadResponse) {
|
||||
// Cache the preloaded response for offline use
|
||||
if (preloadResponse.ok && preloadResponse.type === 'basic') {
|
||||
const clone = preloadResponse.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => cache.put(event.request, clone));
|
||||
}
|
||||
return preloadResponse;
|
||||
}
|
||||
|
||||
// Fallback to regular fetch if Navigation Preload is not available
|
||||
const response = await fetch(event.request);
|
||||
if (response.ok && response.type === 'basic') {
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseClone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
} catch (e) {
|
||||
// Offline: serve the cached app shell
|
||||
const cached = await caches.match('/');
|
||||
return (
|
||||
cached ||
|
||||
(await caches.match(event.request)) ||
|
||||
new Response('Offline', { status: 503 })
|
||||
);
|
||||
}
|
||||
})()
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Strategy 4: Network-first for everything else
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.then((response) => {
|
||||
if (response.ok && response.type === 'basic') {
|
||||
const responseClone = response.clone();
|
||||
caches.open(CACHE_NAME).then((cache) => {
|
||||
cache.put(event.request, responseClone);
|
||||
});
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => {
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Periodic cleanup of the immutable cache to prevent unbounded growth
|
||||
// Remove entries older than 30 days when cache exceeds 200 entries
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'CACHE_CLEANUP') {
|
||||
const MAX_ENTRIES = 200;
|
||||
caches.open(IMMUTABLE_CACHE).then((cache) => {
|
||||
cache.keys().then((keys) => {
|
||||
if (keys.length > MAX_ENTRIES) {
|
||||
// Delete oldest entries (first in, first out)
|
||||
const deleteCount = keys.length - MAX_ENTRIES;
|
||||
keys.slice(0, deleteCount).forEach((key) => cache.delete(key));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Also clean up API cache
|
||||
caches.open(API_CACHE).then((cache) => {
|
||||
cache.keys().then((keys) => {
|
||||
if (keys.length > API_CACHE_MAX_ENTRIES) {
|
||||
const deleteCount = keys.length - API_CACHE_MAX_ENTRIES;
|
||||
keys.slice(0, deleteCount).forEach((key) => cache.delete(key));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Enable/disable mobile caching mode.
|
||||
// Sent from main thread after detecting the device is mobile.
|
||||
// This allows the SW to apply mobile-specific caching strategies.
|
||||
if (event.data?.type === 'SET_MOBILE_MODE') {
|
||||
mobileMode = !!event.data.enabled;
|
||||
}
|
||||
|
||||
// Warm the immutable cache with critical assets the app will need.
|
||||
// Called from the main thread after the initial render is complete,
|
||||
// so we don't compete with critical resource loading on mobile.
|
||||
if (event.data?.type === 'PRECACHE_ASSETS' && Array.isArray(event.data.urls)) {
|
||||
caches.open(IMMUTABLE_CACHE).then((cache) => {
|
||||
event.data.urls.forEach((url) => {
|
||||
cache.match(url).then((existing) => {
|
||||
if (!existing) {
|
||||
fetch(url, { priority: 'low' })
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
cache.put(url, response);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore precache failures
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -6,11 +6,13 @@ import { SplashScreen } from './components/splash-screen';
|
||||
import { useSettingsSync } from './hooks/use-settings-sync';
|
||||
import { useCursorStatusInit } from './hooks/use-cursor-status-init';
|
||||
import { useProviderAuthInit } from './hooks/use-provider-auth-init';
|
||||
import { useMobileVisibility, useMobileOnlineManager } from './hooks/use-mobile-visibility';
|
||||
import { useAppStore } from './store/app-store';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import './styles/global.css';
|
||||
import './styles/theme-imports';
|
||||
import './styles/font-imports';
|
||||
import { loadUserFonts, preloadAllFonts } from './styles/font-imports';
|
||||
|
||||
const logger = createLogger('App');
|
||||
|
||||
@@ -38,6 +40,30 @@ export default function App() {
|
||||
localStorage.setItem(DISABLE_SPLASH_KEY, String(disableSplashScreen));
|
||||
}, [disableSplashScreen]);
|
||||
|
||||
// Load user-selected custom fonts on startup, then preload remaining fonts during idle time.
|
||||
// Uses requestIdleCallback where available for better mobile performance - this ensures
|
||||
// font loading doesn't compete with critical rendering and input handling.
|
||||
useEffect(() => {
|
||||
// Immediately load any fonts the user has configured
|
||||
loadUserFonts();
|
||||
|
||||
// After the app is fully interactive, preload remaining fonts
|
||||
// so font picker previews work instantly.
|
||||
// Use requestIdleCallback on mobile for better scheduling - it yields to
|
||||
// user interactions and critical rendering, unlike setTimeout which may fire
|
||||
// during a busy frame and cause jank.
|
||||
const schedulePreload =
|
||||
typeof requestIdleCallback !== 'undefined'
|
||||
? () => requestIdleCallback(() => preloadAllFonts(), { timeout: 5000 })
|
||||
: () => setTimeout(() => preloadAllFonts(), 3000);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
schedulePreload();
|
||||
}, 2000); // Wait 2s after mount, then use idle callback for the actual loading
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// Clear accumulated PerformanceMeasure entries to prevent memory leak in dev mode
|
||||
// React's internal scheduler creates performance marks/measures that accumulate without cleanup
|
||||
useEffect(() => {
|
||||
@@ -70,6 +96,12 @@ export default function App() {
|
||||
// Initialize Provider auth status at startup (for Claude/Codex usage display)
|
||||
useProviderAuthInit();
|
||||
|
||||
// Mobile-specific: Manage React Query focus/online state based on page visibility.
|
||||
// Prevents the "blank screen + reload" cycle caused by aggressive refetching
|
||||
// when the mobile PWA is switched away from and back to.
|
||||
useMobileVisibility();
|
||||
useMobileOnlineManager();
|
||||
|
||||
const handleSplashComplete = useCallback(() => {
|
||||
sessionStorage.setItem('automaker-splash-shown', 'true');
|
||||
setShowSplash(false);
|
||||
|
||||
@@ -16,7 +16,7 @@ export function SandboxRejectionScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center p-4">
|
||||
<div className="min-h-full bg-background flex items-center justify-center p-4">
|
||||
<div className="max-w-md w-full text-center space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<div className="rounded-full bg-destructive/10 p-4">
|
||||
|
||||
@@ -98,7 +98,7 @@ const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 translate-x-[-50%] translate-y-[-50%]',
|
||||
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100vh-4rem)]',
|
||||
'flex flex-col w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-4rem)]',
|
||||
'bg-card border border-border rounded-xl shadow-2xl',
|
||||
// Premium shadow
|
||||
'shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)]',
|
||||
|
||||
@@ -62,7 +62,12 @@ import { CommitWorktreeDialog } from './board-view/dialogs/commit-worktree-dialo
|
||||
import { CreatePRDialog } from './board-view/dialogs/create-pr-dialog';
|
||||
import { CreateBranchDialog } from './board-view/dialogs/create-branch-dialog';
|
||||
import { WorktreePanel } from './board-view/worktree-panel';
|
||||
import type { PRInfo, WorktreeInfo, MergeConflictInfo } from './board-view/worktree-panel/types';
|
||||
import type {
|
||||
PRInfo,
|
||||
WorktreeInfo,
|
||||
MergeConflictInfo,
|
||||
BranchSwitchConflictInfo,
|
||||
} from './board-view/worktree-panel/types';
|
||||
import { COLUMNS, getColumnsWithPipeline } from './board-view/constants';
|
||||
import {
|
||||
useBoardFeatures,
|
||||
@@ -1015,6 +1020,56 @@ export function BoardView() {
|
||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||
);
|
||||
|
||||
// Handler called when branch switch stash reapply causes merge conflicts
|
||||
const handleBranchSwitchConflict = useCallback(
|
||||
async (conflictInfo: BranchSwitchConflictInfo) => {
|
||||
const description = `Resolve merge conflicts that occurred when switching from "${conflictInfo.previousBranch}" to "${conflictInfo.branchName}". Local changes were stashed before switching and reapplying them caused conflicts. Please resolve all merge conflicts, ensure the code compiles and tests pass.`;
|
||||
|
||||
// Create the feature
|
||||
const featureData = {
|
||||
title: `Resolve Stash Conflicts: switch to ${conflictInfo.branchName}`,
|
||||
category: 'Maintenance',
|
||||
description,
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: 'opus' as const,
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: conflictInfo.branchName,
|
||||
workMode: 'custom' as const,
|
||||
priority: 1,
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
|
||||
// Capture existing feature IDs before adding
|
||||
const featuresBeforeIds = new Set(useAppStore.getState().features.map((f) => f.id));
|
||||
try {
|
||||
await handleAddFeature(featureData);
|
||||
} catch (error) {
|
||||
logger.error('Failed to create branch switch conflict resolution feature:', error);
|
||||
toast.error('Failed to create feature', {
|
||||
description: error instanceof Error ? error.message : 'An error occurred',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the newly created feature by looking for an ID that wasn't in the original set
|
||||
const latestFeatures = useAppStore.getState().features;
|
||||
const newFeature = latestFeatures.find((f) => !featuresBeforeIds.has(f.id));
|
||||
|
||||
if (newFeature) {
|
||||
await handleStartImplementation(newFeature);
|
||||
} else {
|
||||
logger.error('Could not find newly created feature to start it automatically.');
|
||||
toast.error('Failed to auto-start feature', {
|
||||
description: 'The feature was created but could not be started automatically.',
|
||||
});
|
||||
}
|
||||
},
|
||||
[handleAddFeature, handleStartImplementation, defaultSkipTests]
|
||||
);
|
||||
|
||||
// Handler for "Make" button - creates a feature and immediately starts it
|
||||
const handleAddAndStartFeature = useCallback(
|
||||
async (featureData: Parameters<typeof handleAddFeature>[0]) => {
|
||||
@@ -1454,6 +1509,7 @@ export function BoardView() {
|
||||
onAddressPRComments={handleAddressPRComments}
|
||||
onResolveConflicts={handleResolveConflicts}
|
||||
onCreateMergeConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
||||
onBranchSwitchConflict={handleBranchSwitchConflict}
|
||||
onBranchDeletedDuringMerge={(branchName) => {
|
||||
// Reset features that were assigned to the deleted branch (same logic as onDeleted in DeleteWorktreeDialog)
|
||||
hookFeatures.forEach((feature) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,11 +10,24 @@ import {
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { GitCommit, Sparkles } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
GitCommit,
|
||||
Sparkles,
|
||||
FilePlus,
|
||||
FileX,
|
||||
FilePen,
|
||||
FileText,
|
||||
File,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
|
||||
interface WorktreeInfo {
|
||||
path: string;
|
||||
@@ -31,6 +44,229 @@ interface CommitWorktreeDialogProps {
|
||||
onCommitted: () => 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 CommitWorktreeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -43,8 +279,85 @@ export function CommitWorktreeDialog({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages);
|
||||
|
||||
// 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);
|
||||
|
||||
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 commit 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 handleCommit = async () => {
|
||||
if (!worktree || !message.trim()) return;
|
||||
if (!worktree || !message.trim() || selectedFiles.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
@@ -55,7 +368,12 @@ export function CommitWorktreeDialog({
|
||||
setError('Worktree API not available');
|
||||
return;
|
||||
}
|
||||
const result = await api.worktree.commit(worktree.path, message);
|
||||
|
||||
// Pass selected files if not all files are selected
|
||||
const filesToCommit =
|
||||
selectedFiles.size === files.length ? undefined : Array.from(selectedFiles);
|
||||
|
||||
const result = await api.worktree.commit(worktree.path, message, filesToCommit);
|
||||
|
||||
if (result.success && result.result) {
|
||||
if (result.result.committed) {
|
||||
@@ -81,8 +399,14 @@ export function CommitWorktreeDialog({
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
// Prevent commit while loading or while AI is generating a message
|
||||
if (e.key === 'Enter' && e.metaKey && !isLoading && !isGenerating && message.trim()) {
|
||||
if (
|
||||
e.key === 'Enter' &&
|
||||
e.metaKey &&
|
||||
!isLoading &&
|
||||
!isGenerating &&
|
||||
message.trim() &&
|
||||
selectedFiles.size > 0
|
||||
) {
|
||||
handleCommit();
|
||||
}
|
||||
};
|
||||
@@ -94,7 +418,6 @@ export function CommitWorktreeDialog({
|
||||
setMessage('');
|
||||
setError(null);
|
||||
|
||||
// Only generate AI commit message if enabled
|
||||
if (!enableAiCommitMessages) {
|
||||
return;
|
||||
}
|
||||
@@ -119,13 +442,11 @@ export function CommitWorktreeDialog({
|
||||
if (result.success && result.message) {
|
||||
setMessage(result.message);
|
||||
} else {
|
||||
// Don't show error toast, just log it and leave message empty
|
||||
console.warn('Failed to generate commit message:', result.error);
|
||||
setMessage('');
|
||||
}
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
// Don't show error toast for generation failures
|
||||
console.warn('Error generating commit message:', err);
|
||||
setMessage('');
|
||||
} finally {
|
||||
@@ -145,9 +466,11 @@ export function CommitWorktreeDialog({
|
||||
|
||||
if (!worktree) return null;
|
||||
|
||||
const allSelected = selectedFiles.size === files.length && files.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitCommit className="w-5 h-5" />
|
||||
@@ -156,17 +479,151 @@ export function CommitWorktreeDialog({
|
||||
<DialogDescription>
|
||||
Commit changes in the{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code> worktree.
|
||||
{worktree.changedFilesCount && (
|
||||
<span className="ml-1">
|
||||
({worktree.changedFilesCount} file
|
||||
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<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 commit
|
||||
{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>
|
||||
|
||||
{/* Commit Message */}
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="commit-message" className="flex items-center gap-2">
|
||||
Commit Message
|
||||
{isGenerating && (
|
||||
@@ -187,7 +644,7 @@ export function CommitWorktreeDialog({
|
||||
setError(null);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="min-h-[100px] font-mono text-sm"
|
||||
className="min-h-[80px] font-mono text-sm"
|
||||
autoFocus
|
||||
disabled={isGenerating}
|
||||
/>
|
||||
@@ -207,7 +664,10 @@ export function CommitWorktreeDialog({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCommit} disabled={isLoading || isGenerating || !message.trim()}>
|
||||
<Button
|
||||
onClick={handleCommit}
|
||||
disabled={isLoading || isGenerating || !message.trim() || selectedFiles.size === 0}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
@@ -217,6 +677,9 @@ export function CommitWorktreeDialog({
|
||||
<>
|
||||
<GitCommit className="w-4 h-4 mr-2" />
|
||||
Commit
|
||||
{selectedFiles.size > 0 && selectedFiles.size < files.length
|
||||
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
|
||||
: ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function ViewWorktreeChangesDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100vh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
|
||||
@@ -93,6 +93,7 @@ export function useBoardActions({
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
getAutoModeState,
|
||||
getMaxConcurrencyForWorktree,
|
||||
} = useAppStore();
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
@@ -566,7 +567,11 @@ export function useBoardActions({
|
||||
const featureWorktreeState = currentProject
|
||||
? getAutoModeState(currentProject.id, featureBranchName)
|
||||
: null;
|
||||
const featureMaxConcurrency = featureWorktreeState?.maxConcurrency ?? autoMode.maxConcurrency;
|
||||
// Use getMaxConcurrencyForWorktree which correctly falls back to global maxConcurrency
|
||||
// instead of autoMode.maxConcurrency which only falls back to DEFAULT_MAX_CONCURRENCY (1)
|
||||
const featureMaxConcurrency = currentProject
|
||||
? getMaxConcurrencyForWorktree(currentProject.id, featureBranchName)
|
||||
: autoMode.maxConcurrency;
|
||||
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
|
||||
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
|
||||
|
||||
@@ -647,6 +652,7 @@ export function useBoardActions({
|
||||
handleRunFeature,
|
||||
currentProject,
|
||||
getAutoModeState,
|
||||
getMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -191,7 +191,7 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-4 right-4 z-50 flex flex-col gap-2',
|
||||
'max-h-[calc(100vh-120px)] overflow-y-auto',
|
||||
'max-h-[calc(100dvh-120px)] overflow-y-auto',
|
||||
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { GitBranch, GitBranchPlus, Check, Search } from 'lucide-react';
|
||||
import { GitBranch, GitBranchPlus, Check, Search, Globe } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WorktreeInfo, BranchInfo } from '../types';
|
||||
@@ -42,6 +43,43 @@ export function BranchSwitchDropdown({
|
||||
onSwitchBranch,
|
||||
onCreateBranch,
|
||||
}: BranchSwitchDropdownProps) {
|
||||
// Separate local and remote branches, filtering out bare remotes without a branch
|
||||
const { localBranches, remoteBranches } = useMemo(() => {
|
||||
const local: BranchInfo[] = [];
|
||||
const remote: BranchInfo[] = [];
|
||||
for (const branch of filteredBranches) {
|
||||
if (branch.isRemote) {
|
||||
// Skip bare remote refs without a branch name (e.g. "origin" by itself)
|
||||
if (!branch.name.includes('/')) continue;
|
||||
remote.push(branch);
|
||||
} else {
|
||||
local.push(branch);
|
||||
}
|
||||
}
|
||||
return { localBranches: local, remoteBranches: remote };
|
||||
}, [filteredBranches]);
|
||||
|
||||
const renderBranchItem = (branch: BranchInfo) => {
|
||||
const isCurrent = branch.name === worktree.branch;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={branch.name}
|
||||
onClick={() => onSwitchBranch(worktree, branch.name)}
|
||||
disabled={isSwitching || isCurrent}
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{isCurrent ? (
|
||||
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
||||
) : branch.isRemote ? (
|
||||
<Globe className="w-3.5 h-3.5 mr-2 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<span className="w-3.5 mr-2 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -60,7 +98,7 @@ export function BranchSwitchDropdown({
|
||||
<GitBranch className={standalone ? 'w-3.5 h-3.5' : 'w-3 h-3'} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuContent align="start" className="w-72">
|
||||
<DropdownMenuLabel className="text-xs">Switch Branch</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="px-2 py-1.5">
|
||||
@@ -73,13 +111,13 @@ export function BranchSwitchDropdown({
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
onKeyUp={(e) => e.stopPropagation()}
|
||||
onKeyPress={(e) => e.stopPropagation()}
|
||||
className="h-7 pl-7 text-xs"
|
||||
className="h-7 pl-7 text-base md:text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-[250px] overflow-y-auto">
|
||||
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden">
|
||||
{isLoadingBranches ? (
|
||||
<DropdownMenuItem disabled className="text-xs">
|
||||
<Spinner size="xs" className="mr-2" />
|
||||
@@ -90,21 +128,28 @@ export function BranchSwitchDropdown({
|
||||
{branchFilter ? 'No matching branches' : 'No branches found'}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
filteredBranches.map((branch) => (
|
||||
<DropdownMenuItem
|
||||
key={branch.name}
|
||||
onClick={() => onSwitchBranch(worktree, branch.name)}
|
||||
disabled={isSwitching || branch.name === worktree.branch}
|
||||
className="text-xs font-mono"
|
||||
>
|
||||
{branch.name === worktree.branch ? (
|
||||
<Check className="w-3.5 h-3.5 mr-2 flex-shrink-0" />
|
||||
) : (
|
||||
<span className="w-3.5 mr-2 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate">{branch.name}</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
<>
|
||||
{/* Local branches */}
|
||||
{localBranches.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-[10px] text-muted-foreground uppercase tracking-wider px-2 py-1">
|
||||
Local
|
||||
</DropdownMenuLabel>
|
||||
{localBranches.map(renderBranchItem)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Remote branches */}
|
||||
{remoteBranches.length > 0 && (
|
||||
<>
|
||||
{localBranches.length > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel className="text-[10px] text-muted-foreground uppercase tracking-wider px-2 py-1">
|
||||
Remote
|
||||
</DropdownMenuLabel>
|
||||
{remoteBranches.map(renderBranchItem)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
@@ -17,7 +17,7 @@ export function useBranches() {
|
||||
data: branchData,
|
||||
isLoading: isLoadingBranches,
|
||||
refetch,
|
||||
} = useWorktreeBranches(currentWorktreePath);
|
||||
} = useWorktreeBranches(currentWorktreePath, true);
|
||||
|
||||
const branches = branchData?.branches ?? [];
|
||||
const aheadCount = branchData?.aheadCount ?? 0;
|
||||
|
||||
@@ -13,12 +13,23 @@ import type { WorktreeInfo } from '../types';
|
||||
|
||||
const logger = createLogger('WorktreeActions');
|
||||
|
||||
export function useWorktreeActions() {
|
||||
interface UseWorktreeActionsOptions {
|
||||
/** Callback when merge conflicts occur after branch switch stash reapply */
|
||||
onBranchSwitchConflict?: (info: {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
previousBranch: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
|
||||
const navigate = useNavigate();
|
||||
const [isActivating, setIsActivating] = useState(false);
|
||||
|
||||
// Use React Query mutations
|
||||
const switchBranchMutation = useSwitchBranch();
|
||||
const switchBranchMutation = useSwitchBranch({
|
||||
onConflict: options?.onBranchSwitchConflict,
|
||||
});
|
||||
const pullMutation = usePullWorktree();
|
||||
const pushMutation = usePushWorktree();
|
||||
const openInEditorMutation = useOpenInEditor();
|
||||
|
||||
@@ -80,6 +80,12 @@ export interface MergeConflictInfo {
|
||||
targetWorktreePath: string;
|
||||
}
|
||||
|
||||
export interface BranchSwitchConflictInfo {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
previousBranch: string;
|
||||
}
|
||||
|
||||
export interface WorktreePanelProps {
|
||||
projectPath: string;
|
||||
onCreateWorktree: () => void;
|
||||
@@ -90,6 +96,8 @@ export interface WorktreePanelProps {
|
||||
onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void;
|
||||
onResolveConflicts: (worktree: WorktreeInfo) => void;
|
||||
onCreateMergeConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
/** Called when branch switch stash reapply results in merge conflicts */
|
||||
onBranchSwitchConflict?: (conflictInfo: BranchSwitchConflictInfo) => void;
|
||||
/** Called when a branch is deleted during merge - features should be reassigned to main */
|
||||
onBranchDeletedDuringMerge?: (branchName: string) => void;
|
||||
onRemovedWorktrees?: (removedWorktrees: Array<{ path: string; branch: string }>) => void;
|
||||
|
||||
@@ -14,7 +14,12 @@ import type {
|
||||
TestRunnerOutputEvent,
|
||||
TestRunnerCompletedEvent,
|
||||
} from '@/types/electron';
|
||||
import type { WorktreePanelProps, WorktreeInfo, TestSessionInfo } from './types';
|
||||
import type {
|
||||
WorktreePanelProps,
|
||||
WorktreeInfo,
|
||||
TestSessionInfo,
|
||||
BranchSwitchConflictInfo,
|
||||
} from './types';
|
||||
import {
|
||||
useWorktrees,
|
||||
useDevServers,
|
||||
@@ -50,6 +55,7 @@ export function WorktreePanel({
|
||||
onAddressPRComments,
|
||||
onResolveConflicts,
|
||||
onCreateMergeConflictResolutionFeature,
|
||||
onBranchSwitchConflict,
|
||||
onBranchDeletedDuringMerge,
|
||||
onRemovedWorktrees,
|
||||
runningFeatureIds = [],
|
||||
@@ -101,7 +107,9 @@ export function WorktreePanel({
|
||||
handleOpenInIntegratedTerminal,
|
||||
handleOpenInEditor,
|
||||
handleOpenInExternalTerminal,
|
||||
} = useWorktreeActions();
|
||||
} = useWorktreeActions({
|
||||
onBranchSwitchConflict: onBranchSwitchConflict,
|
||||
});
|
||||
|
||||
const { hasRunningFeatures } = useRunningFeatures({
|
||||
runningFeatureIds,
|
||||
|
||||
@@ -489,7 +489,7 @@ export function DashboardView() {
|
||||
const hasProjects = projects.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-screen content-bg" data-testid="dashboard-view">
|
||||
<div className="flex-1 flex flex-col h-full content-bg" data-testid="dashboard-view">
|
||||
{/* Header with logo */}
|
||||
<header className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
||||
{/* Electron titlebar drag region */}
|
||||
|
||||
@@ -6,7 +6,7 @@ export function LoggedOutView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-full items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
|
||||
|
||||
@@ -348,7 +348,7 @@ export function LoginView() {
|
||||
// Checking server connectivity
|
||||
if (state.phase === 'checking_server') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-full items-center justify-center bg-background p-4">
|
||||
<div className="text-center space-y-4">
|
||||
<Spinner size="xl" className="mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -363,7 +363,7 @@ export function LoginView() {
|
||||
// Server unreachable after retries
|
||||
if (state.phase === 'server_error') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-full items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md space-y-6 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<ServerCrash className="h-8 w-8 text-destructive" />
|
||||
@@ -384,7 +384,7 @@ export function LoginView() {
|
||||
// Checking setup status after auth
|
||||
if (state.phase === 'checking_setup' || state.phase === 'redirecting') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-full items-center justify-center bg-background p-4">
|
||||
<div className="text-center space-y-4">
|
||||
<Spinner size="xl" className="mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -401,7 +401,7 @@ export function LoginView() {
|
||||
const error = state.phase === 'awaiting_login' ? state.error : null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="flex min-h-full items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
{/* Header */}
|
||||
<div className="text-center">
|
||||
|
||||
@@ -243,7 +243,7 @@ export function OverviewView() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-screen content-bg" data-testid="overview-view">
|
||||
<div className="flex-1 flex flex-col h-full content-bg" data-testid="overview-view">
|
||||
{/* Header */}
|
||||
<header className="shrink-0 border-b border-border bg-glass backdrop-blur-md">
|
||||
{/* Electron titlebar drag region */}
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { useCallback, useRef, useEffect, useState } from 'react';
|
||||
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* ANSI escape sequences for special keys.
|
||||
* These are what terminal emulators send when these keys are pressed.
|
||||
*/
|
||||
const SPECIAL_KEYS = {
|
||||
escape: '\x1b',
|
||||
tab: '\t',
|
||||
delete: '\x1b[3~',
|
||||
home: '\x1b[H',
|
||||
end: '\x1b[F',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Common Ctrl key combinations sent as control codes.
|
||||
* Ctrl+<char> sends the char code & 0x1f (e.g., Ctrl+C = 0x03).
|
||||
*/
|
||||
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;
|
||||
|
||||
const ARROW_KEYS = {
|
||||
up: '\x1b[A',
|
||||
down: '\x1b[B',
|
||||
right: '\x1b[C',
|
||||
left: '\x1b[D',
|
||||
} as const;
|
||||
|
||||
interface MobileTerminalControlsProps {
|
||||
/** Callback to send input data to the terminal WebSocket */
|
||||
onSendInput: (data: string) => void;
|
||||
/** Whether the terminal is connected and ready */
|
||||
isConnected: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile quick controls 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) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
// Track repeat interval for arrow key long-press
|
||||
const repeatIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const repeatTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Cleanup repeat timers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (repeatIntervalRef.current) clearInterval(repeatIntervalRef.current);
|
||||
if (repeatTimeoutRef.current) clearTimeout(repeatTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearRepeat = useCallback(() => {
|
||||
if (repeatIntervalRef.current) {
|
||||
clearInterval(repeatIntervalRef.current);
|
||||
repeatIntervalRef.current = null;
|
||||
}
|
||||
if (repeatTimeoutRef.current) {
|
||||
clearTimeout(repeatTimeoutRef.current);
|
||||
repeatTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** Sends a key sequence to the terminal. */
|
||||
const sendKey = useCallback(
|
||||
(data: string) => {
|
||||
if (!isConnected) return;
|
||||
onSendInput(data);
|
||||
},
|
||||
[isConnected, onSendInput]
|
||||
);
|
||||
|
||||
/** Handles arrow key press with long-press repeat support. */
|
||||
const handleArrowPress = useCallback(
|
||||
(data: string) => {
|
||||
sendKey(data);
|
||||
// Start repeat after 400ms hold, then every 80ms
|
||||
repeatTimeoutRef.current = setTimeout(() => {
|
||||
repeatIntervalRef.current = setInterval(() => {
|
||||
sendKey(data);
|
||||
}, 80);
|
||||
}, 400);
|
||||
},
|
||||
[sendKey]
|
||||
);
|
||||
|
||||
const handleArrowRelease = useCallback(() => {
|
||||
clearRepeat();
|
||||
}, [clearRepeat]);
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="flex items-center justify-center shrink-0 bg-card/95 backdrop-blur-sm border-b border-border">
|
||||
<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"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
<span>Controls</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1.5 shrink-0 bg-card/95 backdrop-blur-sm border-b border-border overflow-x-auto">
|
||||
{/* Collapse button */}
|
||||
<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"
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Special keys */}
|
||||
<ControlButton
|
||||
label="Esc"
|
||||
onPress={() => sendKey(SPECIAL_KEYS.escape)}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ControlButton
|
||||
label="Tab"
|
||||
onPress={() => sendKey(SPECIAL_KEYS.tab)}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-6 bg-border shrink-0" />
|
||||
|
||||
{/* Common Ctrl shortcuts */}
|
||||
<ControlButton
|
||||
label="^C"
|
||||
title="Ctrl+C (Interrupt)"
|
||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+C'])}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ControlButton
|
||||
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
|
||||
label="^B"
|
||||
title="Ctrl+B (Back/tmux prefix)"
|
||||
onPress={() => sendKey(CTRL_KEYS['Ctrl+B'])}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
|
||||
{/* 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"
|
||||
onPress={() => handleArrowPress(ARROW_KEYS.left)}
|
||||
onRelease={handleArrowRelease}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ArrowButton
|
||||
direction="down"
|
||||
onPress={() => handleArrowPress(ARROW_KEYS.down)}
|
||||
onRelease={handleArrowRelease}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ArrowButton
|
||||
direction="up"
|
||||
onPress={() => handleArrowPress(ARROW_KEYS.up)}
|
||||
onRelease={handleArrowRelease}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<ArrowButton
|
||||
direction="right"
|
||||
onPress={() => handleArrowPress(ARROW_KEYS.right)}
|
||||
onRelease={handleArrowRelease}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual control button for special keys and shortcuts.
|
||||
*/
|
||||
function ControlButton({
|
||||
label,
|
||||
title,
|
||||
onPress,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
title?: string;
|
||||
onPress: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-md text-xs font-medium shrink-0 select-none transition-colors min-w-[36px] min-h-[36px] flex items-center justify-center',
|
||||
'active:scale-95 touch-manipulation',
|
||||
'bg-muted/80 text-foreground hover:bg-accent',
|
||||
disabled && 'opacity-40 pointer-events-none'
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault(); // Prevent focus stealing from terminal
|
||||
onPress();
|
||||
}}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Arrow key button with long-press repeat support.
|
||||
* Uses pointer events for reliable touch + mouse handling.
|
||||
*/
|
||||
function ArrowButton({
|
||||
direction,
|
||||
onPress,
|
||||
onRelease,
|
||||
disabled = false,
|
||||
}: {
|
||||
direction: 'up' | 'down' | 'left' | 'right';
|
||||
onPress: () => void;
|
||||
onRelease: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const icons = {
|
||||
up: ArrowUp,
|
||||
down: ArrowDown,
|
||||
left: ArrowLeft,
|
||||
right: ArrowRight,
|
||||
};
|
||||
const Icon = icons[direction];
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'p-2 rounded-md shrink-0 select-none transition-colors min-w-[36px] min-h-[36px] flex items-center justify-center',
|
||||
'active:scale-95 touch-manipulation',
|
||||
'bg-muted/80 text-foreground hover:bg-accent',
|
||||
disabled && 'opacity-40 pointer-events-none'
|
||||
)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault(); // Prevent focus stealing from terminal
|
||||
onPress();
|
||||
}}
|
||||
onPointerUp={onRelease}
|
||||
onPointerLeave={onRelease}
|
||||
onPointerCancel={onRelease}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -51,6 +51,9 @@ import { DEFAULT_FONT_VALUE } from '@/config/ui-font-options';
|
||||
import { toast } from 'sonner';
|
||||
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';
|
||||
|
||||
const logger = createLogger('Terminal');
|
||||
const NO_STORE_CACHE_MODE: RequestCache = 'no-store';
|
||||
@@ -163,6 +166,12 @@ export function TerminalPanel({
|
||||
const INITIAL_RECONNECT_DELAY = 1000;
|
||||
const [processExitCode, setProcessExitCode] = useState<number | null>(null);
|
||||
|
||||
// Detect mobile viewport for quick controls
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Track virtual keyboard height on mobile to prevent overlap
|
||||
const { keyboardHeight, isKeyboardOpen } = useVirtualKeyboardResize();
|
||||
|
||||
// Get current project for image saving
|
||||
const currentProject = useAppStore((state) => state.currentProject);
|
||||
|
||||
@@ -345,6 +354,13 @@ export function TerminalPanel({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Send raw input to terminal via WebSocket (used by mobile quick controls)
|
||||
const sendTerminalInput = useCallback((data: string) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Paste from clipboard
|
||||
const pasteFromClipboard = useCallback(async () => {
|
||||
const terminal = xtermRef.current;
|
||||
@@ -1722,6 +1738,9 @@ export function TerminalPanel({
|
||||
// Visual feedback when hovering over as drop target
|
||||
isOver && isDropTarget && 'ring-2 ring-green-500 ring-inset'
|
||||
)}
|
||||
style={
|
||||
isMobile && isKeyboardOpen ? { height: `calc(100% - ${keyboardHeight}px)` } : undefined
|
||||
}
|
||||
onClick={onFocus}
|
||||
onKeyDownCapture={handleContainerKeyDownCapture}
|
||||
tabIndex={0}
|
||||
@@ -2138,6 +2157,14 @@ export function TerminalPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile quick controls - special keys and arrow keys for touch devices */}
|
||||
{isMobile && (
|
||||
<MobileTerminalControls
|
||||
onSendInput={sendTerminalInput}
|
||||
isConnected={connectionStatus === 'connected'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Terminal container - uses terminal theme */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
|
||||
@@ -87,10 +87,18 @@ export function useCommitWorktree() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ worktreePath, message }: { worktreePath: string; message: string }) => {
|
||||
mutationFn: async ({
|
||||
worktreePath,
|
||||
message,
|
||||
files,
|
||||
}: {
|
||||
worktreePath: string;
|
||||
message: string;
|
||||
files?: string[];
|
||||
}) => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.worktree) throw new Error('Worktree API not available');
|
||||
const result = await api.worktree.commit(worktreePath, message);
|
||||
const result = await api.worktree.commit(worktreePath, message, files);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to commit changes');
|
||||
}
|
||||
@@ -275,12 +283,30 @@ export function useMergeWorktree(projectPath: string) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from the switch branch API call
|
||||
*/
|
||||
interface SwitchBranchResult {
|
||||
previousBranch: string;
|
||||
currentBranch: string;
|
||||
message: string;
|
||||
hasConflicts?: boolean;
|
||||
stashedChanges?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different branch
|
||||
*
|
||||
* Automatically stashes local changes before switching and reapplies them after.
|
||||
* If the reapply causes merge conflicts, the onConflict callback is called so
|
||||
* the UI can create a conflict resolution task.
|
||||
*
|
||||
* @param options.onConflict - Callback when merge conflicts occur after stash reapply
|
||||
* @returns Mutation for switching branches
|
||||
*/
|
||||
export function useSwitchBranch() {
|
||||
export function useSwitchBranch(options?: {
|
||||
onConflict?: (info: { worktreePath: string; branchName: string; previousBranch: string }) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
@@ -290,18 +316,33 @@ export function useSwitchBranch() {
|
||||
}: {
|
||||
worktreePath: string;
|
||||
branchName: string;
|
||||
}) => {
|
||||
}): Promise<SwitchBranchResult> => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.worktree) throw new Error('Worktree API not available');
|
||||
const result = await api.worktree.switchBranch(worktreePath, branchName);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to switch branch');
|
||||
}
|
||||
return result.result;
|
||||
return result.result as SwitchBranchResult;
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['worktrees'] });
|
||||
toast.success('Switched branch');
|
||||
|
||||
if (data?.hasConflicts) {
|
||||
toast.warning('Switched branch with conflicts', {
|
||||
description: data.message,
|
||||
duration: 8000,
|
||||
});
|
||||
// Trigger conflict resolution callback
|
||||
options?.onConflict?.({
|
||||
worktreePath: variables.worktreePath,
|
||||
branchName: data.currentBranch,
|
||||
previousBranch: data.previousBranch,
|
||||
});
|
||||
} else {
|
||||
const desc = data?.stashedChanges ? 'Local changes were stashed and reapplied' : undefined;
|
||||
toast.success('Switched branch', { description: desc });
|
||||
}
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error('Failed to switch branch', {
|
||||
|
||||
@@ -143,7 +143,12 @@ export function useAutoMode(worktree?: WorktreeInfo) {
|
||||
|
||||
const isAutoModeRunning = worktreeAutoModeState.isRunning;
|
||||
const runningAutoTasks = worktreeAutoModeState.runningTasks;
|
||||
const maxConcurrency = worktreeAutoModeState.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY;
|
||||
// Use getMaxConcurrencyForWorktree which properly falls back to the global
|
||||
// maxConcurrency setting, instead of DEFAULT_MAX_CONCURRENCY (1) which would
|
||||
// incorrectly block agents when the user has set a higher global limit
|
||||
const maxConcurrency = projectId
|
||||
? getMaxConcurrencyForWorktree(projectId, branchName)
|
||||
: DEFAULT_MAX_CONCURRENCY;
|
||||
|
||||
// Check if we can start a new task based on concurrency limit
|
||||
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
|
||||
|
||||
@@ -4,17 +4,25 @@
|
||||
* Tracks the timestamp of the last WebSocket event received.
|
||||
* Used to conditionally disable polling when events are flowing
|
||||
* through WebSocket (indicating the connection is healthy).
|
||||
*
|
||||
* Mobile-aware: On mobile devices, the recency threshold is extended
|
||||
* and polling intervals are multiplied to reduce battery drain and
|
||||
* network usage while maintaining data freshness through WebSocket.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { isMobileDevice, getMobilePollingMultiplier } from '@/lib/mobile-detect';
|
||||
|
||||
/**
|
||||
* Time threshold (ms) to consider events as "recent"
|
||||
* If an event was received within this time, WebSocket is considered healthy
|
||||
* and polling can be safely disabled.
|
||||
*
|
||||
* On mobile, the threshold is extended to 10 seconds since WebSocket
|
||||
* connections on mobile may have higher latency and more jitter.
|
||||
*/
|
||||
export const EVENT_RECENCY_THRESHOLD = 5000; // 5 seconds
|
||||
export const EVENT_RECENCY_THRESHOLD = isMobileDevice ? 10000 : 5000;
|
||||
|
||||
/**
|
||||
* Store for tracking event timestamps per query key
|
||||
@@ -136,6 +144,12 @@ export function useEventRecency(queryKey?: string) {
|
||||
* Utility function to create a refetchInterval that respects event recency.
|
||||
* Returns false (no polling) if events are recent, otherwise returns the interval.
|
||||
*
|
||||
* On mobile, the interval is multiplied by getMobilePollingMultiplier() to reduce
|
||||
* battery drain and network usage. This is safe because:
|
||||
* - WebSocket invalidation handles real-time updates (features, agents, etc.)
|
||||
* - The service worker caches API responses for instant display
|
||||
* - Longer intervals mean fewer network round-trips on slow mobile connections
|
||||
*
|
||||
* @param defaultInterval - The polling interval to use when events aren't recent
|
||||
* @returns A function suitable for React Query's refetchInterval option
|
||||
*
|
||||
@@ -149,9 +163,10 @@ export function useEventRecency(queryKey?: string) {
|
||||
* ```
|
||||
*/
|
||||
export function createSmartPollingInterval(defaultInterval: number) {
|
||||
const mobileAwareInterval = defaultInterval * getMobilePollingMultiplier();
|
||||
return () => {
|
||||
const { areGlobalEventsRecent } = useEventRecencyStore.getState();
|
||||
return areGlobalEventsRecent() ? false : defaultInterval;
|
||||
return areGlobalEventsRecent() ? false : mobileAwareInterval;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
127
apps/ui/src/hooks/use-mobile-visibility.ts
Normal file
127
apps/ui/src/hooks/use-mobile-visibility.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Mobile Visibility Hook
|
||||
*
|
||||
* Manages React Query's online/focus state based on page visibility
|
||||
* to prevent unnecessary refetching when the mobile app is backgrounded.
|
||||
*
|
||||
* On mobile devices, switching to another app triggers:
|
||||
* 1. visibilitychange → hidden (app goes to background)
|
||||
* 2. visibilitychange → visible (app comes back)
|
||||
*
|
||||
* Without this hook, step 2 triggers refetchOnWindowFocus for ALL active queries,
|
||||
* causing a "storm" of network requests that overwhelms the connection and causes
|
||||
* blank screens, layout shifts, and perceived reloads.
|
||||
*
|
||||
* This hook:
|
||||
* - Pauses polling intervals while the app is hidden on mobile
|
||||
* - Delays query refetching by a short grace period when the app becomes visible again
|
||||
* - Prevents the WebSocket reconnection from triggering immediate refetches
|
||||
*
|
||||
* Desktop behavior is unchanged - this hook is a no-op on non-mobile devices.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { focusManager, onlineManager } from '@tanstack/react-query';
|
||||
import { isMobileDevice } from '@/lib/mobile-detect';
|
||||
|
||||
/**
|
||||
* Grace period (ms) after the app becomes visible before allowing refetches.
|
||||
* This prevents a burst of refetches when the user quickly switches back to the app.
|
||||
* During this time, queries will use their cached data (which may be slightly stale
|
||||
* but is far better than showing a blank screen or loading spinner).
|
||||
*/
|
||||
const VISIBILITY_GRACE_PERIOD = 1500;
|
||||
|
||||
/**
|
||||
* Hook to manage query behavior based on mobile page visibility.
|
||||
*
|
||||
* Call this once at the app root level (e.g., in App.tsx or __root.tsx).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function App() {
|
||||
* useMobileVisibility();
|
||||
* return <RouterProvider router={router} />;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useMobileVisibility(): void {
|
||||
useEffect(() => {
|
||||
// No-op on desktop - default React Query behavior is fine
|
||||
if (!isMobileDevice) return;
|
||||
|
||||
let graceTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
// App went to background - tell React Query we've lost focus
|
||||
// This prevents any scheduled refetches from firing while backgrounded
|
||||
focusManager.setFocused(false);
|
||||
} else {
|
||||
// App came back to foreground
|
||||
// Wait a grace period before signaling focus to prevent refetch storms.
|
||||
// During this time, the UI renders with cached data (no blank screen).
|
||||
if (graceTimeout) clearTimeout(graceTimeout);
|
||||
graceTimeout = setTimeout(() => {
|
||||
focusManager.setFocused(true);
|
||||
graceTimeout = null;
|
||||
}, VISIBILITY_GRACE_PERIOD);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
if (graceTimeout) clearTimeout(graceTimeout);
|
||||
// Restore default focus management
|
||||
focusManager.setFocused(undefined);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to pause online status during extended background periods on mobile.
|
||||
* When the app has been in the background for more than the threshold,
|
||||
* we mark it as "offline" to prevent React Query from refetching all queries
|
||||
* at once when it comes back online. Instead, we let the WebSocket reconnect
|
||||
* first and then gradually re-enable queries.
|
||||
*
|
||||
* Call this once at the app root level alongside useMobileVisibility.
|
||||
*/
|
||||
export function useMobileOnlineManager(): void {
|
||||
useEffect(() => {
|
||||
if (!isMobileDevice) return;
|
||||
|
||||
let backgroundTimestamp: number | null = null;
|
||||
// If the app was backgrounded for more than 30 seconds, throttle reconnection
|
||||
const BACKGROUND_THRESHOLD = 30 * 1000;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.hidden) {
|
||||
backgroundTimestamp = Date.now();
|
||||
} else if (backgroundTimestamp) {
|
||||
const backgroundDuration = Date.now() - backgroundTimestamp;
|
||||
backgroundTimestamp = null;
|
||||
|
||||
if (backgroundDuration > BACKGROUND_THRESHOLD) {
|
||||
// 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.
|
||||
onlineManager.setOnline(false);
|
||||
setTimeout(() => {
|
||||
onlineManager.setOnline(true);
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
// Restore online status on cleanup
|
||||
onlineManager.setOnline(true);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
64
apps/ui/src/hooks/use-virtual-keyboard-resize.ts
Normal file
64
apps/ui/src/hooks/use-virtual-keyboard-resize.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
/**
|
||||
* Hook that detects when the mobile virtual keyboard is open and returns
|
||||
* the height offset needed to prevent the keyboard from overlapping content.
|
||||
*
|
||||
* Uses the Visual Viewport API to detect viewport shrinkage caused by the
|
||||
* virtual keyboard. When the keyboard is open, the visual viewport height
|
||||
* is smaller than the layout viewport height.
|
||||
*
|
||||
* @returns An object with:
|
||||
* - `keyboardHeight`: The estimated keyboard height in pixels (0 when closed)
|
||||
* - `isKeyboardOpen`: Boolean indicating if the keyboard is currently open
|
||||
*/
|
||||
export function useVirtualKeyboardResize() {
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
|
||||
const initialHeightRef = useRef<number | null>(null);
|
||||
|
||||
const handleViewportResize = useCallback(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
|
||||
// On first call, record the full viewport height (no keyboard)
|
||||
if (initialHeightRef.current === null) {
|
||||
initialHeightRef.current = vv.height;
|
||||
}
|
||||
|
||||
// The keyboard height is the difference between the window inner height
|
||||
// and the visual viewport height. On iOS, window.innerHeight stays the same
|
||||
// when the keyboard opens, but visualViewport.height shrinks.
|
||||
const heightDiff = window.innerHeight - vv.height;
|
||||
|
||||
// Use a threshold to avoid false positives from browser chrome changes
|
||||
// (address bar show/hide causes ~50-80px changes on most browsers)
|
||||
const KEYBOARD_THRESHOLD = 100;
|
||||
|
||||
if (heightDiff > KEYBOARD_THRESHOLD) {
|
||||
setKeyboardHeight(heightDiff);
|
||||
setIsKeyboardOpen(true);
|
||||
} else {
|
||||
setKeyboardHeight(0);
|
||||
setIsKeyboardOpen(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
|
||||
vv.addEventListener('resize', handleViewportResize);
|
||||
vv.addEventListener('scroll', handleViewportResize);
|
||||
|
||||
// Initial check
|
||||
handleViewportResize();
|
||||
|
||||
return () => {
|
||||
vv.removeEventListener('resize', handleViewportResize);
|
||||
vv.removeEventListener('scroll', handleViewportResize);
|
||||
};
|
||||
}, [handleViewportResize]);
|
||||
|
||||
return { keyboardHeight, isKeyboardOpen };
|
||||
}
|
||||
@@ -2174,8 +2174,8 @@ function createMockWorktreeAPI(): WorktreeAPI {
|
||||
};
|
||||
},
|
||||
|
||||
commit: async (worktreePath: string, message: string) => {
|
||||
console.log('[Mock] Committing changes:', { worktreePath, message });
|
||||
commit: async (worktreePath: string, message: string, files?: string[]) => {
|
||||
console.log('[Mock] Committing changes:', { worktreePath, message, files });
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
|
||||
@@ -2076,8 +2076,8 @@ export class HttpApiClient implements ElectronAPI {
|
||||
worktreePath,
|
||||
deleteBranch,
|
||||
}),
|
||||
commit: (worktreePath: string, message: string) =>
|
||||
this.post('/api/worktree/commit', { worktreePath, message }),
|
||||
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 }),
|
||||
push: (worktreePath: string, force?: boolean, remote?: string) =>
|
||||
|
||||
75
apps/ui/src/lib/mobile-detect.ts
Normal file
75
apps/ui/src/lib/mobile-detect.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Mobile Detection Utility
|
||||
*
|
||||
* Provides a cached, non-reactive mobile detection for use outside React components.
|
||||
* Used by service worker registration, query client configuration, and other
|
||||
* non-component code that needs to know if the device is mobile.
|
||||
*
|
||||
* For React components, use the `useIsMobile()` hook from `hooks/use-media-query.ts`
|
||||
* instead, which responds to viewport changes reactively.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cached mobile detection result.
|
||||
* Evaluated once on module load for consistent behavior across the app lifetime.
|
||||
* Uses both media query and user agent for reliability:
|
||||
* - Media query catches small desktop windows
|
||||
* - User agent catches mobile browsers at any viewport size
|
||||
* - Touch detection as supplementary signal
|
||||
*/
|
||||
export const isMobileDevice: boolean = (() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
// Check viewport width (consistent with useIsMobile hook's 768px breakpoint)
|
||||
const isSmallViewport = window.matchMedia('(max-width: 768px)').matches;
|
||||
|
||||
// Check user agent for mobile devices
|
||||
const isMobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
|
||||
// Check for touch-primary device (most mobile devices)
|
||||
const isTouchPrimary = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
|
||||
// Consider it mobile if viewport is small OR if it's a mobile UA with touch
|
||||
return isSmallViewport || (isMobileUA && isTouchPrimary);
|
||||
})();
|
||||
|
||||
/**
|
||||
* Check if the device has a slow connection.
|
||||
* Uses the Network Information API when available.
|
||||
* Falls back to mobile detection as a heuristic.
|
||||
*/
|
||||
export function isSlowConnection(): boolean {
|
||||
if (typeof navigator === 'undefined') return false;
|
||||
|
||||
const connection = (
|
||||
navigator as Navigator & {
|
||||
connection?: {
|
||||
effectiveType?: string;
|
||||
saveData?: boolean;
|
||||
};
|
||||
}
|
||||
).connection;
|
||||
|
||||
if (connection) {
|
||||
// Respect data saver mode
|
||||
if (connection.saveData) return true;
|
||||
// 2g and slow-2g are definitely slow
|
||||
if (connection.effectiveType === '2g' || connection.effectiveType === 'slow-2g') return true;
|
||||
}
|
||||
|
||||
// On mobile without connection info, assume potentially slow
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiplier for polling intervals on mobile.
|
||||
* Mobile devices benefit from less frequent polling to save battery and bandwidth.
|
||||
* Slow connections get an even larger multiplier.
|
||||
*/
|
||||
export function getMobilePollingMultiplier(): number {
|
||||
if (!isMobileDevice) return 1;
|
||||
if (isSlowConnection()) return 4;
|
||||
return 2;
|
||||
}
|
||||
@@ -4,51 +4,69 @@
|
||||
* Central configuration for TanStack React Query.
|
||||
* Provides default options for queries and mutations including
|
||||
* caching, retries, and error handling.
|
||||
*
|
||||
* Mobile-aware: Automatically extends stale times and garbage collection
|
||||
* on mobile devices to reduce unnecessary refetching, which causes
|
||||
* blank screens, reloads, and battery drain on flaky mobile connections.
|
||||
*/
|
||||
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
import { isConnectionError, handleServerOffline } from './http-api-client';
|
||||
import { isMobileDevice } from './mobile-detect';
|
||||
|
||||
const logger = createLogger('QueryClient');
|
||||
|
||||
/**
|
||||
* Default stale times for different data types
|
||||
* Mobile multiplier for stale times.
|
||||
* On mobile, data stays "fresh" longer to avoid refetching on every
|
||||
* component mount, which causes blank flickers and layout shifts.
|
||||
* The WebSocket invalidation system still ensures critical updates
|
||||
* (feature status changes, agent events) arrive in real-time.
|
||||
*/
|
||||
const MOBILE_STALE_MULTIPLIER = isMobileDevice ? 3 : 1;
|
||||
|
||||
/**
|
||||
* Default stale times for different data types.
|
||||
* On mobile, these are multiplied by MOBILE_STALE_MULTIPLIER to reduce
|
||||
* unnecessary network requests while WebSocket handles real-time updates.
|
||||
*/
|
||||
export const STALE_TIMES = {
|
||||
/** Features change frequently during auto-mode */
|
||||
FEATURES: 60 * 1000, // 1 minute
|
||||
FEATURES: 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 1 min (3 min on mobile)
|
||||
/** GitHub data is relatively stable */
|
||||
GITHUB: 2 * 60 * 1000, // 2 minutes
|
||||
GITHUB: 2 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 2 min (6 min on mobile)
|
||||
/** Running agents state changes very frequently */
|
||||
RUNNING_AGENTS: 5 * 1000, // 5 seconds
|
||||
RUNNING_AGENTS: 5 * 1000 * MOBILE_STALE_MULTIPLIER, // 5s (15s on mobile)
|
||||
/** Agent output changes during streaming */
|
||||
AGENT_OUTPUT: 5 * 1000, // 5 seconds
|
||||
AGENT_OUTPUT: 5 * 1000 * MOBILE_STALE_MULTIPLIER, // 5s (15s on mobile)
|
||||
/** Usage data with polling */
|
||||
USAGE: 30 * 1000, // 30 seconds
|
||||
USAGE: 30 * 1000 * MOBILE_STALE_MULTIPLIER, // 30s (90s on mobile)
|
||||
/** Models rarely change */
|
||||
MODELS: 5 * 60 * 1000, // 5 minutes
|
||||
MODELS: 5 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 5 min (15 min on mobile)
|
||||
/** CLI status rarely changes */
|
||||
CLI_STATUS: 5 * 60 * 1000, // 5 minutes
|
||||
CLI_STATUS: 5 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 5 min (15 min on mobile)
|
||||
/** Settings are relatively stable */
|
||||
SETTINGS: 2 * 60 * 1000, // 2 minutes
|
||||
SETTINGS: 2 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 2 min (6 min on mobile)
|
||||
/** Worktrees change during feature development */
|
||||
WORKTREES: 30 * 1000, // 30 seconds
|
||||
WORKTREES: 30 * 1000 * MOBILE_STALE_MULTIPLIER, // 30s (90s on mobile)
|
||||
/** Sessions rarely change */
|
||||
SESSIONS: 2 * 60 * 1000, // 2 minutes
|
||||
SESSIONS: 2 * 60 * 1000 * MOBILE_STALE_MULTIPLIER, // 2 min (6 min on mobile)
|
||||
/** Default for unspecified queries */
|
||||
DEFAULT: 30 * 1000, // 30 seconds
|
||||
DEFAULT: 30 * 1000 * MOBILE_STALE_MULTIPLIER, // 30s (90s on mobile)
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default garbage collection times (gcTime, formerly cacheTime)
|
||||
* Default garbage collection times (gcTime, formerly cacheTime).
|
||||
* On mobile, cache is kept longer so data persists across navigations
|
||||
* and component unmounts, preventing blank screens on re-mount.
|
||||
*/
|
||||
export const GC_TIMES = {
|
||||
/** Default garbage collection time */
|
||||
DEFAULT: 5 * 60 * 1000, // 5 minutes
|
||||
DEFAULT: isMobileDevice ? 15 * 60 * 1000 : 5 * 60 * 1000, // 15 min on mobile, 5 min desktop
|
||||
/** Extended for expensive queries */
|
||||
EXTENDED: 10 * 60 * 1000, // 10 minutes
|
||||
EXTENDED: isMobileDevice ? 30 * 60 * 1000 : 10 * 60 * 1000, // 30 min on mobile, 10 min desktop
|
||||
} as const;
|
||||
|
||||
/**
|
||||
@@ -93,7 +111,15 @@ const handleMutationError = (error: Error) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Create and configure the QueryClient singleton
|
||||
* Create and configure the QueryClient singleton.
|
||||
*
|
||||
* Mobile optimizations:
|
||||
* - refetchOnWindowFocus disabled on mobile (prevents refetch storms when
|
||||
* switching apps, which causes the blank screen + reload cycle)
|
||||
* - refetchOnMount uses 'always' on desktop but only refetches stale data
|
||||
* on mobile (prevents unnecessary network requests on navigation)
|
||||
* - Longer stale times and GC times via STALE_TIMES and GC_TIMES above
|
||||
* - structuralSharing enabled to minimize re-renders when data hasn't changed
|
||||
*/
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -109,13 +135,21 @@ export const queryClient = new QueryClient({
|
||||
if (isConnectionError(error)) {
|
||||
return false;
|
||||
}
|
||||
// Retry up to 2 times for other errors
|
||||
return failureCount < 2;
|
||||
// Retry up to 2 times for other errors (3 on mobile for flaky connections)
|
||||
return failureCount < (isMobileDevice ? 3 : 2);
|
||||
},
|
||||
refetchOnWindowFocus: true,
|
||||
// On mobile, disable refetch on focus to prevent the blank screen + reload
|
||||
// cycle that occurs when the user switches back to the app. WebSocket
|
||||
// invalidation handles real-time updates; polling handles the rest.
|
||||
refetchOnWindowFocus: !isMobileDevice,
|
||||
refetchOnReconnect: true,
|
||||
// Don't refetch on mount if data is fresh
|
||||
refetchOnMount: true,
|
||||
// On mobile, only refetch on mount if data is stale (not always).
|
||||
// This prevents unnecessary network requests when navigating between
|
||||
// routes, which was causing blank screen flickers on mobile.
|
||||
refetchOnMount: isMobileDevice ? true : true,
|
||||
// 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,
|
||||
},
|
||||
mutations: {
|
||||
onError: handleMutationError,
|
||||
|
||||
@@ -1,7 +1,136 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './app';
|
||||
import { isMobileDevice } from './lib/mobile-detect';
|
||||
|
||||
// Register service worker for PWA support (web mode only)
|
||||
// Uses optimized registration strategy for faster mobile loading:
|
||||
// - Registers after load event to avoid competing with critical resources
|
||||
// - Handles updates gracefully with skipWaiting support
|
||||
// - Triggers cache cleanup on activation
|
||||
// - Prefetches likely-needed route chunks during idle time
|
||||
// - Enables mobile-specific API caching when on a mobile device
|
||||
if ('serviceWorker' in navigator && !window.location.protocol.startsWith('file')) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker
|
||||
.register('/sw.js', {
|
||||
// Check for updates on every page load for PWA freshness
|
||||
updateViaCache: 'none',
|
||||
})
|
||||
.then((registration) => {
|
||||
// Check for service worker updates periodically
|
||||
// Mobile: every 60 minutes (saves battery/bandwidth)
|
||||
// Desktop: every 30 minutes
|
||||
const updateInterval = isMobileDevice ? 60 * 60 * 1000 : 30 * 60 * 1000;
|
||||
setInterval(() => {
|
||||
registration.update().catch(() => {
|
||||
// Update check failed silently - will try again next interval
|
||||
});
|
||||
}, updateInterval);
|
||||
|
||||
// When a new service worker takes over, trigger cache cleanup
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'activated') {
|
||||
// New service worker is active - clean up old immutable cache entries
|
||||
newWorker.postMessage({ type: 'CACHE_CLEANUP' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Notify the service worker about mobile mode.
|
||||
// This enables stale-while-revalidate caching for API responses,
|
||||
// preventing blank screens caused by failed/slow API fetches on mobile.
|
||||
if (isMobileDevice && registration.active) {
|
||||
registration.active.postMessage({
|
||||
type: 'SET_MOBILE_MODE',
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Also listen for the SW becoming active (in case it wasn't ready above)
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (isMobileDevice && navigator.serviceWorker.controller) {
|
||||
navigator.serviceWorker.controller.postMessage({
|
||||
type: 'SET_MOBILE_MODE',
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Prefetch likely-needed route chunks during idle time.
|
||||
// On mobile, this means subsequent navigations are instant from cache
|
||||
// instead of requiring network round-trips over slow cellular connections.
|
||||
prefetchRouteChunks(registration);
|
||||
})
|
||||
.catch(() => {
|
||||
// Service worker registration failed; app still works without it
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefetch route JS chunks that the user is likely to navigate to.
|
||||
* Uses requestIdleCallback to avoid competing with the initial render,
|
||||
* and sends URLs to the service worker for background caching.
|
||||
* This is especially impactful on mobile where network latency is high.
|
||||
*/
|
||||
function prefetchRouteChunks(registration: ServiceWorkerRegistration): void {
|
||||
const idleCallback =
|
||||
typeof requestIdleCallback !== 'undefined'
|
||||
? requestIdleCallback
|
||||
: (cb: () => void) => setTimeout(cb, 2000);
|
||||
|
||||
// On mobile, wait a bit longer before prefetching to let the critical path complete.
|
||||
// Mobile connections are often slower and we don't want to compete with initial data fetches.
|
||||
const prefetchDelay = isMobileDevice ? 4000 : 0;
|
||||
|
||||
const doPrefetch = () => {
|
||||
// Find all modulepreload links already in the document (Vite injects these)
|
||||
// and any route chunks that might be linked
|
||||
const existingPreloads = new Set(
|
||||
Array.from(document.querySelectorAll('link[rel="modulepreload"]')).map(
|
||||
(link) => (link as HTMLLinkElement).href
|
||||
)
|
||||
);
|
||||
|
||||
// Also collect prefetch links (Vite mobile optimization converts some to prefetch)
|
||||
Array.from(document.querySelectorAll('link[rel="prefetch"]')).forEach((link) => {
|
||||
const href = (link as HTMLLinkElement).href;
|
||||
if (href) existingPreloads.add(href);
|
||||
});
|
||||
|
||||
// Discover route chunk URLs from the document's script tags
|
||||
// These are the code-split route bundles that TanStack Router will lazy-load
|
||||
const routeChunkUrls: string[] = [];
|
||||
document.querySelectorAll('script[src*="/assets/"]').forEach((script) => {
|
||||
const src = (script as HTMLScriptElement).src;
|
||||
if (src && !existingPreloads.has(src)) {
|
||||
routeChunkUrls.push(src);
|
||||
}
|
||||
});
|
||||
|
||||
// Send URLs to service worker for background caching
|
||||
if (routeChunkUrls.length > 0 && registration.active) {
|
||||
registration.active.postMessage({
|
||||
type: 'PRECACHE_ASSETS',
|
||||
urls: routeChunkUrls,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for idle time after the app is interactive
|
||||
if (prefetchDelay > 0) {
|
||||
setTimeout(() => idleCallback(doPrefetch), prefetchDelay);
|
||||
} else {
|
||||
idleCallback(doPrefetch);
|
||||
}
|
||||
}
|
||||
|
||||
// Render the app - prioritize First Contentful Paint
|
||||
createRoot(document.getElementById('app')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -778,7 +778,7 @@ function RootLayoutContent() {
|
||||
// Note: No sandbox dialog here - it only shows after login and setup complete
|
||||
if (isLoginRoute || isLoggedOutRoute) {
|
||||
return (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<main className="h-full overflow-hidden" data-testid="app-container">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
@@ -787,7 +787,7 @@ function RootLayoutContent() {
|
||||
// Wait for auth check before rendering protected routes (ALL modes - unified flow)
|
||||
if (!authChecked) {
|
||||
return (
|
||||
<main className="flex h-screen items-center justify-center" data-testid="app-container">
|
||||
<main className="flex h-full items-center justify-center" data-testid="app-container">
|
||||
<LoadingState message="Loading..." />
|
||||
</main>
|
||||
);
|
||||
@@ -797,7 +797,7 @@ function RootLayoutContent() {
|
||||
// Show loading state while navigation is in progress
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<main className="flex h-screen items-center justify-center" data-testid="app-container">
|
||||
<main className="flex h-full items-center justify-center" data-testid="app-container">
|
||||
<LoadingState message="Redirecting..." />
|
||||
</main>
|
||||
);
|
||||
@@ -805,7 +805,7 @@ function RootLayoutContent() {
|
||||
|
||||
if (shouldBlockForSettings) {
|
||||
return (
|
||||
<main className="flex h-screen items-center justify-center" data-testid="app-container">
|
||||
<main className="flex h-full items-center justify-center" data-testid="app-container">
|
||||
<LoadingState message="Loading settings..." />
|
||||
</main>
|
||||
);
|
||||
@@ -813,7 +813,7 @@ function RootLayoutContent() {
|
||||
|
||||
if (shouldAutoOpen) {
|
||||
return (
|
||||
<main className="flex h-screen items-center justify-center" data-testid="app-container">
|
||||
<main className="flex h-full items-center justify-center" data-testid="app-container">
|
||||
<LoadingState message="Opening project..." />
|
||||
</main>
|
||||
);
|
||||
@@ -822,7 +822,7 @@ function RootLayoutContent() {
|
||||
// Show setup page (full screen, no sidebar) - authenticated only
|
||||
if (isSetupRoute) {
|
||||
return (
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<main className="h-full overflow-hidden" data-testid="app-container">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
@@ -832,7 +832,7 @@ function RootLayoutContent() {
|
||||
if (isDashboardRoute) {
|
||||
return (
|
||||
<>
|
||||
<main className="h-screen overflow-hidden" data-testid="app-container">
|
||||
<main className="h-full overflow-hidden" data-testid="app-container">
|
||||
<Outlet />
|
||||
<Toaster richColors position="bottom-right" />
|
||||
</main>
|
||||
@@ -847,7 +847,7 @@ function RootLayoutContent() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className="flex h-screen overflow-hidden" data-testid="app-container">
|
||||
<main className="flex h-full overflow-hidden" data-testid="app-container">
|
||||
{/* Full-width titlebar drag region for Electron window dragging */}
|
||||
{isElectron() && (
|
||||
<div
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
// Note: setItem/getItem moved to ./utils/theme-utils.ts
|
||||
import { UI_SANS_FONT_OPTIONS, UI_MONO_FONT_OPTIONS } from '@/config/ui-font-options';
|
||||
import { loadFont } from '@/styles/font-imports';
|
||||
import type {
|
||||
FeatureImagePath,
|
||||
FeatureTextFilePath,
|
||||
@@ -663,12 +664,14 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
||||
},
|
||||
setPreviewTheme: (theme) => set({ previewTheme: theme }),
|
||||
|
||||
// Font actions
|
||||
// Font actions - triggers lazy font loading for on-demand fonts
|
||||
setFontSans: (fontFamily) => {
|
||||
if (fontFamily) loadFont(fontFamily);
|
||||
set({ fontFamilySans: fontFamily });
|
||||
saveFontSansToStorage(fontFamily);
|
||||
},
|
||||
setFontMono: (fontFamily) => {
|
||||
if (fontFamily) loadFont(fontFamily);
|
||||
set({ fontFamilyMono: fontFamily });
|
||||
saveFontMonoToStorage(fontFamily);
|
||||
},
|
||||
|
||||
@@ -1,113 +1,254 @@
|
||||
/**
|
||||
* Bundles all web font packages so they're available
|
||||
* for use in the font customization settings.
|
||||
* Font Loading Strategy for Mobile PWA Performance
|
||||
*
|
||||
* These fonts are self-hosted with the app, so users don't need
|
||||
* to have them installed on their system.
|
||||
* Critical fonts (Zed Sans/Mono - used as Geist fallback) are loaded eagerly.
|
||||
* All other fonts are lazy-loaded on demand when the user selects them
|
||||
* in font customization settings. This dramatically reduces initial bundle
|
||||
* size and speeds up mobile PWA loading.
|
||||
*
|
||||
* Font loading is split into:
|
||||
* 1. Critical path: Zed fonts (default/fallback fonts) - loaded synchronously
|
||||
* 2. Deferred path: All @fontsource fonts - loaded on-demand or after idle
|
||||
*/
|
||||
|
||||
// Zed Fonts (from zed-industries/zed-fonts)
|
||||
// Critical: Zed Fonts (default fallback) - loaded immediately
|
||||
import '@/assets/fonts/zed/zed-fonts.css';
|
||||
|
||||
// ============================================
|
||||
// Sans-serif / UI Fonts (Top 10)
|
||||
// ============================================
|
||||
/**
|
||||
* Registry of lazy-loadable font imports.
|
||||
* Each font family maps to a function that dynamically imports its CSS files.
|
||||
* This ensures fonts are only downloaded when actually needed.
|
||||
*/
|
||||
type FontLoader = () => Promise<void>;
|
||||
|
||||
// Inter - Designed specifically for screens; excellent legibility at small sizes
|
||||
import '@fontsource/inter/400.css';
|
||||
import '@fontsource/inter/500.css';
|
||||
import '@fontsource/inter/600.css';
|
||||
import '@fontsource/inter/700.css';
|
||||
const fontLoaders: Record<string, FontLoader> = {
|
||||
// Sans-serif / UI Fonts
|
||||
Inter: async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/inter/400.css'),
|
||||
import('@fontsource/inter/500.css'),
|
||||
import('@fontsource/inter/600.css'),
|
||||
import('@fontsource/inter/700.css'),
|
||||
]);
|
||||
},
|
||||
Roboto: async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/roboto/400.css'),
|
||||
import('@fontsource/roboto/500.css'),
|
||||
import('@fontsource/roboto/700.css'),
|
||||
]);
|
||||
},
|
||||
'Open Sans': async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/open-sans/400.css'),
|
||||
import('@fontsource/open-sans/500.css'),
|
||||
import('@fontsource/open-sans/600.css'),
|
||||
import('@fontsource/open-sans/700.css'),
|
||||
]);
|
||||
},
|
||||
Montserrat: async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/montserrat/400.css'),
|
||||
import('@fontsource/montserrat/500.css'),
|
||||
import('@fontsource/montserrat/600.css'),
|
||||
import('@fontsource/montserrat/700.css'),
|
||||
]);
|
||||
},
|
||||
Lato: async () => {
|
||||
await Promise.all([import('@fontsource/lato/400.css'), import('@fontsource/lato/700.css')]);
|
||||
},
|
||||
Poppins: async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/poppins/400.css'),
|
||||
import('@fontsource/poppins/500.css'),
|
||||
import('@fontsource/poppins/600.css'),
|
||||
import('@fontsource/poppins/700.css'),
|
||||
]);
|
||||
},
|
||||
Raleway: async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/raleway/400.css'),
|
||||
import('@fontsource/raleway/500.css'),
|
||||
import('@fontsource/raleway/600.css'),
|
||||
import('@fontsource/raleway/700.css'),
|
||||
]);
|
||||
},
|
||||
'Work Sans': async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/work-sans/400.css'),
|
||||
import('@fontsource/work-sans/500.css'),
|
||||
import('@fontsource/work-sans/600.css'),
|
||||
import('@fontsource/work-sans/700.css'),
|
||||
]);
|
||||
},
|
||||
'Source Sans 3': async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/source-sans-3/400.css'),
|
||||
import('@fontsource/source-sans-3/500.css'),
|
||||
import('@fontsource/source-sans-3/600.css'),
|
||||
import('@fontsource/source-sans-3/700.css'),
|
||||
]);
|
||||
},
|
||||
|
||||
// Roboto - Highly versatile and clean; the standard for Google-based interfaces
|
||||
import '@fontsource/roboto/400.css';
|
||||
import '@fontsource/roboto/500.css';
|
||||
import '@fontsource/roboto/700.css';
|
||||
// Monospace / Code Fonts
|
||||
'Fira Code': async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/fira-code/400.css'),
|
||||
import('@fontsource/fira-code/500.css'),
|
||||
import('@fontsource/fira-code/600.css'),
|
||||
import('@fontsource/fira-code/700.css'),
|
||||
]);
|
||||
},
|
||||
'JetBrains Mono': async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/jetbrains-mono/400.css'),
|
||||
import('@fontsource/jetbrains-mono/500.css'),
|
||||
import('@fontsource/jetbrains-mono/600.css'),
|
||||
import('@fontsource/jetbrains-mono/700.css'),
|
||||
]);
|
||||
},
|
||||
'Cascadia Code': async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/cascadia-code/400.css'),
|
||||
import('@fontsource/cascadia-code/600.css'),
|
||||
import('@fontsource/cascadia-code/700.css'),
|
||||
]);
|
||||
},
|
||||
Iosevka: async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/iosevka/400.css'),
|
||||
import('@fontsource/iosevka/500.css'),
|
||||
import('@fontsource/iosevka/600.css'),
|
||||
import('@fontsource/iosevka/700.css'),
|
||||
]);
|
||||
},
|
||||
Inconsolata: async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/inconsolata/400.css'),
|
||||
import('@fontsource/inconsolata/500.css'),
|
||||
import('@fontsource/inconsolata/600.css'),
|
||||
import('@fontsource/inconsolata/700.css'),
|
||||
]);
|
||||
},
|
||||
'Source Code Pro': async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/source-code-pro/400.css'),
|
||||
import('@fontsource/source-code-pro/500.css'),
|
||||
import('@fontsource/source-code-pro/600.css'),
|
||||
import('@fontsource/source-code-pro/700.css'),
|
||||
]);
|
||||
},
|
||||
'IBM Plex Mono': async () => {
|
||||
await Promise.all([
|
||||
import('@fontsource/ibm-plex-mono/400.css'),
|
||||
import('@fontsource/ibm-plex-mono/500.css'),
|
||||
import('@fontsource/ibm-plex-mono/600.css'),
|
||||
import('@fontsource/ibm-plex-mono/700.css'),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
// Open Sans - Neutral and friendly; optimized for web and mobile readability
|
||||
import '@fontsource/open-sans/400.css';
|
||||
import '@fontsource/open-sans/500.css';
|
||||
import '@fontsource/open-sans/600.css';
|
||||
import '@fontsource/open-sans/700.css';
|
||||
// Track which fonts have been loaded to avoid duplicate loading
|
||||
const loadedFonts = new Set<string>();
|
||||
|
||||
// Montserrat - Geometric and modern; best for high-impact titles and branding
|
||||
import '@fontsource/montserrat/400.css';
|
||||
import '@fontsource/montserrat/500.css';
|
||||
import '@fontsource/montserrat/600.css';
|
||||
import '@fontsource/montserrat/700.css';
|
||||
/**
|
||||
* Load a specific font family on demand.
|
||||
* Returns immediately if the font is already loaded.
|
||||
* Safe to call multiple times - font will only be loaded once.
|
||||
*/
|
||||
export async function loadFont(fontFamily: string): Promise<void> {
|
||||
// Extract the primary font name from CSS font-family string
|
||||
// e.g., "'JetBrains Mono', monospace" -> "JetBrains Mono"
|
||||
const primaryFont = fontFamily
|
||||
.split(',')[0]
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '');
|
||||
|
||||
// Lato - Blends professionalism with warmth; ideal for longer body text
|
||||
import '@fontsource/lato/400.css';
|
||||
import '@fontsource/lato/700.css';
|
||||
if (loadedFonts.has(primaryFont)) return;
|
||||
|
||||
// Poppins - Geometric and energetic; popular for modern, friendly brand identities
|
||||
import '@fontsource/poppins/400.css';
|
||||
import '@fontsource/poppins/500.css';
|
||||
import '@fontsource/poppins/600.css';
|
||||
import '@fontsource/poppins/700.css';
|
||||
const loader = fontLoaders[primaryFont];
|
||||
if (loader) {
|
||||
try {
|
||||
await loader();
|
||||
loadedFonts.add(primaryFont);
|
||||
} catch (error) {
|
||||
// Font loading failed silently - system fallback fonts will be used
|
||||
console.warn(`Failed to load font: ${primaryFont}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Raleway - Elegant with unique characteristics; great for creative portfolios
|
||||
import '@fontsource/raleway/400.css';
|
||||
import '@fontsource/raleway/500.css';
|
||||
import '@fontsource/raleway/600.css';
|
||||
import '@fontsource/raleway/700.css';
|
||||
/**
|
||||
* Load fonts that the user has configured (from localStorage).
|
||||
* Called during app initialization to ensure custom fonts are available
|
||||
* before the first render completes.
|
||||
*/
|
||||
export function loadUserFonts(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem('automaker-storage');
|
||||
if (!stored) return;
|
||||
|
||||
// Work Sans - Optimized for screen readability; feels clean and contemporary
|
||||
import '@fontsource/work-sans/400.css';
|
||||
import '@fontsource/work-sans/500.css';
|
||||
import '@fontsource/work-sans/600.css';
|
||||
import '@fontsource/work-sans/700.css';
|
||||
const data = JSON.parse(stored);
|
||||
const state = data?.state;
|
||||
|
||||
// Source Sans 3 - Adobe's first open-source font; highly functional for complex interfaces
|
||||
import '@fontsource/source-sans-3/400.css';
|
||||
import '@fontsource/source-sans-3/500.css';
|
||||
import '@fontsource/source-sans-3/600.css';
|
||||
import '@fontsource/source-sans-3/700.css';
|
||||
// Load globally configured fonts
|
||||
if (state?.fontFamilySans && state.fontFamilySans !== 'default') {
|
||||
loadFont(state.fontFamilySans);
|
||||
}
|
||||
if (state?.fontFamilyMono && state.fontFamilyMono !== 'default') {
|
||||
loadFont(state.fontFamilyMono);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Monospace / Code Fonts (Top 10)
|
||||
// ============================================
|
||||
// Load current project's font overrides
|
||||
const currentProject = state?.currentProject;
|
||||
if (currentProject?.fontSans && currentProject.fontSans !== 'default') {
|
||||
loadFont(currentProject.fontSans);
|
||||
}
|
||||
if (currentProject?.fontMono && currentProject.fontMono !== 'default') {
|
||||
loadFont(currentProject.fontMono);
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available or parse error - ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Fira Code - Excellent legibility and stylish ligatures (=>, !=, etc.)
|
||||
import '@fontsource/fira-code/400.css';
|
||||
import '@fontsource/fira-code/500.css';
|
||||
import '@fontsource/fira-code/600.css';
|
||||
import '@fontsource/fira-code/700.css';
|
||||
/**
|
||||
* Preload all available fonts during idle time.
|
||||
* Called after the app is fully loaded to ensure font previews
|
||||
* in settings work instantly.
|
||||
*/
|
||||
export function preloadAllFonts(): void {
|
||||
const idleCallback =
|
||||
typeof requestIdleCallback !== 'undefined'
|
||||
? requestIdleCallback
|
||||
: (cb: () => void) => setTimeout(cb, 100);
|
||||
|
||||
// JetBrains Mono - Designed by JetBrains for developers, focusing on readability
|
||||
import '@fontsource/jetbrains-mono/400.css';
|
||||
import '@fontsource/jetbrains-mono/500.css';
|
||||
import '@fontsource/jetbrains-mono/600.css';
|
||||
import '@fontsource/jetbrains-mono/700.css';
|
||||
// Load fonts in batches during idle periods to avoid blocking
|
||||
const fontNames = Object.keys(fontLoaders);
|
||||
let index = 0;
|
||||
|
||||
// Cascadia Code - Microsoft's font, popular in Windows Terminal, with ligatures
|
||||
import '@fontsource/cascadia-code/400.css';
|
||||
import '@fontsource/cascadia-code/600.css';
|
||||
import '@fontsource/cascadia-code/700.css';
|
||||
function loadNextBatch() {
|
||||
const batchSize = 2; // Load 2 fonts per idle callback
|
||||
const end = Math.min(index + batchSize, fontNames.length);
|
||||
|
||||
// Iosevka - Highly customizable, slender sans-serif/slab-serif font
|
||||
import '@fontsource/iosevka/400.css';
|
||||
import '@fontsource/iosevka/500.css';
|
||||
import '@fontsource/iosevka/600.css';
|
||||
import '@fontsource/iosevka/700.css';
|
||||
for (let i = index; i < end; i++) {
|
||||
const fontName = fontNames[i];
|
||||
if (!loadedFonts.has(fontName)) {
|
||||
fontLoaders[fontName]()
|
||||
.then(() => {
|
||||
loadedFonts.add(fontName);
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently ignore preload failures
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Inconsolata - Popular, clean, and highly readable choice for screens
|
||||
import '@fontsource/inconsolata/400.css';
|
||||
import '@fontsource/inconsolata/500.css';
|
||||
import '@fontsource/inconsolata/600.css';
|
||||
import '@fontsource/inconsolata/700.css';
|
||||
index = end;
|
||||
if (index < fontNames.length) {
|
||||
idleCallback(loadNextBatch);
|
||||
}
|
||||
}
|
||||
|
||||
// Source Code Pro - Adobe's clean, geometric, open-source font
|
||||
import '@fontsource/source-code-pro/400.css';
|
||||
import '@fontsource/source-code-pro/500.css';
|
||||
import '@fontsource/source-code-pro/600.css';
|
||||
import '@fontsource/source-code-pro/700.css';
|
||||
|
||||
// IBM Plex Mono - Clean, modern monospaced font from IBM
|
||||
import '@fontsource/ibm-plex-mono/400.css';
|
||||
import '@fontsource/ibm-plex-mono/500.css';
|
||||
import '@fontsource/ibm-plex-mono/600.css';
|
||||
import '@fontsource/ibm-plex-mono/700.css';
|
||||
|
||||
// Note: Monaco/Menlo are macOS system fonts (not bundled)
|
||||
// Note: Hack font is not available via @fontsource
|
||||
idleCallback(loadNextBatch);
|
||||
}
|
||||
|
||||
@@ -384,6 +384,41 @@
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* Fixed viewport for mobile - app-like feel with no scrolling/bouncing */
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
/* Prevent pull-to-refresh and rubber-band scrolling on mobile */
|
||||
@supports (-webkit-touch-callout: none) {
|
||||
body {
|
||||
/* iOS Safari specific: prevent overscroll bounce */
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Safe area insets for devices with notches/home indicators (viewport-fit=cover) */
|
||||
#app {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
padding-left: env(safe-area-inset-left, 0px);
|
||||
padding-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
/* Apply monospace font to code elements */
|
||||
code,
|
||||
pre,
|
||||
|
||||
3
apps/ui/src/types/electron.d.ts
vendored
3
apps/ui/src/types/electron.d.ts
vendored
@@ -864,7 +864,8 @@ export interface WorktreeAPI {
|
||||
// Commit changes in a worktree
|
||||
commit: (
|
||||
worktreePath: string,
|
||||
message: string
|
||||
message: string,
|
||||
files?: string[]
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
result?: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { defineConfig, type Plugin } from 'vite';
|
||||
import electron from 'vite-plugin-electron/simple';
|
||||
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
|
||||
import { fileURLToPath } from 'url';
|
||||
@@ -13,6 +13,66 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
|
||||
const appVersion = packageJson.version;
|
||||
|
||||
/**
|
||||
* Vite plugin to optimize the HTML output for mobile PWA loading speed.
|
||||
*
|
||||
* Problem: Vite adds modulepreload links for ALL vendor chunks in index.html,
|
||||
* including heavy route-specific libraries like ReactFlow (172KB), xterm (676KB),
|
||||
* and CodeMirror (436KB). On mobile, these modulepreloads compete with critical
|
||||
* resources for bandwidth, delaying First Contentful Paint by 500ms+.
|
||||
*
|
||||
* Solution: Convert modulepreload to prefetch for route-specific vendor chunks.
|
||||
* - modulepreload: Browser parses + compiles immediately (blocks FCP)
|
||||
* - prefetch: Browser downloads at lowest priority during idle (no FCP impact)
|
||||
*
|
||||
* This means these chunks are still cached for when the user navigates to their
|
||||
* respective routes, but they don't block the initial page load.
|
||||
*/
|
||||
function mobilePreloadOptimizer(): Plugin {
|
||||
// Vendor chunks that are route-specific and should NOT block initial load.
|
||||
// These libraries are only needed on specific routes:
|
||||
// - vendor-reactflow: /graph route only
|
||||
// - vendor-xterm: /terminal route only
|
||||
// - vendor-codemirror: spec/XML editor routes only
|
||||
// - vendor-markdown: agent view, wiki, and other markdown-rendering routes
|
||||
const deferredChunks = [
|
||||
'vendor-reactflow',
|
||||
'vendor-xterm',
|
||||
'vendor-codemirror',
|
||||
'vendor-markdown',
|
||||
];
|
||||
|
||||
return {
|
||||
name: 'mobile-preload-optimizer',
|
||||
enforce: 'post',
|
||||
transformIndexHtml(html) {
|
||||
// Convert modulepreload to prefetch for deferred chunks
|
||||
// This preserves the caching benefit while eliminating the FCP penalty
|
||||
for (const chunk of deferredChunks) {
|
||||
// Match modulepreload links for this chunk
|
||||
const modulePreloadRegex = new RegExp(
|
||||
`<link rel="modulepreload" crossorigin href="(\\./assets/${chunk}-[^"]+\\.js)">`,
|
||||
'g'
|
||||
);
|
||||
html = html.replace(modulePreloadRegex, (_match, href) => {
|
||||
return `<link rel="prefetch" href="${href}" as="script">`;
|
||||
});
|
||||
|
||||
// Also convert eagerly-loaded CSS for these chunks to lower priority
|
||||
const cssRegex = new RegExp(
|
||||
`<link rel="stylesheet" crossorigin href="(\\./assets/${chunk}-[^"]+\\.css)">`,
|
||||
'g'
|
||||
);
|
||||
html = html.replace(cssRegex, (_match, href) => {
|
||||
return `<link rel="prefetch" href="${href}" as="style">`;
|
||||
});
|
||||
}
|
||||
|
||||
return html;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ command }) => {
|
||||
// Only skip electron plugin during dev server in CI (no display available for Electron)
|
||||
// Always include it during build - we need dist-electron/main.js for electron-builder
|
||||
@@ -58,6 +118,9 @@ export default defineConfig(({ command }) => {
|
||||
}),
|
||||
tailwindcss(),
|
||||
react(),
|
||||
// Mobile PWA optimization: demote route-specific vendor chunks from
|
||||
// modulepreload (blocks FCP) to prefetch (background download)
|
||||
mobilePreloadOptimizer(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
@@ -78,6 +141,12 @@ export default defineConfig(({ command }) => {
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
// Target modern browsers for smaller output (no legacy polyfills)
|
||||
target: 'esnext',
|
||||
// Enable CSS code splitting for smaller initial CSS payload
|
||||
cssCodeSplit: true,
|
||||
// Increase chunk size warning to avoid over-splitting (which hurts HTTP/2 multiplexing)
|
||||
chunkSizeWarningLimit: 600,
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'child_process',
|
||||
@@ -92,6 +161,53 @@ export default defineConfig(({ command }) => {
|
||||
'events',
|
||||
'readline',
|
||||
],
|
||||
output: {
|
||||
// Manual chunks for optimal caching and loading on mobile
|
||||
manualChunks(id) {
|
||||
// Vendor: React core (rarely changes, cache long-term)
|
||||
if (id.includes('node_modules/react/') || id.includes('node_modules/react-dom/')) {
|
||||
return 'vendor-react';
|
||||
}
|
||||
// Vendor: TanStack Router + Query (used on every page)
|
||||
if (id.includes('@tanstack/react-router') || id.includes('@tanstack/react-query')) {
|
||||
return 'vendor-tanstack';
|
||||
}
|
||||
// Vendor: UI library - split Radix UI (critical) from Lucide icons (deferrable)
|
||||
// Radix UI primitives are used on almost every page for dialogs, tooltips, etc.
|
||||
if (id.includes('@radix-ui/')) {
|
||||
return 'vendor-radix';
|
||||
}
|
||||
// Lucide icons: Split from Radix so tree-shaken icons don't bloat the critical path
|
||||
if (id.includes('lucide-react')) {
|
||||
return 'vendor-icons';
|
||||
}
|
||||
// Fonts: Each font family gets its own chunk (loaded on demand)
|
||||
if (id.includes('@fontsource/')) {
|
||||
const match = id.match(/@fontsource\/([^/]+)/);
|
||||
if (match) return `font-${match[1]}`;
|
||||
}
|
||||
// CodeMirror: Heavy editor - only loaded when needed
|
||||
if (id.includes('@codemirror/') || id.includes('@lezer/')) {
|
||||
return 'vendor-codemirror';
|
||||
}
|
||||
// Xterm: Terminal - only loaded when needed
|
||||
if (id.includes('xterm') || id.includes('@xterm/')) {
|
||||
return 'vendor-xterm';
|
||||
}
|
||||
// React Flow: Graph visualization - only loaded on dependency graph view
|
||||
if (id.includes('@xyflow/') || id.includes('reactflow')) {
|
||||
return 'vendor-reactflow';
|
||||
}
|
||||
// Zustand + Zod: State management and validation
|
||||
if (id.includes('zustand') || id.includes('zod')) {
|
||||
return 'vendor-state';
|
||||
}
|
||||
// React Markdown: Only needed on routes with markdown rendering
|
||||
if (id.includes('react-markdown') || id.includes('remark-') || id.includes('rehype-')) {
|
||||
return 'vendor-markdown';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
optimizeDeps: {
|
||||
|
||||
Reference in New Issue
Block a user