diff --git a/apps/server/eslint.config.mjs b/apps/server/eslint.config.mjs
new file mode 100644
index 00000000..008c1f68
--- /dev/null
+++ b/apps/server/eslint.config.mjs
@@ -0,0 +1,74 @@
+import { defineConfig, globalIgnores } from 'eslint/config';
+import js from '@eslint/js';
+import ts from '@typescript-eslint/eslint-plugin';
+import tsParser from '@typescript-eslint/parser';
+
+const eslintConfig = defineConfig([
+ js.configs.recommended,
+ {
+ files: ['**/*.ts'],
+ languageOptions: {
+ parser: tsParser,
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ },
+ globals: {
+ // Node.js globals
+ console: 'readonly',
+ process: 'readonly',
+ Buffer: 'readonly',
+ __dirname: 'readonly',
+ __filename: 'readonly',
+ URL: 'readonly',
+ URLSearchParams: 'readonly',
+ AbortController: 'readonly',
+ AbortSignal: 'readonly',
+ fetch: 'readonly',
+ Response: 'readonly',
+ Request: 'readonly',
+ Headers: 'readonly',
+ FormData: 'readonly',
+ RequestInit: 'readonly',
+ // Timers
+ setTimeout: 'readonly',
+ setInterval: 'readonly',
+ clearTimeout: 'readonly',
+ clearInterval: 'readonly',
+ setImmediate: 'readonly',
+ clearImmediate: 'readonly',
+ queueMicrotask: 'readonly',
+ // Node.js types
+ NodeJS: 'readonly',
+ },
+ },
+ plugins: {
+ '@typescript-eslint': ts,
+ },
+ rules: {
+ ...ts.configs.recommended.rules,
+ '@typescript-eslint/no-unused-vars': [
+ 'warn',
+ {
+ argsIgnorePattern: '^_',
+ varsIgnorePattern: '^_',
+ caughtErrorsIgnorePattern: '^_',
+ ignoreRestSiblings: true,
+ },
+ ],
+ '@typescript-eslint/no-explicit-any': 'warn',
+ // Server code frequently works with terminal output containing ANSI escape codes
+ 'no-control-regex': 'off',
+ '@typescript-eslint/ban-ts-comment': [
+ 'error',
+ {
+ 'ts-nocheck': 'allow-with-description',
+ minimumDescriptionLength: 10,
+ },
+ ],
+ },
+ },
+ globalIgnores(['dist/**', 'node_modules/**']),
+]);
+
+export default eslintConfig;
diff --git a/apps/server/src/routes/worktree/routes/checkout-branch.ts b/apps/server/src/routes/worktree/routes/checkout-branch.ts
index ffa6e5e3..7ffee2c0 100644
--- a/apps/server/src/routes/worktree/routes/checkout-branch.ts
+++ b/apps/server/src/routes/worktree/routes/checkout-branch.ts
@@ -37,7 +37,7 @@ export function createCheckoutBranchHandler() {
}
// Validate branch name (basic validation)
- const invalidChars = /[\s~^:?*\[\\]/;
+ const invalidChars = /[\s~^:?*[\\]/;
if (invalidChars.test(branchName)) {
res.status(400).json({
success: false,
diff --git a/apps/server/src/routes/worktree/routes/open-in-editor.ts b/apps/server/src/routes/worktree/routes/open-in-editor.ts
index c5ea6f9e..f0d620d4 100644
--- a/apps/server/src/routes/worktree/routes/open-in-editor.ts
+++ b/apps/server/src/routes/worktree/routes/open-in-editor.ts
@@ -125,19 +125,14 @@ export function createOpenInEditorHandler() {
`Failed to open in editor, falling back to file manager: ${getErrorMessage(editorError)}`
);
- try {
- const result = await openInFileManager(worktreePath);
- res.json({
- success: true,
- result: {
- message: `Opened ${worktreePath} in ${result.editorName}`,
- editorName: result.editorName,
- },
- });
- } catch (fallbackError) {
- // Both editor and file manager failed
- throw fallbackError;
- }
+ const result = await openInFileManager(worktreePath);
+ res.json({
+ success: true,
+ result: {
+ message: `Opened ${worktreePath} in ${result.editorName}`,
+ editorName: result.editorName,
+ },
+ });
}
} catch (error) {
logError(error, 'Open in editor failed');
diff --git a/apps/server/src/services/claude-usage-service.ts b/apps/server/src/services/claude-usage-service.ts
index 6438b5dc..40cffd7f 100644
--- a/apps/server/src/services/claude-usage-service.ts
+++ b/apps/server/src/services/claude-usage-service.ts
@@ -662,7 +662,7 @@ export class ClaudeUsageService {
resetTime = this.parseResetTime(resetText, type);
// Strip timezone like "(Asia/Dubai)" from the display text
- resetText = resetText.replace(/\s*\([A-Za-z_\/]+\)\s*$/, '').trim();
+ resetText = resetText.replace(/\s*\([A-Za-z_/]+\)\s*$/, '').trim();
}
return { percentage: percentage ?? 0, resetTime, resetText };
diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts
index d81e539c..76cf3174 100644
--- a/apps/server/src/services/dev-server-service.ts
+++ b/apps/server/src/services/dev-server-service.ts
@@ -124,7 +124,7 @@ class DevServerService {
/(?:Local|Network):\s+(https?:\/\/[^\s]+)/i, // Vite format
/(?:ready|started server).*?(?:url:\s*)?(https?:\/\/[^\s,]+)/i, // Next.js format
/(https?:\/\/(?:localhost|127\.0\.0\.1|\[::\]):\d+)/i, // Generic localhost URL
- /(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/i, // Any HTTP(S) URL
+ /(https?:\/\/[^\s<>"{}|\\^`[\]]+)/i, // Any HTTP(S) URL
];
for (const pattern of urlPatterns) {
diff --git a/apps/server/src/services/ideation-service.ts b/apps/server/src/services/ideation-service.ts
index efa32802..0d43252f 100644
--- a/apps/server/src/services/ideation-service.ts
+++ b/apps/server/src/services/ideation-service.ts
@@ -888,7 +888,7 @@ ${contextSection}${existingWorkSection}`;
for (const line of lines) {
// Check for numbered items or markdown headers
- const titleMatch = line.match(/^(?:\d+[\.\)]\s*\*{0,2}|#{1,3}\s+)(.+)/);
+ const titleMatch = line.match(/^(?:\d+[.)]\s*\*{0,2}|#{1,3}\s+)(.+)/);
if (titleMatch) {
// Save previous suggestion
diff --git a/apps/ui/eslint.config.mjs b/apps/ui/eslint.config.mjs
index 3ad4d79d..2400404f 100644
--- a/apps/ui/eslint.config.mjs
+++ b/apps/ui/eslint.config.mjs
@@ -119,7 +119,15 @@ const eslintConfig = defineConfig([
},
rules: {
...ts.configs.recommended.rules,
- '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
+ '@typescript-eslint/no-unused-vars': [
+ 'warn',
+ {
+ argsIgnorePattern: '^_',
+ varsIgnorePattern: '^_',
+ caughtErrorsIgnorePattern: '^_',
+ ignoreRestSiblings: true,
+ },
+ ],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/ban-ts-comment': [
'error',
diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx
index 6f5f8e62..768b40a5 100644
--- a/apps/ui/src/components/views/board-view.tsx
+++ b/apps/ui/src/components/views/board-view.tsx
@@ -1545,7 +1545,8 @@ export function BoardView() {
setSpawnParentFeature(feature);
setShowAddDialog(true);
}}
- onDuplicate={handleDuplicateFeature}
+ onDuplicate={(feature) => handleDuplicateFeature(feature, false)}
+ onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasksAllWorktrees}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
diff --git a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx
index d7760e01..d69ebf8e 100644
--- a/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx
+++ b/apps/ui/src/components/views/board-view/components/kanban-card/card-header.tsx
@@ -31,6 +31,49 @@ import { formatModelName, DEFAULT_MODEL } from '@/lib/agent-context-parser';
import { DeleteConfirmDialog } from '@/components/ui/delete-confirm-dialog';
import { getProviderIconForModel } from '@/components/ui/provider-icon';
+function DuplicateMenuItems({
+ onDuplicate,
+ onDuplicateAsChild,
+}: {
+ onDuplicate?: () => void;
+ onDuplicateAsChild?: () => void;
+}) {
+ if (!onDuplicate) return null;
+ return (
+
+
+ {
+ e.stopPropagation();
+ onDuplicate();
+ }}
+ className="text-xs flex-1 pr-0 rounded-r-none"
+ >
+
+ Duplicate
+
+ {onDuplicateAsChild && (
+
+ )}
+
+ {onDuplicateAsChild && (
+
+ {
+ e.stopPropagation();
+ onDuplicateAsChild();
+ }}
+ className="text-xs"
+ >
+
+ Duplicate as Child
+
+
+ )}
+
+ );
+}
+
interface CardHeaderProps {
feature: Feature;
isDraggable: boolean;
@@ -122,39 +165,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
Spawn Sub-Task
- {onDuplicate && (
-
-
- {
- e.stopPropagation();
- onDuplicate();
- }}
- className="text-xs flex-1 pr-0 rounded-r-none"
- >
-
- Duplicate
-
- {onDuplicateAsChild && (
-
- )}
-
- {onDuplicateAsChild && (
-
- {
- e.stopPropagation();
- onDuplicateAsChild();
- }}
- className="text-xs"
- >
-
- Duplicate as Child
-
-
- )}
-
- )}
+
{/* Model info in dropdown */}
{(() => {
const ProviderIcon = getProviderIconForModel(feature.model);
@@ -217,39 +231,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
- {onDuplicate && (
-
-
- {
- e.stopPropagation();
- onDuplicate();
- }}
- className="text-xs flex-1 pr-0 rounded-r-none"
- >
-
- Duplicate
-
- {onDuplicateAsChild && (
-
- )}
-
- {onDuplicateAsChild && (
-
- {
- e.stopPropagation();
- onDuplicateAsChild();
- }}
- className="text-xs"
- >
-
- Duplicate as Child
-
-
- )}
-
- )}
+
@@ -337,39 +322,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
Spawn Sub-Task
- {onDuplicate && (
-
-
- {
- e.stopPropagation();
- onDuplicate();
- }}
- className="text-xs flex-1 pr-0 rounded-r-none"
- >
-
- Duplicate
-
- {onDuplicateAsChild && (
-
- )}
-
- {onDuplicateAsChild && (
-
- {
- e.stopPropagation();
- onDuplicateAsChild();
- }}
- className="text-xs"
- >
-
- Duplicate as Child
-
-
- )}
-
- )}
+
@@ -440,39 +396,10 @@ export const CardHeaderSection = memo(function CardHeaderSection({
Spawn Sub-Task
- {onDuplicate && (
-
-
- {
- e.stopPropagation();
- onDuplicate();
- }}
- className="text-xs flex-1 pr-0 rounded-r-none"
- >
-
- Duplicate
-
- {onDuplicateAsChild && (
-
- )}
-
- {onDuplicateAsChild && (
-
- {
- e.stopPropagation();
- onDuplicateAsChild();
- }}
- className="text-xs"
- >
-
- Duplicate as Child
-
-
- )}
-
- )}
+
{/* Model info in dropdown */}
{(() => {
const ProviderIcon = getProviderIconForModel(feature.model);
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 d3004f74..143e9c3a 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
@@ -85,6 +85,11 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
throw new Error('Features API not available');
}
+ // Capture previous cache snapshot for synchronous rollback on error
+ const previousFeatures = queryClient.getQueryData(
+ queryKeys.features.all(currentProject.path)
+ );
+
// Optimistically add to React Query cache for immediate board refresh
queryClient.setQueryData(
queryKeys.features.all(currentProject.path),
@@ -95,6 +100,16 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
const result = await api.features.create(currentProject.path, feature as ApiFeature);
if (result.success && result.feature) {
updateFeature(result.feature.id, result.feature as Partial);
+ // Update cache with server-confirmed feature before invalidating
+ queryClient.setQueryData(
+ queryKeys.features.all(currentProject.path),
+ (features) => {
+ if (!features) return features;
+ return features.map((f) =>
+ f.id === result.feature!.id ? { ...f, ...(result.feature as Feature) } : f
+ );
+ }
+ );
} else if (!result.success) {
throw new Error(result.error || 'Failed to create feature on server');
}
@@ -104,7 +119,10 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
});
} catch (error) {
logger.error('Failed to persist feature creation:', error);
- // Rollback optimistic update on error
+ // Rollback optimistic update synchronously on error
+ if (previousFeatures) {
+ queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
+ }
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
@@ -131,7 +149,6 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
try {
const api = getElectronAPI();
if (!api.features) {
- logger.error('Features API not available');
// Rollback optimistic deletion since we can't persist
if (previousFeatures) {
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
@@ -139,7 +156,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
- return;
+ throw new Error('Features API not available');
}
await api.features.delete(currentProject.path, featureId);
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 ef091872..1a84080b 100644
--- a/apps/ui/src/components/views/board-view/kanban-board.tsx
+++ b/apps/ui/src/components/views/board-view/kanban-board.tsx
@@ -46,7 +46,8 @@ interface KanbanBoardProps {
onViewPlan: (feature: Feature) => void;
onApprovePlan: (feature: Feature) => void;
onSpawnTask?: (feature: Feature) => void;
- onDuplicate?: (feature: Feature, asChild: boolean) => void;
+ onDuplicate?: (feature: Feature) => void;
+ onDuplicateAsChild?: (feature: Feature) => void;
featuresWithContext: Set;
runningAutoTasks: string[];
onArchiveAllVerified: () => void;
@@ -284,6 +285,7 @@ export function KanbanBoard({
onApprovePlan,
onSpawnTask,
onDuplicate,
+ onDuplicateAsChild,
featuresWithContext,
runningAutoTasks,
onArchiveAllVerified,
@@ -571,8 +573,8 @@ export function KanbanBoard({
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
- onDuplicate={() => onDuplicate?.(feature, false)}
- onDuplicateAsChild={() => onDuplicate?.(feature, true)}
+ onDuplicate={() => onDuplicate?.(feature)}
+ onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
@@ -615,8 +617,8 @@ export function KanbanBoard({
onViewPlan={() => onViewPlan(feature)}
onApprovePlan={() => onApprovePlan(feature)}
onSpawnTask={() => onSpawnTask?.(feature)}
- onDuplicate={() => onDuplicate?.(feature, false)}
- onDuplicateAsChild={() => onDuplicate?.(feature, true)}
+ onDuplicate={() => onDuplicate?.(feature)}
+ onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx
index b27ec3e4..ad82a4d7 100644
--- a/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx
+++ b/apps/ui/src/components/views/spec-view/components/edit-mode/features-section.tsx
@@ -32,7 +32,6 @@ function featureToInternal(feature: Feature): FeatureWithId {
}
function internalToFeature(internal: FeatureWithId): Feature {
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _id, _locationIds, ...feature } = internal;
return feature;
}
diff --git a/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx b/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx
index c5d6ddd4..b13f35e7 100644
--- a/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx
+++ b/apps/ui/src/components/views/spec-view/components/edit-mode/roadmap-section.tsx
@@ -27,7 +27,6 @@ function phaseToInternal(phase: RoadmapPhase): PhaseWithId {
}
function internalToPhase(internal: PhaseWithId): RoadmapPhase {
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _id, ...phase } = internal;
return phase;
}
diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts
index 22079822..446b7b6f 100644
--- a/apps/ui/src/lib/electron.ts
+++ b/apps/ui/src/lib/electron.ts
@@ -1062,7 +1062,6 @@ if (typeof window !== 'undefined') {
}
// Mock API for development/fallback when no backend is available
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _getMockElectronAPI = (): ElectronAPI => {
return {
ping: async () => 'pong (mock)',
diff --git a/apps/ui/src/store/test-runners-store.ts b/apps/ui/src/store/test-runners-store.ts
index b763c15a..8f8f7984 100644
--- a/apps/ui/src/store/test-runners-store.ts
+++ b/apps/ui/src/store/test-runners-store.ts
@@ -155,7 +155,6 @@ export const useTestRunnersStore = create
const finishedAt = new Date().toISOString();
// Remove from active sessions since it's no longer running
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [session.worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
return {
@@ -202,7 +201,6 @@ export const useTestRunnersStore = create
const session = state.sessions[sessionId];
if (!session) return state;
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [sessionId]: _, ...remainingSessions } = state.sessions;
// Remove from active if this was the active session
@@ -231,7 +229,6 @@ export const useTestRunnersStore = create
});
// Remove from active
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { [worktreePath]: _, ...remainingActive } = state.activeSessionByWorktree;
return {