From cb44f8a7178ae4848e04619cc4b08d5f3ca6f39b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 17 Feb 2026 17:33:11 -0800 Subject: [PATCH] Comprehensive set of mobile and all improvements phase 1 --- .../routes/worktree/routes/discard-changes.ts | 248 ++++++-- .../routes/worktree/switch-branch.test.ts | 60 +- apps/ui/eslint.config.mjs | 23 + apps/ui/package.json | 2 + apps/ui/public/sw.js | 100 ++- apps/ui/src/app.tsx | 26 +- .../views/board-view/board-header.tsx | 2 +- .../components/list-view/list-view.tsx | 18 +- .../discard-worktree-changes-dialog.tsx | 596 ++++++++++++++++++ .../views/board-view/dialogs/index.ts | 1 + .../board-view/hooks/use-board-actions.ts | 86 ++- .../board-view/hooks/use-board-persistence.ts | 34 + .../views/board-view/kanban-board.tsx | 2 +- .../worktree-panel/worktree-panel.tsx | 69 +- ...rols.tsx => mobile-terminal-shortcuts.tsx} | 83 ++- .../terminal-view/sticky-modifier-keys.tsx | 147 +++++ .../views/terminal-view/terminal-panel.tsx | 52 +- apps/ui/src/hooks/use-mobile-visibility.ts | 11 + apps/ui/src/lib/electron.ts | 4 +- apps/ui/src/lib/http-api-client.ts | 53 +- apps/ui/src/lib/query-client.ts | 15 +- apps/ui/src/lib/query-persist.ts | 133 ++++ apps/ui/src/routes/__root.tsx | 262 ++++++-- apps/ui/src/routes/board.lazy.tsx | 6 + apps/ui/src/routes/board.tsx | 11 +- apps/ui/src/routes/graph.lazy.tsx | 6 + apps/ui/src/routes/graph.tsx | 6 +- apps/ui/src/routes/spec.lazy.tsx | 6 + apps/ui/src/routes/spec.tsx | 6 +- apps/ui/src/routes/terminal.lazy.tsx | 11 + apps/ui/src/routes/terminal.tsx | 8 +- apps/ui/src/store/ui-cache-store.ts | 123 ++++ apps/ui/src/types/electron.d.ts | 7 +- apps/ui/src/vite-env.d.ts | 1 + apps/ui/vite.config.mts | 67 ++ package-lock.json | 56 +- 36 files changed, 2037 insertions(+), 304 deletions(-) create mode 100644 apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx rename apps/ui/src/components/views/terminal-view/{mobile-terminal-controls.tsx => mobile-terminal-shortcuts.tsx} (89%) create mode 100644 apps/ui/src/components/views/terminal-view/sticky-modifier-keys.tsx create mode 100644 apps/ui/src/lib/query-persist.ts create mode 100644 apps/ui/src/routes/board.lazy.tsx create mode 100644 apps/ui/src/routes/graph.lazy.tsx create mode 100644 apps/ui/src/routes/spec.lazy.tsx create mode 100644 apps/ui/src/routes/terminal.lazy.tsx create mode 100644 apps/ui/src/store/ui-cache-store.ts diff --git a/apps/server/src/routes/worktree/routes/discard-changes.ts b/apps/server/src/routes/worktree/routes/discard-changes.ts index 4f15e053..514fae7e 100644 --- a/apps/server/src/routes/worktree/routes/discard-changes.ts +++ b/apps/server/src/routes/worktree/routes/discard-changes.ts @@ -1,27 +1,47 @@ /** - * POST /discard-changes endpoint - Discard all uncommitted changes in a worktree + * POST /discard-changes endpoint - Discard uncommitted changes in a worktree * - * This performs a destructive operation that: - * 1. Resets staged changes (git reset HEAD) - * 2. Discards modified tracked files (git checkout .) - * 3. Removes untracked files and directories (git clean -fd) + * Supports two modes: + * 1. Discard ALL changes (when no files array is provided) + * - Resets staged changes (git reset HEAD) + * - Discards modified tracked files (git checkout .) + * - Removes untracked files and directories (git clean -fd) + * + * 2. Discard SELECTED files (when files array is provided) + * - Unstages selected staged files (git reset HEAD -- ) + * - Reverts selected tracked file changes (git checkout -- ) + * - Removes selected untracked files (git clean -fd -- ) * * Note: Git repository validation (isGitRepo) is handled by * the requireGitRepoOnly middleware in index.ts */ import type { Request, Response } from 'express'; -import { exec } from 'child_process'; +import { execFile } from 'child_process'; import { promisify } from 'util'; +import * as path from 'path'; import { getErrorMessage, logError } from '../common.js'; -const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +/** + * Validate that a file path does not escape the worktree directory. + * Prevents path traversal attacks (e.g., ../../etc/passwd). + */ +function validateFilePath(filePath: string, worktreePath: string): boolean { + // Resolve the full path relative to the worktree + const resolved = path.resolve(worktreePath, filePath); + const normalizedWorktree = path.resolve(worktreePath); + // Ensure the resolved path starts with the worktree path + return resolved.startsWith(normalizedWorktree + path.sep) || resolved === normalizedWorktree; +} export function createDiscardChangesHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath } = req.body as { + const { worktreePath, files } = req.body as { worktreePath: string; + files?: string[]; }; if (!worktreePath) { @@ -33,7 +53,7 @@ export function createDiscardChangesHandler() { } // Check for uncommitted changes first - const { stdout: status } = await execAsync('git status --porcelain', { + const { stdout: status } = await execFileAsync('git', ['status', '--porcelain'], { cwd: worktreePath, }); @@ -48,61 +68,197 @@ export function createDiscardChangesHandler() { return; } - // Count the files that will be affected - const lines = status.trim().split('\n').filter(Boolean); - const fileCount = lines.length; - // Get branch name before discarding - const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - }); + const { stdout: branchOutput } = await execFileAsync( + 'git', + ['rev-parse', '--abbrev-ref', 'HEAD'], + { + cwd: worktreePath, + } + ); const branchName = branchOutput.trim(); - // Discard all changes: - // 1. Reset any staged changes - await execAsync('git reset HEAD', { cwd: worktreePath }).catch(() => { - // Ignore errors - might fail if there's nothing staged + // Parse the status output to categorize files + const statusLines = status.trim().split('\n').filter(Boolean); + const allFiles = statusLines.map((line) => { + const fileStatus = line.substring(0, 2).trim(); + const filePath = line.substring(3).trim(); + return { status: fileStatus, path: filePath }; }); - // 2. Discard changes in tracked files - await execAsync('git checkout .', { cwd: worktreePath }).catch(() => { - // Ignore errors - might fail if there are no tracked changes - }); + // Determine which files to discard + const isSelectiveDiscard = files && files.length > 0 && files.length < allFiles.length; - // 3. Remove untracked files and directories - await execAsync('git clean -fd', { cwd: worktreePath }).catch(() => { - // Ignore errors - might fail if there are no untracked files - }); + if (isSelectiveDiscard) { + // Selective discard: only discard the specified files + const filesToDiscard = new Set(files); - // Verify all changes were discarded - const { stdout: finalStatus } = await execAsync('git status --porcelain', { - cwd: worktreePath, - }); + // Validate all requested file paths stay within the worktree + const invalidPaths = files.filter((f) => !validateFilePath(f, worktreePath)); + if (invalidPaths.length > 0) { + res.status(400).json({ + success: false, + error: `Invalid file paths detected (path traversal): ${invalidPaths.join(', ')}`, + }); + return; + } + + // Separate files into categories for proper git operations + const trackedModified: string[] = []; // Modified/deleted tracked files + const stagedFiles: string[] = []; // Files that are staged + const untrackedFiles: string[] = []; // Untracked files (?) + const warnings: string[] = []; + + for (const file of allFiles) { + if (!filesToDiscard.has(file.path)) continue; + + if (file.status === '?') { + untrackedFiles.push(file.path); + } else { + // Check if the file has staged changes (first character of status) + const indexStatus = statusLines + .find((l) => l.substring(3).trim() === file.path) + ?.charAt(0); + if (indexStatus && indexStatus !== ' ' && indexStatus !== '?') { + stagedFiles.push(file.path); + } + // Check for working tree changes (tracked files) + if (file.status === 'M' || file.status === 'D' || file.status === 'A') { + trackedModified.push(file.path); + } + } + } + + // 1. Unstage selected staged files (using execFile to bypass shell) + if (stagedFiles.length > 0) { + try { + await execFileAsync('git', ['reset', 'HEAD', '--', ...stagedFiles], { + cwd: worktreePath, + }); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `Failed to unstage files: ${msg}`); + warnings.push(`Failed to unstage some files: ${msg}`); + } + } + + // 2. Revert selected tracked file changes + if (trackedModified.length > 0) { + try { + await execFileAsync('git', ['checkout', '--', ...trackedModified], { + cwd: worktreePath, + }); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `Failed to revert tracked files: ${msg}`); + warnings.push(`Failed to revert some tracked files: ${msg}`); + } + } + + // 3. Remove selected untracked files + if (untrackedFiles.length > 0) { + try { + await execFileAsync('git', ['clean', '-fd', '--', ...untrackedFiles], { + cwd: worktreePath, + }); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `Failed to clean untracked files: ${msg}`); + warnings.push(`Failed to remove some untracked files: ${msg}`); + } + } + + const fileCount = files.length; + + // Verify the remaining state + const { stdout: finalStatus } = await execFileAsync('git', ['status', '--porcelain'], { + cwd: worktreePath, + }); + + const remainingCount = finalStatus.trim() + ? finalStatus.trim().split('\n').filter(Boolean).length + : 0; + const actualDiscarded = allFiles.length - remainingCount; + + let message = + actualDiscarded < fileCount + ? `Discarded ${actualDiscarded} of ${fileCount} selected files, ${remainingCount} files remaining` + : `Discarded ${actualDiscarded} ${actualDiscarded === 1 ? 'file' : 'files'}`; - if (finalStatus.trim()) { - // Some changes couldn't be discarded (possibly ignored files or permission issues) - const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length; res.json({ success: true, result: { discarded: true, - filesDiscarded: fileCount - remainingCount, + filesDiscarded: actualDiscarded, filesRemaining: remainingCount, branch: branchName, - message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`, + message, + ...(warnings.length > 0 && { warnings }), }, }); } else { - res.json({ - success: true, - result: { - discarded: true, - filesDiscarded: fileCount, - filesRemaining: 0, - branch: branchName, - message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`, - }, + // Discard ALL changes (original behavior) + const fileCount = allFiles.length; + const warnings: string[] = []; + + // 1. Reset any staged changes + try { + await execFileAsync('git', ['reset', 'HEAD'], { cwd: worktreePath }); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `git reset HEAD failed: ${msg}`); + warnings.push(`Failed to unstage changes: ${msg}`); + } + + // 2. Discard changes in tracked files + try { + await execFileAsync('git', ['checkout', '.'], { cwd: worktreePath }); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `git checkout . failed: ${msg}`); + warnings.push(`Failed to revert tracked changes: ${msg}`); + } + + // 3. Remove untracked files and directories + try { + await execFileAsync('git', ['clean', '-fd'], { cwd: worktreePath }); + } catch (error) { + const msg = getErrorMessage(error); + logError(error, `git clean -fd failed: ${msg}`); + warnings.push(`Failed to remove untracked files: ${msg}`); + } + + // Verify all changes were discarded + const { stdout: finalStatus } = await execFileAsync('git', ['status', '--porcelain'], { + cwd: worktreePath, }); + + if (finalStatus.trim()) { + const remainingCount = finalStatus.trim().split('\n').filter(Boolean).length; + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount - remainingCount, + filesRemaining: remainingCount, + branch: branchName, + message: `Discarded ${fileCount - remainingCount} files, ${remainingCount} files could not be removed`, + ...(warnings.length > 0 && { warnings }), + }, + }); + } else { + res.json({ + success: true, + result: { + discarded: true, + filesDiscarded: fileCount, + filesRemaining: 0, + branch: branchName, + message: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'}`, + ...(warnings.length > 0 && { warnings }), + }, + }); + } } } catch (error) { logError(error, 'Discard changes failed'); diff --git a/apps/server/tests/unit/routes/worktree/switch-branch.test.ts b/apps/server/tests/unit/routes/worktree/switch-branch.test.ts index 2cd868c6..49ef3bf5 100644 --- a/apps/server/tests/unit/routes/worktree/switch-branch.test.ts +++ b/apps/server/tests/unit/routes/worktree/switch-branch.test.ts @@ -44,15 +44,30 @@ describe('switch-branch route', () => { if (command === 'git rev-parse --abbrev-ref HEAD') { return { stdout: 'main\n', stderr: '' }; } - if (command === 'git rev-parse --verify feature/test') { + if (command === 'git rev-parse --verify "feature/test"') { return { stdout: 'abc123\n', stderr: '' }; } + if (command === 'git branch -r --format="%(refname:short)"') { + return { stdout: '', stderr: '' }; + } if (command === 'git status --porcelain') { return { stdout: '?? .automaker/\n?? notes.txt\n', stderr: '' }; } if (command === 'git checkout "feature/test"') { return { stdout: '', stderr: '' }; } + if (command === 'git fetch --all --quiet') { + return { stdout: '', stderr: '' }; + } + if (command === 'git stash list') { + return { stdout: '', stderr: '' }; + } + if (command.startsWith('git stash push')) { + return { stdout: '', stderr: '' }; + } + if (command === 'git stash pop') { + return { stdout: '', stderr: '' }; + } return { stdout: '', stderr: '' }; }); @@ -65,12 +80,14 @@ describe('switch-branch route', () => { previousBranch: 'main', currentBranch: 'feature/test', message: "Switched to branch 'feature/test'", + hasConflicts: false, + stashedChanges: false, }, }); expect(mockExec).toHaveBeenCalledWith('git checkout "feature/test"', { cwd: '/repo/path' }); }); - it('should block switching when tracked files are modified', async () => { + it('should stash changes and switch when tracked files are modified', async () => { req.body = { worktreePath: '/repo/path', branchName: 'feature/test', @@ -80,14 +97,34 @@ describe('switch-branch route', () => { if (command === 'git rev-parse --abbrev-ref HEAD') { return { stdout: 'main\n', stderr: '' }; } - if (command === 'git rev-parse --verify feature/test') { + if (command === 'git rev-parse --verify "feature/test"') { return { stdout: 'abc123\n', stderr: '' }; } if (command === 'git status --porcelain') { return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' }; } - if (command === 'git status --short') { - return { stdout: ' M src/index.ts\n?? notes.txt\n', stderr: '' }; + if (command === 'git branch -r --format="%(refname:short)"') { + return { stdout: '', stderr: '' }; + } + if (command === 'git stash list') { + // Return different counts before and after stash to indicate stash was created + if (!mockExec._stashCalled) { + mockExec._stashCalled = true; + return { stdout: '', stderr: '' }; + } + return { stdout: 'stash@{0}: automaker-branch-switch\n', stderr: '' }; + } + if (command.startsWith('git stash push')) { + return { stdout: '', stderr: '' }; + } + if (command === 'git checkout "feature/test"') { + return { stdout: '', stderr: '' }; + } + if (command === 'git fetch --all --quiet') { + return { stdout: '', stderr: '' }; + } + if (command === 'git stash pop') { + return { stdout: 'Already applied.\n', stderr: '' }; } return { stdout: '', stderr: '' }; }); @@ -95,12 +132,15 @@ describe('switch-branch route', () => { const handler = createSwitchBranchHandler(); await handler(req, res); - expect(res.status).toHaveBeenCalledWith(400); expect(res.json).toHaveBeenCalledWith({ - success: false, - error: - 'Cannot switch branches: you have uncommitted changes (M src/index.ts). Please commit your changes first.', - code: 'UNCOMMITTED_CHANGES', + success: true, + result: { + previousBranch: 'main', + currentBranch: 'feature/test', + message: "Switched to branch 'feature/test' (local changes stashed and reapplied)", + hasConflicts: false, + stashedChanges: true, + }, }); }); }); diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs index 2400404f..63e5c1ba 100644 --- a/apps/ui/eslint.config.mjs +++ b/apps/ui/eslint.config.mjs @@ -51,6 +51,7 @@ const eslintConfig = defineConfig([ getComputedStyle: 'readonly', requestAnimationFrame: 'readonly', cancelAnimationFrame: 'readonly', + requestIdleCallback: 'readonly', alert: 'readonly', // DOM Element Types HTMLElement: 'readonly', @@ -62,6 +63,8 @@ const eslintConfig = defineConfig([ HTMLHeadingElement: 'readonly', HTMLParagraphElement: 'readonly', HTMLImageElement: 'readonly', + HTMLLinkElement: 'readonly', + HTMLScriptElement: 'readonly', Element: 'readonly', SVGElement: 'readonly', SVGSVGElement: 'readonly', @@ -91,6 +94,7 @@ const eslintConfig = defineConfig([ Response: 'readonly', RequestInit: 'readonly', RequestCache: 'readonly', + ServiceWorkerRegistration: 'readonly', // Timers setTimeout: 'readonly', setInterval: 'readonly', @@ -138,6 +142,25 @@ const eslintConfig = defineConfig([ ], }, }, + { + files: ['public/sw.js'], + languageOptions: { + globals: { + // Service Worker globals + self: 'readonly', + caches: 'readonly', + fetch: 'readonly', + Headers: 'readonly', + Response: 'readonly', + URL: 'readonly', + setTimeout: 'readonly', + console: 'readonly', + }, + }, + rules: { + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }], + }, + }, globalIgnores([ 'dist/**', 'dist-electron/**', diff --git a/apps/ui/package.json b/apps/ui/package.json index 2a9b71b2..48be5b16 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -82,6 +82,7 @@ "@radix-ui/react-tooltip": "1.2.8", "@tanstack/react-query": "^5.90.17", "@tanstack/react-query-devtools": "^5.91.2", + "@tanstack/react-query-persist-client": "^5.90.22", "@tanstack/react-router": "1.141.6", "@uiw/react-codemirror": "4.25.4", "@xterm/addon-fit": "0.10.0", @@ -96,6 +97,7 @@ "dagre": "0.8.5", "dotenv": "17.2.3", "geist": "1.5.1", + "idb-keyval": "^6.2.2", "lucide-react": "0.562.0", "react": "19.2.3", "react-dom": "19.2.3", diff --git a/apps/ui/public/sw.js b/apps/ui/public/sw.js index 7c24a1fc..ce97fc2a 100644 --- a/apps/ui/public/sw.js +++ b/apps/ui/public/sw.js @@ -1,5 +1,8 @@ // Automaker Service Worker - Optimized for mobile PWA loading performance -const CACHE_NAME = 'automaker-v3'; +// NOTE: CACHE_NAME is injected with a build hash at build time by the swCacheBuster +// Vite plugin (see vite.config.mts). In development it stays as-is; in production +// builds it becomes e.g. 'automaker-v3-a1b2c3d4' for automatic cache invalidation. +const CACHE_NAME = 'automaker-v3'; // replaced at build time → 'automaker-v3-' // Separate cache for immutable hashed assets (long-lived) const IMMUTABLE_CACHE = 'automaker-immutable-v2'; @@ -17,8 +20,47 @@ const SHELL_ASSETS = [ '/favicon.ico', ]; -// Whether mobile caching is enabled (set via message from main thread) +// Whether mobile caching is enabled (set via message from main thread). +// Persisted to Cache Storage so it survives aggressive SW termination on mobile. let mobileMode = false; +const MOBILE_MODE_CACHE_KEY = 'automaker-sw-config'; +const MOBILE_MODE_URL = '/sw-config/mobile-mode'; + +/** + * Persist mobileMode to Cache Storage so it survives SW restarts. + * Service workers on mobile get killed aggressively — without persistence, + * mobileMode resets to false and API caching silently stops working. + */ +async function persistMobileMode(enabled) { + try { + const cache = await caches.open(MOBILE_MODE_CACHE_KEY); + const response = new Response(JSON.stringify({ mobileMode: enabled }), { + headers: { 'Content-Type': 'application/json' }, + }); + await cache.put(MOBILE_MODE_URL, response); + } catch (_e) { + // Best-effort persistence — SW still works without it + } +} + +/** + * Restore mobileMode from Cache Storage on SW startup. + */ +async function restoreMobileMode() { + try { + const cache = await caches.open(MOBILE_MODE_CACHE_KEY); + const response = await cache.match(MOBILE_MODE_URL); + if (response) { + const data = await response.json(); + mobileMode = !!data.mobileMode; + } + } catch (_e) { + // Best-effort restore — defaults to false + } +} + +// Restore mobileMode immediately on SW startup +restoreMobileMode(); // API endpoints that are safe to serve from stale cache on mobile. // These are GET-only, read-heavy endpoints where showing slightly stale data @@ -64,12 +106,13 @@ function isApiCacheFresh(response) { } /** - * Clone a response and add a timestamp header for cache freshness tracking + * Clone a response and add a timestamp header for cache freshness tracking. + * Uses arrayBuffer() instead of blob() to avoid doubling memory for large responses. */ 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(); + const body = await response.clone().arrayBuffer(); return new Response(body, { status: response.status, statusText: response.statusText, @@ -89,7 +132,7 @@ self.addEventListener('install', (event) => { self.addEventListener('activate', (event) => { // Remove old caches (both regular and immutable) - const validCaches = new Set([CACHE_NAME, IMMUTABLE_CACHE, API_CACHE]); + const validCaches = new Set([CACHE_NAME, IMMUTABLE_CACHE, API_CACHE, MOBILE_MODE_CACHE_KEY]); event.waitUntil( Promise.all([ // Clean old caches @@ -116,7 +159,7 @@ self.addEventListener('activate', (event) => { function isImmutableAsset(url) { const path = url.pathname; // Match Vite's hashed asset pattern: /assets/-. - if (path.startsWith('/assets/') && /\-[A-Za-z0-9_-]{6,}\.\w+$/.test(path)) { + if (path.startsWith('/assets/') && /-[A-Za-z0-9_-]{6,}\.\w+$/.test(path)) { return true; } // Font files are immutable (woff2, woff, ttf, otf) @@ -166,29 +209,36 @@ self.addEventListener('fetch', (event) => { 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; - }); + // Helper: start a network fetch that updates the cache on success. + // Lazily invoked so we don't fire a network request when the cache + // is already fresh — saves bandwidth and battery on mobile. + const startNetworkFetch = () => + 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 + // without firing a background fetch — React Query's own refetching + // will request fresh data when its stale time expires. if (cachedResponse && isApiCacheFresh(cachedResponse)) { - // Return cached data instantly - network update happens in background return cachedResponse; } + // From here the cache is either stale or missing — start the network fetch. + const fetchPromise = startNetworkFetch(); + // If we have a stale cached response but network is slow, race them: // Return whichever resolves first (cached immediately vs network) if (cachedResponse) { @@ -283,7 +333,7 @@ self.addEventListener('fetch', (event) => { }); } return response; - } catch (e) { + } catch (_e) { // Offline: serve the cached app shell const cached = await caches.match('/'); return ( @@ -344,8 +394,10 @@ self.addEventListener('message', (event) => { // 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. + // Persisted to Cache Storage so it survives SW restarts on mobile. if (event.data?.type === 'SET_MOBILE_MODE') { mobileMode = !!event.data.enabled; + persistMobileMode(mobileMode); } // Warm the immutable cache with critical assets the app will need. diff --git a/apps/ui/src/app.tsx b/apps/ui/src/app.tsx index 4131329e..a87f57e5 100644 --- a/apps/ui/src/app.tsx +++ b/apps/ui/src/app.tsx @@ -28,8 +28,13 @@ export default function App() { if (savedPreference === 'true') { return false; } - // Only show splash once per session - if (sessionStorage.getItem('automaker-splash-shown')) { + // Only show splash once per browser session. + // Uses localStorage (not sessionStorage) so tab restores after discard + // don't replay the splash — sessionStorage is cleared when a tab is discarded. + // The flag is written on splash complete and cleared when the tab is fully closed + // (via the 'pagehide' + persisted=false event, which fires on true tab close but + // not on discard/background). This gives "once per actual session" semantics. + if (localStorage.getItem('automaker-splash-shown-session')) { return false; } return true; @@ -103,10 +108,25 @@ export default function App() { useMobileOnlineManager(); const handleSplashComplete = useCallback(() => { - sessionStorage.setItem('automaker-splash-shown', 'true'); + // Mark splash as shown for this session (survives tab discard/restore) + localStorage.setItem('automaker-splash-shown-session', 'true'); setShowSplash(false); }, []); + // Clear the splash-shown flag when the tab is truly closed (not just discarded). + // `pagehide` with persisted=false fires on real navigation/close but NOT on discard, + // so discarded tabs that are restored skip the splash while true re-opens show it. + useEffect(() => { + const handlePageHide = (e: PageTransitionEvent) => { + if (!e.persisted) { + // Tab is being closed or navigating away (not going into bfcache) + localStorage.removeItem('automaker-splash-shown-session'); + } + }; + window.addEventListener('pagehide', handlePageHide); + return () => window.removeEventListener('pagehide', handlePageHide); + }, []); + return ( diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 8e3654e3..4a6bb834 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -137,7 +137,7 @@ export function BoardHeader({ }, [isRefreshingBoard, onRefreshBoard]); return ( -
+

No features to display

{onAddFeature && ( - @@ -197,6 +198,10 @@ export const ListView = memo(function ListView({ // Track collapsed state for each status group const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + // Get the keyboard shortcut for adding features + const keyboardShortcuts = useAppStore((state) => state.keyboardShortcuts); + const addFeatureShortcut = keyboardShortcuts.addFeature || 'N'; + // Generate status groups from columnFeaturesMap const statusGroups = useMemo(() => { const columns = getColumnsWithPipeline(pipelineConfig); @@ -439,18 +444,21 @@ export const ListView = memo(function ListView({ })}
- {/* Footer with Add Feature button */} + {/* Footer with Add Feature button, styled like board view */} {onAddFeature && ( -
+
)} diff --git a/apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx new file mode 100644 index 00000000..80ad1b62 --- /dev/null +++ b/apps/ui/src/components/views/board-view/dialogs/discard-worktree-changes-dialog.tsx @@ -0,0 +1,596 @@ +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Undo2, + FilePlus, + FileX, + FilePen, + FileText, + File, + ChevronDown, + ChevronRight, + AlertTriangle, +} from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import { getElectronAPI } from '@/lib/electron'; +import { getHttpApiClient } from '@/lib/http-api-client'; +import { toast } from 'sonner'; +import { cn } from '@/lib/utils'; +import type { FileStatus } from '@/types/electron'; + +interface WorktreeInfo { + path: string; + branch: string; + isMain: boolean; + hasChanges?: boolean; + changedFilesCount?: number; +} + +interface DiscardWorktreeChangesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + worktree: WorktreeInfo | null; + onDiscarded: () => void; +} + +interface ParsedDiffHunk { + header: string; + lines: { + type: 'context' | 'addition' | 'deletion' | 'header'; + content: string; + lineNumber?: { old?: number; new?: number }; + }[]; +} + +interface ParsedFileDiff { + filePath: string; + hunks: ParsedDiffHunk[]; + isNew?: boolean; + isDeleted?: boolean; + isRenamed?: boolean; +} + +const getFileIcon = (status: string) => { + switch (status) { + case 'A': + case '?': + return ; + case 'D': + return ; + case 'M': + case 'U': + return ; + case 'R': + case 'C': + return ; + default: + return ; + } +}; + +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 ( +
+ {content} +
+ ); + } + + return ( +
+ + {lineNumber?.old ?? ''} + + + {lineNumber?.new ?? ''} + + + {prefix[type]} + + + {content || '\u00A0'} + +
+ ); +} + +export function DiscardWorktreeChangesDialog({ + open, + onOpenChange, + worktree, + onDiscarded, +}: DiscardWorktreeChangesDialogProps) { + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + // File selection state + const [files, setFiles] = useState([]); + const [diffContent, setDiffContent] = useState(''); + const [selectedFiles, setSelectedFiles] = useState>(new Set()); + const [expandedFile, setExpandedFile] = useState(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(); + for (const diff of parsedDiffs) { + map.set(diff.filePath, diff); + } + return map; + }, [parsedDiffs]); + + // Load diffs when dialog opens + useEffect(() => { + if (open && worktree) { + setIsLoadingDiffs(true); + setFiles([]); + setDiffContent(''); + setSelectedFiles(new Set()); + setExpandedFile(null); + setError(null); + + const loadDiffs = async () => { + try { + const api = getElectronAPI(); + if (api?.git?.getDiffs) { + const result = await api.git.getDiffs(worktree.path); + if (result.success) { + const fileList = result.files ?? []; + setFiles(fileList); + setDiffContent(result.diff ?? ''); + // Select all files by default + setSelectedFiles(new Set(fileList.map((f) => f.path))); + } + } + } catch (err) { + console.warn('Failed to load diffs for discard dialog:', err); + } finally { + setIsLoadingDiffs(false); + } + }; + + loadDiffs(); + } + }, [open, worktree]); + + const handleToggleFile = useCallback((filePath: string) => { + setSelectedFiles((prev) => { + const next = new Set(prev); + if (next.has(filePath)) { + next.delete(filePath); + } else { + next.add(filePath); + } + return next; + }); + }, []); + + const handleToggleAll = useCallback(() => { + setSelectedFiles((prev) => { + if (prev.size === files.length) { + return new Set(); + } + return new Set(files.map((f) => f.path)); + }); + }, [files]); + + const handleFileClick = useCallback((filePath: string) => { + setExpandedFile((prev) => (prev === filePath ? null : filePath)); + }, []); + + const handleDiscard = async () => { + if (!worktree || selectedFiles.size === 0) return; + + setIsLoading(true); + setError(null); + + try { + const api = getHttpApiClient(); + + // Pass selected files if not all files are selected + const filesToDiscard = + selectedFiles.size === files.length ? undefined : Array.from(selectedFiles); + + const result = await api.worktree.discardChanges(worktree.path, filesToDiscard); + + if (result.success && result.result) { + if (result.result.discarded) { + const fileCount = filesToDiscard ? filesToDiscard.length : result.result.filesDiscarded; + toast.success('Changes discarded', { + description: `Discarded ${fileCount} ${fileCount === 1 ? 'file' : 'files'} in ${worktree.branch}`, + }); + onDiscarded(); + onOpenChange(false); + } else { + toast.info('No changes to discard', { + description: result.result.message, + }); + } + } else { + setError(result.error || 'Failed to discard changes'); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to discard changes'); + } finally { + setIsLoading(false); + } + }; + + if (!worktree) return null; + + const allSelected = selectedFiles.size === files.length && files.length > 0; + + return ( + + + + + + Discard Changes + + + Select which changes to discard in the{' '} + {worktree.branch} worktree. + This action cannot be undone. + + + +
+ {/* File Selection */} +
+
+ + {files.length > 0 && ( + + )} +
+ + {isLoadingDiffs ? ( +
+ + Loading changes... +
+ ) : files.length === 0 ? ( +
+ No changes detected +
+ ) : ( +
+ {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 ( +
+
+ {/* Checkbox */} + handleToggleFile(file.path)} + className="flex-shrink-0" + /> + + {/* Clickable file row to show diff */} + +
+ + {/* Expanded diff view */} + {isExpanded && fileDiff && ( +
+ {fileDiff.hunks.map((hunk, hunkIndex) => ( +
+ {hunk.lines.map((line, lineIndex) => ( + + ))} +
+ ))} +
+ )} + {isExpanded && !fileDiff && ( +
+ {file.status === '?' ? ( + New file - diff preview not available + ) : file.status === 'D' ? ( + File deleted + ) : ( + Diff content not available + )} +
+ )} +
+ ); + })} +
+ )} +
+ + {/* Warning message */} +
+ +

+ This will permanently discard the selected changes. Staged changes will be unstaged, + modifications to tracked files will be reverted, and untracked files will be deleted. + This action cannot be undone. +

+
+ + {error &&

{error}

} +
+ + + + + +
+
+ ); +} diff --git a/apps/ui/src/components/views/board-view/dialogs/index.ts b/apps/ui/src/components/views/board-view/dialogs/index.ts index 5c63a8e0..3b2c9694 100644 --- a/apps/ui/src/components/views/board-view/dialogs/index.ts +++ b/apps/ui/src/components/views/board-view/dialogs/index.ts @@ -5,6 +5,7 @@ export { CompletedFeaturesModal } from './completed-features-modal'; export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog'; export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog'; export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog'; +export { DiscardWorktreeChangesDialog } from './discard-worktree-changes-dialog'; export { EditFeatureDialog } from './edit-feature-dialog'; export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog'; export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog'; diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts index 5f5bed21..40b4247a 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-actions.ts @@ -11,7 +11,7 @@ import { import type { ReasoningEffort } from '@automaker/types'; import { FeatureImagePath as DescriptionImagePath } from '@/components/ui/description-image-dropzone'; import { getElectronAPI } from '@/lib/electron'; -import { isConnectionError, handleServerOffline } from '@/lib/http-api-client'; +import { isConnectionError, handleServerOffline, getHttpApiClient } from '@/lib/http-api-client'; import { toast } from 'sonner'; import { useAutoMode } from '@/hooks/use-auto-mode'; import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations'; @@ -903,17 +903,40 @@ export function useBoardActions({ const handleUnarchiveFeature = useCallback( (feature: Feature) => { - const updates = { + // Determine the branch to restore to: + // - If the feature had a branch assigned, keep it (preserves worktree context) + // - If no branch was assigned, it will show on the primary worktree + const featureBranch = feature.branchName; + + // Check if the feature will be visible on the current worktree view + const willBeVisibleOnCurrentView = !featureBranch + ? !currentWorktreeBranch || + (projectPath ? isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch) : true) + : featureBranch === currentWorktreeBranch; + + const updates: Partial = { status: 'verified' as const, }; updateFeature(feature.id, updates); persistFeatureUpdate(feature.id, updates); - toast.success('Feature restored', { - description: `Moved back to verified: ${truncateDescription(feature.description)}`, - }); + if (willBeVisibleOnCurrentView) { + toast.success('Feature restored', { + description: `Moved back to verified: ${truncateDescription(feature.description)}`, + }); + } else { + toast.success('Feature restored', { + description: `Moved back to verified on branch "${featureBranch}": ${truncateDescription(feature.description)}`, + }); + } }, - [updateFeature, persistFeatureUpdate] + [ + updateFeature, + persistFeatureUpdate, + currentWorktreeBranch, + projectPath, + isPrimaryWorktreeBranch, + ] ); const handleViewOutput = useCallback( @@ -1073,28 +1096,53 @@ export function useBoardActions({ const handleArchiveAllVerified = useCallback(async () => { const verifiedFeatures = features.filter((f) => f.status === 'verified'); + if (verifiedFeatures.length === 0) return; + // Optimistically update all features in the UI immediately for (const feature of verifiedFeatures) { - const isRunning = runningAutoTasks.includes(feature.id); - if (isRunning) { - try { - await autoMode.stopFeature(feature.id); - } catch (error) { - logger.error('Error stopping feature before archive:', error); + updateFeature(feature.id, { status: 'completed' as const }); + } + + // Stop any running features in parallel (non-blocking for the UI) + const runningVerified = verifiedFeatures.filter((f) => runningAutoTasks.includes(f.id)); + if (runningVerified.length > 0) { + await Promise.allSettled( + runningVerified.map((feature) => + autoMode.stopFeature(feature.id).catch((error) => { + logger.error('Error stopping feature before archive:', error); + }) + ) + ); + } + + // Use bulk update API for a single server request instead of N individual calls + try { + if (currentProject) { + const api = getHttpApiClient(); + const featureIds = verifiedFeatures.map((f) => f.id); + const result = await api.features.bulkUpdate(currentProject.path, featureIds, { + status: 'completed' as const, + }); + + if (result.success) { + // Refresh features from server to sync React Query cache + loadFeatures(); + } else { + logger.error('Bulk archive failed:', result); + // Reload features to sync state with server + loadFeatures(); } } - // Archive the feature by setting status to completed - const updates = { - status: 'completed' as const, - }; - updateFeature(feature.id, updates); - persistFeatureUpdate(feature.id, updates); + } catch (error) { + logger.error('Failed to bulk archive features:', error); + // Reload features to sync state with server on error + loadFeatures(); } toast.success('All verified features archived', { description: `Archived ${verifiedFeatures.length} feature(s).`, }); - }, [features, runningAutoTasks, autoMode, updateFeature, persistFeatureUpdate]); + }, [features, runningAutoTasks, autoMode, updateFeature, currentProject, loadFeatures]); const handleDuplicateFeature = useCallback( async (feature: Feature, asChild: boolean = false) => { diff --git a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts index 143e9c3a..2e7ff09e 100644 --- a/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts +++ b/apps/ui/src/components/views/board-view/hooks/use-board-persistence.ts @@ -28,10 +28,29 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps ) => { if (!currentProject) return; + // Capture previous cache snapshot for rollback on error + const previousFeatures = queryClient.getQueryData( + queryKeys.features.all(currentProject.path) + ); + + // Optimistically update React Query cache for immediate board refresh + // This ensures status changes (e.g., restoring archived features) are reflected immediately + queryClient.setQueryData( + queryKeys.features.all(currentProject.path), + (existing) => { + if (!existing) return existing; + return existing.map((f) => (f.id === featureId ? { ...f, ...updates } : f)); + } + ); + try { const api = getElectronAPI(); if (!api.features) { logger.error('Features API not available'); + // Rollback optimistic update since we can't persist + if (previousFeatures) { + queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures); + } return; } @@ -51,6 +70,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps if (result.success && result.feature) { const updatedFeature = result.feature as Feature; updateFeature(updatedFeature.id, updatedFeature as Partial); + // Update cache with server-confirmed feature before invalidating queryClient.setQueryData( queryKeys.features.all(currentProject.path), (features) => { @@ -66,9 +86,23 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps }); } else if (!result.success) { logger.error('API features.update failed', result); + // Rollback optimistic update on failure + if (previousFeatures) { + queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures); + } + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); } } catch (error) { logger.error('Failed to persist feature update:', error); + // Rollback optimistic update on error + if (previousFeatures) { + queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures); + } + queryClient.invalidateQueries({ + queryKey: queryKeys.features.all(currentProject.path), + }); } }, [currentProject, updateFeature, queryClient] diff --git a/apps/ui/src/components/views/board-view/kanban-board.tsx b/apps/ui/src/components/views/board-view/kanban-board.tsx index 1a84080b..1ca92a33 100644 --- a/apps/ui/src/components/views/board-view/kanban-board.tsx +++ b/apps/ui/src/components/views/board-view/kanban-board.tsx @@ -318,7 +318,7 @@ export function KanbanBoard({ return (
{ - if (!discardChangesWorktree) return; - - try { - const api = getHttpApiClient(); - const result = await api.worktree.discardChanges(discardChangesWorktree.path); - - if (result.success) { - toast.success('Changes discarded', { - description: `Discarded changes in ${discardChangesWorktree.branch}`, - }); - // Refresh worktrees to update the changes status - fetchWorktrees({ silent: true }); - } else { - toast.error('Failed to discard changes', { - description: result.error || 'Unknown error', - }); - } - } catch (error) { - toast.error('Failed to discard changes', { - description: error instanceof Error ? error.message : 'Unknown error', - }); - } - }, [discardChangesWorktree, fetchWorktrees]); + const handleDiscardCompleted = useCallback(() => { + fetchWorktrees({ silent: true }); + }, [fetchWorktrees]); // Handle opening the log panel for a specific worktree const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => { @@ -679,17 +656,12 @@ export function WorktreePanel({ projectPath={projectPath} /> - {/* Discard Changes Confirmation Dialog */} - {/* Dev Server Logs Panel */} @@ -1015,17 +987,12 @@ export function WorktreePanel({ projectPath={projectPath} /> - {/* Discard Changes Confirmation Dialog */} - {/* Dev Server Logs Panel */} diff --git a/apps/ui/src/components/views/terminal-view/mobile-terminal-controls.tsx b/apps/ui/src/components/views/terminal-view/mobile-terminal-shortcuts.tsx similarity index 89% rename from apps/ui/src/components/views/terminal-view/mobile-terminal-controls.tsx rename to apps/ui/src/components/views/terminal-view/mobile-terminal-shortcuts.tsx index c0bd674f..09e0112c 100644 --- a/apps/ui/src/components/views/terminal-view/mobile-terminal-controls.tsx +++ b/apps/ui/src/components/views/terminal-view/mobile-terminal-shortcuts.tsx @@ -21,8 +21,6 @@ const SPECIAL_KEYS = { const CTRL_KEYS = { 'Ctrl+C': '\x03', // Interrupt / SIGINT 'Ctrl+Z': '\x1a', // Suspend / SIGTSTP - 'Ctrl+D': '\x04', // EOF - 'Ctrl+L': '\x0c', // Clear screen 'Ctrl+A': '\x01', // Move to beginning of line 'Ctrl+B': '\x02', // Move cursor back (tmux prefix) } as const; @@ -34,7 +32,7 @@ const ARROW_KEYS = { left: '\x1b[D', } as const; -interface MobileTerminalControlsProps { +interface MobileTerminalShortcutsProps { /** Callback to send input data to the terminal WebSocket */ onSendInput: (data: string) => void; /** Whether the terminal is connected and ready */ @@ -42,14 +40,17 @@ interface MobileTerminalControlsProps { } /** - * Mobile quick controls bar for terminal interaction on touch devices. + * Mobile shortcuts bar for terminal interaction on touch devices. * Provides special keys (Escape, Tab, Ctrl+C, etc.) and arrow keys that are * typically unavailable on mobile virtual keyboards. * * Anchored at the top of the terminal panel, above the terminal content. * Can be collapsed to a minimal toggle to maximize terminal space. */ -export function MobileTerminalControls({ onSendInput, isConnected }: MobileTerminalControlsProps) { +export function MobileTerminalShortcuts({ + onSendInput, + isConnected, +}: MobileTerminalShortcutsProps) { const [isCollapsed, setIsCollapsed] = useState(false); // Track repeat interval for arrow key long-press @@ -108,10 +109,10 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
); @@ -123,7 +124,7 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi @@ -132,12 +133,12 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
{/* Special keys */} - sendKey(SPECIAL_KEYS.escape)} disabled={!isConnected} /> - sendKey(SPECIAL_KEYS.tab)} disabled={!isConnected} @@ -147,31 +148,19 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi
{/* Common Ctrl shortcuts */} - sendKey(CTRL_KEYS['Ctrl+C'])} disabled={!isConnected} /> - sendKey(CTRL_KEYS['Ctrl+Z'])} disabled={!isConnected} /> - sendKey(CTRL_KEYS['Ctrl+D'])} - disabled={!isConnected} - /> - sendKey(CTRL_KEYS['Ctrl+L'])} - disabled={!isConnected} - /> - sendKey(CTRL_KEYS['Ctrl+B'])} @@ -181,26 +170,6 @@ export function MobileTerminalControls({ onSendInput, isConnected }: MobileTermi {/* Separator */}
- {/* Navigation keys */} - sendKey(SPECIAL_KEYS.delete)} - disabled={!isConnected} - /> - sendKey(SPECIAL_KEYS.home)} - disabled={!isConnected} - /> - sendKey(SPECIAL_KEYS.end)} - disabled={!isConnected} - /> - - {/* Separator */} -
- {/* Arrow keys with long-press repeat */} + + {/* Separator */} +
+ + {/* Navigation keys */} + sendKey(SPECIAL_KEYS.delete)} + disabled={!isConnected} + /> + sendKey(SPECIAL_KEYS.home)} + disabled={!isConnected} + /> + sendKey(SPECIAL_KEYS.end)} + disabled={!isConnected} + />
); } /** - * Individual control button for special keys and shortcuts. + * Individual shortcut button for special keys. */ -function ControlButton({ +function ShortcutButton({ label, title, onPress, diff --git a/apps/ui/src/components/views/terminal-view/sticky-modifier-keys.tsx b/apps/ui/src/components/views/terminal-view/sticky-modifier-keys.tsx new file mode 100644 index 00000000..a5a9447f --- /dev/null +++ b/apps/ui/src/components/views/terminal-view/sticky-modifier-keys.tsx @@ -0,0 +1,147 @@ +import { useCallback } from 'react'; +import { cn } from '@/lib/utils'; + +export type StickyModifier = 'ctrl' | 'alt' | null; + +interface StickyModifierKeysProps { + /** Currently active sticky modifier (null = none) */ + activeModifier: StickyModifier; + /** Callback when a modifier is toggled */ + onModifierChange: (modifier: StickyModifier) => void; + /** Whether the terminal is connected */ + isConnected: boolean; +} + +/** + * Sticky modifier keys (Ctrl, Alt) for the terminal toolbar. + * + * "Sticky" means: tap a modifier to activate it, then the next key pressed + * in the terminal will be sent with that modifier applied. After the modified + * key is sent, the sticky modifier automatically deactivates. + * + * - Ctrl: Sends the control code (character code & 0x1f) + * - Alt: Sends escape prefix (\x1b) before the character + * + * Tapping an already-active modifier deactivates it (toggle behavior). + */ +export function StickyModifierKeys({ + activeModifier, + onModifierChange, + isConnected, +}: StickyModifierKeysProps) { + const toggleCtrl = useCallback(() => { + onModifierChange(activeModifier === 'ctrl' ? null : 'ctrl'); + }, [activeModifier, onModifierChange]); + + const toggleAlt = useCallback(() => { + onModifierChange(activeModifier === 'alt' ? null : 'alt'); + }, [activeModifier, onModifierChange]); + + return ( +
+ + +
+ ); +} + +/** + * Individual modifier toggle button with active state styling. + */ +function ModifierButton({ + label, + isActive, + onPress, + disabled = false, + title, +}: { + label: string; + isActive: boolean; + onPress: () => void; + disabled?: boolean; + title?: string; +}) { + return ( + + ); +} + +/** + * Apply a sticky modifier to raw terminal input data. + * + * For Ctrl: converts printable ASCII characters to their control-code equivalent. + * e.g. 'c' → \x03 (Ctrl+C), 'a' → \x01 (Ctrl+A) + * + * For Alt: prepends the escape character (\x1b) before the data. + * e.g. 'd' → \x1bd (Alt+D) + * + * Returns null if the modifier cannot be applied (non-ASCII, etc.) + */ +export function applyStickyModifier(data: string, modifier: StickyModifier): string | null { + if (!modifier || !data) return null; + + if (modifier === 'ctrl') { + // Only apply Ctrl to single printable ASCII characters (a-z, A-Z, and some specials) + if (data.length === 1) { + const code = data.charCodeAt(0); + + // Letters a-z or A-Z: Ctrl sends code & 0x1f + if ((code >= 0x41 && code <= 0x5a) || (code >= 0x61 && code <= 0x7a)) { + return String.fromCharCode(code & 0x1f); + } + + // Special Ctrl combinations + // Ctrl+[ = Escape (0x1b) + if (code === 0x5b) return '\x1b'; + // Ctrl+\ = 0x1c + if (code === 0x5c) return '\x1c'; + // Ctrl+] = 0x1d + if (code === 0x5d) return '\x1d'; + // Ctrl+^ = 0x1e + if (code === 0x5e) return '\x1e'; + // Ctrl+_ = 0x1f + if (code === 0x5f) return '\x1f'; + // Ctrl+Space or Ctrl+@ = 0x00 (NUL) + if (code === 0x20 || code === 0x40) return '\x00'; + } + return null; + } + + if (modifier === 'alt') { + // Alt sends ESC prefix followed by the character + return '\x1b' + data; + } + + return null; +} diff --git a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx index 66a0c5c2..6ef15044 100644 --- a/apps/ui/src/components/views/terminal-view/terminal-panel.tsx +++ b/apps/ui/src/components/views/terminal-view/terminal-panel.tsx @@ -53,7 +53,12 @@ import { getElectronAPI } from '@/lib/electron'; import { getApiKey, getSessionToken, getServerUrlSync } from '@/lib/http-api-client'; import { useIsMobile } from '@/hooks/use-media-query'; import { useVirtualKeyboardResize } from '@/hooks/use-virtual-keyboard-resize'; -import { MobileTerminalControls } from './mobile-terminal-controls'; +import { MobileTerminalShortcuts } from './mobile-terminal-shortcuts'; +import { + StickyModifierKeys, + applyStickyModifier, + type StickyModifier, +} from './sticky-modifier-keys'; const logger = createLogger('Terminal'); const NO_STORE_CACHE_MODE: RequestCache = 'no-store'; @@ -158,6 +163,10 @@ export function TerminalPanel({ const showSearchRef = useRef(false); const [isAtBottom, setIsAtBottom] = useState(true); + // Sticky modifier key state (Ctrl or Alt) for the terminal toolbar + const [stickyModifier, setStickyModifier] = useState(null); + const stickyModifierRef = useRef(null); + const [connectionStatus, setConnectionStatus] = useState< 'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'auth_failed' >('connecting'); @@ -166,7 +175,7 @@ export function TerminalPanel({ const INITIAL_RECONNECT_DELAY = 1000; const [processExitCode, setProcessExitCode] = useState(null); - // Detect mobile viewport for quick controls + // Detect mobile viewport for shortcuts bar const isMobile = useIsMobile(); // Track virtual keyboard height on mobile to prevent overlap @@ -354,7 +363,13 @@ export function TerminalPanel({ } }, []); - // Send raw input to terminal via WebSocket (used by mobile quick controls) + // Handle sticky modifier toggle and keep ref in sync + const handleStickyModifierChange = useCallback((modifier: StickyModifier) => { + setStickyModifier(modifier); + stickyModifierRef.current = modifier; + }, []); + + // Send raw input to terminal via WebSocket (used by mobile shortcuts bar) const sendTerminalInput = useCallback((data: string) => { if (wsRef.current?.readyState === WebSocket.OPEN) { wsRef.current.send(JSON.stringify({ type: 'input', data })); @@ -1207,10 +1222,24 @@ export function TerminalPanel({ connect(); - // Handle terminal input + // Handle terminal input - apply sticky modifier if active const dataHandler = terminal.onData((data) => { if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(JSON.stringify({ type: 'input', data })); + const modifier = stickyModifierRef.current; + if (modifier) { + const modified = applyStickyModifier(data, modifier); + if (modified !== null) { + wsRef.current.send(JSON.stringify({ type: 'input', data: modified })); + } else { + // Could not apply modifier (e.g. non-ASCII input), send as-is + wsRef.current.send(JSON.stringify({ type: 'input', data })); + } + // Clear sticky modifier after one key press (one-shot behavior) + stickyModifierRef.current = null; + setStickyModifier(null); + } else { + wsRef.current.send(JSON.stringify({ type: 'input', data })); + } } }); @@ -2037,6 +2066,15 @@ export function TerminalPanel({
+ {/* Sticky modifier keys (Ctrl, Alt) */} + + +
+ {/* Split/close buttons */}