feat: alot, code highlight, related tasks, group by project and other

This commit is contained in:
iliya 2026-02-23 19:45:03 +02:00 committed by Илия
parent e97fa7635f
commit 1b6f7be767
53 changed files with 1417 additions and 456 deletions

View file

@ -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",

View file

@ -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

View file

@ -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) {

View file

@ -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
View 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() } };
}

View file

@ -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,
}) })

View file

@ -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,
}; };

View file

@ -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);

View file

@ -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(

View file

@ -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)

View file

@ -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(),
}; };

View file

@ -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>
) : ( ) : (

View file

@ -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;

View file

@ -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>

View file

@ -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>

View file

@ -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}

View file

@ -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>
); );

View file

@ -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>

View file

@ -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>

View file

@ -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,

View file

@ -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>
); );

View file

@ -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);

View file

@ -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 ?? []);
}

View 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 ?? []);
}

View file

@ -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)}

View file

@ -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 = ({

View file

@ -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 ? (

View file

@ -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}

View file

@ -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>

View file

@ -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>

View file

@ -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"

View file

@ -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}>

View file

@ -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>

View file

@ -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">

View file

@ -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}

View file

@ -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>

View file

@ -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">

View file

@ -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>

View file

@ -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}

View file

@ -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}

View file

@ -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>
); );

View file

@ -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 => {

View file

@ -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>

View file

@ -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)

View file

@ -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,
}); });
}, },

View file

@ -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;

View file

@ -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();
} }
} }

View file

@ -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',
}); });
} }
}, },

View 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];

View file

@ -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' },
};

View file

@ -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;
}

View file

@ -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 {

View file

@ -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'] })
);
});
}); });