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 {