25 Commits

Author SHA1 Message Date
gsxdsm
c429a527c6 Fix event endpoint persistence (#831)
* Changes from fix/event-hook-endpoint

* fix: Allow empty eventHooks/ntfyEndpoints to reconcile from server

Remove the `length > 0` guards in fast-hydrate reconciliation that
prevented intentional empty-array clears from syncing across clients.
Server-side wipe protection (`__allowEmpty*` escape hatches) already
ensures empty arrays in the server are intentional.

Addresses PR #831 review feedback from CodeRabbit and Gemini.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:50:55 -08:00
gsxdsm
1ca1f97caa Fixes critical React crash on the Kanban board view (#830)
* Changes from fix/board-react-crash

* fix: Prevent cascading re-renders and crashes from high-frequency WS events
2026-03-03 22:50:55 -08:00
gsxdsm
3e529570ee Fix dev server hang by reducing log spam and event frequency (#828)
* Changes from fix/dev-server-hang

* fix: Address PR #828 review feedback

- Reset RAF buffer on context changes (worktree switch, dev-server restart)
  to prevent stale output from flushing into new sessions
- Fix high-frequency WebSocket filter to catch auto-mode:event wrapping
  (auto_mode_progress is wrapped in auto-mode:event) and add feature:progress
- Reorder Vite aliases so explicit jsx-runtime entries aren't shadowed by
  the broad /^react(\/|$)/ regex (Vite uses first-match-wins)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: Batch dev server logs and fix React module resolution order

* feat: Add fallback timer for flushing dev server logs in background tabs

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:50:55 -08:00
gsxdsm
c17c45302d refactor: Simplify click URL resolution logic 2026-03-02 21:39:15 -08:00
gsxdsm
982c45e8f2 fix: Remove overly restrictive pattern from summary extraction regex 2026-03-02 21:39:15 -08:00
gsxdsm
3bb2fe0a4b refactor: extract shared isBacklogLikeStatus helper and improve comments
Address PR #825 review feedback:
- Extract duplicated backlog-like status check into shared helper in constants.ts
- Improve spec-parser regex comment to clarify subsection preservation
- Add file path reference in row-actions.tsx comment for traceability

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 21:39:15 -08:00
gsxdsm
15d99e8311 Changes from fix/board-crash-new-feat 2026-03-02 21:39:15 -08:00
gsxdsm
f72dbd5910 fix: E2E test stability and UI performance improvements (#823) 2026-03-02 20:23:44 -08:00
gsxdsm
cdae7bb00d fix: reduce excessive POST /api/auto-mode/context-exists requests
Two changes:
1. Server: Skip morgan logging for context-exists endpoint (like health check)
2. UI: Use a stable fingerprint (feature IDs + statuses) instead of the
   unstable features array reference as the useEffect dependency. This
   prevents re-checking context for all features on every React Query
   refetch when the actual feature set hasn't changed.
2026-03-02 20:23:44 -08:00
gsxdsm
8226501ada fix: use object destructuring in Playwright beforeEach callback
Playwright requires the first argument to use object destructuring pattern.
Changed `_` to `{}` in feature-deep-link.spec.ts beforeEach.
2026-03-02 20:23:44 -08:00
gsxdsm
5c51d70e01 feat: Add conflict source branch tracking and fix auto-mode subscription cascade 2026-03-02 20:23:44 -08:00
gsxdsm
a2db09d2b4 feat: Add conflict source branch detection and fix re-render cascade in BoardView 2026-03-02 20:23:44 -08:00
gsxdsm
64625f5ccf feat: Add settingsService integration for feature defaults and improve worktree handling 2026-03-02 20:23:44 -08:00
gsxdsm
a0319fc4d9 Changes from fix/bug-fixes-1rc 2026-03-02 20:23:44 -08:00
gsxdsm
76499224dc Improve auto-loop event emission and add ntfy notifications (#821) 2026-03-02 20:23:44 -08:00
gsxdsm
2d5f76a336 Fix orphaned features when deleting worktrees (#820)
* Changes from fix/orphaned-features

* fix: Handle feature migration failures and improve UI accessibility

* feat: Add event emission for worktree deletion and feature migration

* fix: Handle OpenCode model errors and prevent duplicate model IDs

* feat: Add summary dialog and async verify with loading state

* fix: Add type attributes to buttons and improve OpenCode model selection

* fix: Add null checks for onVerify callback and opencode model selection
2026-03-02 20:23:44 -08:00
gsxdsm
1680d9307b Add orphaned features management routes and UI integration (#819)
* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* refactor(auto-mode): enhance orphaned feature detection and improve project initialization

- Updated detectOrphanedFeatures method to accept preloaded features, reducing redundant disk reads.
- Improved project initialization by creating required directories and files in parallel for better performance.
- Adjusted planning mode handling in UI components to clarify approval requirements for different modes.
- Added refresh functionality for file editor tabs to ensure content consistency with disk state.

These changes enhance performance, maintainability, and user experience across the application.

* feat(orphaned-features): add orphaned features management routes and UI integration

- Introduced new routes for managing orphaned features, including listing, resolving, and bulk resolving.
- Updated the UI to include an Orphaned Features section in project settings and navigation.
- Enhanced the execution service to support new orphaned feature functionalities.

These changes improve the application's capability to handle orphaned features effectively, enhancing user experience and project management.

* fix: Normalize line endings and resolve stale dirty states in file editor

* chore: Update .gitignore and enhance orphaned feature handling

- Added a blank line in .gitignore for better readability.
- Introduced a hash to worktree paths in orphaned feature resolution to prevent conflicts.
- Added validation for target branch existence during orphaned feature resolution.
- Improved prompt formatting in execution service for clarity.
- Enhanced error handling in project selector for project initialization failures.
- Refactored orphaned features section to improve state management and UI responsiveness.

These changes improve code maintainability and user experience when managing orphaned features.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-02 20:23:44 -08:00
gsxdsm
cce0a341b5 Bug fixes and stability improvements (#815)
* fix(copilot): correct tool.execution_complete event handling

The CopilotProvider was using incorrect event type and data structure
for tool execution completion events from the @github/copilot-sdk,
causing tool call outputs to be empty.

Changes:
- Update event type from 'tool.execution_end' to 'tool.execution_complete'
- Fix data structure to use nested result.content instead of flat result
- Fix error structure to use error.message instead of flat error
- Add success field to match SDK event structure
- Add tests for empty and missing result handling

This aligns with the official @github/copilot-sdk v0.1.16 types
defined in session-events.d.ts.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* refactor(copilot): improve error handling and code quality

Code review improvements:
- Extract magic string '[ERROR]' to TOOL_ERROR_PREFIX constant
- Add null-safe error handling with direct error variable assignment
- Include error codes in error messages for better debugging
- Add JSDoc documentation for tool.execution_complete handler
- Update tests to verify error codes are displayed
- Add missing tool_use_id assertion in error test

These changes improve:
- Code maintainability (no magic strings)
- Debugging experience (error codes now visible)
- Type safety (explicit null checks)
- Test coverage (verify error code formatting)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* test(copilot): add edge case test for error with code field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Changes from fix/bug-fixes-1-0

* fix: Handle detached HEAD state in worktree discovery and recovery

* fix: Remove unused isDevServerStarting prop and md: breakpoint classes

* fix: Add missing dependency and sanitize persisted cache data

* feat: Ensure NODE_ENV is set to test in vitest configs

* feat: Configure Playwright to run only E2E tests

* fix: Improve PR tracking and dev server lifecycle management

* feat: Add settings-based defaults for planning mode, model config, and custom providers. Fixes #816

* feat: Add worktree and branch selector to graph view

* fix: Add timeout and error handling for worktree HEAD ref resolution

* fix: use absolute icon path and place icon outside asar on Linux

The hicolor icon theme index only lists sizes up to 512x512, so an icon
installed only at 1024x1024 is invisible to GNOME/KDE's theme resolver,
causing both the app launcher and taskbar to show a generic icon.
Additionally, BrowserWindow.icon cannot be read by the window manager
when the file is inside app.asar.

- extraResources: copy logo_larger.png to resources/ (outside asar) so
  it lands at /opt/Automaker/resources/logo_larger.png on install
- linux.desktop.Icon: set to the absolute resources path, bypassing the
  hicolor theme lookup and its size constraints entirely
- icon-manager.ts: on Linux production use process.resourcesPath so
  BrowserWindow receives a real filesystem path the WM can read directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: use linux.desktop.entry for custom desktop Icon field

electron-builder v26 rejects arbitrary keys in linux.desktop — the
correct schema wraps custom .desktop overrides inside desktop.entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: set desktop name on Linux so taskbar uses the correct app icon

Without app.setDesktopName(), the window manager cannot associate the
running Electron process with automaker.desktop. GNOME/KDE fall back to
_NET_WM_ICON which defaults to Electron's own bundled icon.

Calling app.setDesktopName('automaker.desktop') before any window is
created sets the _GTK_APPLICATION_ID hint and XDG app_id so the WM
picks up the desktop entry's Icon for the taskbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Fix: memory and context views mobile friendly (#818)

* Changes from fix/memory-and-context-mobile-friendly

* fix: Improve file extension detection and add path traversal protection

* refactor: Extract file extension utilities and add path traversal guards

Code review improvements:
- Extract isMarkdownFilename and isImageFilename to shared image-utils.ts
- Remove duplicated code from context-view.tsx and memory-view.tsx
- Add path traversal guard for context fixture utilities (matching memory)
- Add 7 new tests for context fixture path traversal protection
- Total 61 tests pass

Addresses code review feedback from PR #813

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: Add e2e tests for profiles crud and board background persistence

* Update apps/ui/playwright.config.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Add robust test navigation handling and file filtering

* fix: Format NODE_OPTIONS configuration on single line

* test: Update profiles and board background persistence tests

* test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency

* Update apps/ui/src/components/views/context-view.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: Remove test project directory

* feat: Filter context files by type and improve mobile menu visibility

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Improve test reliability and localhost handling

* chore: Use explicit TEST_USE_EXTERNAL_BACKEND env var for server cleanup

* feat: Add E2E/CI mock mode for provider factory and auth verification

* feat: Add remoteBranch parameter to pull and rebase operations

* chore: Enhance E2E testing setup with worker isolation and auth state management

- Updated .gitignore to include worker-specific test fixtures.
- Modified e2e-tests.yml to implement test sharding for improved CI performance.
- Refactored global setup to authenticate once and save session state for reuse across tests.
- Introduced worker-isolated fixture paths to prevent conflicts during parallel test execution.
- Improved test navigation and loading handling for better reliability.
- Updated various test files to utilize new auth state management and fixture paths.

* fix: Update Playwright configuration and improve test reliability

- Increased the number of workers in Playwright configuration for better parallelism in CI environments.
- Enhanced the board background persistence test to ensure dropdown stability by waiting for the list to populate before interaction, improving test reliability.

* chore: Simplify E2E test configuration and enhance mock implementations

- Updated e2e-tests.yml to run tests in a single shard for streamlined CI execution.
- Enhanced unit tests for worktree list handling by introducing a mock for execGitCommand, improving test reliability and coverage.
- Refactored setup functions to better manage command mocks for git operations in tests.
- Improved error handling in mkdirSafe function to account for undefined stats in certain environments.

* refactor: Improve test configurations and enhance error handling

- Updated Playwright configuration to clear VITE_SERVER_URL, ensuring the frontend uses the Vite proxy and preventing cookie domain mismatches.
- Enhanced MergeRebaseDialog logic to normalize selectedBranch for better handling of various ref formats.
- Improved global setup with a more robust backend health check, throwing an error if the backend is not healthy after retries.
- Refactored project creation tests to handle file existence checks more reliably.
- Added error handling for missing E2E source fixtures to guide setup process.
- Enhanced memory navigation to handle sandbox dialog visibility more effectively.

* refactor: Enhance Git command execution and improve test configurations

- Updated Git command execution to merge environment paths correctly, ensuring proper command execution context.
- Refactored the Git initialization process to handle errors more gracefully and ensure user configuration is set before creating the initial commit.
- Improved test configurations by updating Playwright test identifiers for better clarity and consistency across different project states.
- Enhanced cleanup functions in tests to handle directory removal more robustly, preventing errors during test execution.

* fix: Resolve React hooks errors from duplicate instances in dependency tree

* style: Format alias configuration for improved readability

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: DhanushSantosh <dhanushsantoshs05@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-02 20:23:44 -08:00
gsxdsm
25f43f79fa Fix: memory and context views mobile friendly (#818)
* Changes from fix/memory-and-context-mobile-friendly

* fix: Improve file extension detection and add path traversal protection

* refactor: Extract file extension utilities and add path traversal guards

Code review improvements:
- Extract isMarkdownFilename and isImageFilename to shared image-utils.ts
- Remove duplicated code from context-view.tsx and memory-view.tsx
- Add path traversal guard for context fixture utilities (matching memory)
- Add 7 new tests for context fixture path traversal protection
- Total 61 tests pass

Addresses code review feedback from PR #813

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: Add e2e tests for profiles crud and board background persistence

* Update apps/ui/playwright.config.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix: Add robust test navigation handling and file filtering

* fix: Format NODE_OPTIONS configuration on single line

* test: Update profiles and board background persistence tests

* test: Replace iPhone 13 Pro with Pixel 5 for mobile test consistency

* Update apps/ui/src/components/views/context-view.tsx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* chore: Remove test project directory

* feat: Filter context files by type and improve mobile menu visibility

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-02 20:23:44 -08:00
DhanushSantosh
06fa380a6d fix: set desktop name on Linux so taskbar uses the correct app icon
Without app.setDesktopName(), the window manager cannot associate the
running Electron process with automaker.desktop. GNOME/KDE fall back to
_NET_WM_ICON which defaults to Electron's own bundled icon.

Calling app.setDesktopName('automaker.desktop') before any window is
created sets the _GTK_APPLICATION_ID hint and XDG app_id so the WM
picks up the desktop entry's Icon for the taskbar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 20:23:44 -08:00
DhanushSantosh
a065b12c1a fix: use linux.desktop.entry for custom desktop Icon field
electron-builder v26 rejects arbitrary keys in linux.desktop — the
correct schema wraps custom .desktop overrides inside desktop.entry.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 20:23:44 -08:00
gsxdsm
5e8c6c524a Fix agent output summary for pipeline steps (#812)
* Changes from fix/agent-output-summary-for-pipeline-steps

* feat: Optimize pipeline summary extraction and fix regex vulnerability

* fix: Use fallback summary for pipeline steps when extraction fails

* fix: Strip follow-up session scaffold from pipeline step fallback summaries
2026-03-02 20:23:44 -08:00
DhanushSantosh
4289c465ea fix: use absolute icon path and place icon outside asar on Linux
The hicolor icon theme index only lists sizes up to 512x512, so an icon
installed only at 1024x1024 is invisible to GNOME/KDE's theme resolver,
causing both the app launcher and taskbar to show a generic icon.
Additionally, BrowserWindow.icon cannot be read by the window manager
when the file is inside app.asar.

- extraResources: copy logo_larger.png to resources/ (outside asar) so
  it lands at /opt/Automaker/resources/logo_larger.png on install
- linux.desktop.Icon: set to the absolute resources path, bypassing the
  hicolor theme lookup and its size constraints entirely
- icon-manager.ts: on Linux production use process.resourcesPath so
  BrowserWindow receives a real filesystem path the WM can read directly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 20:23:44 -08:00
DhanushSantosh
a5e35615fd fix: correct production icon path and refresh icon cache on RPM install
- icon-manager.ts: fix production path from '../dist/public' to '../dist'
  Vite copies public/ assets to the root of dist/, not dist/public/, so
  the old path caused electronAppExists() to return null in packaged
  builds — falling through to Electron's default icon in the taskbar
- rpm-after-install.sh: add gtk-update-icon-cache and
  update-desktop-database so GNOME/KDE picks up the installed icon and
  desktop entry immediately without a session restart

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 20:23:44 -08:00
DhanushSantosh
9f14e12991 fix: resolve desktop entry launch failure and hide Electron menu bar on Linux
- Add --ozone-platform-hint=auto before app.whenReady() so Electron
  auto-detects X11 vs Wayland when launched from a desktop entry where
  the display protocol is not guaranteed (fixes immediate crash on
  Fedora/GNOME Wayland sessions)
- Add autoHideMenuBar: true to BrowserWindow so the default Chromium
  File/Edit/View/Help menu bar is hidden by default (still accessible
  via Alt); removes visible Electron significance from the packaged app
- Add scripts/rpm-after-install.sh and wire it via afterInstall in the
  electron-builder RPM config to chmod 4755 chrome-sandbox, ensuring
  the setuid sandbox works on kernels with restricted user namespaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 20:23:44 -08:00
12 changed files with 268 additions and 159 deletions

View File

@@ -1,6 +1,5 @@
// @ts-nocheck - feature update logic with partial updates and image/file handling
import { useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import {
Feature,
FeatureImage,
@@ -18,7 +17,10 @@ import { useVerifyFeature, useResumeFeature } from '@/hooks/mutations';
import { truncateDescription } from '@/lib/utils';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createLogger } from '@automaker/utils/logger';
import { queryKeys } from '@/lib/query-keys';
import {
markFeatureTransitioning,
unmarkFeatureTransitioning,
} from '@/lib/feature-transition-state';
const logger = createLogger('BoardActions');
@@ -116,8 +118,6 @@ export function useBoardActions({
currentWorktreeBranch,
stopFeature,
}: UseBoardActionsProps) {
const queryClient = useQueryClient();
// IMPORTANT: Use individual selectors instead of bare useAppStore() to prevent
// subscribing to the entire store. Bare useAppStore() causes the host component
// (BoardView) to re-render on EVERY store change, which cascades through effects
@@ -125,7 +125,6 @@ export function useBoardActions({
const addFeature = useAppStore((s) => s.addFeature);
const updateFeature = useAppStore((s) => s.updateFeature);
const removeFeature = useAppStore((s) => s.removeFeature);
const moveFeature = useAppStore((s) => s.moveFeature);
const worktreesEnabled = useAppStore((s) => s.useWorktrees);
const enableDependencyBlocking = useAppStore((s) => s.enableDependencyBlocking);
const skipVerificationInAutoMode = useAppStore((s) => s.skipVerificationInAutoMode);
@@ -707,8 +706,7 @@ export function useBoardActions({
try {
const result = await verifyFeatureMutation.mutateAsync(feature.id);
if (result.passes) {
// Immediately move card to verified column (optimistic update)
moveFeature(feature.id, 'verified');
// persistFeatureUpdate handles the optimistic RQ cache update internally
persistFeatureUpdate(feature.id, {
status: 'verified',
justFinishedAt: undefined,
@@ -725,7 +723,7 @@ export function useBoardActions({
// Error toast is already shown by the mutation's onError handler
}
},
[currentProject, verifyFeatureMutation, moveFeature, persistFeatureUpdate]
[currentProject, verifyFeatureMutation, persistFeatureUpdate]
);
const handleResumeFeature = useCallback(
@@ -742,7 +740,6 @@ export function useBoardActions({
const handleManualVerify = useCallback(
(feature: Feature) => {
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, {
status: 'verified',
justFinishedAt: undefined,
@@ -751,7 +748,7 @@ export function useBoardActions({
description: `Marked as verified: ${truncateDescription(feature.description)}`,
});
},
[moveFeature, persistFeatureUpdate]
[persistFeatureUpdate]
);
const handleMoveBackToInProgress = useCallback(
@@ -760,13 +757,12 @@ export function useBoardActions({
status: 'in_progress' as const,
startedAt: new Date().toISOString(),
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
toast.info('Feature moved back', {
description: `Moved back to In Progress: ${truncateDescription(feature.description)}`,
});
},
[updateFeature, persistFeatureUpdate]
[persistFeatureUpdate]
);
const handleOpenFollowUp = useCallback(
@@ -885,7 +881,6 @@ export function useBoardActions({
);
if (result.success) {
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, { status: 'verified' });
toast.success('Feature committed', {
description: `Committed and verified: ${truncateDescription(feature.description)}`,
@@ -907,7 +902,7 @@ export function useBoardActions({
await loadFeatures();
}
},
[currentProject, moveFeature, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
[currentProject, persistFeatureUpdate, loadFeatures, onWorktreeCreated]
);
const handleMergeFeature = useCallback(
@@ -951,17 +946,12 @@ export function useBoardActions({
const handleCompleteFeature = useCallback(
(feature: Feature) => {
const updates = {
status: 'completed' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
persistFeatureUpdate(feature.id, { status: 'completed' as const });
toast.success('Feature completed', {
description: `Archived: ${truncateDescription(feature.description)}`,
});
},
[updateFeature, persistFeatureUpdate]
[persistFeatureUpdate]
);
const handleUnarchiveFeature = useCallback(
@@ -978,11 +968,7 @@ export function useBoardActions({
(projectPath ? isPrimaryWorktreeBranch(projectPath, currentWorktreeBranch) : true)
: featureBranch === currentWorktreeBranch;
const updates: Partial<Feature> = {
status: 'verified' as const,
};
updateFeature(feature.id, updates);
persistFeatureUpdate(feature.id, updates);
persistFeatureUpdate(feature.id, { status: 'verified' as const });
if (willBeVisibleOnCurrentView) {
toast.success('Feature restored', {
@@ -994,13 +980,7 @@ export function useBoardActions({
});
}
},
[
updateFeature,
persistFeatureUpdate,
currentWorktreeBranch,
projectPath,
isPrimaryWorktreeBranch,
]
[persistFeatureUpdate, currentWorktreeBranch, projectPath, isPrimaryWorktreeBranch]
);
const handleViewOutput = useCallback(
@@ -1031,6 +1011,13 @@ export function useBoardActions({
const handleForceStopFeature = useCallback(
async (feature: Feature) => {
// Mark this feature as transitioning so WebSocket-driven query invalidation
// (useAutoModeQueryInvalidation) skips redundant cache invalidations while
// persistFeatureUpdate is handling the optimistic update. Without this guard,
// auto_mode_error / auto_mode_stopped WS events race with the optimistic
// update and cause cache flip-flops that cascade through useBoardColumnFeatures,
// triggering React error #185 on mobile.
markFeatureTransitioning(feature.id);
try {
await stopFeature(feature.id);
@@ -1048,25 +1035,11 @@ export function useBoardActions({
removeRunningTaskFromAllWorktrees(currentProject.id, feature.id);
}
// Optimistically update the React Query features cache so the board
// moves the card immediately. Without this, the card stays in
// "in_progress" until the next poll cycle (30s) because the async
// refetch races with the persistFeatureUpdate write.
if (currentProject) {
queryClient.setQueryData(
queryKeys.features.all(currentProject.path),
(oldFeatures: Feature[] | undefined) => {
if (!oldFeatures) return oldFeatures;
return oldFeatures.map((f) =>
f.id === feature.id ? { ...f, status: targetStatus } : f
);
}
);
}
if (targetStatus !== feature.status) {
moveFeature(feature.id, targetStatus);
// Must await to ensure file is written before user can restart
// persistFeatureUpdate handles the optimistic RQ cache update, the
// Zustand store update (on server response), and the final cache
// invalidation internally — no need for separate queryClient.setQueryData
// or moveFeature calls which would cause redundant re-renders.
await persistFeatureUpdate(feature.id, { status: targetStatus });
}
@@ -1083,9 +1056,15 @@ export function useBoardActions({
toast.error('Failed to stop agent', {
description: error instanceof Error ? error.message : 'An error occurred',
});
} finally {
// Delay unmarking so the refetch triggered by persistFeatureUpdate's
// invalidateQueries() has time to settle before WS-driven invalidations
// are allowed through again. Without this, a WS event arriving during
// the refetch window would trigger a conflicting invalidation.
setTimeout(() => unmarkFeatureTransitioning(feature.id), 500);
}
},
[stopFeature, moveFeature, persistFeatureUpdate, currentProject, queryClient]
[stopFeature, persistFeatureUpdate, currentProject]
);
const handleStartNextFeatures = useCallback(async () => {

View File

@@ -1,5 +1,5 @@
// @ts-nocheck - column filtering logic with dependency resolution and status mapping
import { useMemo, useCallback, useEffect, useRef } from 'react';
import { useMemo, useCallback, useEffect } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import {
createFeatureMap,
@@ -177,9 +177,6 @@ export function useBoardColumnFeatures({
(state) => state.clearRecentlyCompletedFeatures
);
// Track previous feature IDs to detect when features list has been refreshed
const prevFeatureIdsRef = useRef<Set<string>>(new Set());
// Clear recently completed features when the cache refreshes with updated statuses.
//
// RACE CONDITION SCENARIO THIS PREVENTS:
@@ -193,12 +190,16 @@ export function useBoardColumnFeatures({
//
// When the refetch completes with fresh data (status='verified'/'completed'),
// this effect clears the recentlyCompletedFeatures set since it's no longer needed.
// Clear recently completed features when the cache refreshes with updated statuses.
// IMPORTANT: Only depend on `features` (not `recentlyCompletedFeatures`) to avoid a
// re-trigger loop where clearing the set creates a new reference that re-fires this effect.
// Read recentlyCompletedFeatures from the store directly to get the latest value without
// subscribing to it as a dependency.
useEffect(() => {
const currentIds = new Set(features.map((f) => f.id));
const currentRecentlyCompleted = useAppStore.getState().recentlyCompletedFeatures;
if (currentRecentlyCompleted.size === 0) return;
// Check if any recently completed features now have terminal statuses in the new data
// If so, we can clear the tracking since the cache is now fresh
const hasUpdatedStatus = Array.from(recentlyCompletedFeatures).some((featureId) => {
const hasUpdatedStatus = Array.from(currentRecentlyCompleted).some((featureId) => {
const feature = features.find((f) => f.id === featureId);
return feature && (feature.status === 'verified' || feature.status === 'completed');
});
@@ -206,9 +207,7 @@ export function useBoardColumnFeatures({
if (hasUpdatedStatus) {
clearRecentlyCompletedFeatures();
}
prevFeatureIdsRef.current = currentIds;
}, [features, recentlyCompletedFeatures, clearRecentlyCompletedFeatures]);
}, [features, clearRecentlyCompletedFeatures]);
// Memoize column features to prevent unnecessary re-renders
const columnFeaturesMap = useMemo(() => {

View File

@@ -38,7 +38,6 @@ export function useBoardDragDrop({
// subscribing to the entire store. Bare useAppStore() causes the host component
// (BoardView) to re-render on EVERY store change, which cascades through effects
// and triggers React error #185 (maximum update depth exceeded).
const moveFeature = useAppStore((s) => s.moveFeature);
const updateFeature = useAppStore((s) => s.updateFeature);
// Note: getOrCreateWorktreeForFeature removed - worktrees are now created server-side
@@ -207,7 +206,8 @@ export function useBoardDragDrop({
if (targetStatus === draggedFeature.status) return;
// Handle different drag scenarios
// Note: Worktrees are created server-side at execution time based on feature.branchName
// Note: persistFeatureUpdate handles optimistic RQ cache update internally,
// so no separate moveFeature() call is needed.
if (draggedFeature.status === 'backlog' || draggedFeature.status === 'merge_conflict') {
// From backlog
if (targetStatus === 'in_progress') {
@@ -215,7 +215,6 @@ export function useBoardDragDrop({
// Server will derive workDir from feature.branchName
await handleStartImplementation(draggedFeature);
} else {
moveFeature(featureId, targetStatus);
persistFeatureUpdate(featureId, { status: targetStatus });
}
} else if (draggedFeature.status === 'waiting_approval') {
@@ -223,7 +222,6 @@ export function useBoardDragDrop({
// NOTE: This check must come BEFORE skipTests check because waiting_approval
// features often have skipTests=true, and we want status-based handling first
if (targetStatus === 'verified') {
moveFeature(featureId, 'verified');
// Clear justFinishedAt timestamp when manually verifying via drag
persistFeatureUpdate(featureId, {
status: 'verified',
@@ -237,7 +235,6 @@ export function useBoardDragDrop({
});
} else if (targetStatus === 'backlog') {
// Allow moving waiting_approval cards back to backlog
moveFeature(featureId, 'backlog');
// Clear justFinishedAt timestamp when moving back to backlog
persistFeatureUpdate(featureId, {
status: 'backlog',
@@ -269,7 +266,6 @@ export function useBoardDragDrop({
});
}
}
moveFeature(featureId, 'backlog');
persistFeatureUpdate(featureId, { status: 'backlog' });
toast.info(
isRunningTask
@@ -291,7 +287,6 @@ export function useBoardDragDrop({
return;
} else if (targetStatus === 'verified' && draggedFeature.skipTests) {
// Manual verify via drag (only for skipTests features)
moveFeature(featureId, 'verified');
persistFeatureUpdate(featureId, { status: 'verified' });
toast.success('Feature verified', {
description: `Marked as verified: ${draggedFeature.description.slice(
@@ -304,7 +299,6 @@ export function useBoardDragDrop({
// skipTests feature being moved between verified and waiting_approval
if (targetStatus === 'waiting_approval' && draggedFeature.status === 'verified') {
// Move verified feature back to waiting_approval
moveFeature(featureId, 'waiting_approval');
persistFeatureUpdate(featureId, { status: 'waiting_approval' });
toast.info('Feature moved back', {
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
@@ -314,7 +308,6 @@ export function useBoardDragDrop({
});
} else if (targetStatus === 'backlog') {
// Allow moving skipTests cards back to backlog (from verified)
moveFeature(featureId, 'backlog');
persistFeatureUpdate(featureId, { status: 'backlog' });
toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice(
@@ -327,7 +320,6 @@ export function useBoardDragDrop({
// Handle verified TDD (non-skipTests) features being moved back
if (targetStatus === 'waiting_approval') {
// Move verified feature back to waiting_approval
moveFeature(featureId, 'waiting_approval');
persistFeatureUpdate(featureId, { status: 'waiting_approval' });
toast.info('Feature moved back', {
description: `Moved back to Waiting Approval: ${draggedFeature.description.slice(
@@ -337,7 +329,6 @@ export function useBoardDragDrop({
});
} else if (targetStatus === 'backlog') {
// Allow moving verified cards back to backlog
moveFeature(featureId, 'backlog');
persistFeatureUpdate(featureId, { status: 'backlog' });
toast.info('Feature moved to backlog', {
description: `Moved to Backlog: ${draggedFeature.description.slice(
@@ -351,7 +342,6 @@ export function useBoardDragDrop({
[
features,
runningAutoTasks,
moveFeature,
updateFeature,
persistFeatureUpdate,
handleStartImplementation,

View File

@@ -87,37 +87,22 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
);
// Subscribe to auto mode events for notifications (ding sound, toasts)
// Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root
// Note: Query invalidation is handled by useAutoModeQueryInvalidation in the root.
// Note: removeRunningTask is handled by useAutoMode — do NOT duplicate it here,
// as duplicate Zustand mutations cause re-render cascades (React error #185).
useEffect(() => {
const api = getElectronAPI();
if (!api?.autoMode || !currentProject) return;
const { removeRunningTask } = useAppStore.getState();
const projectId = currentProject.id;
const projectPath = currentProject.path;
const unsubscribe = api.autoMode.onEvent((event) => {
// Check if event is for the current project by matching projectPath
const eventProjectPath = ('projectPath' in event && event.projectPath) as string | undefined;
if (eventProjectPath && eventProjectPath !== projectPath) {
// Event is for a different project, ignore it
logger.debug(
`Ignoring auto mode event for different project: ${eventProjectPath} (current: ${projectPath})`
);
return;
}
// Use event's projectPath or projectId if available, otherwise use current project
// Board view only reacts to events for the currently selected project
const eventProjectId = ('projectId' in event && event.projectId) || projectId;
// NOTE: auto_mode_feature_start and auto_mode_feature_complete are NOT handled here
// for feature list reloading. That is handled by useAutoModeQueryInvalidation which
// invalidates the features.all query on those events. Duplicate invalidation here
// caused a re-render cascade through DndContext that triggered React error #185
// (maximum update depth exceeded), crashing the board view with an infinite spinner
// when a new feature was added and moved to in_progress.
if (event.type === 'auto_mode_feature_complete') {
// Play ding sound when feature is done (unless muted)
const { muteDoneSound } = useAppStore.getState();
@@ -126,14 +111,7 @@ export function useBoardFeatures({ currentProject }: UseBoardFeaturesProps) {
audio.play().catch((err) => logger.warn('Could not play ding sound:', err));
}
} else if (event.type === 'auto_mode_error') {
// Remove from running tasks
if (event.featureId) {
const eventBranchName =
'branchName' in event && event.branchName !== undefined ? event.branchName : null;
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
}
// Show error toast
// Show error toast (removeRunningTask is handled by useAutoMode, not here)
const isAuthError =
event.errorType === 'authentication' ||
(event.error &&

View File

@@ -281,6 +281,10 @@ function VirtualizedList<Item extends VirtualListItem>({
);
}
// Stable empty Set to use as default prop value. Using `new Set()` inline in
// the destructuring creates a new reference on every render, defeating memo.
const EMPTY_FEATURE_IDS = new Set<string>();
export const KanbanBoard = memo(function KanbanBoard({
activeFeature,
getColumnFeatures,
@@ -317,7 +321,7 @@ export const KanbanBoard = memo(function KanbanBoard({
onOpenPipelineSettings,
isSelectionMode = false,
selectionTarget = null,
selectedFeatureIds = new Set(),
selectedFeatureIds = EMPTY_FEATURE_IDS,
onToggleFeatureSelection,
onToggleSelectionMode,
onAiSuggest,

View File

@@ -77,14 +77,21 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
// Buffer for batching rapid output events into fewer setState calls.
// Content accumulates here and is flushed via requestAnimationFrame,
// ensuring at most one React re-render per animation frame (~60fps max).
// A fallback setTimeout ensures the buffer is flushed even when RAF is
// throttled (e.g., when the tab is in the background).
const pendingOutputRef = useRef('');
const rafIdRef = useRef<number | null>(null);
const timerIdRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resetPendingOutput = useCallback(() => {
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
if (timerIdRef.current !== null) {
clearTimeout(timerIdRef.current);
timerIdRef.current = null;
}
pendingOutputRef.current = '';
}, []);
@@ -162,7 +169,12 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
}, [resetPendingOutput]);
const flushPendingOutput = useCallback(() => {
// Clear both scheduling handles to prevent duplicate flushes
rafIdRef.current = null;
if (timerIdRef.current !== null) {
clearTimeout(timerIdRef.current);
timerIdRef.current = null;
}
const content = pendingOutputRef.current;
if (!content) return;
pendingOutputRef.current = '';
@@ -192,13 +204,31 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
*
* Uses requestAnimationFrame to batch rapid output events into at most
* one React state update per frame, preventing excessive re-renders.
* A fallback setTimeout(250ms) ensures the buffer is flushed even when
* RAF is throttled (e.g., when the tab is in the background).
* If the pending buffer reaches MAX_LOG_BUFFER_SIZE, flushes immediately
* to prevent unbounded memory growth.
*/
const appendLogs = useCallback(
(content: string) => {
pendingOutputRef.current += content;
// Flush immediately if buffer has reached the size limit
if (pendingOutputRef.current.length >= MAX_LOG_BUFFER_SIZE) {
flushPendingOutput();
return;
}
// Schedule a RAF flush if not already scheduled
if (rafIdRef.current === null) {
rafIdRef.current = requestAnimationFrame(flushPendingOutput);
}
// Schedule a fallback timer flush if not already scheduled,
// to handle cases where RAF is throttled (background tab)
if (timerIdRef.current === null) {
timerIdRef.current = setTimeout(flushPendingOutput, 250);
}
},
[flushPendingOutput]
);

View File

@@ -22,6 +22,8 @@ function arraysEqual(a: string[], b: string[]): boolean {
return a.every((id) => set.has(id));
}
const AUTO_MODE_POLLING_INTERVAL = 30000;
// Stable empty array reference to avoid re-renders from `[] !== []`
const EMPTY_TASKS: string[] = [];
/**
* Generate a worktree key for session storage
@@ -77,8 +79,12 @@ function isPlanApprovalEvent(
* @param worktree - Optional worktree info. If not provided, uses main worktree (branchName = null)
*/
export function useAutoMode(worktree?: WorktreeInfo) {
// Subscribe to stable action functions and scalar state via useShallow.
// IMPORTANT: Do NOT subscribe to autoModeByWorktree here. That object gets a
// new reference on every Zustand mutation to ANY worktree, which would re-render
// every useAutoMode consumer on every store change. Instead, we subscribe to the
// specific worktree's state below using a targeted selector.
const {
autoModeByWorktree,
setAutoModeRunning,
addRunningTask,
removeRunningTask,
@@ -93,7 +99,6 @@ export function useAutoMode(worktree?: WorktreeInfo) {
addRecentlyCompletedFeature,
} = useAppStore(
useShallow((state) => ({
autoModeByWorktree: state.autoModeByWorktree,
setAutoModeRunning: state.setAutoModeRunning,
addRunningTask: state.addRunningTask,
removeRunningTask: state.removeRunningTask,
@@ -144,41 +149,109 @@ export function useAutoMode(worktree?: WorktreeInfo) {
[projects]
);
// Get worktree-specific auto mode state
// Get worktree-specific auto mode state using a TARGETED selector with
// VALUE-BASED equality. This is critical for preventing cascading re-renders
// in board view, where DndContext amplifies every parent re-render.
//
// Why value-based equality matters: Every Zustand `set()` call (including
// `addAutoModeActivity` which fires on every WS event) triggers all subscriber
// selectors to re-run. Even our targeted selector that reads a specific key
// would return a new object reference (from the spread in `removeRunningTask`
// etc.), causing a re-render even when the actual values haven't changed.
// By extracting primitives and comparing with a custom equality function,
// we only re-render when isRunning/runningTasks/maxConcurrency actually change.
const projectId = currentProject?.id;
const worktreeAutoModeState = useMemo(() => {
if (!projectId)
return {
isRunning: false,
runningTasks: [],
branchName: null,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
};
const key = getWorktreeKey(projectId, branchName);
return (
autoModeByWorktree[key] || {
isRunning: false,
runningTasks: [],
branchName,
maxConcurrency: DEFAULT_MAX_CONCURRENCY,
}
);
}, [autoModeByWorktree, projectId, branchName, getWorktreeKey]);
const worktreeKey = useMemo(
() => (projectId ? getWorktreeKey(projectId, branchName) : null),
[projectId, branchName, getWorktreeKey]
);
const isAutoModeRunning = worktreeAutoModeState.isRunning;
const runningAutoTasks = worktreeAutoModeState.runningTasks;
// Use the subscribed worktreeAutoModeState.maxConcurrency (from the reactive
// autoModeByWorktree store slice) so canStartNewTask stays reactive when
// refreshStatus updates worktree state or when the global setting changes.
// Falls back to the subscribed globalMaxConcurrency (also reactive) when no
// per-worktree value is set, and to DEFAULT_MAX_CONCURRENCY when no project.
// Subscribe to this specific worktree's state using useShallow.
// useShallow compares each property of the returned object with Object.is,
// so primitive properties (isRunning: boolean, maxConcurrency: number) are
// naturally stable. Only runningTasks (array) needs additional stabilization
// since filter()/spread creates new array references even for identical content.
const { worktreeIsRunning, worktreeRunningTasksRaw, worktreeMaxConcurrency } = useAppStore(
useShallow((state) => {
if (!worktreeKey) {
return {
worktreeIsRunning: false,
worktreeRunningTasksRaw: EMPTY_TASKS,
worktreeMaxConcurrency: undefined as number | undefined,
};
}
const wt = state.autoModeByWorktree[worktreeKey];
if (!wt) {
return {
worktreeIsRunning: false,
worktreeRunningTasksRaw: EMPTY_TASKS,
worktreeMaxConcurrency: undefined as number | undefined,
};
}
return {
worktreeIsRunning: wt.isRunning,
worktreeRunningTasksRaw: wt.runningTasks,
worktreeMaxConcurrency: wt.maxConcurrency,
};
})
);
// Stabilize runningTasks: useShallow uses Object.is per property, but
// runningTasks gets a new array ref after removeRunningTask/addRunningTask.
// Cache the previous value and only update when content actually changes.
const prevTasksRef = useRef<string[]>(EMPTY_TASKS);
const worktreeRunningTasks = useMemo(() => {
if (worktreeRunningTasksRaw === prevTasksRef.current) return prevTasksRef.current;
if (arraysEqual(prevTasksRef.current, worktreeRunningTasksRaw)) return prevTasksRef.current;
prevTasksRef.current = worktreeRunningTasksRaw;
return worktreeRunningTasksRaw;
}, [worktreeRunningTasksRaw]);
const isAutoModeRunning = worktreeIsRunning;
const runningAutoTasks = worktreeRunningTasks;
// Use worktreeMaxConcurrency (from the reactive per-key selector) so
// canStartNewTask stays reactive when refreshStatus updates worktree state
// or when the global setting changes.
const maxConcurrency = projectId
? (worktreeAutoModeState.maxConcurrency ?? globalMaxConcurrency)
? (worktreeMaxConcurrency ?? globalMaxConcurrency)
: DEFAULT_MAX_CONCURRENCY;
// Check if we can start a new task based on concurrency limit
const canStartNewTask = runningAutoTasks.length < maxConcurrency;
// Batch addAutoModeActivity calls to reduce Zustand set() frequency.
// Without batching, each WS event (especially auto_mode_progress which fires
// rapidly during streaming) triggers a separate set() → all subscriber selectors
// re-evaluate → on mobile this overwhelms React's batching → crash.
// This batches activities in a ref and flushes them in a single set() call.
const pendingActivitiesRef = useRef<Parameters<typeof addAutoModeActivity>[0][]>([]);
const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const batchedAddAutoModeActivity = useCallback(
(activity: Parameters<typeof addAutoModeActivity>[0]) => {
pendingActivitiesRef.current.push(activity);
if (!flushTimerRef.current) {
flushTimerRef.current = setTimeout(() => {
const batch = pendingActivitiesRef.current;
pendingActivitiesRef.current = [];
flushTimerRef.current = null;
// Flush all pending activities in a single store update
for (const act of batch) {
addAutoModeActivity(act);
}
}, 100);
}
},
[addAutoModeActivity]
);
// Cleanup flush timer on unmount
useEffect(() => {
return () => {
if (flushTimerRef.current) {
clearTimeout(flushTimerRef.current);
}
};
}, []);
// Ref to prevent refreshStatus and WebSocket handlers from overwriting optimistic state
// during start/stop transitions.
const isTransitioningRef = useRef(false);
@@ -498,7 +571,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
case 'auto_mode_feature_start':
if (event.featureId) {
addRunningTask(eventProjectId, eventBranchName, event.featureId);
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: 'start',
message: `Started working on feature`,
@@ -514,7 +587,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// briefly appear in backlog due to stale cache data
addRecentlyCompletedFeature(event.featureId);
removeRunningTask(eventProjectId, eventBranchName, event.featureId);
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: 'complete',
message: event.passes
@@ -551,7 +624,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
? `Authentication failed: Please check your API key in Settings or run 'claude login' in terminal to re-authenticate.`
: event.error;
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: 'error',
message: errorMessage,
@@ -568,7 +641,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
case 'auto_mode_progress':
// Log progress updates (throttle to avoid spam)
if (event.featureId && event.content && event.content.length > 10) {
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: 'progress',
message: event.content.substring(0, 200), // Limit message length
@@ -579,7 +652,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
case 'auto_mode_tool':
// Log tool usage
if (event.featureId && event.tool) {
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: 'tool',
message: `Using tool: ${event.tool}`,
@@ -592,7 +665,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Log phase transitions (Planning, Action, Verification)
if (event.featureId && event.phase && event.message) {
logger.debug(`[AutoMode] Phase: ${event.phase} for ${event.featureId}`);
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: event.phase,
message: event.message,
@@ -618,7 +691,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Log when planning phase begins
if (event.featureId && event.mode && event.message) {
logger.debug(`[AutoMode] Planning started (${event.mode}) for ${event.featureId}`);
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: 'planning',
message: event.message,
@@ -631,7 +704,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Log when plan is approved by user
if (event.featureId) {
logger.debug(`[AutoMode] Plan approved for ${event.featureId}`);
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: 'action',
message: event.hasEdits
@@ -646,7 +719,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
// Log when plan is auto-approved (requirePlanApproval=false)
if (event.featureId) {
logger.debug(`[AutoMode] Plan auto-approved for ${event.featureId}`);
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: 'action',
message: 'Plan auto-approved, starting implementation...',
@@ -665,7 +738,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
logger.debug(
`[AutoMode] Plan revision requested for ${event.featureId} (v${revisionEvent.planVersion})`
);
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: 'planning',
message: `Revising plan based on feedback (v${revisionEvent.planVersion})...`,
@@ -681,7 +754,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
logger.debug(
`[AutoMode] Task ${taskEvent.taskId} started for ${event.featureId}: ${taskEvent.taskDescription}`
);
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: 'progress',
message: `▶ Starting ${taskEvent.taskId}: ${taskEvent.taskDescription}`,
@@ -696,7 +769,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
logger.debug(
`[AutoMode] Task ${taskEvent.taskId} completed for ${event.featureId} (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`
);
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: 'progress',
message: `${taskEvent.taskId} done (${taskEvent.tasksCompleted}/${taskEvent.tasksTotal})`,
@@ -714,7 +787,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
logger.debug(
`[AutoMode] Phase ${phaseEvent.phaseNumber} completed for ${event.featureId}`
);
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: 'action',
message: `Phase ${phaseEvent.phaseNumber} completed`,
@@ -742,7 +815,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
logger.debug(
`[AutoMode] Summary saved for ${event.featureId}: ${summaryEvent.summary.substring(0, 100)}...`
);
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId: event.featureId,
type: 'progress',
message: `Summary: ${summaryEvent.summary.substring(0, 100)}...`,
@@ -758,7 +831,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
branchName,
addRunningTask,
removeRunningTask,
addAutoModeActivity,
batchedAddAutoModeActivity,
getProjectIdFromPath,
setPendingPlanApproval,
setAutoModeRunning,
@@ -977,7 +1050,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
removeRunningTask(currentProject.id, branchName, featureId);
logger.info('Feature stopped successfully:', featureId);
addAutoModeActivity({
batchedAddAutoModeActivity({
featureId,
type: 'complete',
message: 'Feature stopped by user',
@@ -993,7 +1066,7 @@ export function useAutoMode(worktree?: WorktreeInfo) {
throw error;
}
},
[currentProject, branchName, removeRunningTask, addAutoModeActivity]
[currentProject, branchName, removeRunningTask, batchedAddAutoModeActivity]
);
return {

View File

@@ -13,6 +13,7 @@ import type { AutoModeEvent, SpecRegenerationEvent, StreamEvent } from '@/types/
import type { IssueValidationEvent } from '@automaker/types';
import { debounce, type DebouncedFunction } from '@automaker/utils/debounce';
import { useEventRecencyStore } from './use-event-recency';
import { isAnyFeatureTransitioning } from '@/lib/feature-transition-state';
/**
* Debounce configuration for auto_mode_progress invalidations
@@ -31,8 +32,10 @@ const FEATURE_LIST_INVALIDATION_EVENTS: AutoModeEvent['type'][] = [
'auto_mode_feature_start',
'auto_mode_feature_complete',
'auto_mode_error',
'auto_mode_started',
'auto_mode_stopped',
// NOTE: auto_mode_started and auto_mode_stopped are intentionally excluded.
// These events signal auto-loop state changes, NOT feature data changes.
// Including them caused unnecessary refetches that raced with optimistic
// updates during start/stop cycles, triggering React error #185 on mobile.
'plan_approval_required',
'plan_approved',
'plan_rejected',
@@ -176,8 +179,12 @@ export function useAutoModeQueryInvalidation(projectPath: string | undefined) {
// This allows polling to be disabled when WebSocket events are flowing
recordGlobalEvent();
// Invalidate feature list for lifecycle events
if (FEATURE_LIST_INVALIDATION_EVENTS.includes(event.type)) {
// Invalidate feature list for lifecycle events.
// Skip invalidation when a feature is mid-transition (e.g., being cancelled)
// because persistFeatureUpdate already handles the optimistic cache update.
// Without this guard, auto_mode_error / auto_mode_stopped WS events race
// with the optimistic update and cause re-render cascades on mobile (React #185).
if (FEATURE_LIST_INVALIDATION_EVENTS.includes(event.type) && !isAnyFeatureTransitioning()) {
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProjectPath),
});

View File

@@ -0,0 +1,19 @@
/**
* Lightweight module-level state tracking which features are mid-transition
* (e.g., being cancelled). Used by useAutoModeQueryInvalidation to skip
* redundant cache invalidations while persistFeatureUpdate is in flight.
*/
const transitioningFeatures = new Set<string>();
export function markFeatureTransitioning(featureId: string): void {
transitioningFeatures.add(featureId);
}
export function unmarkFeatureTransitioning(featureId: string): void {
transitioningFeatures.delete(featureId);
}
export function isAnyFeatureTransitioning(): boolean {
return transitioningFeatures.size > 0;
}

View File

@@ -2763,6 +2763,21 @@ export class HttpApiClient implements ElectronAPI {
headers?: Record<string, string>;
enabled?: boolean;
}>;
eventHooks?: Array<{
id: string;
trigger: string;
enabled: boolean;
action: Record<string, unknown>;
name?: string;
}>;
ntfyEndpoints?: Array<{
id: string;
name: string;
serverUrl: string;
topic: string;
authType: string;
enabled: boolean;
}>;
};
error?: string;
}> => this.get('/api/settings/global'),

View File

@@ -600,10 +600,7 @@ function RootLayoutContent() {
// so updating them won't cause a visible re-render flash.
const serverHooks = (finalSettings as GlobalSettings).eventHooks ?? [];
const currentHooks = useAppStore.getState().eventHooks;
if (
JSON.stringify(serverHooks) !== JSON.stringify(currentHooks) &&
serverHooks.length > 0
) {
if (JSON.stringify(serverHooks) !== JSON.stringify(currentHooks)) {
logger.info(
`[FAST_HYDRATE] Reconciling eventHooks from server (server=${serverHooks.length}, store=${currentHooks.length})`
);

View File

@@ -1044,6 +1044,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set((state) => {
const current = state.autoModeByWorktree[key];
if (!current) return state;
// Idempotent: skip if task is not in the list to avoid creating new
// object references that trigger unnecessary re-renders.
if (!current.runningTasks.includes(taskId)) return state;
return {
autoModeByWorktree: {
...state.autoModeByWorktree,
@@ -1097,13 +1100,20 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
addRecentlyCompletedFeature: (featureId: string) => {
set((state) => {
// Idempotent: skip if already tracked to avoid creating a new Set reference
// that triggers unnecessary re-renders in useBoardColumnFeatures.
if (state.recentlyCompletedFeatures.has(featureId)) return state;
const newSet = new Set(state.recentlyCompletedFeatures);
newSet.add(featureId);
return { recentlyCompletedFeatures: newSet };
});
},
clearRecentlyCompletedFeatures: () => set({ recentlyCompletedFeatures: new Set() }),
clearRecentlyCompletedFeatures: () => {
// Idempotent: skip if already empty to avoid creating a new Set reference.
if (get().recentlyCompletedFeatures.size === 0) return;
set({ recentlyCompletedFeatures: new Set() });
},
setMaxConcurrency: (max) => set({ maxConcurrency: max }),
@@ -1496,7 +1506,11 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set({ eventHooks: hooks });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ eventHooks: hooks });
await httpApi.settings.updateGlobal({
eventHooks: hooks,
// Signal the server that an empty array is intentional (not a wipe from stale state)
...(hooks.length === 0 ? { __allowEmptyEventHooks: true } : {}),
});
} catch (error) {
logger.error('Failed to sync event hooks:', error);
}
@@ -1507,7 +1521,11 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set({ ntfyEndpoints: endpoints });
try {
const httpApi = getHttpApiClient();
await httpApi.settings.updateGlobal({ ntfyEndpoints: endpoints });
await httpApi.settings.updateGlobal({
ntfyEndpoints: endpoints,
// Signal the server that an empty array is intentional (not a wipe from stale state)
...(endpoints.length === 0 ? { __allowEmptyNtfyEndpoints: true } : {}),
});
} catch (error) {
logger.error('Failed to sync ntfy endpoints:', error);
}