From 1b6f7be767ff2e0b3a6703f3dd3a9b25f1e6b8a5 Mon Sep 17 00:00:00 2001 From: iliya Date: Mon, 23 Feb 2026 19:45:03 +0200 Subject: [PATCH] feat: alot, code highlight, related tasks, group by project and other --- package.json | 1 + pnpm-lock.yaml | 54 ++++++ src/main/index.ts | 96 ++++------- src/main/ipc/handlers.ts | 18 ++ src/main/ipc/httpServer.ts | 103 ++++++++++++ src/main/ipc/teams.ts | 138 +++++++++------ src/main/services/team/TeamDataService.ts | 19 ++- src/main/services/team/TeamMemberResolver.ts | 9 +- .../services/team/TeamProvisioningService.ts | 105 ++++++++++-- src/main/services/team/TeamTaskReader.ts | 3 + src/main/services/team/TeamTaskWriter.ts | 1 + .../components/chat/CompactBoundary.tsx | 7 +- .../components/chat/DisplayItemList.tsx | 2 +- .../components/chat/LastOutputDisplay.tsx | 13 +- .../components/chat/UserChatGroup.tsx | 12 +- .../components/chat/items/BaseItem.tsx | 2 +- .../components/chat/markdownComponents.tsx | 7 +- .../chat/viewers/MarkdownViewer.tsx | 24 ++- .../components/common/UpdateDialog.tsx | 7 +- src/renderer/components/layout/Sidebar.tsx | 7 +- .../components/sidebar/GlobalTaskList.tsx | 158 ++++++++++++++---- .../components/sidebar/SidebarTaskItem.tsx | 9 +- .../components/sidebar/TaskFiltersPopover.tsx | 60 +------ .../components/sidebar/taskFiltersState.ts | 60 +++++++ .../components/team/TeamDetailView.tsx | 24 ++- .../team/activity/ActiveTasksBlock.tsx | 6 +- .../components/team/activity/ActivityItem.tsx | 24 ++- .../team/activity/ActivityTimeline.tsx | 12 ++ .../team/dialogs/CreateTaskDialog.tsx | 55 ++++++ .../team/dialogs/LaunchTeamDialog.tsx | 23 ++- .../team/dialogs/TaskCommentsSection.tsx | 28 ++-- .../team/dialogs/TaskDetailDialog.tsx | 150 ++++++++++++----- .../components/team/kanban/KanbanBoard.tsx | 60 ++++++- .../components/team/kanban/KanbanColumn.tsx | 29 +++- .../components/team/members/MemberCard.tsx | 94 ++++++----- .../team/members/MemberDetailDialog.tsx | 20 ++- .../team/members/MemberDetailHeader.tsx | 14 +- .../team/members/MemberExecutionLog.tsx | 12 +- .../components/team/members/MemberList.tsx | 9 +- .../components/team/members/MemberLogsTab.tsx | 4 +- .../team/members/MemberTasksTab.tsx | 24 ++- .../components/team/tasks/TaskList.tsx | 4 +- .../components/team/tasks/TaskRow.tsx | 8 +- src/renderer/components/ui/combobox.tsx | 10 +- src/renderer/hooks/useMentionDetection.ts | 4 +- src/renderer/index.css | 58 +++++++ src/renderer/services/draftStorage.ts | 98 ++++++++--- src/renderer/store/slices/teamSlice.ts | 22 ++- src/renderer/utils/markdownPlugins.ts | 8 + src/renderer/utils/memberHelpers.ts | 31 +++- src/renderer/utils/taskGrouping.ts | 68 ++++++++ src/shared/types/team.ts | 18 +- .../services/team/TeamDataService.test.ts | 41 +++++ 53 files changed, 1417 insertions(+), 456 deletions(-) create mode 100644 src/main/ipc/httpServer.ts create mode 100644 src/renderer/components/sidebar/taskFiltersState.ts create mode 100644 src/renderer/utils/markdownPlugins.ts diff --git a/package.json b/package.json index b2e39cf9..0e0264f2 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "ssh-config": "^5.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a438d88..49553aef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@18.3.27)(react@18.3.1) + rehype-highlight: + specifier: ^7.0.2 + version: 7.0.2 remark-gfm: specifier: ^4.0.1 version: 4.0.1 @@ -3158,9 +3161,15 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + hast-util-to-jsx-runtime@2.3.6: resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} @@ -3170,6 +3179,10 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + hosted-git-info@4.1.0: resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} engines: {node: '>=10'} @@ -3632,6 +3645,9 @@ packages: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -4418,6 +4434,9 @@ packages: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} + rehype-highlight@7.0.2: + resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + remark-gfm@4.0.1: resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} @@ -4997,6 +5016,9 @@ packages: resolution: {integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + unist-util-is@6.0.1: resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} @@ -8569,6 +8591,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-jsx-runtime@2.3.6: dependencies: '@types/estree': 1.0.8 @@ -8589,6 +8615,13 @@ snapshots: transitivePeerDependencies: - supports-color + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + hast-util-whitespace@3.0.0: dependencies: '@types/hast': 3.0.4 @@ -8599,6 +8632,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + highlight.js@11.11.1: {} + hosted-git-info@4.1.0: dependencies: lru-cache: 6.0.0 @@ -9068,6 +9103,12 @@ snapshots: lowercase-keys@2.0.0: {} + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + lru-cache@10.4.3: {} lru-cache@11.2.6: {} @@ -10056,6 +10097,14 @@ snapshots: gopd: 1.2.0 set-function-name: 2.0.2 + rehype-highlight@7.0.2: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text: 4.0.2 + lowlight: 3.3.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + remark-gfm@4.0.1: dependencies: '@types/mdast': 4.0.4 @@ -10772,6 +10821,11 @@ snapshots: dependencies: imurmurhash: 0.1.4 + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-is@6.0.1: dependencies: '@types/unist': 3.0.3 diff --git a/src/main/index.ts b/src/main/index.ts index 9aa6e4c2..2640f5ed 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,12 +17,34 @@ import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, } from '@shared/constants'; import { createLogger } from '@shared/utils/logger'; -import { app, BrowserWindow, ipcMain } from 'electron'; +import { app, BrowserWindow } from 'electron'; import { existsSync } from 'fs'; import { join } from 'path'; +const CONTEXT_CHANGED = 'context:changed'; +const SSH_STATUS = 'ssh:status'; +const TEAM_CHANGE = 'team:change'; +const WINDOW_FULLSCREEN_CHANGED = 'window:fullscreen-changed'; + import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; +import { HttpServer } from './services/infrastructure/HttpServer'; import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder'; +import { + configManager, + LocalFileSystemProvider, + MemberStatsComputer, + NotificationManager, + ServiceContext, + ServiceContextRegistry, + SshConnectionManager, + TeamAgentToolsInstaller, + TeamDataService, + TeamMemberLogsFinder, + TeamProvisioningService, + UpdaterService, +} from './services'; + +const logger = createLogger('App'); // Window icon path for non-mac platforms. const getWindowIconPath = (): string | undefined => { @@ -42,16 +64,6 @@ const getWindowIconPath = (): string | undefined => { return undefined; }; -const logger = createLogger('App'); -// IPC channel constants (duplicated from @preload to avoid boundary violation) -const SSH_STATUS = 'ssh:status'; -const CONTEXT_CHANGED = 'context:changed'; -const WINDOW_FULLSCREEN_CHANGED = 'window:fullscreen-changed'; -const HTTP_SERVER_START = 'httpServer:start'; -const HTTP_SERVER_STOP = 'httpServer:stop'; -const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus'; -const TEAM_CHANGE = 'team:change'; - process.on('unhandledRejection', (reason) => { logger.error('Unhandled promise rejection in main process:', reason); }); @@ -60,22 +72,6 @@ process.on('uncaughtException', (error) => { logger.error('Uncaught exception in main process:', error); }); -import { HttpServer } from './services/infrastructure/HttpServer'; -import { - configManager, - LocalFileSystemProvider, - MemberStatsComputer, - NotificationManager, - ServiceContext, - ServiceContextRegistry, - SshConnectionManager, - TeamAgentToolsInstaller, - TeamDataService, - TeamMemberLogsFinder, - TeamProvisioningService, - UpdaterService, -} from './services'; - // ============================================================================= // Application State // ============================================================================= @@ -334,47 +330,13 @@ function initializeServices(): void { onClaudeRootPathUpdated: (_claudeRootPath: string | null) => { reconfigureLocalContextForClaudeRoot(); }, + }, + { + httpServer, + startHttpServer: () => startHttpServer(handleModeSwitch), } ); - // HTTP Server control IPC handlers - ipcMain.handle(HTTP_SERVER_START, async () => { - try { - if (httpServer.isRunning()) { - return { success: true, data: { running: true, port: httpServer.getPort() } }; - } - await startHttpServer(handleModeSwitch); - // Persist the enabled state - configManager.updateConfig('httpServer', { enabled: true, port: httpServer.getPort() }); - return { success: true, data: { running: true, port: httpServer.getPort() } }; - } catch (error) { - logger.error('Failed to start HTTP server via IPC:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to start server', - }; - } - }); - - ipcMain.handle(HTTP_SERVER_STOP, async () => { - try { - await httpServer.stop(); - // Persist the disabled state - configManager.updateConfig('httpServer', { enabled: false }); - return { success: true, data: { running: false, port: httpServer.getPort() } }; - } catch (error) { - logger.error('Failed to stop HTTP server via IPC:', error); - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to stop server', - }; - } - }); - - ipcMain.handle(HTTP_SERVER_GET_STATUS, () => { - return { success: true, data: { running: httpServer.isRunning(), port: httpServer.getPort() } }; - }); - // Forward SSH state changes to renderer and HTTP SSE clients sshConnectionManager.on('state-change', (status: unknown) => { if (mainWindow && !mainWindow.isDestroyed()) { @@ -451,6 +413,10 @@ function shutdownServices(): void { todoChangeCleanup(); todoChangeCleanup = null; } + if (teamChangeCleanup) { + teamChangeCleanup(); + teamChangeCleanup = null; + } // Dispose all contexts (including local) if (contextRegistry) { diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index f76fd536..6e1c7e36 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -11,6 +11,7 @@ * - notifications.ts: Notification management * - config.ts: App configuration * - ssh.ts: SSH connection management + * - httpServer.ts: HTTP sidecar server control */ import { createLogger } from '@shared/utils/logger'; @@ -22,6 +23,11 @@ import { registerContextHandlers, removeContextHandlers, } from './context'; +import { + initializeHttpServerHandlers, + registerHttpServerHandlers, + removeHttpServerHandlers, +} from './httpServer'; const logger = createLogger('IPC:handlers'); import { registerNotificationHandlers, removeNotificationHandlers } from './notifications'; @@ -62,6 +68,7 @@ import type { TeamProvisioningService, UpdaterService, } from '../services'; +import type { HttpServer } from '../services/infrastructure/HttpServer'; /** * Initializes IPC handlers with service registry. @@ -78,6 +85,10 @@ export function initializeIpcHandlers( rewire: (context: ServiceContext) => void; full: (context: ServiceContext) => void; onClaudeRootPathUpdated: (claudeRootPath: string | null) => Promise | void; + }, + httpServerDeps?: { + httpServer: HttpServer; + startHttpServer: () => Promise; } ): void { // Initialize domain handlers with registry @@ -97,6 +108,9 @@ export function initializeIpcHandlers( initializeConfigHandlers({ onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated, }); + if (httpServerDeps) { + initializeHttpServerHandlers(httpServerDeps.httpServer, httpServerDeps.startHttpServer); + } // Register all handlers registerProjectHandlers(ipcMain); @@ -112,6 +126,9 @@ export function initializeIpcHandlers( registerContextHandlers(ipcMain); registerTeamHandlers(ipcMain); registerWindowHandlers(ipcMain); + if (httpServerDeps) { + registerHttpServerHandlers(ipcMain); + } logger.info('All handlers registered'); } @@ -134,6 +151,7 @@ export function removeIpcHandlers(): void { removeContextHandlers(ipcMain); removeTeamHandlers(ipcMain); removeWindowHandlers(ipcMain); + removeHttpServerHandlers(ipcMain); logger.info('All handlers removed'); } diff --git a/src/main/ipc/httpServer.ts b/src/main/ipc/httpServer.ts new file mode 100644 index 00000000..28ba0927 --- /dev/null +++ b/src/main/ipc/httpServer.ts @@ -0,0 +1,103 @@ +/** + * IPC Handlers for HTTP Server Operations. + * + * Handlers: + * - httpServer:start: Start the HTTP sidecar server + * - httpServer:stop: Stop the HTTP sidecar server + * - httpServer:getStatus: Get HTTP server running status and port + */ + +import { createLogger } from '@shared/utils/logger'; +import { type IpcMain } from 'electron'; + +import { configManager } from '../services'; + +import type { HttpServer } from '../services/infrastructure/HttpServer'; + +const logger = createLogger('IPC:httpServer'); + +let httpServer: HttpServer; +let startServer: () => Promise; + +/** + * Initializes HTTP server handlers with service instances. + */ +export function initializeHttpServerHandlers( + server: HttpServer, + startHttpServer: () => Promise +): void { + httpServer = server; + startServer = startHttpServer; +} + +/** + * Registers all HTTP server IPC handlers. + */ +export function registerHttpServerHandlers(ipcMain: IpcMain): void { + ipcMain.handle('httpServer:start', handleStart); + ipcMain.handle('httpServer:stop', handleStop); + ipcMain.handle('httpServer:getStatus', handleGetStatus); + + logger.info('HTTP server handlers registered'); +} + +/** + * Removes all HTTP server IPC handlers. + */ +export function removeHttpServerHandlers(ipcMain: IpcMain): void { + ipcMain.removeHandler('httpServer:start'); + ipcMain.removeHandler('httpServer:stop'); + ipcMain.removeHandler('httpServer:getStatus'); + + logger.info('HTTP server handlers removed'); +} + +// ============================================================================= +// Handler Implementations +// ============================================================================= + +async function handleStart(): Promise<{ + success: boolean; + data?: { running: boolean; port: number | null }; + error?: string; +}> { + try { + if (httpServer.isRunning()) { + return { success: true, data: { running: true, port: httpServer.getPort() } }; + } + await startServer(); + configManager.updateConfig('httpServer', { enabled: true, port: httpServer.getPort() }); + return { success: true, data: { running: true, port: httpServer.getPort() } }; + } catch (error) { + logger.error('Failed to start HTTP server via IPC:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to start server', + }; + } +} + +async function handleStop(): Promise<{ + success: boolean; + data?: { running: boolean; port: number | null }; + error?: string; +}> { + try { + await httpServer.stop(); + configManager.updateConfig('httpServer', { enabled: false }); + return { success: true, data: { running: false, port: httpServer.getPort() } }; + } catch (error) { + logger.error('Failed to stop HTTP server via IPC:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to stop server', + }; + } +} + +function handleGetStatus(): { + success: boolean; + data: { running: boolean; port: number | null }; +} { + return { success: true, data: { running: httpServer.isRunning(), port: httpServer.getPort() } }; +} diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 62218c45..7e693e5b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -181,60 +181,70 @@ async function handleGetData( if (!validated.valid) { return { success: false, error: validated.error ?? 'Invalid teamName' }; } - return wrapTeamHandler('getData', async () => { - const tn = validated.value!; - const data = await getTeamDataService().getTeamData(tn); - const provisioning = getTeamProvisioningService(); - const isAlive = provisioning.isTeamAlive(tn); - - if (isAlive) { - // Fire-and-forget: relay can take time (waits for lead reply). - void provisioning.relayLeadInboxMessages(tn).catch(() => undefined); + const tn = validated.value!; + let data: TeamData; + try { + data = await getTeamDataService().getTeamData(tn); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if ( + message === `Team not found: ${tn}` && + getTeamProvisioningService().hasProvisioningRun(tn) + ) { + return { success: false, error: 'TEAM_PROVISIONING' }; } + logger.error(`[teams:getData] ${message}`); + return { success: false, error: message }; + } + const provisioning = getTeamProvisioningService(); + const isAlive = provisioning.isTeamAlive(tn); - const live = provisioning.getLiveLeadProcessMessages(tn); - if (live.length === 0) { - return { ...data, isAlive }; + if (isAlive) { + void provisioning.relayLeadInboxMessages(tn).catch(() => undefined); + } + + const live = provisioning.getLiveLeadProcessMessages(tn); + if (live.length === 0) { + return { success: true, data: { ...data, isAlive } }; + } + + const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); + const leadSessionTextFingerprints = new Set(); + for (const msg of data.messages) { + if ((msg as { source?: unknown }).source !== 'lead_session') continue; + if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue; + leadSessionTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`); + } + + const keyFor = (m: { + messageId?: string; + timestamp: string; + from: string; + text: string; + }): string => { + if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) { + return m.messageId; } + return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`; + }; - const normalizeText = (text: string): string => text.trim().replace(/\r\n/g, '\n'); - const leadSessionTextFingerprints = new Set(); - for (const msg of data.messages) { - if ((msg as { source?: unknown }).source !== 'lead_session') continue; - if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue; - leadSessionTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`); - } - - const keyFor = (m: { - messageId?: string; - timestamp: string; - from: string; - text: string; - }): string => { - if (typeof m.messageId === 'string' && m.messageId.trim().length > 0) { - return m.messageId; + const merged: typeof data.messages = []; + const seen = new Set(); + for (const msg of [...data.messages, ...live]) { + if ((msg as { source?: unknown }).source === 'lead_process') { + const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`; + if (leadSessionTextFingerprints.has(fp)) { + continue; } - return `${m.timestamp}\0${m.from}\0${(m.text ?? '').slice(0, 80)}`; - }; - - const merged: typeof data.messages = []; - const seen = new Set(); - for (const msg of [...data.messages, ...live]) { - if ((msg as { source?: unknown }).source === 'lead_process') { - const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`; - if (leadSessionTextFingerprints.has(fp)) { - continue; - } - } - const key = keyFor(msg); - if (seen.has(key)) continue; - seen.add(key); - merged.push(msg); } - merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); + const key = keyFor(msg); + if (seen.has(key)) continue; + seen.add(key); + merged.push(msg); + } + merged.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)); - return { ...data, isAlive, messages: merged }; - }); + return { success: true, data: { ...data, isAlive, messages: merged } }; } async function handleDeleteTeam( @@ -548,14 +558,28 @@ async function handleSendMessage( } } - return wrapTeamHandler('sendMessage', () => - getTeamDataService().sendMessage(validatedTeamName.value!, { + return wrapTeamHandler('sendMessage', async () => { + const tn = validatedTeamName.value!; + const result = await getTeamDataService().sendMessage(tn, { member: validatedMember.value!, text: payload.text!, summary: payload.summary, from: payload.from, - }) - ); + }); + + // Best-effort: if messaging the lead while process is alive, relay immediately (no UI dependency). + try { + const provisioning = getTeamProvisioningService(); + if (provisioning.isTeamAlive(tn)) { + // Avoid reading unrelated inboxes; relayLeadInboxMessages will no-op when nothing new exists. + void provisioning.relayLeadInboxMessages(tn).catch(() => undefined); + } + } catch { + // ignore + } + + return result; + }); } async function handleCreateTask( @@ -596,6 +620,17 @@ async function handleCreateTask( return { success: false, error: 'blockedBy must be an array of task ID strings' }; } } + if (payload.related !== undefined) { + if (!Array.isArray(payload.related) || payload.related.some((id) => typeof id !== 'string')) { + return { success: false, error: 'related must be an array of task ID strings' }; + } + for (const id of payload.related) { + const validated = validateTaskId(id); + if (!validated.valid) { + return { success: false, error: validated.error ?? 'Invalid related task id' }; + } + } + } if (payload.prompt !== undefined) { if (typeof payload.prompt !== 'string') { return { success: false, error: 'prompt must be a string' }; @@ -614,6 +649,7 @@ async function handleCreateTask( description: payload.description?.trim(), owner: payload.owner?.trim() || undefined, blockedBy: payload.blockedBy, + related: payload.related, prompt: payload.prompt?.trim() || undefined, startImmediately: payload.startImmediately, }) diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index ade6030a..76a02e5d 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -38,6 +38,7 @@ import type { TeamSummary, TeamTask, TeamTaskStatus, + TeamTaskWithKanban, UpdateKanbanPatch, } from '@shared/types'; @@ -196,11 +197,17 @@ export class TeamDataService { } } + const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) => { + const col = kanbanState.tasks[task.id]?.column; + const kanbanColumn = col === 'review' || col === 'approved' ? col : undefined; + return { ...task, kanbanColumn }; + }); + const members = this.memberResolver.resolveMembers( config, metaMembers, inboxNames, - tasks, + tasksWithKanban, messages ); @@ -217,10 +224,16 @@ export class TeamDataService { } } + const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) => { + const col = kanbanState.tasks[task.id]?.column; + const kanbanColumn = col === 'review' || col === 'approved' ? col : undefined; + return { ...task, kanbanColumn }; + }); + return { teamName, config, - tasks, + tasks: tasksToReturn, members, messages, kanbanState, @@ -232,6 +245,7 @@ export class TeamDataService { const nextId = await this.taskReader.getNextTaskId(teamName); const blockedBy = request.blockedBy?.filter((id) => id.length > 0) ?? []; + const related = request.related?.filter((id) => id.length > 0 && id !== nextId) ?? []; let description = request.description ? `${request.subject}\n\n${request.description}` @@ -262,6 +276,7 @@ export class TeamDataService { status: shouldStart ? 'in_progress' : 'pending', blocks: [], blockedBy, + related: related.length > 0 ? related : undefined, projectPath, }; diff --git a/src/main/services/team/TeamMemberResolver.ts b/src/main/services/team/TeamMemberResolver.ts index b692f2ab..46821f7f 100644 --- a/src/main/services/team/TeamMemberResolver.ts +++ b/src/main/services/team/TeamMemberResolver.ts @@ -3,7 +3,7 @@ import type { MemberStatus, ResolvedTeamMember, TeamConfig, - TeamTask, + TeamTaskWithKanban, } from '@shared/types'; export class TeamMemberResolver { @@ -11,7 +11,7 @@ export class TeamMemberResolver { config: TeamConfig, metaMembers: TeamConfig['members'], inboxNames: string[], - tasks: TeamTask[], + tasks: TeamTaskWithKanban[], messages: InboxMessage[] ): ResolvedTeamMember[] { const names = new Set(); @@ -70,7 +70,10 @@ export class TeamMemberResolver { const members: ResolvedTeamMember[] = []; for (const name of names) { const ownedTasks = tasks.filter((task) => task.owner === name); - const currentTask = ownedTasks.find((task) => task.status === 'in_progress') ?? null; + const currentTask = + ownedTasks.find( + (task) => task.status === 'in_progress' && task.kanbanColumn !== 'approved' + ) ?? null; const memberMessages = messages.filter((message) => message.from === name); const latestMessage = memberMessages[0] ?? null; const status = this.resolveStatus(latestMessage); diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 3f53f634..a3847323 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -113,8 +113,11 @@ interface ProvisioningRun { leadName: string; startedAt: string; textParts: string[]; - resolve: (text: string) => void; - reject: (error: string) => void; + settled: boolean; + idleHandle: NodeJS.Timeout | null; + idleMs: number; + resolveOnce: (text: string) => void; + rejectOnce: (error: string) => void; timeoutHandle: NodeJS.Timeout; } | null; } @@ -266,6 +269,13 @@ function buildTaskStatusProtocol(teamName: string): string { node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment --text "" --from "" Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task. 8. When sending a message about a specific task, include # in your SendMessage summary field for traceability. +9. Review workflow clarity (IMPORTANT): + - The work task (e.g. #1) is the thing that must end up APPROVED after review. + - If you are reviewing work for task #X, run review approve/request-changes on #X (the work task). + - Do NOT approve a separate "review task" (e.g. #2 created just to ask for a review) — that will put the wrong task into APPROVED. + - Typical flow: + a) Owner finishes work on #X → task complete #X + b) Reviewer accepts → review approve #X Failure to follow this protocol means the task board will show incorrect status.`; } @@ -974,6 +984,8 @@ export class TeamProvisioningService { `[${request.teamName}] Launching with --resume ${previousSessionId} for session continuity` ); } + // New sessions: CLI creates its own ID. No --resume with synthetic name — docs say + // --resume is for existing sessions and may show an interactive picker if not found. try { child = spawn(claudePath, launchArgs, { @@ -1225,18 +1237,43 @@ export class TeamProvisioningService { }), ].join('\n'); - const captureTimeoutMs = 60_000; + const captureTimeoutMs = 15_000; + const captureIdleMs = 800; const capturePromise = new Promise((resolve, reject) => { const timeoutHandle = setTimeout(() => { reject(new Error('Timed out waiting for lead reply')); }, captureTimeoutMs); - run.leadRelayCapture = { + const capture = { leadName, startedAt: nowIso(), - textParts: [], - resolve, - reject, + textParts: [] as string[], + settled: false, + idleHandle: null as NodeJS.Timeout | null, + idleMs: captureIdleMs, timeoutHandle, + resolveOnce: (text: string) => { + if (capture.settled) return; + capture.settled = true; + if (capture.idleHandle) { + clearTimeout(capture.idleHandle); + capture.idleHandle = null; + } + clearTimeout(capture.timeoutHandle); + resolve(text); + }, + rejectOnce: (error: string) => { + if (capture.settled) return; + capture.settled = true; + if (capture.idleHandle) { + clearTimeout(capture.idleHandle); + capture.idleHandle = null; + } + clearTimeout(capture.timeoutHandle); + reject(new Error(error)); + }, + }; + run.leadRelayCapture = { + ...capture, }; }); @@ -1270,9 +1307,15 @@ export class TeamProvisioningService { try { replyText = (await capturePromise).trim() || null; } catch { - // ignore + // Best-effort: if we captured some text but never got result.success, keep it. + const partial = run.leadRelayCapture?.textParts?.join('')?.trim(); + replyText = partial && partial.length > 0 ? partial : null; } finally { if (run.leadRelayCapture) { + if (run.leadRelayCapture.idleHandle) { + clearTimeout(run.leadRelayCapture.idleHandle); + run.leadRelayCapture.idleHandle = null; + } clearTimeout(run.leadRelayCapture.timeoutHandle); run.leadRelayCapture = null; } @@ -1309,6 +1352,13 @@ export class TeamProvisioningService { } } + /** + * Check if a team has an active provisioning run (started but not yet finished). + */ + hasProvisioningRun(teamName: string): boolean { + return this.activeByTeam.has(teamName); + } + /** * Check if a team has a live process. */ @@ -1436,27 +1486,54 @@ export class TeamProvisioningService { // stream-json output has various message types: // {"type":"assistant","content":[{"type":"text","text":"..."},...]} // {"type":"result","subtype":"success",...} - if (msg.type === 'assistant' && Array.isArray(msg.content)) { - const textParts = (msg.content as Record[]) + if (msg.type === 'assistant') { + const content = Array.isArray(msg.content) + ? (msg.content as Record[]) + : (() => { + const message = msg.message; + if (!message || typeof message !== 'object') return null; + const inner = (message as Record).content; + return Array.isArray(inner) ? (inner as Record[]) : null; + })(); + + const textParts = (content ?? []) .filter((part) => part.type === 'text' && typeof part.text === 'string') .map((part) => part.text as string); if (textParts.length > 0) { const text = textParts.join(''); logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`); if (run.leadRelayCapture) { - run.leadRelayCapture.textParts.push(text); + const capture = run.leadRelayCapture; + if (!capture.settled) { + capture.textParts.push(text); + if (capture.idleHandle) { + clearTimeout(capture.idleHandle); + } + capture.idleHandle = setTimeout(() => { + const combined = capture.textParts.join('').trim(); + capture.resolveOnce(combined); + }, capture.idleMs); + } } } } if (msg.type === 'result') { - const subtype = msg.subtype as string | undefined; + const subtype = + typeof msg.subtype === 'string' + ? msg.subtype + : (() => { + const result = msg.result; + if (!result || typeof result !== 'object') return undefined; + const inner = (result as Record).subtype; + return typeof inner === 'string' ? inner : undefined; + })(); if (subtype === 'success') { logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`); if (run.leadRelayCapture) { const capture = run.leadRelayCapture; const combined = capture.textParts.join('').trim(); - capture.resolve(combined); + capture.resolveOnce(combined); } if (!run.provisioningComplete) { void this.handleProvisioningTurnComplete(run); @@ -1466,7 +1543,7 @@ export class TeamProvisioningService { typeof msg.error === 'string' ? msg.error : JSON.stringify(msg.error ?? 'unknown'); logger.warn(`[${run.teamName}] stream-json result: error — ${errorMsg}`); if (run.leadRelayCapture) { - run.leadRelayCapture.reject(errorMsg); + run.leadRelayCapture.rejectOnce(errorMsg); } if (!run.provisioningComplete) { const progress = updateProgress( diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 44761ca8..0b4de97e 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -105,6 +105,9 @@ export class TeamTaskReader { : 'pending', blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined, blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined, + related: Array.isArray(parsed.related) + ? (parsed.related as unknown[]).filter((id): id is string => typeof id === 'string') + : undefined, createdAt, projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined, comments: Array.isArray(parsed.comments) diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index e8b5a94c..c5b7f5d3 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -51,6 +51,7 @@ export class TeamTaskWriter { description: task.description ?? '', blocks: task.blocks ?? [], blockedBy: task.blockedBy ?? [], + related: task.related ?? [], createdAt: task.createdAt ?? new Date().toISOString(), }; diff --git a/src/renderer/components/chat/CompactBoundary.tsx b/src/renderer/components/chat/CompactBoundary.tsx index e5548d41..20617195 100644 --- a/src/renderer/components/chat/CompactBoundary.tsx +++ b/src/renderer/components/chat/CompactBoundary.tsx @@ -10,6 +10,7 @@ import { TOOL_CALL_BORDER, TOOL_CALL_TEXT, } from '@renderer/constants/cssVariables'; +import { rehypePlugins } from '@renderer/utils/markdownPlugins'; import { formatTokensCompact as formatTokens } from '@shared/utils/tokenFormatting'; import { format } from 'date-fns'; import { ChevronRight, Layers } from 'lucide-react'; @@ -146,7 +147,11 @@ export const CompactBoundary = ({ style={{ borderColor: 'var(--chat-ai-border)' }} > {compactContent ? ( - + {compactContent} ) : ( diff --git a/src/renderer/components/chat/DisplayItemList.tsx b/src/renderer/components/chat/DisplayItemList.tsx index 8beec61f..1fd2db8e 100644 --- a/src/renderer/components/chat/DisplayItemList.tsx +++ b/src/renderer/components/chat/DisplayItemList.tsx @@ -96,7 +96,7 @@ export const DisplayItemList = ({ } return ( -
+
{items.map((item, index) => { let itemKey = ''; let element: React.ReactNode = null; diff --git a/src/renderer/components/chat/LastOutputDisplay.tsx b/src/renderer/components/chat/LastOutputDisplay.tsx index 16d47d7a..ede3dcbc 100644 --- a/src/renderer/components/chat/LastOutputDisplay.tsx +++ b/src/renderer/components/chat/LastOutputDisplay.tsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactMarkdown from 'react-markdown'; import { useStore } from '@renderer/store'; +import { rehypePlugins } from '@renderer/utils/markdownPlugins'; import { AlertTriangle, CheckCircle, FileCheck, XCircle } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; @@ -88,7 +89,11 @@ export const LastOutputDisplay = ({ {/* Content - scrollable */}
- + {textContent}
@@ -231,7 +236,11 @@ export const LastOutputDisplay = ({ {/* Plan content - scrollable */}
- + {planContent}
diff --git a/src/renderer/components/chat/UserChatGroup.tsx b/src/renderer/components/chat/UserChatGroup.tsx index 719cc7cb..4a16841e 100644 --- a/src/renderer/components/chat/UserChatGroup.tsx +++ b/src/renderer/components/chat/UserChatGroup.tsx @@ -4,6 +4,7 @@ import ReactMarkdown, { type Components } from 'react-markdown'; import { api } from '@renderer/api'; import { useTabUI } from '@renderer/hooks/useTabUI'; import { useStore } from '@renderer/store'; +import { rehypePlugins } from '@renderer/utils/markdownPlugins'; import { createLogger } from '@shared/utils/logger'; import { format } from 'date-fns'; import { User } from 'lucide-react'; @@ -204,7 +205,10 @@ function createUserMarkdownComponents( if (isBlock) { return ( - + {hl(children)} ); @@ -442,7 +446,11 @@ const UserChatGroupInner = ({ userGroup }: Readonly): React.
- + {displayText}
diff --git a/src/renderer/components/chat/items/BaseItem.tsx b/src/renderer/components/chat/items/BaseItem.tsx index 3f160ce4..66e772f7 100644 --- a/src/renderer/components/chat/items/BaseItem.tsx +++ b/src/renderer/components/chat/items/BaseItem.tsx @@ -181,7 +181,7 @@ export const BaseItem: React.FC = ({ {/* Expanded Content */} {isExpanded && children && (
{children} diff --git a/src/renderer/components/chat/markdownComponents.tsx b/src/renderer/components/chat/markdownComponents.tsx index 5ab54e9d..a78f969f 100644 --- a/src/renderer/components/chat/markdownComponents.tsx +++ b/src/renderer/components/chat/markdownComponents.tsx @@ -110,7 +110,7 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo ), - // Inline code vs block code + // Inline code vs block code (block is highlighted by rehype-highlight; preserve hljs class) code: ({ className, children }) => { const hasLanguageClass = className?.includes('language-'); const content = typeof children === 'string' ? children : ''; @@ -119,7 +119,10 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo if (isBlock) { return ( - + {hl(children)} ); diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 670b4050..f8730c96 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -23,6 +23,7 @@ import { PROSE_TABLE_HEADER_BG, } from '@renderer/constants/cssVariables'; import { useStore } from '@renderer/store'; +import { rehypePlugins } from '@renderer/utils/markdownPlugins'; import { FileText } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; @@ -137,7 +138,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon ), - // Code: inline vs block detection + // Code: inline vs block detection (block code is highlighted by rehype-highlight; preserve hljs class) code: (props) => { const { className: codeClassName, @@ -155,7 +156,10 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon if (isBlock) { return ( - + {hl(children)} ); @@ -163,7 +167,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon // Inline code — no hl(); parent block element's hl() descends here return ( (
 = ({
 
   return (
     
= ({ )} {/* Markdown content with scroll */} -
-
- +
+
+ {content}
diff --git a/src/renderer/components/common/UpdateDialog.tsx b/src/renderer/components/common/UpdateDialog.tsx index e63121e1..be83c21d 100644 --- a/src/renderer/components/common/UpdateDialog.tsx +++ b/src/renderer/components/common/UpdateDialog.tsx @@ -10,6 +10,7 @@ import ReactMarkdown from 'react-markdown'; import { markdownComponents } from '@renderer/components/chat/markdownComponents'; import { useStore } from '@renderer/store'; +import { rehypePlugins } from '@renderer/utils/markdownPlugins'; import { X } from 'lucide-react'; import remarkGfm from 'remark-gfm'; @@ -150,7 +151,11 @@ export const UpdateDialog = (): React.JSX.Element | null => { color: 'var(--color-text-muted)', }} > - + {normalizeReleaseNotes(releaseNotes)}
diff --git a/src/renderer/components/layout/Sidebar.tsx b/src/renderer/components/layout/Sidebar.tsx index 557f3ae7..79a9afe3 100644 --- a/src/renderer/components/layout/Sidebar.tsx +++ b/src/renderer/components/layout/Sidebar.tsx @@ -16,11 +16,12 @@ import { useShallow } from 'zustand/react/shallow'; import { DateGroupedSessions } from '../sidebar/DateGroupedSessions'; import { GlobalTaskList } from '../sidebar/GlobalTaskList'; -import { defaultTaskFiltersState, TaskFiltersPopover } from '../sidebar/TaskFiltersPopover'; +import { TaskFiltersPopover } from '../sidebar/TaskFiltersPopover'; +import { defaultTaskFiltersState } from '../sidebar/taskFiltersState'; import { SidebarHeader } from './SidebarHeader'; -import type { TaskFiltersState } from '../sidebar/TaskFiltersPopover'; +import type { TaskFiltersState } from '../sidebar/taskFiltersState'; type SidebarTab = 'tasks' | 'sessions'; @@ -106,7 +107,7 @@ export const Sidebar = (): React.JSX.Element => { }} >
(loadGroupingMode); const searchInputRef = useRef(null); const hasFetchedRef = useRef(false); const readState = useReadStateSnapshot(); + const setGroupingMode = (mode: TaskGroupingMode): void => { + setGroupingModeState(mode); + saveGroupingMode(mode); + }; + useEffect(() => { if (!hasFetchedRef.current) { hasFetchedRef.current = true; @@ -144,6 +183,10 @@ export const GlobalTaskList = ({ const grouped = useMemo(() => groupTasksByDate(filtered), [filtered]); const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]); + const projectGroups = useMemo(() => groupTasksByProject(filtered), [filtered]); + + const hasContent = + groupingMode === 'time' ? categories.length > 0 : projectGroups.some((g) => g.tasks.length > 0); return (
@@ -192,6 +235,23 @@ export const GlobalTaskList = ({ )}
+ {/* Grouping mode */} +
+ Group by: + +
+ {/* Content */}
{globalTasksLoading && globalTasks.length === 0 && ( @@ -202,7 +262,7 @@ export const GlobalTaskList = ({
)} - {!globalTasksLoading && categories.length === 0 && ( + {!globalTasksLoading && !hasContent && (
@@ -211,38 +271,68 @@ export const GlobalTaskList = ({
)} - {categories.map((category) => { - const tasks = grouped[category]; - let lastTeam: string | null = null; - - return ( -
- {/* Date header */} -
- {dateCategoryLabels[category] ?? category} + {groupingMode === 'project' && + projectGroups.map((group) => { + if (group.tasks.length === 0) return null; + let lastTeam: string | null = null; + return ( +
+
+ {group.projectLabel} +
+ {group.tasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + +
+ ); + })}
+ ); + })} - {tasks.map((task) => { - const showTeamHeader = task.teamName !== lastTeam; - lastTeam = task.teamName; + {groupingMode === 'time' && + categories.map((category) => { + const tasks = grouped[category]; + let lastTeam: string | null = null; - return ( -
- {showTeamHeader && ( -
- Team: {task.teamDisplayName} -
- )} - -
- ); - })} -
- ); - })} + return ( +
+
+ {dateCategoryLabels[category] ?? category} +
+ + {tasks.map((task) => { + const showTeamHeader = task.teamName !== lastTeam; + lastTeam = task.teamName; + + return ( +
+ {showTeamHeader && ( +
+ Team: {task.teamDisplayName} +
+ )} + +
+ ); + })} +
+ ); + })}
); diff --git a/src/renderer/components/sidebar/SidebarTaskItem.tsx b/src/renderer/components/sidebar/SidebarTaskItem.tsx index 3f2e8661..8655749e 100644 --- a/src/renderer/components/sidebar/SidebarTaskItem.tsx +++ b/src/renderer/components/sidebar/SidebarTaskItem.tsx @@ -1,7 +1,7 @@ import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount'; import { useStore } from '@renderer/store'; import { format, isThisYear, isToday, isYesterday } from 'date-fns'; -import { CheckCircle2, Circle, Loader2 } from 'lucide-react'; +import { CheckCircle2, Circle, Eye, Loader2, ShieldCheck } from 'lucide-react'; import type { GlobalTask, TeamTaskStatus } from '@shared/types'; import type { LucideIcon } from 'lucide-react'; @@ -30,7 +30,12 @@ interface SidebarTaskItemProps { export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Element => { const openTeamTab = useStore((s) => s.openTeamTab); const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments); - const cfg = statusConfig[task.status] ?? statusConfig.pending; + const cfg = + task.kanbanColumn === 'approved' + ? ({ icon: ShieldCheck, color: 'text-emerald-400', label: 'approved' } as const) + : task.kanbanColumn === 'review' + ? ({ icon: Eye, color: 'text-amber-400', label: 'in review' } as const) + : (statusConfig[task.status] ?? statusConfig.pending); const StatusIcon = cfg.icon; const dateLabel = formatTaskDate(task.createdAt); diff --git a/src/renderer/components/sidebar/TaskFiltersPopover.tsx b/src/renderer/components/sidebar/TaskFiltersPopover.tsx index b832a3d6..42014c47 100644 --- a/src/renderer/components/sidebar/TaskFiltersPopover.tsx +++ b/src/renderer/components/sidebar/TaskFiltersPopover.tsx @@ -1,27 +1,10 @@ -import { useSyncExternalStore } from 'react'; - import { Button } from '@renderer/components/ui/button'; import { Checkbox } from '@renderer/components/ui/checkbox'; import { Combobox } from '@renderer/components/ui/combobox'; import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover'; -import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage'; import { Filter } from 'lucide-react'; -export type TaskStatusFilterId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved'; - -const STATUS_OPTIONS: { id: TaskStatusFilterId; label: string }[] = [ - { id: 'todo', label: 'TODO' }, - { id: 'in_progress', label: 'IN PROGRESS' }, - { id: 'done', label: 'DONE' }, - { id: 'review', label: 'REVIEW' }, - { id: 'approved', label: 'APPROVED' }, -]; - -export interface TaskFiltersState { - statusIds: Set; - teamName: string | null; - unreadOnly: boolean; -} +import { STATUS_OPTIONS, type TaskFiltersState, type TaskStatusFilterId } from './taskFiltersState'; interface TaskFiltersPopoverProps { open: boolean; @@ -152,44 +135,3 @@ export const TaskFiltersPopover = ({ ); }; - -export const defaultTaskFiltersState = (): TaskFiltersState => ({ - statusIds: new Set(STATUS_OPTIONS.map((o) => o.id)), - teamName: null, - unreadOnly: false, -}); - -export function taskMatchesStatus( - task: { status: string; kanbanColumn?: 'review' | 'approved' }, - statusIds: Set -): boolean { - if (statusIds.size === 0) return false; - if (statusIds.size === STATUS_OPTIONS.length) return true; - - const inTodo = task.status === 'pending' && !task.kanbanColumn; - const inProgress = task.status === 'in_progress'; - const inDone = task.status === 'completed' && !task.kanbanColumn; - const inReview = task.kanbanColumn === 'review'; - const inApproved = task.kanbanColumn === 'approved'; - - return ( - (statusIds.has('todo') && inTodo) || - (statusIds.has('in_progress') && inProgress) || - (statusIds.has('done') && inDone) || - (statusIds.has('review') && inReview) || - (statusIds.has('approved') && inApproved) - ); -} - -export function useReadStateSnapshot(): ReturnType { - return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); -} - -export function getTaskUnreadCount( - readState: ReturnType, - teamName: string, - taskId: string, - comments: { createdAt: string }[] | undefined -): number { - return getUnreadCount(readState, teamName, taskId, comments ?? []); -} diff --git a/src/renderer/components/sidebar/taskFiltersState.ts b/src/renderer/components/sidebar/taskFiltersState.ts new file mode 100644 index 00000000..03f69d8e --- /dev/null +++ b/src/renderer/components/sidebar/taskFiltersState.ts @@ -0,0 +1,60 @@ +import { useSyncExternalStore } from 'react'; + +import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage'; + +export type TaskStatusFilterId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved'; + +export const STATUS_OPTIONS: { id: TaskStatusFilterId; label: string }[] = [ + { id: 'todo', label: 'TODO' }, + { id: 'in_progress', label: 'IN PROGRESS' }, + { id: 'done', label: 'DONE' }, + { id: 'review', label: 'REVIEW' }, + { id: 'approved', label: 'APPROVED' }, +]; + +export interface TaskFiltersState { + statusIds: Set; + teamName: string | null; + unreadOnly: boolean; +} + +export const defaultTaskFiltersState = (): TaskFiltersState => ({ + statusIds: new Set(STATUS_OPTIONS.map((o) => o.id)), + teamName: null, + unreadOnly: false, +}); + +export function taskMatchesStatus( + task: { status: string; kanbanColumn?: 'review' | 'approved' }, + statusIds: Set +): boolean { + if (statusIds.size === 0) return false; + if (statusIds.size === STATUS_OPTIONS.length) return true; + + const inTodo = task.status === 'pending' && !task.kanbanColumn; + const inProgress = task.status === 'in_progress' && !task.kanbanColumn; + const inDone = task.status === 'completed' && !task.kanbanColumn; + const inReview = task.kanbanColumn === 'review'; + const inApproved = task.kanbanColumn === 'approved'; + + return ( + (statusIds.has('todo') && inTodo) || + (statusIds.has('in_progress') && inProgress) || + (statusIds.has('done') && inDone) || + (statusIds.has('review') && inReview) || + (statusIds.has('approved') && inApproved) + ); +} + +export function useReadStateSnapshot(): ReturnType { + return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export function getTaskUnreadCount( + readState: ReturnType, + teamName: string, + taskId: string, + comments: { createdAt: string }[] | undefined +): number { + return getUnreadCount(readState, teamName, taskId, comments ?? []); +} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index dfd91893..71179da1 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -32,12 +32,14 @@ import { TeamSessionsSection } from './TeamSessionsSection'; import type { KanbanFilterState } from './kanban/KanbanFilterPopover'; import type { MessagesFilterState } from './messages/MessagesFilterPopover'; import type { Session } from '@renderer/types/data'; -import type { InboxMessage, ResolvedTeamMember, TeamTask } from '@shared/types'; +import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; interface TeamDetailViewProps { teamName: string; } +const ACTIVE_PROVISIONING_STATES = new Set(['validating', 'spawning', 'monitoring', 'verifying']); + interface CreateTaskDialogState { open: boolean; defaultSubject: string; @@ -50,7 +52,7 @@ interface TimeWindow { end: number; } -function filterKanbanTasks(tasks: TeamTask[], query: string): TeamTask[] { +function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTaskWithKanban[] { if (query.startsWith('#')) { const id = query.slice(1); return tasks.filter((t) => t.id === id); @@ -66,7 +68,7 @@ function filterKanbanTasks(tasks: TeamTask[], query: string): TeamTask[] { export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => { const [requestChangesTaskId, setRequestChangesTaskId] = useState(null); - const [selectedTask, setSelectedTask] = useState(null); + const [selectedTask, setSelectedTask] = useState(null); const [selectedMember, setSelectedMember] = useState(null); const [pendingRepliesByMember, setPendingRepliesByMember] = useState>({}); const [createTaskDialog, setCreateTaskDialog] = useState({ @@ -113,6 +115,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele reviewActionError, launchTeam, provisioningError, + isTeamProvisioning, kanbanFilterQuery, clearKanbanFilter, } = useStore( @@ -136,6 +139,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele reviewActionError: s.reviewActionError, launchTeam: s.launchTeam, provisioningError: s.provisioningError, + isTeamProvisioning: Object.values(s.provisioningRuns).some( + (run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state) + ), kanbanFilterQuery: s.kanbanFilterQuery, clearKanbanFilter: s.clearKanbanFilter, })) @@ -369,6 +375,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele description: string, owner?: string, blockedBy?: string[], + related?: string[], prompt?: string, startImmediately?: boolean ): void => { @@ -380,11 +387,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele description: description || undefined, owner, blockedBy, + related, prompt, startImmediately, }); - if (prompt && owner && data?.isAlive && startImmediately !== false) { + if (prompt && owner && data?.isAlive && !isTeamProvisioning && startImmediately !== false) { const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`; try { await api.teams.processSend(teamName, msg); @@ -527,6 +535,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele taskMap={taskMap} pendingRepliesByMember={pendingRepliesByMember} isTeamAlive={data.isAlive} + isTeamProvisioning={isTeamProvisioning} onMemberClick={setSelectedMember} onSendMessage={(member) => { setSendDialogRecipient(member.name); @@ -714,6 +723,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele { openCreateTaskDialog(subject, description); @@ -757,6 +768,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele teamName={teamName} tasks={data.tasks} messages={data.messages} + isTeamAlive={data.isAlive} + isTeamProvisioning={isTeamProvisioning} onClose={() => setSelectedMember(null)} onSendMessage={() => { const name = selectedMember?.name ?? ''; @@ -781,7 +794,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele teamName={teamName} members={data.members} tasks={data.tasks} - isTeamAlive={data.isAlive} + isTeamAlive={data.isAlive && !isTeamProvisioning} defaultSubject={createTaskDialog.defaultSubject} defaultDescription={createTaskDialog.defaultDescription} defaultOwner={createTaskDialog.defaultOwner} @@ -803,6 +816,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele setLaunchDialogOpen(false)} diff --git a/src/renderer/components/team/activity/ActiveTasksBlock.tsx b/src/renderer/components/team/activity/ActiveTasksBlock.tsx index 7e9be1d8..0f6c455f 100644 --- a/src/renderer/components/team/activity/ActiveTasksBlock.tsx +++ b/src/renderer/components/team/activity/ActiveTasksBlock.tsx @@ -3,13 +3,13 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { Loader2 } from 'lucide-react'; -import type { ResolvedTeamMember, TeamTask } from '@shared/types'; +import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; interface ActiveTasksBlockProps { members: ResolvedTeamMember[]; - tasks: TeamTask[]; + tasks: TeamTaskWithKanban[]; onMemberClick?: (member: ResolvedTeamMember) => void; - onTaskClick?: (task: TeamTask) => void; + onTaskClick?: (task: TeamTaskWithKanban) => void; } export const ActiveTasksBlock = ({ diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 39708494..8a53450c 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -30,6 +30,8 @@ interface ActivityItemProps { memberRole?: string; memberColor?: string; recipientColor?: string; + /** When true, show a blue unread dot. */ + isUnread?: boolean; onMemberNameClick?: (memberName: string) => void; onCreateTask?: (subject: string, description: string) => void; onReply?: (message: InboxMessage) => void; @@ -127,6 +129,7 @@ export const ActivityItem = ({ memberRole, memberColor, recipientColor, + isUnread, onMemberNameClick, onCreateTask, onReply, @@ -175,7 +178,7 @@ export const ActivityItem = ({ }; const summaryText = message.summary || autoSummary || ''; - const HeaderTag = systemLabel ? 'button' : 'div'; + const isHeaderClickable = Boolean(systemLabel); return (
- {/* Header — clickable when system message to toggle expand */} - due to nested buttons inside) */} + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button, tabIndex, onKeyDown below; nested buttons prevent using native button */} +
setIsExpanded((v) => !v) : undefined} + onClick={isHeaderClickable ? () => setIsExpanded((v) => !v) : undefined} onKeyDown={ - systemLabel + isHeaderClickable ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); @@ -205,6 +210,9 @@ export const ActivityItem = ({ : undefined } > + {isUnread ? ( + + ) : null} {/* Chevron for collapsible system messages */} {systemLabel ? (
-
+
{/* Content — collapsed for system messages, expanded for others */} {isExpanded ? ( diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 137cb808..ff7b3ca1 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -9,6 +9,10 @@ import type { InboxMessage, ResolvedTeamMember } from '@shared/types'; interface ActivityTimelineProps { messages: InboxMessage[]; members?: ResolvedTeamMember[]; + /** Set of message keys that have been read; messages not in this set show an unread dot. */ + readSet?: Set; + /** Function to get a stable key for a message (used with readSet). */ + getMessageKey?: (message: InboxMessage) => string; onCreateTaskFromMessage?: (subject: string, description: string) => void; onReplyToMessage?: (message: InboxMessage) => void; onMemberClick?: (member: ResolvedTeamMember) => void; @@ -23,6 +27,7 @@ const MessageRowWithObserver = ({ memberRole, memberColor, recipientColor, + isUnread, onMemberNameClick, onCreateTask, onReply, @@ -32,6 +37,7 @@ const MessageRowWithObserver = ({ memberRole?: string; memberColor?: string; recipientColor?: string; + isUnread?: boolean; onMemberNameClick?: (name: string) => void; onCreateTask?: (subject: string, description: string) => void; onReply?: (message: InboxMessage) => void; @@ -74,6 +80,7 @@ const MessageRowWithObserver = ({ memberRole={memberRole} memberColor={memberColor} recipientColor={recipientColor} + isUnread={isUnread} onMemberNameClick={onMemberNameClick} onCreateTask={onCreateTask} onReply={onReply} @@ -85,6 +92,8 @@ const MessageRowWithObserver = ({ export const ActivityTimeline = ({ messages, members, + readSet, + getMessageKey, onCreateTaskFromMessage, onReplyToMessage, onMemberClick, @@ -126,6 +135,8 @@ export const ActivityTimeline = ({ const recipientColor = recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined); const messageKey = `${message.messageId ?? index}-${message.timestamp}-${message.from}`; + const isUnread = + readSet !== undefined && getMessageKey ? !readSet.has(getMessageKey(message)) : false; return ( void; @@ -69,6 +70,7 @@ export const CreateTaskDialog = ({ }); const [owner, setOwner] = useState(defaultOwner); const [blockedBy, setBlockedBy] = useState([]); + const [related, setRelated] = useState([]); const [startImmediately, setStartImmediately] = useState(true); const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` }); const [prevOpen, setPrevOpen] = useState(false); @@ -80,6 +82,7 @@ export const CreateTaskDialog = ({ } setOwner(defaultOwner); setBlockedBy([]); + setRelated([]); setStartImmediately(isTeamAlive); promptDraft.clearDraft(); } @@ -109,6 +112,12 @@ export const CreateTaskDialog = ({ ); }; + const toggleRelated = (taskId: string): void => { + setRelated((prev) => + prev.includes(taskId) ? prev.filter((id) => id !== taskId) : [...prev, taskId] + ); + }; + const handleSubmit = (): void => { if (!canSubmit) return; onSubmit( @@ -116,6 +125,7 @@ export const CreateTaskDialog = ({ descriptionDraft.value.trim(), owner || undefined, blockedBy.length > 0 ? blockedBy : undefined, + related.length > 0 ? related : undefined, promptDraft.value.trim() || undefined, startImmediately ); @@ -296,6 +306,51 @@ export const CreateTaskDialog = ({ ) : null}
) : null} + + {availableTasks.length > 0 ? ( +
+ +
+ {availableTasks.map((t) => { + const isSelected = related.includes(t.id); + return ( + + ); + })} +
+ {related.length > 0 ? ( +

+ Related: {related.map((id) => `#${id}`).join(', ')} +

+ ) : null} +
+ ) : null}
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 4bdb1256..b1f6936d 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -16,14 +16,21 @@ import { Label } from '@renderer/components/ui/label'; import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea'; import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence'; import { cn } from '@renderer/lib/utils'; +import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { Check, CheckCircle2, Loader2 } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { Project, TeamLaunchRequest, TeamProvisioningPrepareResult } from '@shared/types'; +import type { + Project, + ResolvedTeamMember, + TeamLaunchRequest, + TeamProvisioningPrepareResult, +} from '@shared/types'; interface LaunchTeamDialogProps { open: boolean; teamName: string; + members: ResolvedTeamMember[]; defaultProjectPath?: string; provisioningError: string | null; onClose: () => void; @@ -66,6 +73,7 @@ function renderHighlightedText(text: string, query: string): React.JSX.Element { export const LaunchTeamDialog = ({ open, teamName, + members, defaultProjectPath, provisioningError, onClose, @@ -198,12 +206,13 @@ export const LaunchTeamDialog = ({ const mentionSuggestions = useMemo( () => - projects.map((p) => ({ - id: p.path, - name: p.name, - subtitle: p.path, + members.map((m) => ({ + id: m.name, + name: m.name, + subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined, + color: m.color, })), - [projects] + [members] ); const activeError = localError ?? provisioningError; @@ -381,7 +390,7 @@ export const LaunchTeamDialog = ({ value={promptDraft.value} onValueChange={promptDraft.setValue} suggestions={mentionSuggestions} - placeholder="Instructions for team lead... Use @ to mention projects." + placeholder="Instructions for team lead... Use @ to mention team members." footerRight={ promptDraft.isSaved ? ( Draft saved diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index d2b2bcb1..2a9f3720 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -23,6 +23,8 @@ interface TaskCommentsSectionProps { taskId: string; comments: TaskComment[]; members: ResolvedTeamMember[]; + /** When true, the "Comments" header is not rendered (e.g. inside a collapsible section). */ + hideHeader?: boolean; } export const TaskCommentsSection = ({ @@ -30,6 +32,7 @@ export const TaskCommentsSection = ({ taskId, comments, members, + hideHeader = false, }: TaskCommentsSectionProps): React.JSX.Element => { const addTaskComment = useStore((s) => s.addTaskComment); const addingComment = useStore((s) => s.addingComment); @@ -78,23 +81,22 @@ export const TaskCommentsSection = ({ return (
-
- - Comments - {comments.length > 0 ? ( - - {comments.length} - - ) : null} -
+ {!hideHeader ? ( +
+ + Comments + {comments.length > 0 ? ( + + {comments.length} + + ) : null} +
+ ) : null} {comments.length > 0 ? (
{comments.map((comment) => ( -
+
; + taskMap: Map; members: ResolvedTeamMember[]; onClose: () => void; onScrollToTask?: (taskId: string) => void; @@ -76,11 +74,30 @@ export const TaskDetailDialog = ({ ); } + const kanbanColumn = kanbanTaskState?.column ?? currentTask.kanbanColumn; const status = currentTask.status; - const statusStyle = TASK_STATUS_STYLES[status]; - const statusLabel = TASK_STATUS_LABELS[status]; + const statusStyle = + kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn] + ? { + bg: KANBAN_COLUMN_DISPLAY[kanbanColumn].bg, + text: KANBAN_COLUMN_DISPLAY[kanbanColumn].text, + } + : TASK_STATUS_STYLES[status]; + const statusLabel = + kanbanColumn && KANBAN_COLUMN_DISPLAY[kanbanColumn] + ? KANBAN_COLUMN_DISPLAY[kanbanColumn].label + : TASK_STATUS_LABELS[status]; const blockedByIds = currentTask.blockedBy?.filter((id) => id.length > 0) ?? []; const blocksIds = currentTask.blocks?.filter((id) => id.length > 0) ?? []; + const relatedIds = (currentTask.related ?? []).filter( + (id) => id.length > 0 && id !== currentTask.id + ); + const relatedByIds = Array.from(taskMap.values()) + .filter( + (t) => + t.id !== currentTask.id && Array.isArray(t.related) && t.related.includes(currentTask.id) + ) + .map((t) => t.id); return ( !v && onClose()}> @@ -132,19 +149,15 @@ export const TaskDetailDialog = ({
{/* Description */} -
-
- - Description -
+ {currentTask.description ? ( -
+
) : (

No description

)} -
+
{/* Dependencies */} {blockedByIds.length > 0 ? ( @@ -203,6 +216,56 @@ export const TaskDetailDialog = ({
) : null} + {/* Related tasks (explicit) */} + {relatedIds.length > 0 || relatedByIds.length > 0 ? ( +
+
+ + Related tasks +
+ + {relatedIds.length > 0 ? ( +
+ Links + {relatedIds.map((id) => { + const depTask = taskMap.get(id); + return ( + + ); + })} +
+ ) : null} + + {relatedByIds.length > 0 ? ( +
+ Linked from + {relatedByIds.map((id) => { + const depTask = taskMap.get(id); + return ( + + ); + })} +
+ ) : null} +
+ ) : null} + {/* Review info */} {kanbanTaskState ? (
@@ -218,28 +281,35 @@ export const TaskDetailDialog = ({ ) : null} {/* Comments */} - - - {/* Separator */} -
- - {/* Session Logs — sessions that reference this task */} -
-

- Execution Logs -

- 0 + ? (currentTask.comments?.length ?? 0) + : undefined + } + defaultOpen + > + -
+ + + {/* Execution Logs — sessions that reference this task */} + +
+ +
+
+ + ) : null} + {!currentTask && isAwaitingReply ? ( + <> + + + awaiting reply + + + ) : null} +
{(() => { const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType); return roleLabel ? ( @@ -125,42 +168,9 @@ export const MemberCard = ({
- - {currentTask ? ( -
- - working on - -
- ) : null} - - {!currentTask && isAwaitingReply ? ( -
- - awaiting reply -
- ) : null}
void; onSendMessage: () => void; onAssignTask: () => void; - onTaskClick: (task: TeamTask) => void; + onTaskClick: (task: TeamTaskWithKanban) => void; } export const MemberDetailDialog = ({ @@ -32,6 +34,8 @@ export const MemberDetailDialog = ({ teamName, tasks, messages, + isTeamAlive, + isTeamProvisioning, onClose, onSendMessage, onAssignTask, @@ -61,9 +65,13 @@ export const MemberDetailDialog = ({ return ( !nextOpen && onClose()}> - + - + - + diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index 59032f68..cc649151 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -7,12 +7,18 @@ import type { ResolvedTeamMember } from '@shared/types'; interface MemberDetailHeaderProps { member: ResolvedTeamMember; + isTeamAlive?: boolean; + isTeamProvisioning?: boolean; } -export const MemberDetailHeader = ({ member }: MemberDetailHeaderProps): React.JSX.Element => { +export const MemberDetailHeader = ({ + member, + isTeamAlive, + isTeamProvisioning, +}: MemberDetailHeaderProps): React.JSX.Element => { const role = member.role || formatAgentRole(member.agentType); - const presenceLabel = getPresenceLabel(member); - const dotClass = getMemberDotClass(member); + const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning); + const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning); return (
@@ -25,7 +31,7 @@ export const MemberDetailHeader = ({ member }: MemberDetailHeaderProps): React.J />
diff --git a/src/renderer/components/team/members/MemberExecutionLog.tsx b/src/renderer/components/team/members/MemberExecutionLog.tsx index 626abca7..b6b14c47 100644 --- a/src/renderer/components/team/members/MemberExecutionLog.tsx +++ b/src/renderer/components/team/members/MemberExecutionLog.tsx @@ -41,7 +41,7 @@ export const MemberExecutionLog = ({ } return ( -
+
{conversation.items.map((item) => { if (item.type === 'system') { return ; @@ -92,8 +92,8 @@ const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => { const text = group.content.rawText ?? group.content.text ?? ''; if (!text.trim()) { return ( -
-
+
+
{format(group.timestamp, 'h:mm:ss a')}
@@ -104,12 +104,12 @@ const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => { } return ( -
-
+
+
{format(group.timestamp, 'h:mm:ss a')}
-
+
diff --git a/src/renderer/components/team/members/MemberList.tsx b/src/renderer/components/team/members/MemberList.tsx index eede5163..98994ee0 100644 --- a/src/renderer/components/team/members/MemberList.tsx +++ b/src/renderer/components/team/members/MemberList.tsx @@ -3,18 +3,19 @@ import { getMemberColor } from '@shared/constants/memberColors'; import { MemberCard } from './MemberCard'; import type { TaskStatusCounts } from '@renderer/utils/pathNormalize'; -import type { ResolvedTeamMember, TeamTask } from '@shared/types'; +import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; interface MemberListProps { members: ResolvedTeamMember[]; memberTaskCounts?: Map; - taskMap?: Map; + taskMap?: Map; pendingRepliesByMember?: Record; isTeamAlive?: boolean; + isTeamProvisioning?: boolean; onMemberClick?: (member: ResolvedTeamMember) => void; onSendMessage?: (member: ResolvedTeamMember) => void; onAssignTask?: (member: ResolvedTeamMember) => void; - onOpenTask?: (task: TeamTask) => void; + onOpenTask?: (task: TeamTaskWithKanban) => void; } export const MemberList = ({ @@ -23,6 +24,7 @@ export const MemberList = ({ taskMap, pendingRepliesByMember, isTeamAlive, + isTeamProvisioning, onMemberClick, onSendMessage, onAssignTask, @@ -49,6 +51,7 @@ export const MemberList = ({ memberColor={member.color ?? getMemberColor(index)} taskCounts={memberTaskCounts?.get(member.name.toLowerCase())} isTeamAlive={isTeamAlive} + isTeamProvisioning={isTeamProvisioning} currentTask={currentTask} isAwaitingReply={awaitingReply} onOpenTask={currentTask ? () => onOpenTask?.(currentTask) : undefined} diff --git a/src/renderer/components/team/members/MemberLogsTab.tsx b/src/renderer/components/team/members/MemberLogsTab.tsx index a9c5725e..c6cbf0ee 100644 --- a/src/renderer/components/team/members/MemberLogsTab.tsx +++ b/src/renderer/components/team/members/MemberLogsTab.tsx @@ -154,7 +154,7 @@ export const MemberLogsTab = ({ } return ( -
+
{logs.map((log) => ( )} {!detailLoading && detailChunks && ( -
+
void; + tasks: TeamTaskWithKanban[]; + onTaskClick?: (task: TeamTaskWithKanban) => void; } const STATUS_ORDER: Record = { @@ -37,7 +41,15 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
{visibleTasks.map((task) => { - const style = TASK_STATUS_STYLES[task.status]; + const col = task.kanbanColumn; + const style = + col && KANBAN_COLUMN_DISPLAY[col] + ? { bg: KANBAN_COLUMN_DISPLAY[col].bg, text: KANBAN_COLUMN_DISPLAY[col].text } + : TASK_STATUS_STYLES[task.status]; + const label = + col && KANBAN_COLUMN_DISPLAY[col] + ? KANBAN_COLUMN_DISPLAY[col].label + : TASK_STATUS_LABELS[task.status]; return ( ); diff --git a/src/renderer/components/team/tasks/TaskList.tsx b/src/renderer/components/team/tasks/TaskList.tsx index a751ba23..4f775c2f 100644 --- a/src/renderer/components/team/tasks/TaskList.tsx +++ b/src/renderer/components/team/tasks/TaskList.tsx @@ -2,10 +2,10 @@ import { useMemo, useState } from 'react'; import { TaskRow } from './TaskRow'; -import type { TeamTask } from '@shared/types'; +import type { TeamTaskWithKanban } from '@shared/types'; interface TaskListProps { - tasks: TeamTask[]; + tasks: TeamTaskWithKanban[]; } export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => { diff --git a/src/renderer/components/team/tasks/TaskRow.tsx b/src/renderer/components/team/tasks/TaskRow.tsx index 0651f288..37aaf7c3 100644 --- a/src/renderer/components/team/tasks/TaskRow.tsx +++ b/src/renderer/components/team/tasks/TaskRow.tsx @@ -1,7 +1,7 @@ -import type { TeamTask } from '@shared/types'; +import type { TeamTaskWithKanban } from '@shared/types'; interface TaskRowProps { - task: TeamTask; + task: TeamTaskWithKanban; } export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { @@ -13,7 +13,9 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => { {task.id} {task.subject} {task.owner ?? '\u2014'} - {task.status} + + {task.kanbanColumn ?? task.status} + {blockedByIds.length > 0 ? ( {blockedByIds.map((id) => `#${id}`).join(', ')} diff --git a/src/renderer/components/ui/combobox.tsx b/src/renderer/components/ui/combobox.tsx index 326ee245..991ca4a9 100644 --- a/src/renderer/components/ui/combobox.tsx +++ b/src/renderer/components/ui/combobox.tsx @@ -74,20 +74,21 @@ export const Combobox = ({ className="flex size-full flex-col overflow-hidden rounded-md bg-[var(--color-surface)]" shouldFilter={false} > -
+
e.stopPropagation()} > - + {emptyMessage} {options @@ -112,6 +113,7 @@ export const Combobox = ({ setSearch(''); }} className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-0 pr-2 text-xs outline-none data-[selected=true]:bg-[var(--color-surface-raised)] data-[selected=true]:text-[var(--color-text)]" + style={{ paddingLeft: 0 }} > {renderOption ? ( renderOption(option, isSelected, search) diff --git a/src/renderer/hooks/useMentionDetection.ts b/src/renderer/hooks/useMentionDetection.ts index 51bf0fd0..0230dab6 100644 --- a/src/renderer/hooks/useMentionDetection.ts +++ b/src/renderer/hooks/useMentionDetection.ts @@ -62,6 +62,8 @@ const MIRROR_PROPS = [ 'wordSpacing', ] as const; +const MENTION_DROPDOWN_OFFSET_PX = 10; + /** * Calculates caret coordinates relative to the textarea element * using a mirror div technique. @@ -180,7 +182,7 @@ export function useMentionDetection({ if (!textarea) return; const coords = getCaretCoordinates(textarea, triggerIdx, text); setDropdownPosition({ - top: coords.top + coords.height, + top: coords.top + coords.height + MENTION_DROPDOWN_OFFSET_PX, left: 0, }); }, diff --git a/src/renderer/index.css b/src/renderer/index.css index d4cd8e0f..c2156d05 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -383,6 +383,64 @@ --skeleton-base-dim: rgba(205, 208, 215, 0.6); } +/* rehype-highlight (highlight.js) — map hljs classes to app theme variables */ +.hljs { + color: var(--color-text); + background: transparent; +} +.hljs-keyword, +.hljs-selector-tag, +.hljs-addition { + color: var(--syntax-keyword); +} +.hljs-string, +.hljs-doctag { + color: var(--syntax-string); +} +.hljs-comment, +.hljs-quote { + color: var(--syntax-comment); + font-style: italic; +} +.hljs-number, +.hljs-literal { + color: var(--syntax-number); +} +.hljs-built_in, +.hljs-type, +.hljs-class .hljs-title { + color: var(--syntax-type); +} +.hljs-title.function_, +.hljs-function .hljs-title { + color: var(--syntax-function); +} +.hljs-params, +.hljs-attr, +.hljs-variable, +.hljs-template-variable, +.hljs-attribute { + color: var(--color-text); +} +.hljs-symbol, +.hljs-bullet, +.hljs-subst, +.hljs-meta, +.hljs-meta .hljs-keyword, +.hljs-selector-attr, +.hljs-selector-pseudo, +.hljs-link, +.hljs-regexp { + color: var(--syntax-operator); +} +.hljs-deletion { + color: var(--diff-removed-text); +} +.hljs-section { + font-weight: 600; + color: var(--syntax-keyword); +} + * { margin: 0; padding: 0; diff --git a/src/renderer/services/draftStorage.ts b/src/renderer/services/draftStorage.ts index 15d63ae8..616474b3 100644 --- a/src/renderer/services/draftStorage.ts +++ b/src/renderer/services/draftStorage.ts @@ -8,67 +8,115 @@ interface StoredDraft { timestamp: number; } +let idbUnavailable = false; +let idbUnavailableLogged = false; +const fallbackStore = new Map(); + +function markIdbUnavailable(): void { + if (!idbUnavailableLogged) { + idbUnavailableLogged = true; + console.warn( + '[draftStorage] IndexedDB unavailable, using in-memory draft storage for this session.' + ); + } + idbUnavailable = true; +} + +function fallbackSave(key: string, value: string): void { + const fullKey = `${DRAFT_KEY_PREFIX}${key}`; + fallbackStore.set(fullKey, { value, timestamp: Date.now() }); +} + +function fallbackLoad(key: string): string | null { + const fullKey = `${DRAFT_KEY_PREFIX}${key}`; + const stored = fallbackStore.get(fullKey); + if (!stored) return null; + if (Date.now() - stored.timestamp > DRAFT_TTL_MS) { + fallbackStore.delete(fullKey); + return null; + } + return stored.value; +} + +function fallbackDelete(key: string): void { + fallbackStore.delete(`${DRAFT_KEY_PREFIX}${key}`); +} + +function fallbackCleanupExpired(): void { + const now = Date.now(); + for (const [fullKey, stored] of fallbackStore.entries()) { + if (now - stored.timestamp > DRAFT_TTL_MS) fallbackStore.delete(fullKey); + } +} + async function saveDraft(key: string, value: string): Promise { + if (idbUnavailable) { + fallbackSave(key, value); + return; + } try { - const stored: StoredDraft = { - value, - timestamp: Date.now(), - }; + const stored: StoredDraft = { value, timestamp: Date.now() }; await set(`${DRAFT_KEY_PREFIX}${key}`, stored); - } catch (error) { - console.error(`[draftStorage] Failed to save draft for ${key}:`, error); + } catch { + markIdbUnavailable(); + fallbackSave(key, value); } } async function loadDraft(key: string): Promise { + if (idbUnavailable) return fallbackLoad(key); try { const stored = await get(`${DRAFT_KEY_PREFIX}${key}`); - if (!stored) { - return null; - } - + if (!stored) return null; const age = Date.now() - stored.timestamp; if (age > DRAFT_TTL_MS) { void deleteDraft(key); return null; } - return stored.value; - } catch (error) { - console.error(`[draftStorage] Failed to load draft for ${key}:`, error); - return null; + } catch { + markIdbUnavailable(); + return fallbackLoad(key); } } async function deleteDraft(key: string): Promise { + if (idbUnavailable) { + fallbackDelete(key); + return; + } try { await del(`${DRAFT_KEY_PREFIX}${key}`); - } catch (error) { - console.error(`[draftStorage] Failed to delete draft for ${key}:`, error); + } catch { + markIdbUnavailable(); + fallbackDelete(key); } } async function cleanupExpired(): Promise { + if (idbUnavailable) { + fallbackCleanupExpired(); + return; + } try { const allKeys = await keys(); const draftKeys = allKeys.filter( (k): k is IDBValidKey & string => typeof k === 'string' && k.startsWith(DRAFT_KEY_PREFIX) ); - const now = Date.now(); - for (const fullKey of draftKeys) { try { const stored = await get(fullKey); - if (stored && now - stored.timestamp > DRAFT_TTL_MS) { - await del(fullKey); - } - } catch (error) { - console.error(`[draftStorage] Failed to check/delete key ${fullKey}:`, error); + if (stored && now - stored.timestamp > DRAFT_TTL_MS) await del(fullKey); + } catch { + markIdbUnavailable(); + fallbackCleanupExpired(); + return; } } - } catch (error) { - console.error('[draftStorage] Failed to cleanup expired drafts:', error); + } catch { + markIdbUnavailable(); + fallbackCleanupExpired(); } } diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index aafeadf7..547d5abb 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -1,6 +1,9 @@ import { api } from '@renderer/api'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; +import { createLogger } from '@shared/utils/logger'; + +const logger = createLogger('teamSlice'); import type { AppState } from '../types'; import type { @@ -250,9 +253,8 @@ export const createTeamSlice: StateCreator = (set, } } } catch (error) { - // If provisioning is in progress for this team, stay in loading state - // instead of showing an error — the file watcher / progress callback will - // trigger a refresh once config.json is written. + // If provisioning is in progress for this team, stay in loading state; + // file watcher / progress callback will refresh once config is written. const isProvisioning = Object.values(get().provisioningRuns).some( (run) => run.teamName === teamName && @@ -268,15 +270,17 @@ export const createTeamSlice: StateCreator = (set, return; } + const message = + error instanceof IpcError + ? error.message + : error instanceof Error + ? error.message + : 'Failed to fetch team data'; + logger.error(`[team:getData] ${message}`); set({ selectedTeamLoading: false, selectedTeamData: null, - selectedTeamError: - error instanceof IpcError - ? error.message - : error instanceof Error - ? error.message - : 'Failed to fetch team data', + selectedTeamError: message, }); } }, diff --git a/src/renderer/utils/markdownPlugins.ts b/src/renderer/utils/markdownPlugins.ts new file mode 100644 index 00000000..4e05d5e7 --- /dev/null +++ b/src/renderer/utils/markdownPlugins.ts @@ -0,0 +1,8 @@ +/** + * Rehype plugins for markdown rendering (used with react-markdown). + * Rehype runs after remark; rehype-highlight adds syntax highlighting to code blocks. + */ + +import rehypeHighlight from 'rehype-highlight'; + +export const rehypePlugins = [rehypeHighlight]; diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 4a106d00..90596d3b 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -11,15 +11,28 @@ export const STATUS_DOT_COLORS: Record = { unknown: 'bg-zinc-600', }; -export function getMemberDotClass(member: ResolvedTeamMember, isTeamAlive?: boolean): string { - if (isTeamAlive === false) return STATUS_DOT_COLORS.terminated; +export function getMemberDotClass( + member: ResolvedTeamMember, + isTeamAlive?: boolean, + isTeamProvisioning?: boolean +): string { if (member.status === 'terminated') return STATUS_DOT_COLORS.terminated; - return member.currentTaskId ? STATUS_DOT_COLORS.active : STATUS_DOT_COLORS.idle; + if (isTeamProvisioning) return STATUS_DOT_COLORS.unknown; + if (isTeamAlive === false) return STATUS_DOT_COLORS.terminated; + if (member.status === 'unknown') return STATUS_DOT_COLORS.unknown; + if (member.currentTaskId) return STATUS_DOT_COLORS.active; + return member.status === 'active' ? STATUS_DOT_COLORS.active : STATUS_DOT_COLORS.idle; } -export function getPresenceLabel(member: ResolvedTeamMember, isTeamAlive?: boolean): string { - if (isTeamAlive === false) return 'offline'; +export function getPresenceLabel( + member: ResolvedTeamMember, + isTeamAlive?: boolean, + isTeamProvisioning?: boolean +): string { if (member.status === 'terminated') return 'terminated'; + if (isTeamProvisioning) return 'connecting'; + if (isTeamAlive === false) return 'offline'; + if (member.status === 'unknown') return 'unknown'; return member.currentTaskId ? 'working' : 'idle'; } @@ -36,3 +49,11 @@ export const TASK_STATUS_LABELS: Record = { completed: 'Completed', deleted: 'Deleted', }; + +export const KANBAN_COLUMN_DISPLAY: Record< + 'review' | 'approved', + { label: string; bg: string; text: string } +> = { + review: { label: 'In Review', bg: 'bg-amber-500/15', text: 'text-amber-400' }, + approved: { label: 'Approved', bg: 'bg-emerald-500/15', text: 'text-emerald-400' }, +}; diff --git a/src/renderer/utils/taskGrouping.ts b/src/renderer/utils/taskGrouping.ts index 025731ce..c639c1ce 100644 --- a/src/renderer/utils/taskGrouping.ts +++ b/src/renderer/utils/taskGrouping.ts @@ -1,3 +1,4 @@ +import { normalizePath } from '@renderer/utils/pathNormalize'; import { differenceInDays, isToday, isYesterday } from 'date-fns'; import { DATE_CATEGORY_ORDER } from '../types/tabs'; @@ -7,6 +8,12 @@ import type { GlobalTask } from '@shared/types'; export type DateGroupedTasks = Record; +export interface ProjectTaskGroup { + projectKey: string; + projectLabel: string; + tasks: GlobalTask[]; +} + function getDateCategory(dateStr: string | undefined): DateCategory { if (!dateStr) return 'Older'; const d = new Date(dateStr); @@ -46,3 +53,64 @@ export function groupTasksByDate(tasks: GlobalTask[]): DateGroupedTasks { export function getNonEmptyTaskCategories(groups: DateGroupedTasks): DateCategory[] { return DATE_CATEGORY_ORDER.filter((cat) => groups[cat].length > 0); } + +const NO_PROJECT_KEY = '__no_project__'; +const NO_PROJECT_LABEL = 'Without project'; + +function trimTrailingPathSep(p: string): string { + let s = p; + while (s.length > 0 && (s.endsWith('/') || s.endsWith('\\'))) s = s.slice(0, -1); + return s; +} + +function projectLabelFromPath(path: string): string { + const normalized = trimTrailingPathSep(path); + const segments = normalized + .split('/') + .flatMap((s) => s.split('\\')) + .filter(Boolean); + return segments.length > 0 ? segments[segments.length - 1] : path || NO_PROJECT_LABEL; +} + +export function groupTasksByProject(tasks: GlobalTask[]): ProjectTaskGroup[] { + const byKey = new Map(); + + for (const task of tasks) { + const path = task.projectPath?.trim() ?? ''; + const key = path ? normalizePath(path) : NO_PROJECT_KEY; + let entry = byKey.get(key); + if (!entry) { + entry = { path: path || '', tasks: [] }; + byKey.set(key, entry); + } + entry.tasks.push(task); + } + + for (const entry of byKey.values()) { + entry.tasks.sort((a, b) => { + const cmp = a.teamName.localeCompare(b.teamName); + if (cmp !== 0) return cmp; + const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return dateB - dateA; + }); + } + + const groups: ProjectTaskGroup[] = []; + for (const [key, { path, tasks: list }] of byKey) { + const projectLabel = key === NO_PROJECT_KEY ? NO_PROJECT_LABEL : projectLabelFromPath(path); + groups.push({ projectKey: key, projectLabel, tasks: list }); + } + + groups.sort((a, b) => { + const tsA = Math.max( + ...a.tasks.map((t) => (t.createdAt ? new Date(t.createdAt).getTime() : 0)) + ); + const tsB = Math.max( + ...b.tasks.map((t) => (t.createdAt ? new Date(t.createdAt).getTime() : 0)) + ); + return tsB - tsA; + }); + + return groups; +} diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 54557af1..015a09bc 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -64,11 +64,22 @@ export interface TeamTask { status: TeamTaskStatus; blocks?: string[]; blockedBy?: string[]; + /** + * Explicit task links (non-blocking). Used for navigation between related tasks, + * e.g. "review task" ↔ "work task". + */ + related?: string[]; createdAt?: string; projectPath?: string; comments?: TaskComment[]; } +/** Task enriched for UI/DTO use (overlay from kanban-state.json). */ +export interface TeamTaskWithKanban extends TeamTask { + /** Set when task is in team kanban (review or approved column). */ + kanbanColumn?: 'review' | 'approved'; +} + export interface InboxMessage { from: string; to?: string; @@ -130,7 +141,7 @@ export interface ResolvedTeamMember { export interface TeamData { teamName: string; config: TeamConfig; - tasks: TeamTask[]; + tasks: TeamTaskWithKanban[]; members: ResolvedTeamMember[]; messages: InboxMessage[]; kanbanState: KanbanState; @@ -153,6 +164,7 @@ export interface CreateTaskRequest { description?: string; owner?: string; blockedBy?: string[]; + related?: string[]; prompt?: string; startImmediately?: boolean; } @@ -220,12 +232,10 @@ export interface TeamProvisioningProgress { cliLogsTail?: string; } -export interface GlobalTask extends TeamTask { +export interface GlobalTask extends TeamTaskWithKanban { teamName: string; teamDisplayName: string; projectPath?: string; - /** Set when task is in team kanban (review or approved column). */ - kanbanColumn?: 'review' | 'approved'; } export interface MemberSubagentSummary { diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index daf62c0e..4fdfa0be 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -163,4 +163,45 @@ describe('TeamDataService', () => { expect.objectContaining({ status: 'pending', owner: 'alice', createdBy: 'user' }) ); }); + + it('persists explicit related task links when creating a task', async () => { + const createTaskMock = vi.fn(async () => undefined); + const service = new TeamDataService( + { + listTeams: vi.fn(), + getConfig: vi.fn(async () => ({ name: 'My team', members: [] })), + } as never, + { + getNextTaskId: vi.fn(async () => '3'), + getTasks: vi.fn(async () => []), + } as never, + { + listInboxNames: vi.fn(async () => []), + getMessages: vi.fn(async () => []), + } as never, + {} as never, + { + createTask: createTaskMock, + addBlocksEntry: vi.fn(async () => undefined), + } as never, + { + resolveMembers: vi.fn(() => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })), + garbageCollect: vi.fn(async () => undefined), + } as never + ); + + const result = await service.createTask('my-team', { + subject: 'Review work task', + related: ['1', '2'], + }); + + expect(result.related).toEqual(['1', '2']); + expect(createTaskMock).toHaveBeenCalledWith( + 'my-team', + expect.objectContaining({ related: ['1', '2'] }) + ); + }); });