From 47ea576ec8eaf6288c6cbff84144a0f506994b15 Mon Sep 17 00:00:00 2001 From: Marco Cello <88449921+marcocello@users.noreply.github.com> Date: Sun, 1 Mar 2026 23:12:25 +0100 Subject: [PATCH 01/12] feat(task): enhance task feature management and cleanup - Introduced new functions for task feature operations including create, delete, update, and sync from/to repository. - Refactored cleanupTask function to utilize a dedicated cleanupTaskFromData function for better readability and error handling. - Added IPC handlers for managing task features, including syncing and retrieving feature details. - Updated task-related types to include feature management capabilities. - Improved terminal component styling and behavior, ensuring a consistent dark theme. - Removed unused AI description generation code and related types. --- .../apps/app/e2e/27-ai-description.spec.ts | 146 --- packages/apps/app/e2e/fixtures/electron.ts | 6 +- packages/apps/app/electron.vite.config.ts | 8 + packages/apps/app/src/main/db/migrations.ts | 158 +++ packages/apps/app/src/main/index.ts | 44 +- packages/apps/app/src/preload/index.ts | 21 +- packages/apps/app/src/renderer/src/App.tsx | 10 + .../renderer/src/components/tabs/TabBar.tsx | 35 +- .../src/client/CreateProjectDialog.tsx | 10 +- .../src/client/ProjectSettingsDialog.tsx | 1 + .../projects/src/main/handlers.test.ts | 234 +++- .../domains/projects/src/main/handlers.ts | 178 ++- packages/domains/projects/src/main/index.ts | 10 + .../projects/src/main/repo-feature-sync.ts | 1056 +++++++++++++++++ packages/domains/projects/src/shared/types.ts | 68 ++ .../src/client/UserSettingsDialog.tsx | 361 +++++- .../domains/tags/src/main/handlers.test.ts | 1 + packages/domains/tags/src/main/handlers.ts | 8 + .../task-terminals/src/main/handlers.test.ts | 1 + packages/domains/task/DOMAIN.md | 3 +- .../task/src/client/CreateTaskDialog.tsx | 5 +- .../domains/task/src/client/FeaturePanel.tsx | 340 ++++++ .../task/src/client/TaskDetailPage.tsx | 133 ++- .../task/src/client/TaskMetadataSidebar.tsx | 80 +- .../domains/task/src/client/usePanelSizes.ts | 5 +- packages/domains/task/src/main/ai.ts | 61 - .../domains/task/src/main/handlers.test.ts | 346 +++++- packages/domains/task/src/main/handlers.ts | 255 +++- packages/domains/task/src/main/index.ts | 1 - packages/domains/task/src/shared/types.ts | 10 +- .../domains/terminal/src/client/Terminal.tsx | 52 +- packages/shared/types/src/api.ts | 67 +- 32 files changed, 3260 insertions(+), 454 deletions(-) delete mode 100644 packages/apps/app/e2e/27-ai-description.spec.ts create mode 100644 packages/domains/projects/src/main/repo-feature-sync.ts create mode 100644 packages/domains/task/src/client/FeaturePanel.tsx delete mode 100644 packages/domains/task/src/main/ai.ts diff --git a/packages/apps/app/e2e/27-ai-description.spec.ts b/packages/apps/app/e2e/27-ai-description.spec.ts deleted file mode 100644 index f191b507..00000000 --- a/packages/apps/app/e2e/27-ai-description.spec.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { test, expect, seed, goHome, clickProject } from './fixtures/electron' -import { TEST_PROJECT_PATH } from './fixtures/electron' - -test.describe('AI description generation', () => { - let projectAbbrev: string - let taskId: string - - test.beforeAll(async ({ electronApp, mainWindow }) => { - // Mock the AI IPC handler to avoid calling real CLI - await electronApp.evaluate(({ ipcMain }) => { - ipcMain.removeHandler('ai:generate-description') - ipcMain.handle('ai:generate-description', async (_event, title: string, mode: string) => { - if (mode === 'terminal') { - return { success: false, error: 'AI not available in terminal mode' } - } - // Simulate a short delay like a real API call - await new Promise(r => setTimeout(r, 300)) - return { success: true, description: `Mock description for: ${title}` } - }) - }) - - const s = seed(mainWindow) - const p = await s.createProject({ name: 'Desc Gen Test', color: '#a855f7', path: TEST_PROJECT_PATH }) - projectAbbrev = p.name.slice(0, 2).toUpperCase() - const t = await s.createTask({ projectId: p.id, title: 'Implement login flow', status: 'in_progress' }) - taskId = t.id - await s.refreshData() - - await goHome(mainWindow) - await clickProject(mainWindow, projectAbbrev) - - // Open task detail - await mainWindow.getByText('Implement login flow').first().click() - await expect(mainWindow.getByTestId('task-settings-panel').last()).toBeVisible() - }) - - const generateBtn = (page: import('@playwright/test').Page) => - page.getByTestId('task-settings-panel').last().getByTestId('generate-description-button') - - const clickGenerate = async (page: import('@playwright/test').Page) => { - const btn = generateBtn(page) - await btn.scrollIntoViewIfNeeded() - await btn.evaluate((el) => { - ;(el as HTMLButtonElement).click() - }) - } - - test('generate button visible in claude-code mode', async ({ mainWindow }) => { - await expect(generateBtn(mainWindow)).toBeVisible() - }) - - test('generate button shows sparkles icon', async ({ mainWindow }) => { - const btn = generateBtn(mainWindow) - await expect(btn.locator('.lucide-sparkles')).toBeVisible() - }) - - test('clicking generate shows loading spinner', async ({ mainWindow }) => { - await clickGenerate(mainWindow) - // Spinner/disabled state can be very brief in CI; assert generation starts by waiting - // for description to appear in persisted task state. - await expect - .poll(async () => { - const task = await mainWindow.evaluate((id) => window.api.db.getTask(id), taskId) - return task?.description ?? '' - }) - .toContain('Mock description for: Implement login flow') - }) - - test('generated description appears in editor', async ({ mainWindow }) => { - await expect - .poll(async () => { - const task = await mainWindow.evaluate((id) => window.api.db.getTask(id), taskId) - return task?.description ?? '' - }) - .toContain('Mock description for: Implement login flow') - - const editor = mainWindow - .getByTestId('task-settings-panel') - .last() - .locator('[contenteditable="true"]') - .first() - await expect(editor).toContainText('Mock description for: Implement login flow') - }) - - test('generated description persisted to DB', async ({ mainWindow }) => { - await expect - .poll(async () => { - const task = await mainWindow.evaluate((id) => window.api.db.getTask(id), taskId) - return task?.description ?? '' - }) - .toContain('Mock description for: Implement login flow') - }) - - test('button re-enabled after generation', async ({ mainWindow }) => { - await expect(generateBtn(mainWindow)).toBeEnabled() - // Sparkles icon restored (not spinner) - await expect(generateBtn(mainWindow).locator('.lucide-sparkles')).toBeVisible() - }) - - test('button hidden in terminal mode', async ({ mainWindow }) => { - // Switch to terminal mode - const modeTrigger = mainWindow.getByRole('combobox').filter({ hasText: /Claude Code|Codex|Terminal/ }) - await modeTrigger.click() - await mainWindow.getByRole('option', { name: 'Terminal' }).click() - - await expect(generateBtn(mainWindow)).not.toBeVisible() - }) - - test('button visible again in codex mode', async ({ mainWindow }) => { - const modeTrigger = mainWindow.getByRole('combobox').filter({ hasText: /Claude Code|Codex|Terminal/ }) - await modeTrigger.click() - await mainWindow.getByRole('option', { name: 'Codex' }).click() - - await expect(generateBtn(mainWindow)).toBeVisible() - }) - - test('generate works in codex mode too', async ({ mainWindow }) => { - // Clear existing description first - await mainWindow.evaluate((id) => - window.api.db.updateTask({ id, description: null }), taskId) - await expect.poll(async () => { - const task = await mainWindow.evaluate((id) => window.api.db.getTask(id), taskId) - return task?.description - }).toBeFalsy() - - // Navigate away and back to reload clean state - await goHome(mainWindow) - await clickProject(mainWindow, projectAbbrev) - await mainWindow.getByText('Implement login flow').first().click() - await expect(mainWindow.getByTestId('task-settings-panel').last()).toBeVisible() - - await clickGenerate(mainWindow) - await expect - .poll(async () => { - const task = await mainWindow.evaluate((id) => window.api.db.getTask(id), taskId) - return task?.description ?? '' - }) - .toContain('Mock description for: Implement login flow') - - // Switch back to claude-code for clean state - const modeTrigger = mainWindow.getByRole('combobox').filter({ hasText: /Claude Code|Codex|Terminal/ }) - await modeTrigger.click() - await mainWindow.getByRole('option', { name: 'Claude Code' }).click() - await expect(generateBtn(mainWindow)).toBeVisible() - }) -}) diff --git a/packages/apps/app/e2e/fixtures/electron.ts b/packages/apps/app/e2e/fixtures/electron.ts index 6f603837..b59720e6 100644 --- a/packages/apps/app/e2e/fixtures/electron.ts +++ b/packages/apps/app/e2e/fixtures/electron.ts @@ -333,7 +333,7 @@ export const test = base.extend({ /** Seed helpers — call window.api methods to create test data without UI interaction */ export function seed(page: Page) { return { - createProject: (data: { name: string; color: string; path?: string }) => + createProject: (data: { name: string; color: string; path?: string; taskBackend?: 'db' }) => page.evaluate((d) => window.api.db.createProject(d), data), createTask: (data: { @@ -384,6 +384,7 @@ export function seed(page: Page) { name?: string color?: string path?: string | null + taskBackend?: 'db' autoCreateWorktreeOnTaskCreate?: boolean | null columnsConfig?: Array<{ id: string @@ -409,9 +410,6 @@ export function seed(page: Page) { getSetting: (key: string) => page.evaluate((k) => window.api.settings.get(k), key), - setTheme: (theme: 'light' | 'dark' | 'system') => - page.evaluate((t) => window.api.theme.set(t), theme), - /** Re-fetch all data from DB into React state */ refreshData: () => page.evaluate(async () => { diff --git a/packages/apps/app/electron.vite.config.ts b/packages/apps/app/electron.vite.config.ts index c67e0e17..52d2d568 100644 --- a/packages/apps/app/electron.vite.config.ts +++ b/packages/apps/app/electron.vite.config.ts @@ -14,6 +14,9 @@ const root = resolve(__dirname, '../../..') export default defineConfig(({ mode }) => { const env = loadEnv(mode, root, '') + const devHost = env.SLAYZONE_DEV_HOST || '127.0.0.1' + const parsedDevPort = Number.parseInt(env.SLAYZONE_DEV_PORT || '5173', 10) + const devPort = Number.isFinite(parsedDevPort) ? parsedDevPort : 5173 return { main: { @@ -41,6 +44,11 @@ export default defineConfig(({ mode }) => { }, renderer: { envDir: root, + server: { + host: devHost, + port: devPort, + strictPort: false + }, define: { __POSTHOG_API_KEY__: JSON.stringify(env.POSTHOG_API_KEY ?? ''), __POSTHOG_HOST__: JSON.stringify(env.POSTHOG_HOST ?? ''), diff --git a/packages/apps/app/src/main/db/migrations.ts b/packages/apps/app/src/main/db/migrations.ts index 782d9e49..71b58e26 100644 --- a/packages/apps/app/src/main/db/migrations.ts +++ b/packages/apps/app/src/main/db/migrations.ts @@ -5,6 +5,51 @@ interface Migration { up: (db: Database.Database) => void } +function tableExists(db: Database.Database, table: string): boolean { + const row = db.prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?" + ).get(table) as { name: string } | undefined + return Boolean(row) +} + +function hasColumn(db: Database.Database, table: string, column: string): boolean { + if (!tableExists(db, table)) return false + const rows = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }> + return rows.some((row) => row.name === column) +} + +function hasForeignKeyOnColumn(db: Database.Database, table: string, column: string): boolean { + if (!tableExists(db, table)) return false + const rows = db.prepare(`PRAGMA foreign_key_list(${table})`).all() as Array<{ from: string }> + return rows.some((row) => row.from === column) +} + +function rebuildTerminalTabsWithoutTaskForeignKey(db: Database.Database): void { + if (!tableExists(db, 'terminal_tabs')) return + if (!hasForeignKeyOnColumn(db, 'terminal_tabs', 'task_id')) return + db.exec(` + ALTER TABLE terminal_tabs RENAME TO terminal_tabs_old; + + CREATE TABLE terminal_tabs ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + label TEXT, + mode TEXT NOT NULL DEFAULT 'terminal', + is_main INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + group_id TEXT + ); + + INSERT INTO terminal_tabs (id, task_id, label, mode, is_main, position, created_at, group_id) + SELECT id, task_id, label, mode, is_main, position, created_at, group_id + FROM terminal_tabs_old; + + DROP TABLE terminal_tabs_old; + CREATE INDEX IF NOT EXISTS idx_terminal_tabs_task ON terminal_tabs(task_id); + `) +} + const migrations: Migration[] = [ { version: 1, @@ -726,6 +771,12 @@ const migrations: Migration[] = [ if (!hasColumnsConfig) { db.exec(`ALTER TABLE projects ADD COLUMN columns_config TEXT DEFAULT NULL`) } + if (!hasColumn(db, 'projects', 'task_backend')) { + db.exec(` + ALTER TABLE projects + ADD COLUMN task_backend TEXT NOT NULL DEFAULT 'db'; + `) + } } }, { @@ -762,6 +813,9 @@ const migrations: Migration[] = [ updateStmt.run(JSON.stringify(filtered), row.key) } } + // terminal_tabs.task_id FK blocks repo-file tasks (task not persisted in tasks table) + // Recreate table without FK while preserving data. + rebuildTerminalTabsWithoutTaskForeignKey(db) } }, { @@ -805,6 +859,40 @@ const migrations: Migration[] = [ updateStmt.run(JSON.stringify(filtered), row.key) } } + + if (!hasColumn(db, 'projects', 'feature_repo_integration_enabled')) { + db.exec(` + ALTER TABLE projects + ADD COLUMN feature_repo_integration_enabled INTEGER NOT NULL DEFAULT 0; + `) + } + if (!hasColumn(db, 'projects', 'feature_repo_features_path')) { + db.exec(` + ALTER TABLE projects + ADD COLUMN feature_repo_features_path TEXT NOT NULL DEFAULT 'docs/features'; + `) + } + db.exec(` + CREATE TABLE IF NOT EXISTS project_feature_task_links ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + feature_id TEXT DEFAULT NULL, + feature_title TEXT NOT NULL DEFAULT '', + feature_file_path TEXT NOT NULL, + content_hash TEXT DEFAULT NULL, + last_sync_source TEXT NOT NULL DEFAULT 'repo', + last_sync_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(project_id, feature_file_path), + UNIQUE(project_id, task_id) + ); + CREATE INDEX IF NOT EXISTS idx_project_feature_links_project + ON project_feature_task_links(project_id); + CREATE INDEX IF NOT EXISTS idx_project_feature_links_task + ON project_feature_task_links(task_id); + `) } }, { @@ -816,6 +904,24 @@ const migrations: Migration[] = [ if (!hasColumnsConfig) { db.exec(`ALTER TABLE projects ADD COLUMN columns_config TEXT DEFAULT NULL`) } + + if (!hasColumn(db, 'project_feature_task_links', 'last_sync_source')) { + db.exec(` + ALTER TABLE project_feature_task_links + ADD COLUMN last_sync_source TEXT NOT NULL DEFAULT 'repo'; + `) + } + if (!hasColumn(db, 'project_feature_task_links', 'last_sync_at')) { + db.exec(` + ALTER TABLE project_feature_task_links + ADD COLUMN last_sync_at TEXT DEFAULT NULL; + `) + } + db.exec(` + UPDATE project_feature_task_links + SET last_sync_source = COALESCE(NULLIF(last_sync_source, ''), 'repo'), + last_sync_at = COALESCE(last_sync_at, updated_at, datetime('now')) + `) } }, { @@ -975,6 +1081,56 @@ const migrations: Migration[] = [ } ] +function ensureSchemaBackfills(db: Database.Database): void { + // Safety net: if user_version was bumped externally but column wasn't actually added, + // auto-heal on startup to prevent runtime "no such column" crashes. + if (!hasColumn(db, 'projects', 'task_backend')) { + db.exec(`ALTER TABLE projects ADD COLUMN task_backend TEXT NOT NULL DEFAULT 'db';`) + } + if (!hasColumn(db, 'projects', 'feature_repo_integration_enabled')) { + db.exec(`ALTER TABLE projects ADD COLUMN feature_repo_integration_enabled INTEGER NOT NULL DEFAULT 0;`) + } + if (!hasColumn(db, 'projects', 'feature_repo_features_path')) { + db.exec(`ALTER TABLE projects ADD COLUMN feature_repo_features_path TEXT NOT NULL DEFAULT 'docs/features';`) + } + db.exec(` + CREATE TABLE IF NOT EXISTS project_feature_task_links ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, + feature_id TEXT DEFAULT NULL, + feature_title TEXT NOT NULL DEFAULT '', + feature_file_path TEXT NOT NULL, + content_hash TEXT DEFAULT NULL, + last_sync_source TEXT NOT NULL DEFAULT 'repo', + last_sync_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(project_id, feature_file_path), + UNIQUE(project_id, task_id) + ); + UPDATE project_feature_task_links + SET last_sync_source = COALESCE(NULLIF(last_sync_source, ''), 'repo'), + last_sync_at = COALESCE(last_sync_at, updated_at, datetime('now')); + CREATE INDEX IF NOT EXISTS idx_project_feature_links_project + ON project_feature_task_links(project_id); + CREATE INDEX IF NOT EXISTS idx_project_feature_links_task + ON project_feature_task_links(task_id); + `) + if (!hasColumn(db, 'project_feature_task_links', 'last_sync_source')) { + db.exec(`ALTER TABLE project_feature_task_links ADD COLUMN last_sync_source TEXT NOT NULL DEFAULT 'repo';`) + } + if (!hasColumn(db, 'project_feature_task_links', 'last_sync_at')) { + db.exec(`ALTER TABLE project_feature_task_links ADD COLUMN last_sync_at TEXT DEFAULT NULL;`) + } + db.exec(` + UPDATE project_feature_task_links + SET last_sync_source = COALESCE(NULLIF(last_sync_source, ''), 'repo'), + last_sync_at = COALESCE(last_sync_at, updated_at, datetime('now')); + `) + rebuildTerminalTabsWithoutTaskForeignKey(db) +} + export function runMigrations(db: Database.Database): void { const currentVersion = db.pragma('user_version', { simple: true }) as number @@ -987,4 +1143,6 @@ export function runMigrations(db: Database.Database): void { console.log(`Migration ${migration.version} applied`) } } + + ensureSchemaBackfills(db) } diff --git a/packages/apps/app/src/main/index.ts b/packages/apps/app/src/main/index.ts index 54f06165..d6e94b21 100644 --- a/packages/apps/app/src/main/index.ts +++ b/packages/apps/app/src/main/index.ts @@ -44,10 +44,11 @@ import logoSolid from '../../resources/logo-solid.svg?asset' import { getDatabase, closeDatabase, getDiagnosticsDatabase, closeDiagnosticsDatabase } from './db' // Domain handlers import { registerProjectHandlers } from '@slayzone/projects/main' -import { configureTaskRuntimeAdapters, registerTaskHandlers, registerAiHandlers, registerFilesHandlers } from '@slayzone/task/main' +import { configureTaskRuntimeAdapters, registerTaskHandlers, registerFilesHandlers } from '@slayzone/task/main' import { registerTagHandlers } from '@slayzone/tags/main' import { registerSettingsHandlers, registerThemeHandlers } from '@slayzone/settings/main' import { registerPtyHandlers, registerUsageHandlers, killAllPtys, killPtysByTaskId, startIdleChecker, stopIdleChecker } from '@slayzone/terminal/main' +import { getRepoFeatureSyncConfig, syncAllProjectFeatureTasks } from '@slayzone/projects/main' import { registerTerminalTabsHandlers } from '@slayzone/task-terminals/main' import { registerWorktreeHandlers } from '@slayzone/worktrees/main' import { registerDiagnosticsHandlers, registerProcessDiagnostics, recordDiagnosticEvent, stopDiagnostics } from '@slayzone/diagnostics/main' @@ -63,6 +64,7 @@ import { WEBVIEW_DESKTOP_HANDOFF_SCRIPT } from '../shared/webview-desktop-handof const DEFAULT_WINDOW_WIDTH = 1760 const DEFAULT_WINDOW_HEIGHT = 1280 +const FEATURE_REPO_SYNC_POLL_INTERVAL_MS = 30_000 // Splash screen: self-contained HTML with inline logo SVG and typewriter animation const splashLogoSvg = readFileSync(logoSolid, 'utf-8') @@ -192,6 +194,7 @@ let inlineDevToolsView: BrowserView | null = null let inlineDevToolsViewAttached = false let inlineDeviceToolbarDisableTimers: NodeJS.Timeout[] = [] let linearSyncPoller: NodeJS.Timeout | null = null +let featureRepoSyncPoller: NodeJS.Timeout | null = null let mcpCleanup: (() => void) | null = null let pendingOAuthCallback: { code?: string; error?: string } | null = null let oauthCallbackServer: HttpServer | null = null @@ -808,7 +811,6 @@ app.whenReady().then(async () => { // Register domain handlers (inject ipcMain and db) registerProjectHandlers(ipcMain, db) registerTaskHandlers(ipcMain, db) - registerAiHandlers(ipcMain) registerTagHandlers(ipcMain, db) registerSettingsHandlers(ipcMain, db) registerThemeHandlers(ipcMain, db) @@ -859,6 +861,37 @@ app.whenReady().then(async () => { linearSyncPoller = startLinearSyncPoller(db) logBoot('integration poller started') + const runFeatureRepoSync = (): void => { + try { + const sync = syncAllProjectFeatureTasks(db) + if (sync.created > 0 || sync.updated > 0) { + mainWindow?.webContents.send('tasks:changed') + } + if (sync.errors.length > 0) { + console.warn('[feature-sync] warnings:', sync.errors.join(' | ')) + } + } catch (err) { + console.error('[feature-sync] poll failed:', err) + } + } + const getFeatureRepoSyncIntervalMs = (): number => { + try { + const config = getRepoFeatureSyncConfig(db) + const seconds = Number.isFinite(config.pollIntervalSeconds) ? config.pollIntervalSeconds : 30 + return Math.max(5_000, Math.min(3_600_000, Math.round(seconds * 1_000))) + } catch { + return FEATURE_REPO_SYNC_POLL_INTERVAL_MS + } + } + const scheduleFeatureRepoSync = (): void => { + const nextIntervalMs = getFeatureRepoSyncIntervalMs() + featureRepoSyncPoller = setTimeout(() => { + runFeatureRepoSync() + scheduleFeatureRepoSync() + }, nextIntervalMs) + } + runFeatureRepoSync() + scheduleFeatureRepoSync() initAutoUpdater() logBoot('auto-updater initialized') @@ -1140,6 +1173,9 @@ app.whenReady().then(async () => { ipcMain.handle('app:getVersion', () => app.getVersion()) ipcMain.handle('app:is-context-manager-enabled', () => isContextManagerEnabled) + ipcMain.handle('app:open-project-settings', () => { + emitOpenProjectSettings() + }) ipcMain.handle('app:restart-for-update', () => restartForUpdate()) ipcMain.handle('app:check-for-updates', () => checkForUpdates()) ipcMain.handle('app:cli-status', () => ({ installed: existsSync(CLI_TARGET) })) @@ -1756,6 +1792,10 @@ app.on('will-quit', () => { clearInterval(linearSyncPoller) linearSyncPoller = null } + if (featureRepoSyncPoller) { + clearTimeout(featureRepoSyncPoller) + featureRepoSyncPoller = null + } mcpCleanup?.() stopDiagnostics() stopIdleChecker() diff --git a/packages/apps/app/src/preload/index.ts b/packages/apps/app/src/preload/index.ts index 61b4f961..0a145d6e 100644 --- a/packages/apps/app/src/preload/index.ts +++ b/packages/apps/app/src/preload/index.ts @@ -15,20 +15,28 @@ window.addEventListener('drop', (e) => { // Custom APIs for renderer const api: ElectronAPI = { - ai: { - generateDescription: (title, mode) => ipcRenderer.invoke('ai:generate-description', title, mode) - }, db: { // Projects getProjects: () => ipcRenderer.invoke('db:projects:getAll'), createProject: (data) => ipcRenderer.invoke('db:projects:create', data), updateProject: (data) => ipcRenderer.invoke('db:projects:update', data), + syncProjectFeatures: (projectId) => ipcRenderer.invoke('db:projects:syncFeatures', projectId), + syncAllProjectFeatures: () => ipcRenderer.invoke('db:projects:syncAllFeatures'), + getProjectFeatureSyncConfig: () => ipcRenderer.invoke('db:projects:getFeatureSyncConfig'), + setProjectFeatureSyncConfig: (input) => ipcRenderer.invoke('db:projects:setFeatureSyncConfig', input), deleteProject: (id) => ipcRenderer.invoke('db:projects:delete', id), // Tasks getTasks: () => ipcRenderer.invoke('db:tasks:getAll'), getTasksByProject: (projectId) => ipcRenderer.invoke('db:tasks:getByProject', projectId), getTask: (id) => ipcRenderer.invoke('db:tasks:get', id), + getTaskFeatureContext: (taskId) => ipcRenderer.invoke('db:tasks:getFeatureContext', taskId), + getTaskFeatureDetails: (taskId) => ipcRenderer.invoke('db:tasks:getFeatureDetails', taskId), + syncTaskFeatureFromRepo: (taskId) => ipcRenderer.invoke('db:tasks:syncFeatureFromRepo', taskId), + syncTaskFeatureToRepo: (taskId) => ipcRenderer.invoke('db:tasks:syncFeatureToRepo', taskId), + createTaskFeature: (taskId, input) => ipcRenderer.invoke('db:tasks:createFeature', taskId, input), + deleteTaskFeature: (taskId) => ipcRenderer.invoke('db:tasks:deleteFeature', taskId), + updateTaskFeature: (taskId, input) => ipcRenderer.invoke('db:tasks:updateFeature', taskId, input), getSubTasks: (parentId) => ipcRenderer.invoke('db:tasks:getSubTasks', parentId), createTask: (data) => ipcRenderer.invoke('db:tasks:create', data), updateTask: (data) => ipcRenderer.invoke('db:tasks:update', data), @@ -70,9 +78,9 @@ const api: ElectronAPI = { theme: { getEffective: () => ipcRenderer.invoke('theme:get-effective'), getSource: () => ipcRenderer.invoke('theme:get-source'), - set: (theme: 'light' | 'dark' | 'system') => ipcRenderer.invoke('theme:set', theme), - onChange: (callback: (theme: 'light' | 'dark') => void) => { - const handler = (_event: unknown, theme: 'light' | 'dark') => callback(theme) + set: (theme) => ipcRenderer.invoke('theme:set', theme), + onChange: (callback) => { + const handler = (_: unknown, effective: 'light' | 'dark') => callback(effective) ipcRenderer.on('theme:changed', handler) return () => ipcRenderer.removeListener('theme:changed', handler) } @@ -165,6 +173,7 @@ const api: ElectronAPI = { return () => ipcRenderer.removeListener('app:update-status', handler) }, dataReady: () => ipcRenderer.send('app:data-ready'), + openProjectSettings: () => ipcRenderer.invoke('app:open-project-settings'), restartForUpdate: () => ipcRenderer.invoke('app:restart-for-update'), checkForUpdates: () => ipcRenderer.invoke('app:check-for-updates'), cliStatus: () => ipcRenderer.invoke('app:cli-status'), diff --git a/packages/apps/app/src/renderer/src/App.tsx b/packages/apps/app/src/renderer/src/App.tsx index 7424575c..9c6c5069 100644 --- a/packages/apps/app/src/renderer/src/App.tsx +++ b/packages/apps/app/src/renderer/src/App.tsx @@ -1015,12 +1015,22 @@ function App(): React.JSX.Element { setProjects((prev) => [...prev, project]) setSelectedProjectId(project.id) setCreateProjectOpen(false) + void window.api.db.getTasks().then((allTasks) => { + setTasks(allTasks) + }).catch((err) => { + console.error('[App] Failed to refresh tasks after project create:', err) + }) } const handleProjectUpdated = (project: Project): void => { updateProject(project) setEditingProject(null) validateProjectPath(project) + void window.api.db.getTasks().then((allTasks) => { + setTasks(allTasks) + }).catch((err) => { + console.error('[App] Failed to refresh tasks after project update:', err) + }) } const handleProjectNameSave = async (): Promise => { diff --git a/packages/apps/app/src/renderer/src/components/tabs/TabBar.tsx b/packages/apps/app/src/renderer/src/components/tabs/TabBar.tsx index 49950b80..889b74cb 100644 --- a/packages/apps/app/src/renderer/src/components/tabs/TabBar.tsx +++ b/packages/apps/app/src/renderer/src/components/tabs/TabBar.tsx @@ -59,10 +59,12 @@ function TabContent({ title, isActive, isDragging, onClose, terminalState, isSub return (
)} {isSubTask && SUB} - {title} + {title} {onClose && ( +
+ +
+ Default feature.yaml folder + + setRepoFeatureConfig((prev) => ({ ...prev, defaultFeaturesPath: e.target.value })) + } + onBlur={() => { + void handleRepoFeatureConfigChange({ + defaultFeaturesPath: repoFeatureConfig.defaultFeaturesPath + }) + }} + placeholder="docs/features" + /> +
+ +
+ Polling interval (seconds) + + setRepoFeatureConfig((prev) => ({ + ...prev, + pollIntervalSeconds: Number.parseInt(e.target.value || '30', 10) || 30 + })) + } + onBlur={() => { + void handleRepoFeatureConfigChange({ + pollIntervalSeconds: repoFeatureConfig.pollIntervalSeconds + }) + }} + placeholder="30" + /> +
+ +
+ {repoFeatureProjects.length === 0 ? ( +

No projects found.

+ ) : ( + repoFeatureProjects.map((project) => { + const enabled = project.feature_repo_integration_enabled === 1 + const isSaving = repoFeatureSavingProjectId === project.id + return ( +
+
+
+

{project.name}

+

+ {project.path || 'Repository path not set'} +

+
+ + void handleToggleProjectRepoFeatures(project, checked === true) + } + /> +
+
+ { + const value = e.target.value + setRepoFeatureProjects((prev) => + prev.map((item) => + item.id === project.id + ? { ...item, feature_repo_features_path: value } + : item + ) + ) + }} + onBlur={(e) => void handleProjectFeaturePathSave(project, e.target.value)} + disabled={!enabled || isSaving} + placeholder={repoFeatureConfig.defaultFeaturesPath} + /> + + +
+
+ ) + }) + )} +
+ + {repoFeaturesMessage ? ( +

{repoFeaturesMessage}

+ ) : null} + )} diff --git a/packages/domains/tags/src/main/handlers.test.ts b/packages/domains/tags/src/main/handlers.test.ts index 20f7edcd..6eb53aa5 100644 --- a/packages/domains/tags/src/main/handlers.test.ts +++ b/packages/domains/tags/src/main/handlers.test.ts @@ -83,6 +83,7 @@ describe('db:taskTags:setForTask', () => { const result = h.invoke('db:taskTags:getForTask', taskId) as unknown[] expect(result).toHaveLength(0) }) + }) h.cleanup() diff --git a/packages/domains/tags/src/main/handlers.ts b/packages/domains/tags/src/main/handlers.ts index 0aa96d83..60d06e26 100644 --- a/packages/domains/tags/src/main/handlers.ts +++ b/packages/domains/tags/src/main/handlers.ts @@ -3,6 +3,10 @@ import type { Database } from 'better-sqlite3' import type { CreateTagInput, UpdateTagInput } from '@slayzone/tags/shared' export function registerTagHandlers(ipcMain: IpcMain, db: Database): void { + const taskExistsInDb = (taskId: string): boolean => { + const row = db.prepare('SELECT 1 FROM tasks WHERE id = ?').get(taskId) as { 1: number } | undefined + return Boolean(row) + } // Tags CRUD ipcMain.handle('db:tags:getAll', () => { @@ -47,6 +51,7 @@ export function registerTagHandlers(ipcMain: IpcMain, db: Database): void { // Task-Tag associations ipcMain.handle('db:taskTags:getForTask', (_, taskId: string) => { + if (!taskExistsInDb(taskId)) return [] return db .prepare( `SELECT tags.* FROM tags @@ -67,6 +72,9 @@ export function registerTagHandlers(ipcMain: IpcMain, db: Database): void { }) ipcMain.handle('db:taskTags:setForTask', (_, taskId: string, tagIds: string[]) => { + // Ignore invalid or deleted task ids. + if (!taskExistsInDb(taskId)) return + const deleteStmt = db.prepare('DELETE FROM task_tags WHERE task_id = ?') const insertStmt = db.prepare('INSERT INTO task_tags (task_id, tag_id) VALUES (?, ?)') diff --git a/packages/domains/task-terminals/src/main/handlers.test.ts b/packages/domains/task-terminals/src/main/handlers.test.ts index 2eb31491..0103941e 100644 --- a/packages/domains/task-terminals/src/main/handlers.test.ts +++ b/packages/domains/task-terminals/src/main/handlers.test.ts @@ -33,6 +33,7 @@ describe('tabs:ensureMain', () => { const tab = h.invoke('tabs:ensureMain', taskId, 'codex') as { mode: string } expect(tab.mode).toBe('codex') }) + }) describe('tabs:list', () => { diff --git a/packages/domains/task/DOMAIN.md b/packages/domains/task/DOMAIN.md index c43f522d..37c9d127 100644 --- a/packages/domains/task/DOMAIN.md +++ b/packages/domains/task/DOMAIN.md @@ -1,6 +1,6 @@ # Task Domain -Task CRUD operations, detail view, and AI-powered description generation. +Task CRUD operations and detail view. ## Contracts (shared/) @@ -24,7 +24,6 @@ Also exports validation schemas (`createTaskSchema`, `updateTaskSchema`) and for ## Main Process (main/) - `registerTaskHandlers(ipcMain, db)` - Task CRUD, archive, reorder -- `registerAiHandlers(ipcMain)` - AI description generation - `registerFilesHandlers(ipcMain)` - Temp image saving for AI ## Client (client/) diff --git a/packages/domains/task/src/client/CreateTaskDialog.tsx b/packages/domains/task/src/client/CreateTaskDialog.tsx index efe3a1bb..3a109b0e 100644 --- a/packages/domains/task/src/client/CreateTaskDialog.tsx +++ b/packages/domains/task/src/client/CreateTaskDialog.tsx @@ -322,7 +322,10 @@ export function CreateTaskDialog({ -