feat: alot, code highlight, related tasks, group by project and other
This commit is contained in:
parent
e97fa7635f
commit
1b6f7be767
53 changed files with 1417 additions and 456 deletions
|
|
@ -85,6 +85,7 @@
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
|
"rehype-highlight": "^7.0.2",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"ssh-config": "^5.0.4",
|
"ssh-config": "^5.0.4",
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,9 @@ importers:
|
||||||
react-markdown:
|
react-markdown:
|
||||||
specifier: ^10.1.0
|
specifier: ^10.1.0
|
||||||
version: 10.1.0(@types/react@18.3.27)(react@18.3.1)
|
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:
|
remark-gfm:
|
||||||
specifier: ^4.0.1
|
specifier: ^4.0.1
|
||||||
version: 4.0.1
|
version: 4.0.1
|
||||||
|
|
@ -3158,9 +3161,15 @@ packages:
|
||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
hast-util-to-jsx-runtime@2.3.6:
|
||||||
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
|
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:
|
hast-util-whitespace@3.0.0:
|
||||||
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
|
||||||
|
|
||||||
|
|
@ -3170,6 +3179,10 @@ packages:
|
||||||
hermes-parser@0.25.1:
|
hermes-parser@0.25.1:
|
||||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
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:
|
hosted-git-info@4.1.0:
|
||||||
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -3632,6 +3645,9 @@ packages:
|
||||||
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
|
resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
lowlight@3.3.0:
|
||||||
|
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
|
||||||
|
|
||||||
lru-cache@10.4.3:
|
lru-cache@10.4.3:
|
||||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||||
|
|
||||||
|
|
@ -4418,6 +4434,9 @@ packages:
|
||||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
rehype-highlight@7.0.2:
|
||||||
|
resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==}
|
||||||
|
|
||||||
remark-gfm@4.0.1:
|
remark-gfm@4.0.1:
|
||||||
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
|
||||||
|
|
||||||
|
|
@ -4997,6 +5016,9 @@ packages:
|
||||||
resolution: {integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==}
|
resolution: {integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==}
|
||||||
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
|
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:
|
unist-util-is@6.0.1:
|
||||||
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
|
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
|
||||||
|
|
||||||
|
|
@ -8569,6 +8591,10 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
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:
|
hast-util-to-jsx-runtime@2.3.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.8
|
'@types/estree': 1.0.8
|
||||||
|
|
@ -8589,6 +8615,13 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
hast-util-whitespace@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/hast': 3.0.4
|
'@types/hast': 3.0.4
|
||||||
|
|
@ -8599,6 +8632,8 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
hermes-estree: 0.25.1
|
hermes-estree: 0.25.1
|
||||||
|
|
||||||
|
highlight.js@11.11.1: {}
|
||||||
|
|
||||||
hosted-git-info@4.1.0:
|
hosted-git-info@4.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
lru-cache: 6.0.0
|
lru-cache: 6.0.0
|
||||||
|
|
@ -9068,6 +9103,12 @@ snapshots:
|
||||||
|
|
||||||
lowercase-keys@2.0.0: {}
|
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@10.4.3: {}
|
||||||
|
|
||||||
lru-cache@11.2.6: {}
|
lru-cache@11.2.6: {}
|
||||||
|
|
@ -10056,6 +10097,14 @@ snapshots:
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
set-function-name: 2.0.2
|
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:
|
remark-gfm@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/mdast': 4.0.4
|
'@types/mdast': 4.0.4
|
||||||
|
|
@ -10772,6 +10821,11 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
imurmurhash: 0.1.4
|
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:
|
unist-util-is@6.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
|
|
|
||||||
|
|
@ -17,12 +17,34 @@ import {
|
||||||
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
|
WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL,
|
||||||
} from '@shared/constants';
|
} from '@shared/constants';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
import { app, BrowserWindow } from 'electron';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import { join } from 'path';
|
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 { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||||
|
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||||
import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder';
|
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.
|
// Window icon path for non-mac platforms.
|
||||||
const getWindowIconPath = (): string | undefined => {
|
const getWindowIconPath = (): string | undefined => {
|
||||||
|
|
@ -42,16 +64,6 @@ const getWindowIconPath = (): string | undefined => {
|
||||||
return 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) => {
|
process.on('unhandledRejection', (reason) => {
|
||||||
logger.error('Unhandled promise rejection in main process:', 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);
|
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
|
// Application State
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -334,47 +330,13 @@ function initializeServices(): void {
|
||||||
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
|
onClaudeRootPathUpdated: (_claudeRootPath: string | null) => {
|
||||||
reconfigureLocalContextForClaudeRoot();
|
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
|
// Forward SSH state changes to renderer and HTTP SSE clients
|
||||||
sshConnectionManager.on('state-change', (status: unknown) => {
|
sshConnectionManager.on('state-change', (status: unknown) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
|
@ -451,6 +413,10 @@ function shutdownServices(): void {
|
||||||
todoChangeCleanup();
|
todoChangeCleanup();
|
||||||
todoChangeCleanup = null;
|
todoChangeCleanup = null;
|
||||||
}
|
}
|
||||||
|
if (teamChangeCleanup) {
|
||||||
|
teamChangeCleanup();
|
||||||
|
teamChangeCleanup = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Dispose all contexts (including local)
|
// Dispose all contexts (including local)
|
||||||
if (contextRegistry) {
|
if (contextRegistry) {
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
* - notifications.ts: Notification management
|
* - notifications.ts: Notification management
|
||||||
* - config.ts: App configuration
|
* - config.ts: App configuration
|
||||||
* - ssh.ts: SSH connection management
|
* - ssh.ts: SSH connection management
|
||||||
|
* - httpServer.ts: HTTP sidecar server control
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
@ -22,6 +23,11 @@ import {
|
||||||
registerContextHandlers,
|
registerContextHandlers,
|
||||||
removeContextHandlers,
|
removeContextHandlers,
|
||||||
} from './context';
|
} from './context';
|
||||||
|
import {
|
||||||
|
initializeHttpServerHandlers,
|
||||||
|
registerHttpServerHandlers,
|
||||||
|
removeHttpServerHandlers,
|
||||||
|
} from './httpServer';
|
||||||
|
|
||||||
const logger = createLogger('IPC:handlers');
|
const logger = createLogger('IPC:handlers');
|
||||||
import { registerNotificationHandlers, removeNotificationHandlers } from './notifications';
|
import { registerNotificationHandlers, removeNotificationHandlers } from './notifications';
|
||||||
|
|
@ -62,6 +68,7 @@ import type {
|
||||||
TeamProvisioningService,
|
TeamProvisioningService,
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
} from '../services';
|
} from '../services';
|
||||||
|
import type { HttpServer } from '../services/infrastructure/HttpServer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes IPC handlers with service registry.
|
* Initializes IPC handlers with service registry.
|
||||||
|
|
@ -78,6 +85,10 @@ export function initializeIpcHandlers(
|
||||||
rewire: (context: ServiceContext) => void;
|
rewire: (context: ServiceContext) => void;
|
||||||
full: (context: ServiceContext) => void;
|
full: (context: ServiceContext) => void;
|
||||||
onClaudeRootPathUpdated: (claudeRootPath: string | null) => Promise<void> | void;
|
onClaudeRootPathUpdated: (claudeRootPath: string | null) => Promise<void> | void;
|
||||||
|
},
|
||||||
|
httpServerDeps?: {
|
||||||
|
httpServer: HttpServer;
|
||||||
|
startHttpServer: () => Promise<void>;
|
||||||
}
|
}
|
||||||
): void {
|
): void {
|
||||||
// Initialize domain handlers with registry
|
// Initialize domain handlers with registry
|
||||||
|
|
@ -97,6 +108,9 @@ export function initializeIpcHandlers(
|
||||||
initializeConfigHandlers({
|
initializeConfigHandlers({
|
||||||
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
||||||
});
|
});
|
||||||
|
if (httpServerDeps) {
|
||||||
|
initializeHttpServerHandlers(httpServerDeps.httpServer, httpServerDeps.startHttpServer);
|
||||||
|
}
|
||||||
|
|
||||||
// Register all handlers
|
// Register all handlers
|
||||||
registerProjectHandlers(ipcMain);
|
registerProjectHandlers(ipcMain);
|
||||||
|
|
@ -112,6 +126,9 @@ export function initializeIpcHandlers(
|
||||||
registerContextHandlers(ipcMain);
|
registerContextHandlers(ipcMain);
|
||||||
registerTeamHandlers(ipcMain);
|
registerTeamHandlers(ipcMain);
|
||||||
registerWindowHandlers(ipcMain);
|
registerWindowHandlers(ipcMain);
|
||||||
|
if (httpServerDeps) {
|
||||||
|
registerHttpServerHandlers(ipcMain);
|
||||||
|
}
|
||||||
|
|
||||||
logger.info('All handlers registered');
|
logger.info('All handlers registered');
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +151,7 @@ export function removeIpcHandlers(): void {
|
||||||
removeContextHandlers(ipcMain);
|
removeContextHandlers(ipcMain);
|
||||||
removeTeamHandlers(ipcMain);
|
removeTeamHandlers(ipcMain);
|
||||||
removeWindowHandlers(ipcMain);
|
removeWindowHandlers(ipcMain);
|
||||||
|
removeHttpServerHandlers(ipcMain);
|
||||||
|
|
||||||
logger.info('All handlers removed');
|
logger.info('All handlers removed');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
src/main/ipc/httpServer.ts
Normal file
103
src/main/ipc/httpServer.ts
Normal file
|
|
@ -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<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes HTTP server handlers with service instances.
|
||||||
|
*/
|
||||||
|
export function initializeHttpServerHandlers(
|
||||||
|
server: HttpServer,
|
||||||
|
startHttpServer: () => Promise<void>
|
||||||
|
): 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() } };
|
||||||
|
}
|
||||||
|
|
@ -181,60 +181,70 @@ async function handleGetData(
|
||||||
if (!validated.valid) {
|
if (!validated.valid) {
|
||||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||||
}
|
}
|
||||||
return wrapTeamHandler('getData', async () => {
|
const tn = validated.value!;
|
||||||
const tn = validated.value!;
|
let data: TeamData;
|
||||||
const data = await getTeamDataService().getTeamData(tn);
|
try {
|
||||||
const provisioning = getTeamProvisioningService();
|
data = await getTeamDataService().getTeamData(tn);
|
||||||
const isAlive = provisioning.isTeamAlive(tn);
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
if (isAlive) {
|
if (
|
||||||
// Fire-and-forget: relay can take time (waits for lead reply).
|
message === `Team not found: ${tn}` &&
|
||||||
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
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 (isAlive) {
|
||||||
if (live.length === 0) {
|
void provisioning.relayLeadInboxMessages(tn).catch(() => undefined);
|
||||||
return { ...data, isAlive };
|
}
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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 merged: typeof data.messages = [];
|
||||||
const leadSessionTextFingerprints = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const msg of data.messages) {
|
for (const msg of [...data.messages, ...live]) {
|
||||||
if ((msg as { source?: unknown }).source !== 'lead_session') continue;
|
if ((msg as { source?: unknown }).source === 'lead_process') {
|
||||||
if (typeof msg.from !== 'string' || typeof msg.text !== 'string') continue;
|
const fp = `${msg.from}\0${normalizeText(msg.text ?? '')}`;
|
||||||
leadSessionTextFingerprints.add(`${msg.from}\0${normalizeText(msg.text)}`);
|
if (leadSessionTextFingerprints.has(fp)) {
|
||||||
}
|
continue;
|
||||||
|
|
||||||
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 merged: typeof data.messages = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
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(
|
async function handleDeleteTeam(
|
||||||
|
|
@ -548,14 +558,28 @@ async function handleSendMessage(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return wrapTeamHandler('sendMessage', () =>
|
return wrapTeamHandler('sendMessage', async () => {
|
||||||
getTeamDataService().sendMessage(validatedTeamName.value!, {
|
const tn = validatedTeamName.value!;
|
||||||
|
const result = await getTeamDataService().sendMessage(tn, {
|
||||||
member: validatedMember.value!,
|
member: validatedMember.value!,
|
||||||
text: payload.text!,
|
text: payload.text!,
|
||||||
summary: payload.summary,
|
summary: payload.summary,
|
||||||
from: payload.from,
|
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(
|
async function handleCreateTask(
|
||||||
|
|
@ -596,6 +620,17 @@ async function handleCreateTask(
|
||||||
return { success: false, error: 'blockedBy must be an array of task ID strings' };
|
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 (payload.prompt !== undefined) {
|
||||||
if (typeof payload.prompt !== 'string') {
|
if (typeof payload.prompt !== 'string') {
|
||||||
return { success: false, error: 'prompt must be a string' };
|
return { success: false, error: 'prompt must be a string' };
|
||||||
|
|
@ -614,6 +649,7 @@ async function handleCreateTask(
|
||||||
description: payload.description?.trim(),
|
description: payload.description?.trim(),
|
||||||
owner: payload.owner?.trim() || undefined,
|
owner: payload.owner?.trim() || undefined,
|
||||||
blockedBy: payload.blockedBy,
|
blockedBy: payload.blockedBy,
|
||||||
|
related: payload.related,
|
||||||
prompt: payload.prompt?.trim() || undefined,
|
prompt: payload.prompt?.trim() || undefined,
|
||||||
startImmediately: payload.startImmediately,
|
startImmediately: payload.startImmediately,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import type {
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
|
TeamTaskWithKanban,
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
} from '@shared/types';
|
} 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(
|
const members = this.memberResolver.resolveMembers(
|
||||||
config,
|
config,
|
||||||
metaMembers,
|
metaMembers,
|
||||||
inboxNames,
|
inboxNames,
|
||||||
tasks,
|
tasksWithKanban,
|
||||||
messages
|
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 {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
config,
|
config,
|
||||||
tasks,
|
tasks: tasksToReturn,
|
||||||
members,
|
members,
|
||||||
messages,
|
messages,
|
||||||
kanbanState,
|
kanbanState,
|
||||||
|
|
@ -232,6 +245,7 @@ export class TeamDataService {
|
||||||
const nextId = await this.taskReader.getNextTaskId(teamName);
|
const nextId = await this.taskReader.getNextTaskId(teamName);
|
||||||
|
|
||||||
const blockedBy = request.blockedBy?.filter((id) => id.length > 0) ?? [];
|
const blockedBy = request.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||||
|
const related = request.related?.filter((id) => id.length > 0 && id !== nextId) ?? [];
|
||||||
|
|
||||||
let description = request.description
|
let description = request.description
|
||||||
? `${request.subject}\n\n${request.description}`
|
? `${request.subject}\n\n${request.description}`
|
||||||
|
|
@ -262,6 +276,7 @@ export class TeamDataService {
|
||||||
status: shouldStart ? 'in_progress' : 'pending',
|
status: shouldStart ? 'in_progress' : 'pending',
|
||||||
blocks: [],
|
blocks: [],
|
||||||
blockedBy,
|
blockedBy,
|
||||||
|
related: related.length > 0 ? related : undefined,
|
||||||
projectPath,
|
projectPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type {
|
||||||
MemberStatus,
|
MemberStatus,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TeamConfig,
|
TeamConfig,
|
||||||
TeamTask,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
export class TeamMemberResolver {
|
export class TeamMemberResolver {
|
||||||
|
|
@ -11,7 +11,7 @@ export class TeamMemberResolver {
|
||||||
config: TeamConfig,
|
config: TeamConfig,
|
||||||
metaMembers: TeamConfig['members'],
|
metaMembers: TeamConfig['members'],
|
||||||
inboxNames: string[],
|
inboxNames: string[],
|
||||||
tasks: TeamTask[],
|
tasks: TeamTaskWithKanban[],
|
||||||
messages: InboxMessage[]
|
messages: InboxMessage[]
|
||||||
): ResolvedTeamMember[] {
|
): ResolvedTeamMember[] {
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
|
|
@ -70,7 +70,10 @@ export class TeamMemberResolver {
|
||||||
const members: ResolvedTeamMember[] = [];
|
const members: ResolvedTeamMember[] = [];
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
const ownedTasks = tasks.filter((task) => task.owner === name);
|
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 memberMessages = messages.filter((message) => message.from === name);
|
||||||
const latestMessage = memberMessages[0] ?? null;
|
const latestMessage = memberMessages[0] ?? null;
|
||||||
const status = this.resolveStatus(latestMessage);
|
const status = this.resolveStatus(latestMessage);
|
||||||
|
|
|
||||||
|
|
@ -113,8 +113,11 @@ interface ProvisioningRun {
|
||||||
leadName: string;
|
leadName: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
textParts: string[];
|
textParts: string[];
|
||||||
resolve: (text: string) => void;
|
settled: boolean;
|
||||||
reject: (error: string) => void;
|
idleHandle: NodeJS.Timeout | null;
|
||||||
|
idleMs: number;
|
||||||
|
resolveOnce: (text: string) => void;
|
||||||
|
rejectOnce: (error: string) => void;
|
||||||
timeoutHandle: NodeJS.Timeout;
|
timeoutHandle: NodeJS.Timeout;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
@ -266,6 +269,13 @@ function buildTaskStatusProtocol(teamName: string): string {
|
||||||
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment <taskId> --text "<summary of your finding or decision>" --from "<your-name>"
|
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment <taskId> --text "<summary of your finding or decision>" --from "<your-name>"
|
||||||
Do NOT comment on trivial coordination messages. Only comment when the information is valuable context for the task.
|
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 #<taskId> in your SendMessage summary field for traceability.
|
8. When sending a message about a specific task, include #<taskId> 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.`;
|
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`
|
`[${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 {
|
try {
|
||||||
child = spawn(claudePath, launchArgs, {
|
child = spawn(claudePath, launchArgs, {
|
||||||
|
|
@ -1225,18 +1237,43 @@ export class TeamProvisioningService {
|
||||||
}),
|
}),
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const captureTimeoutMs = 60_000;
|
const captureTimeoutMs = 15_000;
|
||||||
|
const captureIdleMs = 800;
|
||||||
const capturePromise = new Promise<string>((resolve, reject) => {
|
const capturePromise = new Promise<string>((resolve, reject) => {
|
||||||
const timeoutHandle = setTimeout(() => {
|
const timeoutHandle = setTimeout(() => {
|
||||||
reject(new Error('Timed out waiting for lead reply'));
|
reject(new Error('Timed out waiting for lead reply'));
|
||||||
}, captureTimeoutMs);
|
}, captureTimeoutMs);
|
||||||
run.leadRelayCapture = {
|
const capture = {
|
||||||
leadName,
|
leadName,
|
||||||
startedAt: nowIso(),
|
startedAt: nowIso(),
|
||||||
textParts: [],
|
textParts: [] as string[],
|
||||||
resolve,
|
settled: false,
|
||||||
reject,
|
idleHandle: null as NodeJS.Timeout | null,
|
||||||
|
idleMs: captureIdleMs,
|
||||||
timeoutHandle,
|
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 {
|
try {
|
||||||
replyText = (await capturePromise).trim() || null;
|
replyText = (await capturePromise).trim() || null;
|
||||||
} catch {
|
} 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 {
|
} finally {
|
||||||
if (run.leadRelayCapture) {
|
if (run.leadRelayCapture) {
|
||||||
|
if (run.leadRelayCapture.idleHandle) {
|
||||||
|
clearTimeout(run.leadRelayCapture.idleHandle);
|
||||||
|
run.leadRelayCapture.idleHandle = null;
|
||||||
|
}
|
||||||
clearTimeout(run.leadRelayCapture.timeoutHandle);
|
clearTimeout(run.leadRelayCapture.timeoutHandle);
|
||||||
run.leadRelayCapture = null;
|
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.
|
* Check if a team has a live process.
|
||||||
*/
|
*/
|
||||||
|
|
@ -1436,27 +1486,54 @@ export class TeamProvisioningService {
|
||||||
// stream-json output has various message types:
|
// stream-json output has various message types:
|
||||||
// {"type":"assistant","content":[{"type":"text","text":"..."},...]}
|
// {"type":"assistant","content":[{"type":"text","text":"..."},...]}
|
||||||
// {"type":"result","subtype":"success",...}
|
// {"type":"result","subtype":"success",...}
|
||||||
if (msg.type === 'assistant' && Array.isArray(msg.content)) {
|
if (msg.type === 'assistant') {
|
||||||
const textParts = (msg.content as Record<string, unknown>[])
|
const content = Array.isArray(msg.content)
|
||||||
|
? (msg.content as Record<string, unknown>[])
|
||||||
|
: (() => {
|
||||||
|
const message = msg.message;
|
||||||
|
if (!message || typeof message !== 'object') return null;
|
||||||
|
const inner = (message as Record<string, unknown>).content;
|
||||||
|
return Array.isArray(inner) ? (inner as Record<string, unknown>[]) : null;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const textParts = (content ?? [])
|
||||||
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
.filter((part) => part.type === 'text' && typeof part.text === 'string')
|
||||||
.map((part) => part.text as string);
|
.map((part) => part.text as string);
|
||||||
if (textParts.length > 0) {
|
if (textParts.length > 0) {
|
||||||
const text = textParts.join('');
|
const text = textParts.join('');
|
||||||
logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`);
|
logger.debug(`[${run.teamName}] assistant: ${text.slice(0, 200)}`);
|
||||||
if (run.leadRelayCapture) {
|
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') {
|
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<string, unknown>).subtype;
|
||||||
|
return typeof inner === 'string' ? inner : undefined;
|
||||||
|
})();
|
||||||
if (subtype === 'success') {
|
if (subtype === 'success') {
|
||||||
logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`);
|
logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`);
|
||||||
if (run.leadRelayCapture) {
|
if (run.leadRelayCapture) {
|
||||||
const capture = run.leadRelayCapture;
|
const capture = run.leadRelayCapture;
|
||||||
const combined = capture.textParts.join('').trim();
|
const combined = capture.textParts.join('').trim();
|
||||||
capture.resolve(combined);
|
capture.resolveOnce(combined);
|
||||||
}
|
}
|
||||||
if (!run.provisioningComplete) {
|
if (!run.provisioningComplete) {
|
||||||
void this.handleProvisioningTurnComplete(run);
|
void this.handleProvisioningTurnComplete(run);
|
||||||
|
|
@ -1466,7 +1543,7 @@ export class TeamProvisioningService {
|
||||||
typeof msg.error === 'string' ? msg.error : JSON.stringify(msg.error ?? 'unknown');
|
typeof msg.error === 'string' ? msg.error : JSON.stringify(msg.error ?? 'unknown');
|
||||||
logger.warn(`[${run.teamName}] stream-json result: error — ${errorMsg}`);
|
logger.warn(`[${run.teamName}] stream-json result: error — ${errorMsg}`);
|
||||||
if (run.leadRelayCapture) {
|
if (run.leadRelayCapture) {
|
||||||
run.leadRelayCapture.reject(errorMsg);
|
run.leadRelayCapture.rejectOnce(errorMsg);
|
||||||
}
|
}
|
||||||
if (!run.provisioningComplete) {
|
if (!run.provisioningComplete) {
|
||||||
const progress = updateProgress(
|
const progress = updateProgress(
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,9 @@ export class TeamTaskReader {
|
||||||
: 'pending',
|
: 'pending',
|
||||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
|
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
|
||||||
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy 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,
|
createdAt,
|
||||||
projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined,
|
projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined,
|
||||||
comments: Array.isArray(parsed.comments)
|
comments: Array.isArray(parsed.comments)
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export class TeamTaskWriter {
|
||||||
description: task.description ?? '',
|
description: task.description ?? '',
|
||||||
blocks: task.blocks ?? [],
|
blocks: task.blocks ?? [],
|
||||||
blockedBy: task.blockedBy ?? [],
|
blockedBy: task.blockedBy ?? [],
|
||||||
|
related: task.related ?? [],
|
||||||
createdAt: task.createdAt ?? new Date().toISOString(),
|
createdAt: task.createdAt ?? new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
TOOL_CALL_BORDER,
|
TOOL_CALL_BORDER,
|
||||||
TOOL_CALL_TEXT,
|
TOOL_CALL_TEXT,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
|
import { rehypePlugins } from '@renderer/utils/markdownPlugins';
|
||||||
import { formatTokensCompact as formatTokens } from '@shared/utils/tokenFormatting';
|
import { formatTokensCompact as formatTokens } from '@shared/utils/tokenFormatting';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { ChevronRight, Layers } from 'lucide-react';
|
import { ChevronRight, Layers } from 'lucide-react';
|
||||||
|
|
@ -146,7 +147,11 @@ export const CompactBoundary = ({
|
||||||
style={{ borderColor: 'var(--chat-ai-border)' }}
|
style={{ borderColor: 'var(--chat-ai-border)' }}
|
||||||
>
|
>
|
||||||
{compactContent ? (
|
{compactContent ? (
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={markdownComponents}
|
||||||
|
>
|
||||||
{compactContent}
|
{compactContent}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ export const DisplayItemList = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
let itemKey = '';
|
let itemKey = '';
|
||||||
let element: React.ReactNode = null;
|
let element: React.ReactNode = null;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { rehypePlugins } from '@renderer/utils/markdownPlugins';
|
||||||
import { AlertTriangle, CheckCircle, FileCheck, XCircle } from 'lucide-react';
|
import { AlertTriangle, CheckCircle, FileCheck, XCircle } from 'lucide-react';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -88,7 +89,11 @@ export const LastOutputDisplay = ({
|
||||||
|
|
||||||
{/* Content - scrollable */}
|
{/* Content - scrollable */}
|
||||||
<div className="max-h-96 overflow-y-auto px-4 py-3" data-search-content>
|
<div className="max-h-96 overflow-y-auto px-4 py-3" data-search-content>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={mdComponents}
|
||||||
|
>
|
||||||
{textContent}
|
{textContent}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -231,7 +236,11 @@ export const LastOutputDisplay = ({
|
||||||
|
|
||||||
{/* Plan content - scrollable */}
|
{/* Plan content - scrollable */}
|
||||||
<div className="max-h-96 overflow-y-auto px-4 py-3">
|
<div className="max-h-96 overflow-y-auto px-4 py-3">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={mdComponents}
|
||||||
|
>
|
||||||
{planContent}
|
{planContent}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import ReactMarkdown, { type Components } from 'react-markdown';
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { useTabUI } from '@renderer/hooks/useTabUI';
|
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { rehypePlugins } from '@renderer/utils/markdownPlugins';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { User } from 'lucide-react';
|
import { User } from 'lucide-react';
|
||||||
|
|
@ -204,7 +205,10 @@ function createUserMarkdownComponents(
|
||||||
|
|
||||||
if (isBlock) {
|
if (isBlock) {
|
||||||
return (
|
return (
|
||||||
<code className="block font-mono text-xs" style={{ color: userTextColor }}>
|
<code
|
||||||
|
className={`block font-mono text-xs ${className ?? ''}`.trim()}
|
||||||
|
style={{ color: userTextColor }}
|
||||||
|
>
|
||||||
{hl(children)}
|
{hl(children)}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
|
|
@ -442,7 +446,11 @@ const UserChatGroupInner = ({ userGroup }: Readonly<UserChatGroupProps>): React.
|
||||||
<CopyButton text={textContent} bgColor="var(--chat-user-bg)" />
|
<CopyButton text={textContent} bgColor="var(--chat-user-bg)" />
|
||||||
|
|
||||||
<div className="text-sm" style={{ color: 'var(--chat-user-text)' }} data-search-content>
|
<div className="text-sm" style={{ color: 'var(--chat-user-text)' }} data-search-content>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={userMarkdownComponents}>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={userMarkdownComponents}
|
||||||
|
>
|
||||||
{displayText}
|
{displayText}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,7 @@ export const BaseItem: React.FC<BaseItemProps> = ({
|
||||||
{/* Expanded Content */}
|
{/* Expanded Content */}
|
||||||
{isExpanded && children && (
|
{isExpanded && children && (
|
||||||
<div
|
<div
|
||||||
className="ml-2 mt-2 space-y-3 pl-6"
|
className="ml-2 mt-2 min-w-0 space-y-3 pl-6"
|
||||||
style={{ borderLeft: '2px solid var(--color-border)' }}
|
style={{ borderLeft: '2px solid var(--color-border)' }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,7 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo
|
||||||
</del>
|
</del>
|
||||||
),
|
),
|
||||||
|
|
||||||
// Inline code vs block code
|
// Inline code vs block code (block is highlighted by rehype-highlight; preserve hljs class)
|
||||||
code: ({ className, children }) => {
|
code: ({ className, children }) => {
|
||||||
const hasLanguageClass = className?.includes('language-');
|
const hasLanguageClass = className?.includes('language-');
|
||||||
const content = typeof children === 'string' ? children : '';
|
const content = typeof children === 'string' ? children : '';
|
||||||
|
|
@ -119,7 +119,10 @@ export function createMarkdownComponents(searchCtx: SearchContext | null): Compo
|
||||||
|
|
||||||
if (isBlock) {
|
if (isBlock) {
|
||||||
return (
|
return (
|
||||||
<code className="block font-mono text-xs" style={{ color: 'var(--color-text)' }}>
|
<code
|
||||||
|
className={`block font-mono text-xs ${className ?? ''}`.trim()}
|
||||||
|
style={{ color: 'var(--color-text)' }}
|
||||||
|
>
|
||||||
{hl(children)}
|
{hl(children)}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
PROSE_TABLE_HEADER_BG,
|
PROSE_TABLE_HEADER_BG,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { rehypePlugins } from '@renderer/utils/markdownPlugins';
|
||||||
import { FileText } from 'lucide-react';
|
import { FileText } from 'lucide-react';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -137,7 +138,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
||||||
</del>
|
</del>
|
||||||
),
|
),
|
||||||
|
|
||||||
// Code: inline vs block detection
|
// Code: inline vs block detection (block code is highlighted by rehype-highlight; preserve hljs class)
|
||||||
code: (props) => {
|
code: (props) => {
|
||||||
const {
|
const {
|
||||||
className: codeClassName,
|
className: codeClassName,
|
||||||
|
|
@ -155,7 +156,10 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
||||||
|
|
||||||
if (isBlock) {
|
if (isBlock) {
|
||||||
return (
|
return (
|
||||||
<code className="font-mono text-xs" style={{ color: COLOR_TEXT }}>
|
<code
|
||||||
|
className={`break-all font-mono text-xs ${codeClassName ?? ''}`.trim()}
|
||||||
|
style={{ color: COLOR_TEXT }}
|
||||||
|
>
|
||||||
{hl(children)}
|
{hl(children)}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
|
|
@ -163,7 +167,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
||||||
// Inline code — no hl(); parent block element's hl() descends here
|
// Inline code — no hl(); parent block element's hl() descends here
|
||||||
return (
|
return (
|
||||||
<code
|
<code
|
||||||
className="rounded px-1.5 py-0.5 font-mono text-xs"
|
className="break-all rounded px-1.5 py-0.5 font-mono text-xs"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: PROSE_CODE_BG,
|
backgroundColor: PROSE_CODE_BG,
|
||||||
color: PROSE_CODE_TEXT,
|
color: PROSE_CODE_TEXT,
|
||||||
|
|
@ -177,7 +181,7 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
||||||
// Code blocks
|
// Code blocks
|
||||||
pre: ({ children }) => (
|
pre: ({ children }) => (
|
||||||
<pre
|
<pre
|
||||||
className="my-3 overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
|
className="my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: PROSE_PRE_BG,
|
backgroundColor: PROSE_PRE_BG,
|
||||||
border: `1px solid ${PROSE_PRE_BORDER}`,
|
border: `1px solid ${PROSE_PRE_BORDER}`,
|
||||||
|
|
@ -296,7 +300,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`overflow-hidden rounded-lg shadow-sm ${copyable && !label ? 'group relative' : ''} ${className}`}
|
className={`min-w-0 overflow-hidden rounded-lg shadow-sm ${copyable && !label ? 'group relative' : ''} ${className}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: CODE_BG,
|
backgroundColor: CODE_BG,
|
||||||
border: `1px solid ${CODE_BORDER}`,
|
border: `1px solid ${CODE_BORDER}`,
|
||||||
|
|
@ -328,9 +332,13 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Markdown content with scroll */}
|
{/* Markdown content with scroll */}
|
||||||
<div className={`overflow-auto ${maxHeight}`}>
|
<div className={`min-w-0 overflow-auto ${maxHeight}`}>
|
||||||
<div className="p-4">
|
<div className="min-w-0 break-words p-4">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={components}
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import ReactMarkdown from 'react-markdown';
|
||||||
|
|
||||||
import { markdownComponents } from '@renderer/components/chat/markdownComponents';
|
import { markdownComponents } from '@renderer/components/chat/markdownComponents';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { rehypePlugins } from '@renderer/utils/markdownPlugins';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
|
|
||||||
|
|
@ -150,7 +151,11 @@ export const UpdateDialog = (): React.JSX.Element | null => {
|
||||||
color: 'var(--color-text-muted)',
|
color: 'var(--color-text-muted)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
<ReactMarkdown
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
components={markdownComponents}
|
||||||
|
>
|
||||||
{normalizeReleaseNotes(releaseNotes)}
|
{normalizeReleaseNotes(releaseNotes)}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,12 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { DateGroupedSessions } from '../sidebar/DateGroupedSessions';
|
import { DateGroupedSessions } from '../sidebar/DateGroupedSessions';
|
||||||
import { GlobalTaskList } from '../sidebar/GlobalTaskList';
|
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 { SidebarHeader } from './SidebarHeader';
|
||||||
|
|
||||||
import type { TaskFiltersState } from '../sidebar/TaskFiltersPopover';
|
import type { TaskFiltersState } from '../sidebar/taskFiltersState';
|
||||||
|
|
||||||
type SidebarTab = 'tasks' | 'sessions';
|
type SidebarTab = 'tasks' | 'sessions';
|
||||||
|
|
||||||
|
|
@ -106,7 +107,7 @@ export const Sidebar = (): React.JSX.Element => {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex min-w-0 flex-1 flex-col overflow-hidden"
|
className="flex min-w-0 flex-1 flex-col overflow-hidden pr-2"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
minWidth: sidebarCollapsed ? 0 : width,
|
minWidth: sidebarCollapsed ? 0 : width,
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,56 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@renderer/components/ui/select';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { getNonEmptyTaskCategories, groupTasksByDate } from '@renderer/utils/taskGrouping';
|
import {
|
||||||
|
getNonEmptyTaskCategories,
|
||||||
|
groupTasksByDate,
|
||||||
|
groupTasksByProject,
|
||||||
|
} from '@renderer/utils/taskGrouping';
|
||||||
import { ListTodo, Search, X } from 'lucide-react';
|
import { ListTodo, Search, X } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { SidebarTaskItem } from './SidebarTaskItem';
|
import { SidebarTaskItem } from './SidebarTaskItem';
|
||||||
|
import { TaskFiltersPopover } from './TaskFiltersPopover';
|
||||||
import {
|
import {
|
||||||
defaultTaskFiltersState,
|
defaultTaskFiltersState,
|
||||||
getTaskUnreadCount,
|
getTaskUnreadCount,
|
||||||
TaskFiltersPopover,
|
|
||||||
taskMatchesStatus,
|
taskMatchesStatus,
|
||||||
useReadStateSnapshot,
|
useReadStateSnapshot,
|
||||||
} from './TaskFiltersPopover';
|
} from './taskFiltersState';
|
||||||
|
|
||||||
import type { TaskFiltersState } from './TaskFiltersPopover';
|
import type { TaskFiltersState } from './taskFiltersState';
|
||||||
import type { GlobalTask } from '@shared/types';
|
import type { GlobalTask } from '@shared/types';
|
||||||
|
|
||||||
|
const TASK_GROUPING_STORAGE_KEY = 'sidebarTasksGrouping';
|
||||||
|
|
||||||
|
export type TaskGroupingMode = 'project' | 'time';
|
||||||
|
|
||||||
|
function loadGroupingMode(): TaskGroupingMode {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(TASK_GROUPING_STORAGE_KEY);
|
||||||
|
if (v === 'project' || v === 'time') return v;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return 'project';
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveGroupingMode(mode: TaskGroupingMode): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TASK_GROUPING_STORAGE_KEY, mode);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface GlobalTaskListProps {
|
export interface GlobalTaskListProps {
|
||||||
/** When true, do not render the header row (Tasks + Filters); parent renders tabs and filters. */
|
/** When true, do not render the header row (Tasks + Filters); parent renders tabs and filters. */
|
||||||
hideHeader?: boolean;
|
hideHeader?: boolean;
|
||||||
|
|
@ -90,10 +123,16 @@ export const GlobalTaskList = ({
|
||||||
const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen;
|
const filtersPopoverOpen = externalFiltersPopoverOpen ?? internalFiltersPopoverOpen;
|
||||||
const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen;
|
const setFiltersPopoverOpen = externalOnFiltersPopoverOpenChange ?? setInternalFiltersPopoverOpen;
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [groupingMode, setGroupingModeState] = useState<TaskGroupingMode>(loadGroupingMode);
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
const hasFetchedRef = useRef(false);
|
const hasFetchedRef = useRef(false);
|
||||||
const readState = useReadStateSnapshot();
|
const readState = useReadStateSnapshot();
|
||||||
|
|
||||||
|
const setGroupingMode = (mode: TaskGroupingMode): void => {
|
||||||
|
setGroupingModeState(mode);
|
||||||
|
saveGroupingMode(mode);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasFetchedRef.current) {
|
if (!hasFetchedRef.current) {
|
||||||
hasFetchedRef.current = true;
|
hasFetchedRef.current = true;
|
||||||
|
|
@ -144,6 +183,10 @@ export const GlobalTaskList = ({
|
||||||
|
|
||||||
const grouped = useMemo(() => groupTasksByDate(filtered), [filtered]);
|
const grouped = useMemo(() => groupTasksByDate(filtered), [filtered]);
|
||||||
const categories = useMemo(() => getNonEmptyTaskCategories(grouped), [grouped]);
|
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 (
|
return (
|
||||||
<div className="flex size-full min-w-0 flex-col">
|
<div className="flex size-full min-w-0 flex-col">
|
||||||
|
|
@ -192,6 +235,23 @@ export const GlobalTaskList = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Grouping mode */}
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center gap-2 border-b px-2 py-1"
|
||||||
|
style={{ borderColor: 'var(--color-border)' }}
|
||||||
|
>
|
||||||
|
<span className="shrink-0 text-[11px] text-text-muted">Group by:</span>
|
||||||
|
<Select value={groupingMode} onValueChange={(v) => setGroupingMode(v as TaskGroupingMode)}>
|
||||||
|
<SelectTrigger className="h-7 min-w-0 flex-1 border-[var(--color-border)] px-2 text-[11px]">
|
||||||
|
<SelectValue placeholder="Group by" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="project">Project</SelectItem>
|
||||||
|
<SelectItem value="time">Time</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{globalTasksLoading && globalTasks.length === 0 && (
|
{globalTasksLoading && globalTasks.length === 0 && (
|
||||||
|
|
@ -202,7 +262,7 @@ export const GlobalTaskList = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!globalTasksLoading && categories.length === 0 && (
|
{!globalTasksLoading && !hasContent && (
|
||||||
<div className="flex flex-col items-center gap-2 px-4 py-8 text-text-muted">
|
<div className="flex flex-col items-center gap-2 px-4 py-8 text-text-muted">
|
||||||
<ListTodo className="size-8 opacity-40" />
|
<ListTodo className="size-8 opacity-40" />
|
||||||
<span className="text-[12px]">
|
<span className="text-[12px]">
|
||||||
|
|
@ -211,38 +271,68 @@ export const GlobalTaskList = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{categories.map((category) => {
|
{groupingMode === 'project' &&
|
||||||
const tasks = grouped[category];
|
projectGroups.map((group) => {
|
||||||
let lastTeam: string | null = null;
|
if (group.tasks.length === 0) return null;
|
||||||
|
let lastTeam: string | null = null;
|
||||||
return (
|
return (
|
||||||
<div key={category}>
|
<div key={group.projectKey}>
|
||||||
{/* Date header */}
|
<div
|
||||||
<div
|
className="sticky top-0 z-10 px-3 py-1.5 text-[11px] font-semibold text-text-secondary"
|
||||||
className="sticky top-0 z-10 px-3 py-1.5 text-[11px] font-semibold text-text-secondary"
|
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
|
||||||
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
|
>
|
||||||
>
|
{group.projectLabel}
|
||||||
{dateCategoryLabels[category] ?? category}
|
</div>
|
||||||
|
{group.tasks.map((task) => {
|
||||||
|
const showTeamHeader = task.teamName !== lastTeam;
|
||||||
|
lastTeam = task.teamName;
|
||||||
|
return (
|
||||||
|
<div key={`${task.teamName}-${task.id}`}>
|
||||||
|
{showTeamHeader && (
|
||||||
|
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
|
||||||
|
Team: {task.teamDisplayName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SidebarTaskItem task={task} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{tasks.map((task) => {
|
{groupingMode === 'time' &&
|
||||||
const showTeamHeader = task.teamName !== lastTeam;
|
categories.map((category) => {
|
||||||
lastTeam = task.teamName;
|
const tasks = grouped[category];
|
||||||
|
let lastTeam: string | null = null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`${task.teamName}-${task.id}`}>
|
<div key={category}>
|
||||||
{showTeamHeader && (
|
<div
|
||||||
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
|
className="sticky top-0 z-10 px-3 py-1.5 text-[11px] font-semibold text-text-secondary"
|
||||||
Team: {task.teamDisplayName}
|
style={{ backgroundColor: 'var(--color-surface-sidebar)' }}
|
||||||
</div>
|
>
|
||||||
)}
|
{dateCategoryLabels[category] ?? category}
|
||||||
<SidebarTaskItem task={task} />
|
</div>
|
||||||
</div>
|
|
||||||
);
|
{tasks.map((task) => {
|
||||||
})}
|
const showTeamHeader = task.teamName !== lastTeam;
|
||||||
</div>
|
lastTeam = task.teamName;
|
||||||
);
|
|
||||||
})}
|
return (
|
||||||
|
<div key={`${task.teamName}-${task.id}`}>
|
||||||
|
{showTeamHeader && (
|
||||||
|
<div className="px-3 pb-0.5 pt-1.5 text-[10px] font-medium text-text-muted">
|
||||||
|
Team: {task.teamDisplayName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SidebarTaskItem task={task} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
import { useUnreadCommentCount } from '@renderer/hooks/useUnreadCommentCount';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { format, isThisYear, isToday, isYesterday } from 'date-fns';
|
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 { GlobalTask, TeamTaskStatus } from '@shared/types';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
@ -30,7 +30,12 @@ interface SidebarTaskItemProps {
|
||||||
export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Element => {
|
export const SidebarTaskItem = ({ task }: SidebarTaskItemProps): React.JSX.Element => {
|
||||||
const openTeamTab = useStore((s) => s.openTeamTab);
|
const openTeamTab = useStore((s) => s.openTeamTab);
|
||||||
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
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 StatusIcon = cfg.icon;
|
||||||
const dateLabel = formatTaskDate(task.createdAt);
|
const dateLabel = formatTaskDate(task.createdAt);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,10 @@
|
||||||
import { useSyncExternalStore } from 'react';
|
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
import { Combobox } from '@renderer/components/ui/combobox';
|
import { Combobox } from '@renderer/components/ui/combobox';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
|
||||||
import { getSnapshot, getUnreadCount, subscribe } from '@renderer/services/commentReadStorage';
|
|
||||||
import { Filter } from 'lucide-react';
|
import { Filter } from 'lucide-react';
|
||||||
|
|
||||||
export type TaskStatusFilterId = 'todo' | 'in_progress' | 'done' | 'review' | 'approved';
|
import { STATUS_OPTIONS, type TaskFiltersState, type TaskStatusFilterId } from './taskFiltersState';
|
||||||
|
|
||||||
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<TaskStatusFilterId>;
|
|
||||||
teamName: string | null;
|
|
||||||
unreadOnly: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TaskFiltersPopoverProps {
|
interface TaskFiltersPopoverProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -152,44 +135,3 @@ export const TaskFiltersPopover = ({
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
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<TaskStatusFilterId>
|
|
||||||
): 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<typeof getSnapshot> {
|
|
||||||
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTaskUnreadCount(
|
|
||||||
readState: ReturnType<typeof getSnapshot>,
|
|
||||||
teamName: string,
|
|
||||||
taskId: string,
|
|
||||||
comments: { createdAt: string }[] | undefined
|
|
||||||
): number {
|
|
||||||
return getUnreadCount(readState, teamName, taskId, comments ?? []);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
60
src/renderer/components/sidebar/taskFiltersState.ts
Normal file
60
src/renderer/components/sidebar/taskFiltersState.ts
Normal file
|
|
@ -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<TaskStatusFilterId>;
|
||||||
|
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<TaskStatusFilterId>
|
||||||
|
): 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<typeof getSnapshot> {
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskUnreadCount(
|
||||||
|
readState: ReturnType<typeof getSnapshot>,
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
comments: { createdAt: string }[] | undefined
|
||||||
|
): number {
|
||||||
|
return getUnreadCount(readState, teamName, taskId, comments ?? []);
|
||||||
|
}
|
||||||
|
|
@ -32,12 +32,14 @@ import { TeamSessionsSection } from './TeamSessionsSection';
|
||||||
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
||||||
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
|
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
|
||||||
import type { Session } from '@renderer/types/data';
|
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 {
|
interface TeamDetailViewProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACTIVE_PROVISIONING_STATES = new Set(['validating', 'spawning', 'monitoring', 'verifying']);
|
||||||
|
|
||||||
interface CreateTaskDialogState {
|
interface CreateTaskDialogState {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
defaultSubject: string;
|
defaultSubject: string;
|
||||||
|
|
@ -50,7 +52,7 @@ interface TimeWindow {
|
||||||
end: number;
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterKanbanTasks(tasks: TeamTask[], query: string): TeamTask[] {
|
function filterKanbanTasks(tasks: TeamTaskWithKanban[], query: string): TeamTaskWithKanban[] {
|
||||||
if (query.startsWith('#')) {
|
if (query.startsWith('#')) {
|
||||||
const id = query.slice(1);
|
const id = query.slice(1);
|
||||||
return tasks.filter((t) => t.id === id);
|
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 => {
|
export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Element => {
|
||||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = useState<TeamTask | null>(null);
|
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
||||||
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
|
const [selectedMember, setSelectedMember] = useState<ResolvedTeamMember | null>(null);
|
||||||
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>({});
|
const [pendingRepliesByMember, setPendingRepliesByMember] = useState<Record<string, number>>({});
|
||||||
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
const [createTaskDialog, setCreateTaskDialog] = useState<CreateTaskDialogState>({
|
||||||
|
|
@ -113,6 +115,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
reviewActionError,
|
reviewActionError,
|
||||||
launchTeam,
|
launchTeam,
|
||||||
provisioningError,
|
provisioningError,
|
||||||
|
isTeamProvisioning,
|
||||||
kanbanFilterQuery,
|
kanbanFilterQuery,
|
||||||
clearKanbanFilter,
|
clearKanbanFilter,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
|
|
@ -136,6 +139,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
reviewActionError: s.reviewActionError,
|
reviewActionError: s.reviewActionError,
|
||||||
launchTeam: s.launchTeam,
|
launchTeam: s.launchTeam,
|
||||||
provisioningError: s.provisioningError,
|
provisioningError: s.provisioningError,
|
||||||
|
isTeamProvisioning: Object.values(s.provisioningRuns).some(
|
||||||
|
(run) => run.teamName === teamName && ACTIVE_PROVISIONING_STATES.has(run.state)
|
||||||
|
),
|
||||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||||
clearKanbanFilter: s.clearKanbanFilter,
|
clearKanbanFilter: s.clearKanbanFilter,
|
||||||
}))
|
}))
|
||||||
|
|
@ -369,6 +375,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
description: string,
|
description: string,
|
||||||
owner?: string,
|
owner?: string,
|
||||||
blockedBy?: string[],
|
blockedBy?: string[],
|
||||||
|
related?: string[],
|
||||||
prompt?: string,
|
prompt?: string,
|
||||||
startImmediately?: boolean
|
startImmediately?: boolean
|
||||||
): void => {
|
): void => {
|
||||||
|
|
@ -380,11 +387,12 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
owner,
|
owner,
|
||||||
blockedBy,
|
blockedBy,
|
||||||
|
related,
|
||||||
prompt,
|
prompt,
|
||||||
startImmediately,
|
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}`;
|
const msg = `New task assigned to ${owner}: "${subject}". Instructions:\n${prompt}`;
|
||||||
try {
|
try {
|
||||||
await api.teams.processSend(teamName, msg);
|
await api.teams.processSend(teamName, msg);
|
||||||
|
|
@ -527,6 +535,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
taskMap={taskMap}
|
taskMap={taskMap}
|
||||||
pendingRepliesByMember={pendingRepliesByMember}
|
pendingRepliesByMember={pendingRepliesByMember}
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive}
|
||||||
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
onMemberClick={setSelectedMember}
|
onMemberClick={setSelectedMember}
|
||||||
onSendMessage={(member) => {
|
onSendMessage={(member) => {
|
||||||
setSendDialogRecipient(member.name);
|
setSendDialogRecipient(member.name);
|
||||||
|
|
@ -714,6 +723,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
<ActivityTimeline
|
<ActivityTimeline
|
||||||
messages={filteredMessages}
|
messages={filteredMessages}
|
||||||
members={data.members}
|
members={data.members}
|
||||||
|
readSet={readSet}
|
||||||
|
getMessageKey={toMessageKey}
|
||||||
onMemberClick={setSelectedMember}
|
onMemberClick={setSelectedMember}
|
||||||
onCreateTaskFromMessage={(subject, description) => {
|
onCreateTaskFromMessage={(subject, description) => {
|
||||||
openCreateTaskDialog(subject, description);
|
openCreateTaskDialog(subject, description);
|
||||||
|
|
@ -757,6 +768,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
tasks={data.tasks}
|
tasks={data.tasks}
|
||||||
messages={data.messages}
|
messages={data.messages}
|
||||||
|
isTeamAlive={data.isAlive}
|
||||||
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
onClose={() => setSelectedMember(null)}
|
onClose={() => setSelectedMember(null)}
|
||||||
onSendMessage={() => {
|
onSendMessage={() => {
|
||||||
const name = selectedMember?.name ?? '';
|
const name = selectedMember?.name ?? '';
|
||||||
|
|
@ -781,7 +794,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={data.members}
|
members={data.members}
|
||||||
tasks={data.tasks}
|
tasks={data.tasks}
|
||||||
isTeamAlive={data.isAlive}
|
isTeamAlive={data.isAlive && !isTeamProvisioning}
|
||||||
defaultSubject={createTaskDialog.defaultSubject}
|
defaultSubject={createTaskDialog.defaultSubject}
|
||||||
defaultDescription={createTaskDialog.defaultDescription}
|
defaultDescription={createTaskDialog.defaultDescription}
|
||||||
defaultOwner={createTaskDialog.defaultOwner}
|
defaultOwner={createTaskDialog.defaultOwner}
|
||||||
|
|
@ -803,6 +816,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
<LaunchTeamDialog
|
<LaunchTeamDialog
|
||||||
open={launchDialogOpen}
|
open={launchDialogOpen}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
members={data?.members ?? []}
|
||||||
defaultProjectPath={data.config.projectPath}
|
defaultProjectPath={data.config.projectPath}
|
||||||
provisioningError={provisioningError}
|
provisioningError={provisioningError}
|
||||||
onClose={() => setLaunchDialogOpen(false)}
|
onClose={() => setLaunchDialogOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,13 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface ActiveTasksBlockProps {
|
interface ActiveTasksBlockProps {
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
tasks: TeamTask[];
|
tasks: TeamTaskWithKanban[];
|
||||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||||
onTaskClick?: (task: TeamTask) => void;
|
onTaskClick?: (task: TeamTaskWithKanban) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ActiveTasksBlock = ({
|
export const ActiveTasksBlock = ({
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ interface ActivityItemProps {
|
||||||
memberRole?: string;
|
memberRole?: string;
|
||||||
memberColor?: string;
|
memberColor?: string;
|
||||||
recipientColor?: string;
|
recipientColor?: string;
|
||||||
|
/** When true, show a blue unread dot. */
|
||||||
|
isUnread?: boolean;
|
||||||
onMemberNameClick?: (memberName: string) => void;
|
onMemberNameClick?: (memberName: string) => void;
|
||||||
onCreateTask?: (subject: string, description: string) => void;
|
onCreateTask?: (subject: string, description: string) => void;
|
||||||
onReply?: (message: InboxMessage) => void;
|
onReply?: (message: InboxMessage) => void;
|
||||||
|
|
@ -127,6 +129,7 @@ export const ActivityItem = ({
|
||||||
memberRole,
|
memberRole,
|
||||||
memberColor,
|
memberColor,
|
||||||
recipientColor,
|
recipientColor,
|
||||||
|
isUnread,
|
||||||
onMemberNameClick,
|
onMemberNameClick,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
onReply,
|
onReply,
|
||||||
|
|
@ -175,7 +178,7 @@ export const ActivityItem = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const summaryText = message.summary || autoSummary || '';
|
const summaryText = message.summary || autoSummary || '';
|
||||||
const HeaderTag = systemLabel ? 'button' : 'div';
|
const isHeaderClickable = Boolean(systemLabel);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
|
|
@ -186,16 +189,18 @@ export const ActivityItem = ({
|
||||||
borderLeft: `3px solid ${colors.border}`,
|
borderLeft: `3px solid ${colors.border}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header — clickable when system message to toggle expand */}
|
{/* Header — div with role=button (cannot use <button> due to nested buttons inside) */}
|
||||||
<HeaderTag
|
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- role=button, tabIndex, onKeyDown below; nested buttons prevent using native button */}
|
||||||
type={systemLabel ? 'button' : undefined}
|
<div
|
||||||
|
role={isHeaderClickable ? 'button' : undefined}
|
||||||
|
tabIndex={isHeaderClickable ? 0 : undefined}
|
||||||
className={[
|
className={[
|
||||||
'flex items-center gap-2 px-3 py-2',
|
'flex items-center gap-2 px-3 py-2',
|
||||||
systemLabel ? 'w-full cursor-pointer select-none border-0 bg-transparent text-left' : '',
|
isHeaderClickable ? 'cursor-pointer select-none' : '',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
onClick={systemLabel ? () => setIsExpanded((v) => !v) : undefined}
|
onClick={isHeaderClickable ? () => setIsExpanded((v) => !v) : undefined}
|
||||||
onKeyDown={
|
onKeyDown={
|
||||||
systemLabel
|
isHeaderClickable
|
||||||
? (e) => {
|
? (e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -205,6 +210,9 @@ export const ActivityItem = ({
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{isUnread ? (
|
||||||
|
<span className="size-2 shrink-0 rounded-full bg-blue-500" title="Unread" aria-hidden />
|
||||||
|
) : null}
|
||||||
{/* Chevron for collapsible system messages */}
|
{/* Chevron for collapsible system messages */}
|
||||||
{systemLabel ? (
|
{systemLabel ? (
|
||||||
<ChevronRight
|
<ChevronRight
|
||||||
|
|
@ -374,7 +382,7 @@ export const ActivityItem = ({
|
||||||
{timestamp}
|
{timestamp}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</HeaderTag>
|
</div>
|
||||||
|
|
||||||
{/* Content — collapsed for system messages, expanded for others */}
|
{/* Content — collapsed for system messages, expanded for others */}
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ import type { InboxMessage, ResolvedTeamMember } from '@shared/types';
|
||||||
interface ActivityTimelineProps {
|
interface ActivityTimelineProps {
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
members?: ResolvedTeamMember[];
|
members?: ResolvedTeamMember[];
|
||||||
|
/** Set of message keys that have been read; messages not in this set show an unread dot. */
|
||||||
|
readSet?: Set<string>;
|
||||||
|
/** Function to get a stable key for a message (used with readSet). */
|
||||||
|
getMessageKey?: (message: InboxMessage) => string;
|
||||||
onCreateTaskFromMessage?: (subject: string, description: string) => void;
|
onCreateTaskFromMessage?: (subject: string, description: string) => void;
|
||||||
onReplyToMessage?: (message: InboxMessage) => void;
|
onReplyToMessage?: (message: InboxMessage) => void;
|
||||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||||
|
|
@ -23,6 +27,7 @@ const MessageRowWithObserver = ({
|
||||||
memberRole,
|
memberRole,
|
||||||
memberColor,
|
memberColor,
|
||||||
recipientColor,
|
recipientColor,
|
||||||
|
isUnread,
|
||||||
onMemberNameClick,
|
onMemberNameClick,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
onReply,
|
onReply,
|
||||||
|
|
@ -32,6 +37,7 @@ const MessageRowWithObserver = ({
|
||||||
memberRole?: string;
|
memberRole?: string;
|
||||||
memberColor?: string;
|
memberColor?: string;
|
||||||
recipientColor?: string;
|
recipientColor?: string;
|
||||||
|
isUnread?: boolean;
|
||||||
onMemberNameClick?: (name: string) => void;
|
onMemberNameClick?: (name: string) => void;
|
||||||
onCreateTask?: (subject: string, description: string) => void;
|
onCreateTask?: (subject: string, description: string) => void;
|
||||||
onReply?: (message: InboxMessage) => void;
|
onReply?: (message: InboxMessage) => void;
|
||||||
|
|
@ -74,6 +80,7 @@ const MessageRowWithObserver = ({
|
||||||
memberRole={memberRole}
|
memberRole={memberRole}
|
||||||
memberColor={memberColor}
|
memberColor={memberColor}
|
||||||
recipientColor={recipientColor}
|
recipientColor={recipientColor}
|
||||||
|
isUnread={isUnread}
|
||||||
onMemberNameClick={onMemberNameClick}
|
onMemberNameClick={onMemberNameClick}
|
||||||
onCreateTask={onCreateTask}
|
onCreateTask={onCreateTask}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
|
|
@ -85,6 +92,8 @@ const MessageRowWithObserver = ({
|
||||||
export const ActivityTimeline = ({
|
export const ActivityTimeline = ({
|
||||||
messages,
|
messages,
|
||||||
members,
|
members,
|
||||||
|
readSet,
|
||||||
|
getMessageKey,
|
||||||
onCreateTaskFromMessage,
|
onCreateTaskFromMessage,
|
||||||
onReplyToMessage,
|
onReplyToMessage,
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
|
|
@ -126,6 +135,8 @@ export const ActivityTimeline = ({
|
||||||
const recipientColor =
|
const recipientColor =
|
||||||
recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined);
|
recipientInfo?.color ?? (message.to ? getMemberColorByName(message.to) : undefined);
|
||||||
const messageKey = `${message.messageId ?? index}-${message.timestamp}-${message.from}`;
|
const messageKey = `${message.messageId ?? index}-${message.timestamp}-${message.from}`;
|
||||||
|
const isUnread =
|
||||||
|
readSet !== undefined && getMessageKey ? !readSet.has(getMessageKey(message)) : false;
|
||||||
return (
|
return (
|
||||||
<MessageRowWithObserver
|
<MessageRowWithObserver
|
||||||
key={messageKey}
|
key={messageKey}
|
||||||
|
|
@ -133,6 +144,7 @@ export const ActivityTimeline = ({
|
||||||
memberRole={info?.role}
|
memberRole={info?.role}
|
||||||
memberColor={info?.color}
|
memberColor={info?.color}
|
||||||
recipientColor={recipientColor}
|
recipientColor={recipientColor}
|
||||||
|
isUnread={isUnread}
|
||||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||||
onCreateTask={onCreateTaskFromMessage}
|
onCreateTask={onCreateTaskFromMessage}
|
||||||
onReply={onReplyToMessage}
|
onReply={onReplyToMessage}
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ interface CreateTaskDialogProps {
|
||||||
description: string,
|
description: string,
|
||||||
owner?: string,
|
owner?: string,
|
||||||
blockedBy?: string[],
|
blockedBy?: string[],
|
||||||
|
related?: string[],
|
||||||
prompt?: string,
|
prompt?: string,
|
||||||
startImmediately?: boolean
|
startImmediately?: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
|
@ -69,6 +70,7 @@ export const CreateTaskDialog = ({
|
||||||
});
|
});
|
||||||
const [owner, setOwner] = useState<string>(defaultOwner);
|
const [owner, setOwner] = useState<string>(defaultOwner);
|
||||||
const [blockedBy, setBlockedBy] = useState<string[]>([]);
|
const [blockedBy, setBlockedBy] = useState<string[]>([]);
|
||||||
|
const [related, setRelated] = useState<string[]>([]);
|
||||||
const [startImmediately, setStartImmediately] = useState(true);
|
const [startImmediately, setStartImmediately] = useState(true);
|
||||||
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
|
const promptDraft = useDraftPersistence({ key: `createTask:${teamName}:prompt` });
|
||||||
const [prevOpen, setPrevOpen] = useState(false);
|
const [prevOpen, setPrevOpen] = useState(false);
|
||||||
|
|
@ -80,6 +82,7 @@ export const CreateTaskDialog = ({
|
||||||
}
|
}
|
||||||
setOwner(defaultOwner);
|
setOwner(defaultOwner);
|
||||||
setBlockedBy([]);
|
setBlockedBy([]);
|
||||||
|
setRelated([]);
|
||||||
setStartImmediately(isTeamAlive);
|
setStartImmediately(isTeamAlive);
|
||||||
promptDraft.clearDraft();
|
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 => {
|
const handleSubmit = (): void => {
|
||||||
if (!canSubmit) return;
|
if (!canSubmit) return;
|
||||||
onSubmit(
|
onSubmit(
|
||||||
|
|
@ -116,6 +125,7 @@ export const CreateTaskDialog = ({
|
||||||
descriptionDraft.value.trim(),
|
descriptionDraft.value.trim(),
|
||||||
owner || undefined,
|
owner || undefined,
|
||||||
blockedBy.length > 0 ? blockedBy : undefined,
|
blockedBy.length > 0 ? blockedBy : undefined,
|
||||||
|
related.length > 0 ? related : undefined,
|
||||||
promptDraft.value.trim() || undefined,
|
promptDraft.value.trim() || undefined,
|
||||||
startImmediately
|
startImmediately
|
||||||
);
|
);
|
||||||
|
|
@ -296,6 +306,51 @@ export const CreateTaskDialog = ({
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{availableTasks.length > 0 ? (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>Related tasks (optional)</Label>
|
||||||
|
<div className="max-h-32 overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||||
|
{availableTasks.map((t) => {
|
||||||
|
const isSelected = related.includes(t.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`related:${t.id}`}
|
||||||
|
type="button"
|
||||||
|
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-purple-500/15 text-purple-300'
|
||||||
|
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)]'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleRelated(t.id)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex size-3.5 shrink-0 items-center justify-center rounded-sm border text-[9px] ${
|
||||||
|
isSelected
|
||||||
|
? 'border-purple-400 bg-purple-500/30 text-purple-300'
|
||||||
|
: 'border-[var(--color-border-emphasis)]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isSelected ? '\u2713' : ''}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="shrink-0 px-1 py-0 text-[10px] font-normal"
|
||||||
|
>
|
||||||
|
#{t.id}
|
||||||
|
</Badge>
|
||||||
|
<span className="truncate">{t.subject}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{related.length > 0 ? (
|
||||||
|
<p className="text-[11px] text-purple-300">
|
||||||
|
Related: {related.map((id) => `#${id}`).join(', ')}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
|
|
||||||
|
|
@ -16,14 +16,21 @@ import { Label } from '@renderer/components/ui/label';
|
||||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||||
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
import { useDraftPersistence } from '@renderer/hooks/useDraftPersistence';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
|
import { Check, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
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 {
|
interface LaunchTeamDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
members: ResolvedTeamMember[];
|
||||||
defaultProjectPath?: string;
|
defaultProjectPath?: string;
|
||||||
provisioningError: string | null;
|
provisioningError: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
|
@ -66,6 +73,7 @@ function renderHighlightedText(text: string, query: string): React.JSX.Element {
|
||||||
export const LaunchTeamDialog = ({
|
export const LaunchTeamDialog = ({
|
||||||
open,
|
open,
|
||||||
teamName,
|
teamName,
|
||||||
|
members,
|
||||||
defaultProjectPath,
|
defaultProjectPath,
|
||||||
provisioningError,
|
provisioningError,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -198,12 +206,13 @@ export const LaunchTeamDialog = ({
|
||||||
|
|
||||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
() =>
|
() =>
|
||||||
projects.map((p) => ({
|
members.map((m) => ({
|
||||||
id: p.path,
|
id: m.name,
|
||||||
name: p.name,
|
name: m.name,
|
||||||
subtitle: p.path,
|
subtitle: formatAgentRole(m.role) ?? formatAgentRole(m.agentType) ?? undefined,
|
||||||
|
color: m.color,
|
||||||
})),
|
})),
|
||||||
[projects]
|
[members]
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeError = localError ?? provisioningError;
|
const activeError = localError ?? provisioningError;
|
||||||
|
|
@ -381,7 +390,7 @@ export const LaunchTeamDialog = ({
|
||||||
value={promptDraft.value}
|
value={promptDraft.value}
|
||||||
onValueChange={promptDraft.setValue}
|
onValueChange={promptDraft.setValue}
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
placeholder="Instructions for team lead... Use @ to mention projects."
|
placeholder="Instructions for team lead... Use @ to mention team members."
|
||||||
footerRight={
|
footerRight={
|
||||||
promptDraft.isSaved ? (
|
promptDraft.isSaved ? (
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ interface TaskCommentsSectionProps {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
comments: TaskComment[];
|
comments: TaskComment[];
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
|
/** When true, the "Comments" header is not rendered (e.g. inside a collapsible section). */
|
||||||
|
hideHeader?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskCommentsSection = ({
|
export const TaskCommentsSection = ({
|
||||||
|
|
@ -30,6 +32,7 @@ export const TaskCommentsSection = ({
|
||||||
taskId,
|
taskId,
|
||||||
comments,
|
comments,
|
||||||
members,
|
members,
|
||||||
|
hideHeader = false,
|
||||||
}: TaskCommentsSectionProps): React.JSX.Element => {
|
}: TaskCommentsSectionProps): React.JSX.Element => {
|
||||||
const addTaskComment = useStore((s) => s.addTaskComment);
|
const addTaskComment = useStore((s) => s.addTaskComment);
|
||||||
const addingComment = useStore((s) => s.addingComment);
|
const addingComment = useStore((s) => s.addingComment);
|
||||||
|
|
@ -78,23 +81,22 @@ export const TaskCommentsSection = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={commentsRef}>
|
<div ref={commentsRef}>
|
||||||
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
|
{!hideHeader ? (
|
||||||
<MessageSquare size={12} />
|
<div className="mb-2 flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
|
||||||
Comments
|
<MessageSquare size={12} />
|
||||||
{comments.length > 0 ? (
|
Comments
|
||||||
<span className="rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0 text-[10px]">
|
{comments.length > 0 ? (
|
||||||
{comments.length}
|
<span className="rounded-full bg-[var(--color-surface-raised)] px-1.5 py-0 text-[10px]">
|
||||||
</span>
|
{comments.length}
|
||||||
) : null}
|
</span>
|
||||||
</div>
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{comments.length > 0 ? (
|
{comments.length > 0 ? (
|
||||||
<div className="mb-3 space-y-2">
|
<div className="mb-3 space-y-2">
|
||||||
{comments.map((comment) => (
|
{comments.map((comment) => (
|
||||||
<div
|
<div key={comment.id} className="group p-2.5">
|
||||||
key={comment.id}
|
|
||||||
className="group rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5"
|
|
||||||
>
|
|
||||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||||
<span
|
<span
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||||
|
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
||||||
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
|
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
|
@ -13,27 +14,24 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@renderer/components/ui/dialog';
|
} from '@renderer/components/ui/dialog';
|
||||||
import { markAsRead } from '@renderer/services/commentReadStorage';
|
import { markAsRead } from '@renderer/services/commentReadStorage';
|
||||||
import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers';
|
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
|
||||||
import {
|
import {
|
||||||
ArrowLeftFromLine,
|
KANBAN_COLUMN_DISPLAY,
|
||||||
ArrowRightFromLine,
|
TASK_STATUS_LABELS,
|
||||||
Clock,
|
TASK_STATUS_STYLES,
|
||||||
FileText,
|
} from '@renderer/utils/memberHelpers';
|
||||||
PenLine,
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
User,
|
import { ArrowLeftFromLine, ArrowRightFromLine, Clock, Link2, PenLine, User } from 'lucide-react';
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||||
|
|
||||||
import type { KanbanTaskState, ResolvedTeamMember, TeamTask } from '@shared/types';
|
import type { KanbanTaskState, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface TaskDetailDialogProps {
|
interface TaskDetailDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
task: TeamTask | null;
|
task: TeamTaskWithKanban | null;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
kanbanTaskState?: KanbanTaskState;
|
kanbanTaskState?: KanbanTaskState;
|
||||||
taskMap: Map<string, TeamTask>;
|
taskMap: Map<string, TeamTaskWithKanban>;
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onScrollToTask?: (taskId: string) => void;
|
onScrollToTask?: (taskId: string) => void;
|
||||||
|
|
@ -76,11 +74,30 @@ export const TaskDetailDialog = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kanbanColumn = kanbanTaskState?.column ?? currentTask.kanbanColumn;
|
||||||
const status = currentTask.status;
|
const status = currentTask.status;
|
||||||
const statusStyle = TASK_STATUS_STYLES[status];
|
const statusStyle =
|
||||||
const statusLabel = TASK_STATUS_LABELS[status];
|
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 blockedByIds = currentTask.blockedBy?.filter((id) => id.length > 0) ?? [];
|
||||||
const blocksIds = currentTask.blocks?.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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
|
@ -132,19 +149,15 @@ export const TaskDetailDialog = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<CollapsibleTeamSection title="Description" defaultOpen>
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
|
|
||||||
<FileText size={12} />
|
|
||||||
Description
|
|
||||||
</div>
|
|
||||||
{currentTask.description ? (
|
{currentTask.description ? (
|
||||||
<div className="max-h-[200px] overflow-y-auto rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
<div className="max-h-[200px] overflow-y-auto">
|
||||||
<MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" />
|
<MarkdownViewer content={currentTask.description} maxHeight="max-h-[180px]" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">No description</p>
|
<p className="text-xs text-[var(--color-text-muted)]">No description</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
{/* Dependencies */}
|
{/* Dependencies */}
|
||||||
{blockedByIds.length > 0 ? (
|
{blockedByIds.length > 0 ? (
|
||||||
|
|
@ -203,6 +216,56 @@ export const TaskDetailDialog = ({
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Related tasks (explicit) */}
|
||||||
|
{relatedIds.length > 0 || relatedByIds.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs font-medium text-[var(--color-text-muted)]">
|
||||||
|
<Link2 size={12} />
|
||||||
|
Related tasks
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{relatedIds.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">Links</span>
|
||||||
|
{relatedIds.map((id) => {
|
||||||
|
const depTask = taskMap.get(id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`related:${currentTask.id}:${id}`}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center rounded bg-purple-500/15 px-1.5 py-0.5 text-[10px] font-medium text-purple-300 transition-colors hover:bg-purple-500/25"
|
||||||
|
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||||
|
onClick={() => handleDependencyClick(id)}
|
||||||
|
>
|
||||||
|
#{id}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{relatedByIds.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">Linked from</span>
|
||||||
|
{relatedByIds.map((id) => {
|
||||||
|
const depTask = taskMap.get(id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`related-by:${currentTask.id}:${id}`}
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center rounded bg-fuchsia-500/15 px-1.5 py-0.5 text-[10px] font-medium text-fuchsia-300 transition-colors hover:bg-fuchsia-500/25"
|
||||||
|
title={depTask ? `#${id}: ${depTask.subject}` : `#${id}`}
|
||||||
|
onClick={() => handleDependencyClick(id)}
|
||||||
|
>
|
||||||
|
#{id}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Review info */}
|
{/* Review info */}
|
||||||
{kanbanTaskState ? (
|
{kanbanTaskState ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -218,28 +281,35 @@ export const TaskDetailDialog = ({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Comments */}
|
{/* Comments */}
|
||||||
<TaskCommentsSection
|
<CollapsibleTeamSection
|
||||||
teamName={teamName}
|
title="Comments"
|
||||||
taskId={currentTask.id}
|
badge={
|
||||||
comments={currentTask.comments ?? []}
|
(currentTask.comments?.length ?? 0) > 0
|
||||||
members={members}
|
? (currentTask.comments?.length ?? 0)
|
||||||
/>
|
: undefined
|
||||||
|
}
|
||||||
{/* Separator */}
|
defaultOpen
|
||||||
<div className="border-t border-[var(--color-border)]" />
|
>
|
||||||
|
<TaskCommentsSection
|
||||||
{/* Session Logs — sessions that reference this task */}
|
|
||||||
<div className="min-w-0 overflow-hidden">
|
|
||||||
<h4 className="mb-2 text-xs font-medium text-[var(--color-text-muted)]">
|
|
||||||
Execution Logs
|
|
||||||
</h4>
|
|
||||||
<MemberLogsTab
|
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
taskId={currentTask.id}
|
taskId={currentTask.id}
|
||||||
taskOwner={currentTask.owner}
|
comments={currentTask.comments ?? []}
|
||||||
taskStatus={currentTask.status}
|
members={members}
|
||||||
|
hideHeader
|
||||||
/>
|
/>
|
||||||
</div>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
{/* Execution Logs — sessions that reference this task */}
|
||||||
|
<CollapsibleTeamSection title="Execution Logs" defaultOpen>
|
||||||
|
<div className="min-w-0 overflow-hidden">
|
||||||
|
<MemberLogsTab
|
||||||
|
teamName={teamName}
|
||||||
|
taskId={currentTask.id}
|
||||||
|
taskOwner={currentTask.owner}
|
||||||
|
taskStatus={currentTask.status}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,15 @@ import { useMemo, useState } from 'react';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { Columns3, LayoutGrid } from 'lucide-react';
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ClipboardList,
|
||||||
|
Columns3,
|
||||||
|
Eye,
|
||||||
|
LayoutGrid,
|
||||||
|
PlayCircle,
|
||||||
|
ShieldCheck,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { KanbanColumn } from './KanbanColumn';
|
import { KanbanColumn } from './KanbanColumn';
|
||||||
import { KanbanFilterPopover } from './KanbanFilterPopover';
|
import { KanbanFilterPopover } from './KanbanFilterPopover';
|
||||||
|
|
@ -13,6 +21,37 @@ import type { KanbanFilterState } from './KanbanFilterPopover';
|
||||||
import type { Session } from '@renderer/types/data';
|
import type { Session } from '@renderer/types/data';
|
||||||
import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types';
|
import type { KanbanColumnId, KanbanState, ResolvedTeamMember, TeamTask } from '@shared/types';
|
||||||
|
|
||||||
|
const COLUMN_ACCENTS: Record<
|
||||||
|
KanbanColumnId,
|
||||||
|
{ headerBg: string; bodyBg: string; icon: React.ReactNode }
|
||||||
|
> = {
|
||||||
|
todo: {
|
||||||
|
headerBg: 'rgba(59, 130, 246, 0.12)',
|
||||||
|
bodyBg: 'rgba(59, 130, 246, 0.05)',
|
||||||
|
icon: <ClipboardList size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
|
||||||
|
},
|
||||||
|
in_progress: {
|
||||||
|
headerBg: 'rgba(234, 179, 8, 0.14)',
|
||||||
|
bodyBg: 'rgba(234, 179, 8, 0.06)',
|
||||||
|
icon: <PlayCircle size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
|
||||||
|
},
|
||||||
|
done: {
|
||||||
|
headerBg: 'rgba(34, 197, 94, 0.12)',
|
||||||
|
bodyBg: 'rgba(34, 197, 94, 0.05)',
|
||||||
|
icon: <CheckCircle2 size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
headerBg: 'rgba(139, 92, 246, 0.12)',
|
||||||
|
bodyBg: 'rgba(139, 92, 246, 0.05)',
|
||||||
|
icon: <Eye size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
|
||||||
|
},
|
||||||
|
approved: {
|
||||||
|
headerBg: 'rgba(34, 197, 94, 0.24)',
|
||||||
|
bodyBg: 'rgba(34, 197, 94, 0.11)',
|
||||||
|
icon: <ShieldCheck size={14} className="shrink-0 text-[var(--color-text-muted)]" />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
interface KanbanBoardProps {
|
interface KanbanBoardProps {
|
||||||
tasks: TeamTask[];
|
tasks: TeamTask[];
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -184,8 +223,16 @@ export const KanbanBoard = ({
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||||
{COLUMNS.map((column) => {
|
{COLUMNS.map((column) => {
|
||||||
const columnTasks = grouped.get(column.id) ?? [];
|
const columnTasks = grouped.get(column.id) ?? [];
|
||||||
|
const accent = COLUMN_ACCENTS[column.id];
|
||||||
return (
|
return (
|
||||||
<KanbanColumn key={column.id} title={column.title} count={columnTasks.length}>
|
<KanbanColumn
|
||||||
|
key={column.id}
|
||||||
|
title={column.title}
|
||||||
|
count={columnTasks.length}
|
||||||
|
icon={accent.icon}
|
||||||
|
headerBg={accent.headerBg}
|
||||||
|
bodyBg={accent.bodyBg}
|
||||||
|
>
|
||||||
{renderCards(column.id, columnTasks)}
|
{renderCards(column.id, columnTasks)}
|
||||||
</KanbanColumn>
|
</KanbanColumn>
|
||||||
);
|
);
|
||||||
|
|
@ -195,9 +242,16 @@ export const KanbanBoard = ({
|
||||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||||
{COLUMNS.map((column) => {
|
{COLUMNS.map((column) => {
|
||||||
const columnTasks = grouped.get(column.id) ?? [];
|
const columnTasks = grouped.get(column.id) ?? [];
|
||||||
|
const accent = COLUMN_ACCENTS[column.id];
|
||||||
return (
|
return (
|
||||||
<div key={column.id} className="w-64 shrink-0">
|
<div key={column.id} className="w-64 shrink-0">
|
||||||
<KanbanColumn title={column.title} count={columnTasks.length}>
|
<KanbanColumn
|
||||||
|
title={column.title}
|
||||||
|
count={columnTasks.length}
|
||||||
|
icon={accent.icon}
|
||||||
|
headerBg={accent.headerBg}
|
||||||
|
bodyBg={accent.bodyBg}
|
||||||
|
>
|
||||||
{renderCards(column.id, columnTasks)}
|
{renderCards(column.id, columnTasks)}
|
||||||
</KanbanColumn>
|
</KanbanColumn>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,37 @@
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
|
||||||
interface KanbanColumnProps {
|
interface KanbanColumnProps {
|
||||||
title: string;
|
title: string;
|
||||||
count: number;
|
count: number;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
headerBg?: string;
|
||||||
|
bodyBg?: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KanbanColumn = ({ title, count, children }: KanbanColumnProps): React.JSX.Element => {
|
export const KanbanColumn = ({
|
||||||
|
title,
|
||||||
|
count,
|
||||||
|
icon,
|
||||||
|
headerBg,
|
||||||
|
bodyBg,
|
||||||
|
children,
|
||||||
|
}: KanbanColumnProps): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
<section className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
|
<section
|
||||||
<header className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
|
className={cn(
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-[var(--color-text)]">
|
'rounded-md border border-[var(--color-border)]',
|
||||||
|
!bodyBg && 'bg-[var(--color-surface)]'
|
||||||
|
)}
|
||||||
|
style={bodyBg ? { backgroundColor: bodyBg } : undefined}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2"
|
||||||
|
style={headerBg ? { backgroundColor: headerBg } : undefined}
|
||||||
|
>
|
||||||
|
<h4 className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-[var(--color-text)]">
|
||||||
|
{icon}
|
||||||
{title}
|
{title}
|
||||||
</h4>
|
</h4>
|
||||||
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
|
<Badge variant="secondary" className="px-2 py-0.5 text-[10px] font-normal">
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,15 @@ import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/u
|
||||||
import { ListPlus, Loader2, MessageSquare } from 'lucide-react';
|
import { ListPlus, Loader2, MessageSquare } from 'lucide-react';
|
||||||
|
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface MemberCardProps {
|
interface MemberCardProps {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
memberColor: string;
|
memberColor: string;
|
||||||
taskCounts?: TaskStatusCounts | null;
|
taskCounts?: TaskStatusCounts | null;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
currentTask?: TeamTask | null;
|
isTeamProvisioning?: boolean;
|
||||||
|
currentTask?: TeamTaskWithKanban | null;
|
||||||
isAwaitingReply?: boolean;
|
isAwaitingReply?: boolean;
|
||||||
onOpenTask?: () => void;
|
onOpenTask?: () => void;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
|
@ -25,6 +26,7 @@ export const MemberCard = ({
|
||||||
memberColor,
|
memberColor,
|
||||||
taskCounts,
|
taskCounts,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
|
isTeamProvisioning,
|
||||||
currentTask,
|
currentTask,
|
||||||
isAwaitingReply,
|
isAwaitingReply,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
|
|
@ -32,8 +34,8 @@ export const MemberCard = ({
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
}: MemberCardProps): React.JSX.Element => {
|
}: MemberCardProps): React.JSX.Element => {
|
||||||
const dotClass = getMemberDotClass(member, isTeamAlive);
|
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning);
|
||||||
const presenceLabel = getPresenceLabel(member, isTeamAlive);
|
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning);
|
||||||
const colors = getTeamColorSet(memberColor);
|
const colors = getTeamColorSet(memberColor);
|
||||||
const pending = taskCounts?.pending ?? 0;
|
const pending = taskCounts?.pending ?? 0;
|
||||||
const inProgress = taskCounts?.inProgress ?? 0;
|
const inProgress = taskCounts?.inProgress ?? 0;
|
||||||
|
|
@ -73,12 +75,53 @@ export const MemberCard = ({
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
className={`absolute -bottom-0.5 -right-0.5 size-2.5 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
||||||
aria-label={member.status}
|
aria-label={presenceLabel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-[var(--color-text)]">
|
<div className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm">
|
||||||
{member.name}
|
<span className="shrink-0 font-medium text-[var(--color-text)]">{member.name}</span>
|
||||||
</span>
|
{currentTask ? (
|
||||||
|
<>
|
||||||
|
<Loader2
|
||||||
|
className="size-3 shrink-0 animate-spin"
|
||||||
|
style={{ color: colors.border }}
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
working on
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="min-w-0 shrink truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
||||||
|
style={{ border: `1px solid ${colors.border}40` }}
|
||||||
|
title="Open task"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenTask?.();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{currentTask.id} {currentTask.subject.slice(0, 36)}
|
||||||
|
{currentTask.subject.length > 36 ? '…' : ''}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{!currentTask && isAwaitingReply ? (
|
||||||
|
<>
|
||||||
|
<Loader2
|
||||||
|
className="size-3 shrink-0 animate-spin"
|
||||||
|
style={{ color: colors.border }}
|
||||||
|
/>
|
||||||
|
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||||
|
awaiting reply
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||||
return roleLabel ? (
|
return roleLabel ? (
|
||||||
|
|
@ -125,42 +168,9 @@ export const MemberCard = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currentTask ? (
|
|
||||||
<div className="mt-1 flex items-center gap-2 pl-9 text-[10px] text-[var(--color-text-muted)]">
|
|
||||||
<Loader2 className="size-3 animate-spin" style={{ color: colors.border }} />
|
|
||||||
<span className="truncate">working on</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="truncate rounded px-1.5 py-0.5 text-[10px] font-medium text-[var(--color-text)] transition-opacity hover:opacity-90 focus:outline-none focus:ring-1 focus:ring-[var(--color-border)]"
|
|
||||||
style={{ border: `1px solid ${colors.border}40` }}
|
|
||||||
title="Open task"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onOpenTask?.();
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
#{currentTask.id} {currentTask.subject.slice(0, 36)}
|
|
||||||
{currentTask.subject.length > 36 ? '…' : ''}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{!currentTask && isAwaitingReply ? (
|
|
||||||
<div className="mt-1 flex items-center gap-2 pl-9 text-[10px] text-[var(--color-text-muted)]">
|
|
||||||
<Loader2 className="size-3 animate-spin" style={{ color: colors.border }} />
|
|
||||||
<span className="truncate">awaiting reply</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="h-0.5 rounded-b bg-[var(--color-border)]"
|
className="h-px rounded-b bg-[var(--color-border)]"
|
||||||
role="progressbar"
|
role="progressbar"
|
||||||
aria-valuenow={completed}
|
aria-valuenow={completed}
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
|
|
|
||||||
|
|
@ -12,18 +12,20 @@ import { MemberMessagesTab } from './MemberMessagesTab';
|
||||||
import { MemberStatsTab } from './MemberStatsTab';
|
import { MemberStatsTab } from './MemberStatsTab';
|
||||||
import { MemberTasksTab } from './MemberTasksTab';
|
import { MemberTasksTab } from './MemberTasksTab';
|
||||||
|
|
||||||
import type { InboxMessage, ResolvedTeamMember, TeamTask } from '@shared/types';
|
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface MemberDetailDialogProps {
|
interface MemberDetailDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
member: ResolvedTeamMember | null;
|
member: ResolvedTeamMember | null;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
tasks: TeamTask[];
|
tasks: TeamTaskWithKanban[];
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
|
isTeamAlive?: boolean;
|
||||||
|
isTeamProvisioning?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSendMessage: () => void;
|
onSendMessage: () => void;
|
||||||
onAssignTask: () => void;
|
onAssignTask: () => void;
|
||||||
onTaskClick: (task: TeamTask) => void;
|
onTaskClick: (task: TeamTaskWithKanban) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberDetailDialog = ({
|
export const MemberDetailDialog = ({
|
||||||
|
|
@ -32,6 +34,8 @@ export const MemberDetailDialog = ({
|
||||||
teamName,
|
teamName,
|
||||||
tasks,
|
tasks,
|
||||||
messages,
|
messages,
|
||||||
|
isTeamAlive,
|
||||||
|
isTeamProvisioning,
|
||||||
onClose,
|
onClose,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
|
|
@ -61,9 +65,13 @@ export const MemberDetailDialog = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
<Dialog open={open} onOpenChange={(nextOpen) => !nextOpen && onClose()}>
|
||||||
<DialogContent className="sm:max-w-4xl">
|
<DialogContent className="min-w-0 overflow-hidden sm:max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<MemberDetailHeader member={member} />
|
<MemberDetailHeader
|
||||||
|
member={member}
|
||||||
|
isTeamAlive={isTeamAlive}
|
||||||
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
|
/>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<MemberDetailStats
|
<MemberDetailStats
|
||||||
|
|
@ -110,7 +118,7 @@ export const MemberDetailDialog = ({
|
||||||
<TabsContent value="stats">
|
<TabsContent value="stats">
|
||||||
<MemberStatsTab teamName={teamName} memberName={member.name} />
|
<MemberStatsTab teamName={teamName} memberName={member.name} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="logs">
|
<TabsContent value="logs" className="min-w-0 overflow-hidden">
|
||||||
<MemberLogsTab teamName={teamName} memberName={member.name} />
|
<MemberLogsTab teamName={teamName} memberName={member.name} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,18 @@ import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
||||||
interface MemberDetailHeaderProps {
|
interface MemberDetailHeaderProps {
|
||||||
member: ResolvedTeamMember;
|
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 role = member.role || formatAgentRole(member.agentType);
|
||||||
const presenceLabel = getPresenceLabel(member);
|
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning);
|
||||||
const dotClass = getMemberDotClass(member);
|
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -25,7 +31,7 @@ export const MemberDetailHeader = ({ member }: MemberDetailHeaderProps): React.J
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
className={`absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
className={`absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-[var(--color-surface)] ${dotClass}`}
|
||||||
aria-label={member.status}
|
aria-label={presenceLabel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export const MemberExecutionLog = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="min-w-0 space-y-6 overflow-hidden">
|
||||||
{conversation.items.map((item) => {
|
{conversation.items.map((item) => {
|
||||||
if (item.type === 'system') {
|
if (item.type === 'system') {
|
||||||
return <SystemChatGroup key={item.group.id} systemGroup={item.group} />;
|
return <SystemChatGroup key={item.group.id} systemGroup={item.group} />;
|
||||||
|
|
@ -92,8 +92,8 @@ const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
|
||||||
const text = group.content.rawText ?? group.content.text ?? '';
|
const text = group.content.rawText ?? group.content.text ?? '';
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<div className="flex min-w-0 justify-end">
|
||||||
<div className="max-w-[85%] rounded-2xl rounded-br-sm border border-[var(--color-border)] bg-[var(--chat-user-bg)] px-4 py-3">
|
<div className="min-w-0 max-w-[85%] rounded-2xl rounded-br-sm border border-[var(--color-border)] bg-[var(--chat-user-bg)] px-4 py-3">
|
||||||
<div className="text-[10px] text-[var(--color-text-muted)]">
|
<div className="text-[10px] text-[var(--color-text-muted)]">
|
||||||
{format(group.timestamp, 'h:mm:ss a')}
|
{format(group.timestamp, 'h:mm:ss a')}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -104,12 +104,12 @@ const UserLogItem = ({ group }: { group: UserGroup }): React.JSX.Element => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<div className="flex min-w-0 justify-end">
|
||||||
<div className="max-w-[85%] rounded-2xl rounded-br-sm border border-[var(--chat-user-border)] bg-[var(--chat-user-bg)] px-4 py-3">
|
<div className="min-w-0 max-w-full rounded-2xl rounded-br-sm border border-[var(--chat-user-border)] bg-[var(--chat-user-bg)] px-4 py-3">
|
||||||
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
|
<div className="text-right text-[10px] text-[var(--color-text-muted)]">
|
||||||
{format(group.timestamp, 'h:mm:ss a')}
|
{format(group.timestamp, 'h:mm:ss a')}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm text-[var(--chat-user-text)]">
|
<div className="mt-2 min-w-0 break-words text-sm text-[var(--chat-user-text)]">
|
||||||
<MarkdownViewer content={text} copyable />
|
<MarkdownViewer content={text} copyable />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,19 @@ import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { MemberCard } from './MemberCard';
|
import { MemberCard } from './MemberCard';
|
||||||
|
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type { ResolvedTeamMember, TeamTask } from '@shared/types';
|
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface MemberListProps {
|
interface MemberListProps {
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
memberTaskCounts?: Map<string, TaskStatusCounts>;
|
memberTaskCounts?: Map<string, TaskStatusCounts>;
|
||||||
taskMap?: Map<string, TeamTask>;
|
taskMap?: Map<string, TeamTaskWithKanban>;
|
||||||
pendingRepliesByMember?: Record<string, number>;
|
pendingRepliesByMember?: Record<string, number>;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
|
isTeamProvisioning?: boolean;
|
||||||
onMemberClick?: (member: ResolvedTeamMember) => void;
|
onMemberClick?: (member: ResolvedTeamMember) => void;
|
||||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||||
onAssignTask?: (member: ResolvedTeamMember) => void;
|
onAssignTask?: (member: ResolvedTeamMember) => void;
|
||||||
onOpenTask?: (task: TeamTask) => void;
|
onOpenTask?: (task: TeamTaskWithKanban) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberList = ({
|
export const MemberList = ({
|
||||||
|
|
@ -23,6 +24,7 @@ export const MemberList = ({
|
||||||
taskMap,
|
taskMap,
|
||||||
pendingRepliesByMember,
|
pendingRepliesByMember,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
|
isTeamProvisioning,
|
||||||
onMemberClick,
|
onMemberClick,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
|
|
@ -49,6 +51,7 @@ export const MemberList = ({
|
||||||
memberColor={member.color ?? getMemberColor(index)}
|
memberColor={member.color ?? getMemberColor(index)}
|
||||||
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
taskCounts={memberTaskCounts?.get(member.name.toLowerCase())}
|
||||||
isTeamAlive={isTeamAlive}
|
isTeamAlive={isTeamAlive}
|
||||||
|
isTeamProvisioning={isTeamProvisioning}
|
||||||
currentTask={currentTask}
|
currentTask={currentTask}
|
||||||
isAwaitingReply={awaitingReply}
|
isAwaitingReply={awaitingReply}
|
||||||
onOpenTask={currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
onOpenTask={currentTask ? () => onOpenTask?.(currentTask) : undefined}
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ export const MemberLogsTab = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-h-[400px] min-w-0 space-y-1.5 overflow-y-auto overflow-x-hidden pr-1">
|
<div className="max-h-[400px] w-full min-w-0 space-y-1.5 overflow-y-auto overflow-x-hidden pr-1">
|
||||||
{logs.map((log) => (
|
{logs.map((log) => (
|
||||||
<LogCard
|
<LogCard
|
||||||
key={
|
key={
|
||||||
|
|
@ -251,7 +251,7 @@ const LogCard = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!detailLoading && detailChunks && (
|
{!detailLoading && detailChunks && (
|
||||||
<div className="max-h-[360px] min-w-0 overflow-y-auto overflow-x-hidden pr-1">
|
<div className="max-h-[360px] w-full min-w-0 overflow-y-auto overflow-x-hidden pr-1">
|
||||||
<MemberExecutionLog
|
<MemberExecutionLog
|
||||||
chunks={detailChunks}
|
chunks={detailChunks}
|
||||||
memberName={log.kind === 'lead_session' ? (log.memberName ?? undefined) : undefined}
|
memberName={log.kind === 'lead_session' ? (log.memberName ?? undefined) : undefined}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
import { Badge } from '@renderer/components/ui/badge';
|
import { Badge } from '@renderer/components/ui/badge';
|
||||||
import { TASK_STATUS_LABELS, TASK_STATUS_STYLES } from '@renderer/utils/memberHelpers';
|
import {
|
||||||
|
KANBAN_COLUMN_DISPLAY,
|
||||||
|
TASK_STATUS_LABELS,
|
||||||
|
TASK_STATUS_STYLES,
|
||||||
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
import type { TeamTask } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface MemberTasksTabProps {
|
interface MemberTasksTabProps {
|
||||||
tasks: TeamTask[];
|
tasks: TeamTaskWithKanban[];
|
||||||
onTaskClick?: (task: TeamTask) => void;
|
onTaskClick?: (task: TeamTaskWithKanban) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_ORDER: Record<string, number> = {
|
const STATUS_ORDER: Record<string, number> = {
|
||||||
|
|
@ -37,7 +41,15 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
|
||||||
<div className="max-h-[320px] overflow-y-auto">
|
<div className="max-h-[320px] overflow-y-auto">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{visibleTasks.map((task) => {
|
{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 (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -54,7 +66,7 @@ export const MemberTasksTab = ({ tasks, onTaskClick }: MemberTasksTabProps): Rea
|
||||||
<span
|
<span
|
||||||
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${style.bg} ${style.text}`}
|
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ${style.bg} ${style.text}`}
|
||||||
>
|
>
|
||||||
{TASK_STATUS_LABELS[task.status]}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { TaskRow } from './TaskRow';
|
import { TaskRow } from './TaskRow';
|
||||||
|
|
||||||
import type { TeamTask } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface TaskListProps {
|
interface TaskListProps {
|
||||||
tasks: TeamTask[];
|
tasks: TeamTaskWithKanban[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => {
|
export const TaskList = ({ tasks }: TaskListProps): React.JSX.Element => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { TeamTask } from '@shared/types';
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
interface TaskRowProps {
|
interface TaskRowProps {
|
||||||
task: TeamTask;
|
task: TeamTaskWithKanban;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
||||||
|
|
@ -13,7 +13,9 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
|
||||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.id}</td>
|
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.id}</td>
|
||||||
<td className="px-3 py-2 text-sm text-[var(--color-text)]">{task.subject}</td>
|
<td className="px-3 py-2 text-sm text-[var(--color-text)]">{task.subject}</td>
|
||||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.owner ?? '\u2014'}</td>
|
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.owner ?? '\u2014'}</td>
|
||||||
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.status}</td>
|
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{task.kanbanColumn ?? task.status}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-2 text-xs">
|
<td className="px-3 py-2 text-xs">
|
||||||
{blockedByIds.length > 0 ? (
|
{blockedByIds.length > 0 ? (
|
||||||
<span className="text-yellow-300">{blockedByIds.map((id) => `#${id}`).join(', ')}</span>
|
<span className="text-yellow-300">{blockedByIds.map((id) => `#${id}`).join(', ')}</span>
|
||||||
|
|
|
||||||
|
|
@ -74,20 +74,21 @@ export const Combobox = ({
|
||||||
className="flex size-full flex-col overflow-hidden rounded-md bg-[var(--color-surface)]"
|
className="flex size-full flex-col overflow-hidden rounded-md bg-[var(--color-surface)]"
|
||||||
shouldFilter={false}
|
shouldFilter={false}
|
||||||
>
|
>
|
||||||
<div className="flex items-center border-b border-[var(--color-border)] px-2">
|
<div className="flex items-center border-b border-[var(--color-border)]">
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
value={search}
|
value={search}
|
||||||
onValueChange={setSearch}
|
onValueChange={setSearch}
|
||||||
placeholder={searchPlaceholder}
|
placeholder={searchPlaceholder}
|
||||||
className="flex h-8 w-full border-0 bg-transparent py-1 text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]"
|
className="flex h-8 w-full border-0 bg-transparent px-2 py-1 text-xs text-[var(--color-text)] outline-none placeholder:text-[var(--color-text-muted)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CommandPrimitive.List
|
<CommandPrimitive.List
|
||||||
id={listboxId}
|
id={listboxId}
|
||||||
className="max-h-72 overflow-y-auto overscroll-contain px-2 py-1"
|
className="max-h-72 overflow-y-auto overscroll-contain py-1 pl-0 pr-2"
|
||||||
|
style={{ paddingLeft: 0 }}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
onWheel={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<CommandPrimitive.Empty className="px-2 py-4 text-center text-xs text-[var(--color-text-muted)]">
|
<CommandPrimitive.Empty className="py-4 pr-2 text-center text-xs text-[var(--color-text-muted)]">
|
||||||
{emptyMessage}
|
{emptyMessage}
|
||||||
</CommandPrimitive.Empty>
|
</CommandPrimitive.Empty>
|
||||||
{options
|
{options
|
||||||
|
|
@ -112,6 +113,7 @@ export const Combobox = ({
|
||||||
setSearch('');
|
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)]"
|
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 ? (
|
||||||
renderOption(option, isSelected, search)
|
renderOption(option, isSelected, search)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ const MIRROR_PROPS = [
|
||||||
'wordSpacing',
|
'wordSpacing',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const MENTION_DROPDOWN_OFFSET_PX = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates caret coordinates relative to the textarea element
|
* Calculates caret coordinates relative to the textarea element
|
||||||
* using a mirror div technique.
|
* using a mirror div technique.
|
||||||
|
|
@ -180,7 +182,7 @@ export function useMentionDetection({
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
const coords = getCaretCoordinates(textarea, triggerIdx, text);
|
const coords = getCaretCoordinates(textarea, triggerIdx, text);
|
||||||
setDropdownPosition({
|
setDropdownPosition({
|
||||||
top: coords.top + coords.height,
|
top: coords.top + coords.height + MENTION_DROPDOWN_OFFSET_PX,
|
||||||
left: 0,
|
left: 0,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -383,6 +383,64 @@
|
||||||
--skeleton-base-dim: rgba(205, 208, 215, 0.6);
|
--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;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
|
||||||
|
|
@ -8,67 +8,115 @@ interface StoredDraft {
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let idbUnavailable = false;
|
||||||
|
let idbUnavailableLogged = false;
|
||||||
|
const fallbackStore = new Map<string, StoredDraft>();
|
||||||
|
|
||||||
|
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<void> {
|
async function saveDraft(key: string, value: string): Promise<void> {
|
||||||
|
if (idbUnavailable) {
|
||||||
|
fallbackSave(key, value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const stored: StoredDraft = {
|
const stored: StoredDraft = { value, timestamp: Date.now() };
|
||||||
value,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
};
|
|
||||||
await set(`${DRAFT_KEY_PREFIX}${key}`, stored);
|
await set(`${DRAFT_KEY_PREFIX}${key}`, stored);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error(`[draftStorage] Failed to save draft for ${key}:`, error);
|
markIdbUnavailable();
|
||||||
|
fallbackSave(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDraft(key: string): Promise<string | null> {
|
async function loadDraft(key: string): Promise<string | null> {
|
||||||
|
if (idbUnavailable) return fallbackLoad(key);
|
||||||
try {
|
try {
|
||||||
const stored = await get<StoredDraft>(`${DRAFT_KEY_PREFIX}${key}`);
|
const stored = await get<StoredDraft>(`${DRAFT_KEY_PREFIX}${key}`);
|
||||||
if (!stored) {
|
if (!stored) return null;
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const age = Date.now() - stored.timestamp;
|
const age = Date.now() - stored.timestamp;
|
||||||
if (age > DRAFT_TTL_MS) {
|
if (age > DRAFT_TTL_MS) {
|
||||||
void deleteDraft(key);
|
void deleteDraft(key);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return stored.value;
|
return stored.value;
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error(`[draftStorage] Failed to load draft for ${key}:`, error);
|
markIdbUnavailable();
|
||||||
return null;
|
return fallbackLoad(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteDraft(key: string): Promise<void> {
|
async function deleteDraft(key: string): Promise<void> {
|
||||||
|
if (idbUnavailable) {
|
||||||
|
fallbackDelete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await del(`${DRAFT_KEY_PREFIX}${key}`);
|
await del(`${DRAFT_KEY_PREFIX}${key}`);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error(`[draftStorage] Failed to delete draft for ${key}:`, error);
|
markIdbUnavailable();
|
||||||
|
fallbackDelete(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupExpired(): Promise<void> {
|
async function cleanupExpired(): Promise<void> {
|
||||||
|
if (idbUnavailable) {
|
||||||
|
fallbackCleanupExpired();
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const allKeys = await keys();
|
const allKeys = await keys();
|
||||||
const draftKeys = allKeys.filter(
|
const draftKeys = allKeys.filter(
|
||||||
(k): k is IDBValidKey & string => typeof k === 'string' && k.startsWith(DRAFT_KEY_PREFIX)
|
(k): k is IDBValidKey & string => typeof k === 'string' && k.startsWith(DRAFT_KEY_PREFIX)
|
||||||
);
|
);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
for (const fullKey of draftKeys) {
|
for (const fullKey of draftKeys) {
|
||||||
try {
|
try {
|
||||||
const stored = await get<StoredDraft>(fullKey);
|
const stored = await get<StoredDraft>(fullKey);
|
||||||
if (stored && now - stored.timestamp > DRAFT_TTL_MS) {
|
if (stored && now - stored.timestamp > DRAFT_TTL_MS) await del(fullKey);
|
||||||
await del(fullKey);
|
} catch {
|
||||||
}
|
markIdbUnavailable();
|
||||||
} catch (error) {
|
fallbackCleanupExpired();
|
||||||
console.error(`[draftStorage] Failed to check/delete key ${fullKey}:`, error);
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('[draftStorage] Failed to cleanup expired drafts:', error);
|
markIdbUnavailable();
|
||||||
|
fallbackCleanupExpired();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { normalizePath } from '@renderer/utils/pathNormalize';
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
|
||||||
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
||||||
|
const logger = createLogger('teamSlice');
|
||||||
|
|
||||||
import type { AppState } from '../types';
|
import type { AppState } from '../types';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -250,9 +253,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If provisioning is in progress for this team, stay in loading state
|
// If provisioning is in progress for this team, stay in loading state;
|
||||||
// instead of showing an error — the file watcher / progress callback will
|
// file watcher / progress callback will refresh once config is written.
|
||||||
// trigger a refresh once config.json is written.
|
|
||||||
const isProvisioning = Object.values(get().provisioningRuns).some(
|
const isProvisioning = Object.values(get().provisioningRuns).some(
|
||||||
(run) =>
|
(run) =>
|
||||||
run.teamName === teamName &&
|
run.teamName === teamName &&
|
||||||
|
|
@ -268,15 +270,17 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const message =
|
||||||
|
error instanceof IpcError
|
||||||
|
? error.message
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'Failed to fetch team data';
|
||||||
|
logger.error(`[team:getData] ${message}`);
|
||||||
set({
|
set({
|
||||||
selectedTeamLoading: false,
|
selectedTeamLoading: false,
|
||||||
selectedTeamData: null,
|
selectedTeamData: null,
|
||||||
selectedTeamError:
|
selectedTeamError: message,
|
||||||
error instanceof IpcError
|
|
||||||
? error.message
|
|
||||||
: error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: 'Failed to fetch team data',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
8
src/renderer/utils/markdownPlugins.ts
Normal file
8
src/renderer/utils/markdownPlugins.ts
Normal file
|
|
@ -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];
|
||||||
|
|
@ -11,15 +11,28 @@ export const STATUS_DOT_COLORS: Record<MemberStatus, string> = {
|
||||||
unknown: 'bg-zinc-600',
|
unknown: 'bg-zinc-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getMemberDotClass(member: ResolvedTeamMember, isTeamAlive?: boolean): string {
|
export function getMemberDotClass(
|
||||||
if (isTeamAlive === false) return STATUS_DOT_COLORS.terminated;
|
member: ResolvedTeamMember,
|
||||||
|
isTeamAlive?: boolean,
|
||||||
|
isTeamProvisioning?: boolean
|
||||||
|
): string {
|
||||||
if (member.status === 'terminated') return STATUS_DOT_COLORS.terminated;
|
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 {
|
export function getPresenceLabel(
|
||||||
if (isTeamAlive === false) return 'offline';
|
member: ResolvedTeamMember,
|
||||||
|
isTeamAlive?: boolean,
|
||||||
|
isTeamProvisioning?: boolean
|
||||||
|
): string {
|
||||||
if (member.status === 'terminated') return 'terminated';
|
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';
|
return member.currentTaskId ? 'working' : 'idle';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,3 +49,11 @@ export const TASK_STATUS_LABELS: Record<TeamTaskStatus, string> = {
|
||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
deleted: 'Deleted',
|
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' },
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { differenceInDays, isToday, isYesterday } from 'date-fns';
|
import { differenceInDays, isToday, isYesterday } from 'date-fns';
|
||||||
|
|
||||||
import { DATE_CATEGORY_ORDER } from '../types/tabs';
|
import { DATE_CATEGORY_ORDER } from '../types/tabs';
|
||||||
|
|
@ -7,6 +8,12 @@ import type { GlobalTask } from '@shared/types';
|
||||||
|
|
||||||
export type DateGroupedTasks = Record<DateCategory, GlobalTask[]>;
|
export type DateGroupedTasks = Record<DateCategory, GlobalTask[]>;
|
||||||
|
|
||||||
|
export interface ProjectTaskGroup {
|
||||||
|
projectKey: string;
|
||||||
|
projectLabel: string;
|
||||||
|
tasks: GlobalTask[];
|
||||||
|
}
|
||||||
|
|
||||||
function getDateCategory(dateStr: string | undefined): DateCategory {
|
function getDateCategory(dateStr: string | undefined): DateCategory {
|
||||||
if (!dateStr) return 'Older';
|
if (!dateStr) return 'Older';
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
|
|
@ -46,3 +53,64 @@ export function groupTasksByDate(tasks: GlobalTask[]): DateGroupedTasks {
|
||||||
export function getNonEmptyTaskCategories(groups: DateGroupedTasks): DateCategory[] {
|
export function getNonEmptyTaskCategories(groups: DateGroupedTasks): DateCategory[] {
|
||||||
return DATE_CATEGORY_ORDER.filter((cat) => groups[cat].length > 0);
|
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<string, { path: string; tasks: GlobalTask[] }>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,11 +64,22 @@ export interface TeamTask {
|
||||||
status: TeamTaskStatus;
|
status: TeamTaskStatus;
|
||||||
blocks?: string[];
|
blocks?: string[];
|
||||||
blockedBy?: string[];
|
blockedBy?: string[];
|
||||||
|
/**
|
||||||
|
* Explicit task links (non-blocking). Used for navigation between related tasks,
|
||||||
|
* e.g. "review task" ↔ "work task".
|
||||||
|
*/
|
||||||
|
related?: string[];
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
comments?: TaskComment[];
|
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 {
|
export interface InboxMessage {
|
||||||
from: string;
|
from: string;
|
||||||
to?: string;
|
to?: string;
|
||||||
|
|
@ -130,7 +141,7 @@ export interface ResolvedTeamMember {
|
||||||
export interface TeamData {
|
export interface TeamData {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
config: TeamConfig;
|
config: TeamConfig;
|
||||||
tasks: TeamTask[];
|
tasks: TeamTaskWithKanban[];
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
kanbanState: KanbanState;
|
kanbanState: KanbanState;
|
||||||
|
|
@ -153,6 +164,7 @@ export interface CreateTaskRequest {
|
||||||
description?: string;
|
description?: string;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
blockedBy?: string[];
|
blockedBy?: string[];
|
||||||
|
related?: string[];
|
||||||
prompt?: string;
|
prompt?: string;
|
||||||
startImmediately?: boolean;
|
startImmediately?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -220,12 +232,10 @@ export interface TeamProvisioningProgress {
|
||||||
cliLogsTail?: string;
|
cliLogsTail?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GlobalTask extends TeamTask {
|
export interface GlobalTask extends TeamTaskWithKanban {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
teamDisplayName: string;
|
teamDisplayName: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
/** Set when task is in team kanban (review or approved column). */
|
|
||||||
kanbanColumn?: 'review' | 'approved';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MemberSubagentSummary {
|
export interface MemberSubagentSummary {
|
||||||
|
|
|
||||||
|
|
@ -163,4 +163,45 @@ describe('TeamDataService', () => {
|
||||||
expect.objectContaining({ status: 'pending', owner: 'alice', createdBy: 'user' })
|
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'] })
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue