feat: implement custom project path management and task relationship features
- Added API endpoints for adding and removing custom project paths, allowing persistence across app restarts. - Implemented IPC handlers for managing custom project paths in the application. - Introduced functionality for adding and removing task relationships (blockedBy, blocks, related) between tasks, enhancing task management capabilities. - Updated relevant components and services to support the new task relationship features and ensure proper integration with the existing task management system. - Enhanced notifications for task relationships to improve user awareness of task dependencies.
This commit is contained in:
parent
a0764e39d1
commit
4c5f0c3cc2
27 changed files with 1353 additions and 30 deletions
|
|
@ -17,6 +17,8 @@
|
||||||
* - POST /api/config/triggers/:triggerId/test - Test trigger
|
* - POST /api/config/triggers/:triggerId/test - Test trigger
|
||||||
* - POST /api/config/pin-session - Pin session
|
* - POST /api/config/pin-session - Pin session
|
||||||
* - POST /api/config/unpin-session - Unpin session
|
* - POST /api/config/unpin-session - Unpin session
|
||||||
|
* - POST /api/config/add-custom-project-path - Add custom project path
|
||||||
|
* - POST /api/config/remove-custom-project-path - Remove custom project path
|
||||||
* - POST /api/config/select-folders - No-op in browser
|
* - POST /api/config/select-folders - No-op in browser
|
||||||
* - POST /api/config/open-in-editor - No-op in browser
|
* - POST /api/config/open-in-editor - No-op in browser
|
||||||
*/
|
*/
|
||||||
|
|
@ -470,6 +472,44 @@ export function registerConfigRoutes(app: FastifyInstance): void {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add custom project path
|
||||||
|
app.post<{ Body: { projectPath: string } }>(
|
||||||
|
'/api/config/add-custom-project-path',
|
||||||
|
async (request): Promise<ConfigResult> => {
|
||||||
|
try {
|
||||||
|
const { projectPath } = request.body;
|
||||||
|
if (!projectPath || typeof projectPath !== 'string') {
|
||||||
|
return { success: false, error: 'Project path is required and must be a string' };
|
||||||
|
}
|
||||||
|
|
||||||
|
configManager.addCustomProjectPath(projectPath);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in POST /api/config/add-custom-project-path:', error);
|
||||||
|
return { success: false, error: getErrorMessage(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove custom project path
|
||||||
|
app.post<{ Body: { projectPath: string } }>(
|
||||||
|
'/api/config/remove-custom-project-path',
|
||||||
|
async (request): Promise<ConfigResult> => {
|
||||||
|
try {
|
||||||
|
const { projectPath } = request.body;
|
||||||
|
if (!projectPath || typeof projectPath !== 'string') {
|
||||||
|
return { success: false, error: 'Project path is required and must be a string' };
|
||||||
|
}
|
||||||
|
|
||||||
|
configManager.removeCustomProjectPath(projectPath);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in POST /api/config/remove-custom-project-path:', error);
|
||||||
|
return { success: false, error: getErrorMessage(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Select folders - no-op in browser mode
|
// Select folders - no-op in browser mode
|
||||||
app.post('/api/config/select-folders', async (): Promise<ConfigResult<string[]>> => {
|
app.post('/api/config/select-folders', async (): Promise<ConfigResult<string[]>> => {
|
||||||
return { success: true, data: [] };
|
return { success: true, data: [] };
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,61 @@ async function resolveTeamDisplayName(teamName: string): Promise<string> {
|
||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inbox message types that are internal coordination noise — not useful as OS notifications.
|
||||||
|
* Matches renderer-side NOISE_TYPES in agentMessageFormatting.ts.
|
||||||
|
*/
|
||||||
|
const INBOX_NOISE_TYPES = new Set([
|
||||||
|
'idle_notification',
|
||||||
|
'shutdown_approved',
|
||||||
|
'teammate_terminated',
|
||||||
|
'shutdown_request',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an inbox message text that may be serialized JSON.
|
||||||
|
* Returns null if not valid JSON or not an object.
|
||||||
|
*/
|
||||||
|
function parseInboxJson(text: string): Record<string, unknown> | null {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed.startsWith('{')) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(trimmed) as unknown;
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// not JSON — plain text message
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true if the inbox message text is a noise type that should not trigger an OS notification. */
|
||||||
|
function isInboxNoiseMessage(text: string): boolean {
|
||||||
|
const parsed = parseInboxJson(text);
|
||||||
|
if (!parsed) return false;
|
||||||
|
return typeof parsed.type === 'string' && INBOX_NOISE_TYPES.has(parsed.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts human-readable summary and body from an inbox message.
|
||||||
|
* Handles both plain text and serialized JSON ({"type":"message","content":"...","summary":"..."}).
|
||||||
|
*/
|
||||||
|
function extractNotificationContent(text: string): { summary: string; body: string } {
|
||||||
|
const parsed = parseInboxJson(text);
|
||||||
|
if (!parsed) return { summary: text.slice(0, 80), body: text };
|
||||||
|
|
||||||
|
const content = typeof parsed.content === 'string' ? parsed.content : null;
|
||||||
|
const summary = typeof parsed.summary === 'string' ? parsed.summary : null;
|
||||||
|
const message = typeof parsed.message === 'string' ? parsed.message : null;
|
||||||
|
|
||||||
|
const bestBody = content || message || summary || text;
|
||||||
|
const bestSummary =
|
||||||
|
summary || (content ? content.slice(0, 80) : null) || message || text.slice(0, 80);
|
||||||
|
|
||||||
|
return { summary: bestSummary, body: bestBody };
|
||||||
|
}
|
||||||
|
|
||||||
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
|
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
|
||||||
// Check config toggle
|
// Check config toggle
|
||||||
const config = configManager.getConfig();
|
const config = configManager.getConfig();
|
||||||
|
|
@ -145,10 +200,11 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const messages = await teamInboxReader.getMessagesFor(teamName, memberName);
|
const messages = await teamInboxReader.getMessagesFor(teamName, memberName);
|
||||||
|
const isFirstLoad = !inboxMessageCounts.has(key);
|
||||||
const prevCount = inboxMessageCounts.get(key) ?? 0;
|
const prevCount = inboxMessageCounts.get(key) ?? 0;
|
||||||
|
|
||||||
if (prevCount === 0) {
|
if (isFirstLoad) {
|
||||||
// First load — seed count, don't notify
|
// First load — seed count, don't notify for pre-existing messages
|
||||||
inboxMessageCounts.set(key, messages.length);
|
inboxMessageCounts.set(key, messages.length);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -167,14 +223,17 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
||||||
for (const msg of newMessages) {
|
for (const msg of newMessages) {
|
||||||
// Skip messages sent from our own UI
|
// Skip messages sent from our own UI
|
||||||
if (msg.source && suppressedSources.has(msg.source)) continue;
|
if (msg.source && suppressedSources.has(msg.source)) continue;
|
||||||
|
// Skip internal coordination noise (idle_notification, shutdown_*, etc.)
|
||||||
|
if (isInboxNoiseMessage(msg.text)) continue;
|
||||||
|
|
||||||
const fromLabel = msg.from || 'Unknown';
|
const fromLabel = msg.from || 'Unknown';
|
||||||
const summary = msg.summary || msg.text.slice(0, 60);
|
const extracted = extractNotificationContent(msg.text);
|
||||||
|
const summary = msg.summary || extracted.summary;
|
||||||
|
|
||||||
showTeamNativeNotification({
|
showTeamNativeNotification({
|
||||||
title: teamDisplayName,
|
title: teamDisplayName,
|
||||||
subtitle: `${fromLabel}: ${summary}`,
|
subtitle: `${fromLabel}: ${summary}`,
|
||||||
body: msg.text,
|
body: extracted.body,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -344,16 +403,17 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
||||||
if (cfg.notifications.enabled && cfg.notifications.notifyOnInboxMessages) {
|
if (cfg.notifications.enabled && cfg.notifications.notifyOnInboxMessages) {
|
||||||
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
|
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
|
||||||
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||||
// Only notify for messages addressed to the human user
|
// Only notify for messages addressed to the human user, skip noise
|
||||||
if (latest?.to === 'user') {
|
if (latest?.to === 'user' && !isInboxNoiseMessage(latest.text)) {
|
||||||
const fromLabel = latest.from || 'team-lead';
|
const fromLabel = latest.from || 'team-lead';
|
||||||
const summary = latest.summary || latest.text.slice(0, 60);
|
const extracted = extractNotificationContent(latest.text);
|
||||||
|
const summary = latest.summary || extracted.summary;
|
||||||
void resolveTeamDisplayName(teamName)
|
void resolveTeamDisplayName(teamName)
|
||||||
.then((displayName) => {
|
.then((displayName) => {
|
||||||
showTeamNativeNotification({
|
showTeamNativeNotification({
|
||||||
title: displayName,
|
title: displayName,
|
||||||
subtitle: `${fromLabel}: ${summary}`,
|
subtitle: `${fromLabel}: ${summary}`,
|
||||||
body: latest.text,
|
body: extracted.body,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,10 @@ export function registerConfigHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle('config:getClaudeRootInfo', handleGetClaudeRootInfo);
|
ipcMain.handle('config:getClaudeRootInfo', handleGetClaudeRootInfo);
|
||||||
ipcMain.handle('config:findWslClaudeRoots', handleFindWslClaudeRoots);
|
ipcMain.handle('config:findWslClaudeRoots', handleFindWslClaudeRoots);
|
||||||
|
|
||||||
|
// Custom project path handlers
|
||||||
|
ipcMain.handle('config:addCustomProjectPath', handleAddCustomProjectPath);
|
||||||
|
ipcMain.handle('config:removeCustomProjectPath', handleRemoveCustomProjectPath);
|
||||||
|
|
||||||
// Editor handlers
|
// Editor handlers
|
||||||
ipcMain.handle('config:openInEditor', handleOpenInEditor);
|
ipcMain.handle('config:openInEditor', handleOpenInEditor);
|
||||||
|
|
||||||
|
|
@ -624,6 +628,46 @@ async function handleOpenInEditor(_event: IpcMainInvokeEvent): Promise<IpcResult
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for 'config:addCustomProjectPath' - Persists a custom project path.
|
||||||
|
*/
|
||||||
|
async function handleAddCustomProjectPath(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
projectPath: string
|
||||||
|
): Promise<IpcResult> {
|
||||||
|
try {
|
||||||
|
if (!projectPath || typeof projectPath !== 'string') {
|
||||||
|
return { success: false, error: 'Project path is required and must be a string' };
|
||||||
|
}
|
||||||
|
|
||||||
|
configManager.addCustomProjectPath(projectPath);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in config:addCustomProjectPath:', error);
|
||||||
|
return { success: false, error: getErrorMessage(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for 'config:removeCustomProjectPath' - Removes a custom project path.
|
||||||
|
*/
|
||||||
|
async function handleRemoveCustomProjectPath(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
projectPath: string
|
||||||
|
): Promise<IpcResult> {
|
||||||
|
try {
|
||||||
|
if (!projectPath || typeof projectPath !== 'string') {
|
||||||
|
return { success: false, error: 'Project path is required and must be a string' };
|
||||||
|
}
|
||||||
|
|
||||||
|
configManager.removeCustomProjectPath(projectPath);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in config:removeCustomProjectPath:', error);
|
||||||
|
return { success: false, error: getErrorMessage(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler for 'config:selectFolders' - Opens native folder selection dialog.
|
* Handler for 'config:selectFolders' - Opens native folder selection dialog.
|
||||||
* Allows users to select one or more folders for trigger project scope.
|
* Allows users to select one or more folders for trigger project scope.
|
||||||
|
|
@ -1091,6 +1135,8 @@ export function removeConfigHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler('config:unhideSession');
|
ipcMain.removeHandler('config:unhideSession');
|
||||||
ipcMain.removeHandler('config:hideSessions');
|
ipcMain.removeHandler('config:hideSessions');
|
||||||
ipcMain.removeHandler('config:unhideSessions');
|
ipcMain.removeHandler('config:unhideSessions');
|
||||||
|
ipcMain.removeHandler('config:addCustomProjectPath');
|
||||||
|
ipcMain.removeHandler('config:removeCustomProjectPath');
|
||||||
ipcMain.removeHandler('config:selectFolders');
|
ipcMain.removeHandler('config:selectFolders');
|
||||||
ipcMain.removeHandler('config:selectClaudeRootFolder');
|
ipcMain.removeHandler('config:selectClaudeRootFolder');
|
||||||
ipcMain.removeHandler('config:getClaudeRootInfo');
|
ipcMain.removeHandler('config:getClaudeRootInfo');
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { getAppIconPath } from '@main/utils/appIcon';
|
||||||
import {
|
import {
|
||||||
TEAM_ADD_MEMBER,
|
TEAM_ADD_MEMBER,
|
||||||
TEAM_ADD_TASK_COMMENT,
|
TEAM_ADD_TASK_COMMENT,
|
||||||
|
TEAM_ADD_TASK_RELATIONSHIP,
|
||||||
TEAM_ALIVE_LIST,
|
TEAM_ALIVE_LIST,
|
||||||
TEAM_CANCEL_PROVISIONING,
|
TEAM_CANCEL_PROVISIONING,
|
||||||
TEAM_CREATE,
|
TEAM_CREATE,
|
||||||
|
|
@ -29,6 +30,7 @@ import {
|
||||||
TEAM_PROVISIONING_PROGRESS,
|
TEAM_PROVISIONING_PROGRESS,
|
||||||
TEAM_PROVISIONING_STATUS,
|
TEAM_PROVISIONING_STATUS,
|
||||||
TEAM_REMOVE_MEMBER,
|
TEAM_REMOVE_MEMBER,
|
||||||
|
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_RESTORE,
|
TEAM_RESTORE,
|
||||||
TEAM_RESTORE_TASK,
|
TEAM_RESTORE_TASK,
|
||||||
|
|
@ -216,6 +218,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
||||||
ipcMain.handle(TEAM_SET_TASK_CLARIFICATION, handleSetTaskClarification);
|
ipcMain.handle(TEAM_SET_TASK_CLARIFICATION, handleSetTaskClarification);
|
||||||
ipcMain.handle(TEAM_SHOW_MESSAGE_NOTIFICATION, handleShowMessageNotification);
|
ipcMain.handle(TEAM_SHOW_MESSAGE_NOTIFICATION, handleShowMessageNotification);
|
||||||
|
ipcMain.handle(TEAM_ADD_TASK_RELATIONSHIP, handleAddTaskRelationship);
|
||||||
|
ipcMain.handle(TEAM_REMOVE_TASK_RELATIONSHIP, handleRemoveTaskRelationship);
|
||||||
logger.info('Team handlers registered');
|
logger.info('Team handlers registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -262,6 +266,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
||||||
ipcMain.removeHandler(TEAM_SET_TASK_CLARIFICATION);
|
ipcMain.removeHandler(TEAM_SET_TASK_CLARIFICATION);
|
||||||
ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION);
|
ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION);
|
||||||
|
ipcMain.removeHandler(TEAM_ADD_TASK_RELATIONSHIP);
|
||||||
|
ipcMain.removeHandler(TEAM_REMOVE_TASK_RELATIONSHIP);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTeamDataService(): TeamDataService {
|
function getTeamDataService(): TeamDataService {
|
||||||
|
|
@ -1715,14 +1721,21 @@ export function showTeamNativeNotification(opts: {
|
||||||
body: string;
|
body: string;
|
||||||
}): void {
|
}): void {
|
||||||
const config = ConfigManager.getInstance().getConfig();
|
const config = ConfigManager.getInstance().getConfig();
|
||||||
if (!config.notifications.enabled) return;
|
if (!config.notifications.enabled) {
|
||||||
if (config.notifications.snoozedUntil && Date.now() < config.notifications.snoozedUntil) return;
|
logger.debug('[native-notification] skipped: notifications disabled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (config.notifications.snoozedUntil && Date.now() < config.notifications.snoozedUntil) {
|
||||||
|
logger.debug('[native-notification] skipped: snoozed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof Notification === 'undefined' ||
|
typeof Notification === 'undefined' ||
|
||||||
typeof Notification.isSupported !== 'function' ||
|
typeof Notification.isSupported !== 'function' ||
|
||||||
!Notification.isSupported()
|
!Notification.isSupported()
|
||||||
) {
|
) {
|
||||||
|
logger.warn('[native-notification] skipped: Notification not supported on this platform');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1744,6 +1757,14 @@ export function showTeamNativeNotification(opts: {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
notification.on('show', () => {
|
||||||
|
logger.debug(`[native-notification] shown: "${opts.title}" — ${opts.subtitle ?? ''}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
notification.on('failed', (_, error) => {
|
||||||
|
logger.warn(`[native-notification] failed: ${error}`);
|
||||||
|
});
|
||||||
|
|
||||||
notification.show();
|
notification.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1766,3 +1787,66 @@ async function handleAddTaskComment(
|
||||||
getTeamDataService().addTaskComment(vTeam.value!, vTask.value!, text.trim())
|
getTeamDataService().addTaskComment(vTeam.value!, vTask.value!, text.trim())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_RELATIONSHIP_TYPES = ['blockedBy', 'blocks', 'related'] as const;
|
||||||
|
type RelationshipType = (typeof VALID_RELATIONSHIP_TYPES)[number];
|
||||||
|
|
||||||
|
async function handleAddTaskRelationship(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
taskId: unknown,
|
||||||
|
targetId: unknown,
|
||||||
|
type: unknown
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
const vTeam = validateTeamName(teamName);
|
||||||
|
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||||
|
const vTask = validateTaskId(taskId);
|
||||||
|
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||||||
|
const vTarget = validateTaskId(targetId);
|
||||||
|
if (!vTarget.valid) return { success: false, error: vTarget.error ?? 'Invalid targetId' };
|
||||||
|
if (typeof type !== 'string' || !VALID_RELATIONSHIP_TYPES.includes(type as RelationshipType)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `type must be one of: ${VALID_RELATIONSHIP_TYPES.join(', ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapTeamHandler('addTaskRelationship', () =>
|
||||||
|
getTeamDataService().addTaskRelationship(
|
||||||
|
vTeam.value!,
|
||||||
|
vTask.value!,
|
||||||
|
vTarget.value!,
|
||||||
|
type as RelationshipType
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveTaskRelationship(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
taskId: unknown,
|
||||||
|
targetId: unknown,
|
||||||
|
type: unknown
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
const vTeam = validateTeamName(teamName);
|
||||||
|
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||||
|
const vTask = validateTaskId(taskId);
|
||||||
|
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||||||
|
const vTarget = validateTaskId(targetId);
|
||||||
|
if (!vTarget.valid) return { success: false, error: vTarget.error ?? 'Invalid targetId' };
|
||||||
|
if (typeof type !== 'string' || !VALID_RELATIONSHIP_TYPES.includes(type as RelationshipType)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `type must be one of: ${VALID_RELATIONSHIP_TYPES.join(', ')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapTeamHandler('removeTaskRelationship', () =>
|
||||||
|
getTeamDataService().removeTaskRelationship(
|
||||||
|
vTeam.value!,
|
||||||
|
vTask.value!,
|
||||||
|
vTarget.value!,
|
||||||
|
type as RelationshipType
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -196,10 +196,6 @@ export class ProjectScanner {
|
||||||
// 1. Scan all projects using existing logic
|
// 1. Scan all projects using existing logic
|
||||||
const projects = await this.scan();
|
const projects = await this.scan();
|
||||||
|
|
||||||
if (projects.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Convert each project to a simple RepositoryGroup (git resolution disabled)
|
// 2. Convert each project to a simple RepositoryGroup (git resolution disabled)
|
||||||
// Git identity resolution is bypassed to avoid blocking I/O on startup.
|
// Git identity resolution is bypassed to avoid blocking I/O on startup.
|
||||||
// Each project becomes a single-worktree group.
|
// Each project becomes a single-worktree group.
|
||||||
|
|
@ -223,6 +219,40 @@ export class ProjectScanner {
|
||||||
totalSessions: project.sessions.length,
|
totalSessions: project.sessions.length,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 3. Merge custom project paths from config (persisted "Select Folder" picks)
|
||||||
|
const { configManager } = await import('../infrastructure/ConfigManager');
|
||||||
|
const customPaths = configManager.getCustomProjectPaths();
|
||||||
|
const existingPaths = new Set(groups.flatMap((g) => g.worktrees.map((w) => w.path)));
|
||||||
|
|
||||||
|
for (const customPath of customPaths) {
|
||||||
|
if (existingPaths.has(customPath)) {
|
||||||
|
continue; // Already discovered by scanner — skip
|
||||||
|
}
|
||||||
|
|
||||||
|
const encodedId = customPath.replace(/[/\\]/g, '-');
|
||||||
|
const folderName = customPath.split(/[/\\]/).filter(Boolean).pop() ?? customPath;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
id: encodedId,
|
||||||
|
identity: null,
|
||||||
|
worktrees: [
|
||||||
|
{
|
||||||
|
id: encodedId,
|
||||||
|
path: customPath,
|
||||||
|
name: folderName,
|
||||||
|
isMainWorktree: true,
|
||||||
|
source: 'unknown' as const,
|
||||||
|
sessions: [],
|
||||||
|
createdAt: now,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
name: folderName,
|
||||||
|
mostRecentSession: undefined,
|
||||||
|
totalSessions: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Sort by most recent activity (same order as the full git-aware version)
|
// Sort by most recent activity (same order as the full git-aware version)
|
||||||
groups.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
|
groups.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,8 @@ export interface GeneralConfig {
|
||||||
agentLanguage: string;
|
agentLanguage: string;
|
||||||
autoExpandAIGroups: boolean;
|
autoExpandAIGroups: boolean;
|
||||||
useNativeTitleBar: boolean;
|
useNativeTitleBar: boolean;
|
||||||
|
/** Paths manually added via "Select Folder" that persist across app restarts */
|
||||||
|
customProjectPaths: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DisplayConfig {
|
export interface DisplayConfig {
|
||||||
|
|
@ -260,6 +262,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
agentLanguage: 'system',
|
agentLanguage: 'system',
|
||||||
autoExpandAIGroups: false,
|
autoExpandAIGroups: false,
|
||||||
useNativeTitleBar: false,
|
useNativeTitleBar: false,
|
||||||
|
customProjectPaths: [],
|
||||||
},
|
},
|
||||||
display: {
|
display: {
|
||||||
showTimestamps: true,
|
showTimestamps: true,
|
||||||
|
|
@ -845,6 +848,58 @@ export class ConfigManager {
|
||||||
this.saveConfig();
|
this.saveConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Custom Project Path Management
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a custom project path (from "Select Folder" dialog).
|
||||||
|
* Persisted across app restarts.
|
||||||
|
* @param projectPath - Absolute filesystem path to the project
|
||||||
|
*/
|
||||||
|
addCustomProjectPath(projectPath: string): void {
|
||||||
|
if (!projectPath || projectPath.trim().length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = path.normalize(projectPath.trim());
|
||||||
|
if (!path.isAbsolute(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.config.general.customProjectPaths.includes(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config.general.customProjectPaths.push(normalized);
|
||||||
|
this.saveConfig();
|
||||||
|
logger.info(`Custom project path added: ${normalized}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a custom project path.
|
||||||
|
* @param projectPath - The path to remove
|
||||||
|
*/
|
||||||
|
removeCustomProjectPath(projectPath: string): void {
|
||||||
|
const normalized = path.normalize(projectPath.trim());
|
||||||
|
const index = this.config.general.customProjectPaths.indexOf(normalized);
|
||||||
|
if (index === -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.config.general.customProjectPaths.splice(index, 1);
|
||||||
|
this.saveConfig();
|
||||||
|
logger.info(`Custom project path removed: ${normalized}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all custom project paths.
|
||||||
|
* @returns Array of absolute filesystem paths
|
||||||
|
*/
|
||||||
|
getCustomProjectPaths(): string[] {
|
||||||
|
return [...this.config.general.customProjectPaths];
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// SSH Profile Management
|
// SSH Profile Management
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,103 @@ function updateHighwatermark(paths, taskId) {
|
||||||
atomicWrite(hwmPath, String(taskId));
|
atomicWrite(hwmPath, String(taskId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseIdList(value) {
|
||||||
|
if (!value || value === true) return [];
|
||||||
|
var ids = String(value).split(',').map(function(s) { return s.trim(); }).filter(Boolean);
|
||||||
|
for (var k = 0; k < ids.length; k++) {
|
||||||
|
if (!/^\d+$/.test(ids[k])) die('Invalid task ID in list: ' + ids[k]);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskExists(paths, taskId) {
|
||||||
|
try {
|
||||||
|
fs.accessSync(path.join(paths.tasksDir, String(taskId) + '.json'), fs.constants.F_OK);
|
||||||
|
return true;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTaskObject(paths, taskId) {
|
||||||
|
var taskPath = path.join(paths.tasksDir, String(taskId) + '.json');
|
||||||
|
var t = readJson(taskPath, null);
|
||||||
|
if (!t) die('Task not found: #' + taskId);
|
||||||
|
return { task: t, taskPath: taskPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function wouldCreateBlockCycle(paths, sourceId, targetId) {
|
||||||
|
var visited = {};
|
||||||
|
var stack = [String(targetId)];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
var current = stack.pop();
|
||||||
|
if (current === String(sourceId)) return true;
|
||||||
|
if (visited[current]) continue;
|
||||||
|
visited[current] = true;
|
||||||
|
try {
|
||||||
|
var t = readJson(path.join(paths.tasksDir, current + '.json'), null);
|
||||||
|
if (t && Array.isArray(t.blockedBy)) {
|
||||||
|
for (var i = 0; i < t.blockedBy.length; i++) stack.push(String(t.blockedBy[i]));
|
||||||
|
}
|
||||||
|
} catch (e) { /* skip */ }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkTasks(paths, taskId, targetId, type) {
|
||||||
|
var id = String(taskId), target = String(targetId);
|
||||||
|
if (id === target) die('Cannot link a task to itself');
|
||||||
|
if (!taskExists(paths, id)) die('Task not found: #' + id);
|
||||||
|
if (!taskExists(paths, target)) die('Task not found: #' + target);
|
||||||
|
|
||||||
|
if (type === 'blocked-by') {
|
||||||
|
if (wouldCreateBlockCycle(paths, id, target))
|
||||||
|
die('Circular dependency: #' + target + ' already depends on #' + id);
|
||||||
|
var refA = readTaskObject(paths, id);
|
||||||
|
var bb = Array.isArray(refA.task.blockedBy) ? refA.task.blockedBy : [];
|
||||||
|
if (!bb.includes(target)) { refA.task.blockedBy = bb.concat([target]); atomicWrite(refA.taskPath, JSON.stringify(refA.task, null, 2)); }
|
||||||
|
var refB = readTaskObject(paths, target);
|
||||||
|
var bl = Array.isArray(refB.task.blocks) ? refB.task.blocks : [];
|
||||||
|
if (!bl.includes(id)) { refB.task.blocks = bl.concat([id]); atomicWrite(refB.taskPath, JSON.stringify(refB.task, null, 2)); }
|
||||||
|
} else if (type === 'blocks') {
|
||||||
|
linkTasks(paths, target, id, 'blocked-by');
|
||||||
|
return;
|
||||||
|
} else if (type === 'related') {
|
||||||
|
var rA = readTaskObject(paths, id);
|
||||||
|
var relA = Array.isArray(rA.task.related) ? rA.task.related : [];
|
||||||
|
if (!relA.includes(target)) { rA.task.related = relA.concat([target]); atomicWrite(rA.taskPath, JSON.stringify(rA.task, null, 2)); }
|
||||||
|
var rB = readTaskObject(paths, target);
|
||||||
|
var relB = Array.isArray(rB.task.related) ? rB.task.related : [];
|
||||||
|
if (!relB.includes(id)) { rB.task.related = relB.concat([id]); atomicWrite(rB.taskPath, JSON.stringify(rB.task, null, 2)); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlinkTasks(paths, taskId, targetId, type) {
|
||||||
|
var id = String(taskId), target = String(targetId);
|
||||||
|
if (!taskExists(paths, id)) die('Task not found: #' + id);
|
||||||
|
|
||||||
|
if (type === 'blocked-by') {
|
||||||
|
var refA = readTaskObject(paths, id);
|
||||||
|
refA.task.blockedBy = (Array.isArray(refA.task.blockedBy) ? refA.task.blockedBy : []).filter(function(x) { return x !== target; });
|
||||||
|
atomicWrite(refA.taskPath, JSON.stringify(refA.task, null, 2));
|
||||||
|
if (taskExists(paths, target)) {
|
||||||
|
var refB = readTaskObject(paths, target);
|
||||||
|
refB.task.blocks = (Array.isArray(refB.task.blocks) ? refB.task.blocks : []).filter(function(x) { return x !== id; });
|
||||||
|
atomicWrite(refB.taskPath, JSON.stringify(refB.task, null, 2));
|
||||||
|
}
|
||||||
|
} else if (type === 'blocks') {
|
||||||
|
unlinkTasks(paths, target, id, 'blocked-by');
|
||||||
|
return;
|
||||||
|
} else if (type === 'related') {
|
||||||
|
var rA = readTaskObject(paths, id);
|
||||||
|
rA.task.related = (Array.isArray(rA.task.related) ? rA.task.related : []).filter(function(x) { return x !== target; });
|
||||||
|
atomicWrite(rA.taskPath, JSON.stringify(rA.task, null, 2));
|
||||||
|
if (taskExists(paths, target)) {
|
||||||
|
var rB = readTaskObject(paths, target);
|
||||||
|
rB.task.related = (Array.isArray(rB.task.related) ? rB.task.related : []).filter(function(x) { return x !== id; });
|
||||||
|
atomicWrite(rB.taskPath, JSON.stringify(rB.task, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function createTask(paths, flags) {
|
function createTask(paths, flags) {
|
||||||
const subject = typeof flags.subject === 'string' ? flags.subject.trim() : '';
|
const subject = typeof flags.subject === 'string' ? flags.subject.trim() : '';
|
||||||
if (!subject) die('Missing --subject');
|
if (!subject) die('Missing --subject');
|
||||||
|
|
@ -324,6 +421,11 @@ function createTask(paths, flags) {
|
||||||
? flags['active-form']
|
? flags['active-form']
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
var blockedByIds = parseIdList(flags['blocked-by']);
|
||||||
|
var relatedIds = parseIdList(flags.related);
|
||||||
|
for (var v = 0; v < blockedByIds.length; v++) { if (!taskExists(paths, blockedByIds[v])) die('Blocked-by task not found: #' + blockedByIds[v]); }
|
||||||
|
for (var w = 0; w < relatedIds.length; w++) { if (!taskExists(paths, relatedIds[w])) die('Related task not found: #' + relatedIds[w]); }
|
||||||
|
|
||||||
ensureDir(paths.tasksDir);
|
ensureDir(paths.tasksDir);
|
||||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined;
|
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined;
|
||||||
let nextId;
|
let nextId;
|
||||||
|
|
@ -341,7 +443,8 @@ function createTask(paths, flags) {
|
||||||
createdBy: from,
|
createdBy: from,
|
||||||
status,
|
status,
|
||||||
blocks: [],
|
blocks: [],
|
||||||
blockedBy: [],
|
blockedBy: blockedByIds,
|
||||||
|
related: relatedIds.length > 0 ? relatedIds : undefined,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const fd = fs.openSync(taskPath, 'wx');
|
const fd = fs.openSync(taskPath, 'wx');
|
||||||
|
|
@ -356,6 +459,20 @@ function createTask(paths, flags) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
updateHighwatermark(paths, nextId);
|
updateHighwatermark(paths, nextId);
|
||||||
|
|
||||||
|
// Set reverse links for blockedBy (target.blocks += nextId)
|
||||||
|
for (var bi = 0; bi < blockedByIds.length; bi++) {
|
||||||
|
var dep = readTaskObject(paths, blockedByIds[bi]);
|
||||||
|
var depBl = Array.isArray(dep.task.blocks) ? dep.task.blocks : [];
|
||||||
|
if (!depBl.includes(nextId)) { dep.task.blocks = depBl.concat([nextId]); atomicWrite(dep.taskPath, JSON.stringify(dep.task, null, 2)); }
|
||||||
|
}
|
||||||
|
// Set reverse links for related (bidirectional)
|
||||||
|
for (var ri = 0; ri < relatedIds.length; ri++) {
|
||||||
|
var rel = readTaskObject(paths, relatedIds[ri]);
|
||||||
|
var relL = Array.isArray(rel.task.related) ? rel.task.related : [];
|
||||||
|
if (!relL.includes(nextId)) { rel.task.related = relL.concat([nextId]); atomicWrite(rel.taskPath, JSON.stringify(rel.task, null, 2)); }
|
||||||
|
}
|
||||||
|
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -638,6 +755,9 @@ function taskBriefing(paths, teamName, flags) {
|
||||||
if (t.description && t.description !== t.subject) {
|
if (t.description && t.description !== t.subject) {
|
||||||
parts.push(' Description: ' + t.description.slice(0, 500));
|
parts.push(' Description: ' + t.description.slice(0, 500));
|
||||||
}
|
}
|
||||||
|
if (t.blocks && t.blocks.length > 0) {
|
||||||
|
parts.push(' Blocks: ' + t.blocks.map(function(id) { return '#' + id; }).join(', '));
|
||||||
|
}
|
||||||
if (t.blockedBy && t.blockedBy.length > 0) {
|
if (t.blockedBy && t.blockedBy.length > 0) {
|
||||||
parts.push(' Blocked by: ' + t.blockedBy.map(function(id) { return '#' + id; }).join(', '));
|
parts.push(' Blocked by: ' + t.blockedBy.map(function(id) { return '#' + id; }).join(', '));
|
||||||
}
|
}
|
||||||
|
|
@ -703,7 +823,13 @@ function printHelp() {
|
||||||
' node teamctl.js task set-status <id> <pending|in_progress|completed|deleted> [--team <team>]',
|
' node teamctl.js task set-status <id> <pending|in_progress|completed|deleted> [--team <team>]',
|
||||||
' node teamctl.js task complete <id> [--team <team>]',
|
' node teamctl.js task complete <id> [--team <team>]',
|
||||||
' node teamctl.js task start <id> [--team <team>]',
|
' node teamctl.js task start <id> [--team <team>]',
|
||||||
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team <team>]',
|
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--blocked-by 2,3] [--related 5] [--status ...] [--notify --from "member"] [--team <team>]',
|
||||||
|
' node teamctl.js task link <id> --blocked-by <targetId> [--team <team>]',
|
||||||
|
' node teamctl.js task link <id> --blocks <targetId> [--team <team>]',
|
||||||
|
' node teamctl.js task link <id> --related <targetId> [--team <team>]',
|
||||||
|
' node teamctl.js task unlink <id> --blocked-by <targetId> [--team <team>]',
|
||||||
|
' node teamctl.js task unlink <id> --blocks <targetId> [--team <team>]',
|
||||||
|
' node teamctl.js task unlink <id> --related <targetId> [--team <team>]',
|
||||||
' node teamctl.js task set-owner <id> <member|clear> [--notify --from "member"] [--team <team>]',
|
' node teamctl.js task set-owner <id> <member|clear> [--notify --from "member"] [--team <team>]',
|
||||||
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
|
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
|
||||||
' node teamctl.js task set-clarification <id> <lead|user|clear> [--from "member"] [--team <team>]',
|
' node teamctl.js task set-clarification <id> <lead|user|clear> [--from "member"] [--team <team>]',
|
||||||
|
|
@ -876,6 +1002,30 @@ async function main() {
|
||||||
taskBriefing(paths, teamName, args.flags);
|
taskBriefing(paths, teamName, args.flags);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === 'link') {
|
||||||
|
var linkId = rest[0] || args.flags.id;
|
||||||
|
if (!linkId) die('Usage: task link <id> --blocked-by|--blocks|--related <targetId>');
|
||||||
|
var linkBbF = args.flags['blocked-by'], linkBlF = args.flags.blocks, linkRelF = args.flags.related;
|
||||||
|
var linkCnt = (linkBbF ? 1 : 0) + (linkBlF ? 1 : 0) + (linkRelF ? 1 : 0);
|
||||||
|
if (linkCnt !== 1) die('Specify exactly one: --blocked-by, --blocks, or --related');
|
||||||
|
var linkTp = linkBbF ? 'blocked-by' : linkBlF ? 'blocks' : 'related';
|
||||||
|
var linkTv = linkBbF || linkBlF || linkRelF;
|
||||||
|
linkTasks(paths, String(linkId), String(linkTv), linkTp);
|
||||||
|
process.stdout.write('OK task #' + linkId + ' ' + linkTp + ' #' + linkTv + '\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'unlink') {
|
||||||
|
var unlinkId = rest[0] || args.flags.id;
|
||||||
|
if (!unlinkId) die('Usage: task unlink <id> --blocked-by|--blocks|--related <targetId>');
|
||||||
|
var unlinkBbF = args.flags['blocked-by'], unlinkBlF = args.flags.blocks, unlinkRelF = args.flags.related;
|
||||||
|
var unlinkCnt = (unlinkBbF ? 1 : 0) + (unlinkBlF ? 1 : 0) + (unlinkRelF ? 1 : 0);
|
||||||
|
if (unlinkCnt !== 1) die('Specify exactly one: --blocked-by, --blocks, or --related');
|
||||||
|
var unlinkTp = unlinkBbF ? 'blocked-by' : unlinkBlF ? 'blocks' : 'related';
|
||||||
|
var unlinkTv = unlinkBbF || unlinkBlF || unlinkRelF;
|
||||||
|
unlinkTasks(paths, String(unlinkId), String(unlinkTv), unlinkTp);
|
||||||
|
process.stdout.write('OK task #' + unlinkId + ' unlinked ' + unlinkTp + ' #' + unlinkTv + '\n');
|
||||||
|
return;
|
||||||
|
}
|
||||||
die('Unknown task action: ' + String(action));
|
die('Unknown task action: ' + String(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -783,6 +783,24 @@ export class TeamDataService {
|
||||||
await this.taskWriter.setNeedsClarification(teamName, taskId, value);
|
await this.taskWriter.setNeedsClarification(teamName, taskId, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addTaskRelationship(
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
targetId: string,
|
||||||
|
type: 'blockedBy' | 'blocks' | 'related'
|
||||||
|
): Promise<void> {
|
||||||
|
await this.taskWriter.addRelationship(teamName, taskId, targetId, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeTaskRelationship(
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
targetId: string,
|
||||||
|
type: 'blockedBy' | 'blocks' | 'related'
|
||||||
|
): Promise<void> {
|
||||||
|
await this.taskWriter.removeRelationship(teamName, taskId, targetId, type);
|
||||||
|
}
|
||||||
|
|
||||||
async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
|
async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
|
||||||
const comment = await this.taskWriter.addComment(teamName, taskId, text);
|
const comment = await this.taskWriter.addComment(teamName, taskId, text);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -350,6 +350,9 @@ function buildTaskStatusProtocol(teamName: string): string {
|
||||||
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
|
If the lead replies via SendMessage instead, clear the flag yourself once you have the answer:
|
||||||
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> clear --from "<your-name>"
|
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-clarification <taskId> clear --from "<your-name>"
|
||||||
d) Do NOT set clarification to "user" yourself — only the team lead escalates to the user.
|
d) Do NOT set clarification to "user" yourself — only the team lead escalates to the user.
|
||||||
|
11. DEPENDENCY AWARENESS:
|
||||||
|
When your task has blockedBy dependencies, check if they are completed before starting.
|
||||||
|
When you complete a task that blocks others, mention this in your completion message so blocked teammates can proceed.
|
||||||
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.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -377,6 +380,15 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
|
||||||
`- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> <member-name> --notify --from "${leadName}"`,
|
`- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> <member-name> --notify --from "${leadName}"`,
|
||||||
`- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> clear`,
|
`- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> clear`,
|
||||||
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
|
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
|
||||||
|
`- Create with deps: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --owner "<member>" --notify --from "${leadName}"`,
|
||||||
|
`- Link dependency: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --blocked-by <targetId>`,
|
||||||
|
`- Link related: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --related <targetId>`,
|
||||||
|
`- Unlink: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task unlink <id> --blocked-by <targetId>`,
|
||||||
|
``,
|
||||||
|
`Dependency guidelines:`,
|
||||||
|
`- Use --blocked-by when a task cannot start until another is done.`,
|
||||||
|
`- Use --related to link related work (e.g. frontend + backend) without blocking.`,
|
||||||
|
`- Avoid over-specifying. Only add dependencies when execution order matters.`,
|
||||||
``,
|
``,
|
||||||
`Notification policy:`,
|
`Notification policy:`,
|
||||||
`- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.`,
|
`- The --notify flag sends the assignment to the member automatically, so do NOT send a separate SendMessage for the same task.`,
|
||||||
|
|
@ -445,7 +457,10 @@ function buildMemberTaskSnapshot(memberName: string, tasks: TeamTask[]): string
|
||||||
|
|
||||||
const lines = activeTasks.map((t) => {
|
const lines = activeTasks.map((t) => {
|
||||||
const desc = t.description ? ` — ${t.description.slice(0, 120)}` : '';
|
const desc = t.description ? ` — ${t.description.slice(0, 120)}` : '';
|
||||||
return ` - #${t.id} [${t.status}] ${t.subject}${desc}`;
|
const deps = t.blockedBy?.length
|
||||||
|
? ` [blocked by: ${t.blockedBy.map((id) => `#${id}`).join(', ')}]`
|
||||||
|
: '';
|
||||||
|
return ` - #${t.id} [${t.status}] ${t.subject}${deps}${desc}`;
|
||||||
});
|
});
|
||||||
return `\nYour pending tasks from last session (RESUME these immediately):\n${lines.join('\n')}\n`;
|
return `\nYour pending tasks from last session (RESUME these immediately):\n${lines.join('\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|
@ -460,7 +475,10 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
|
||||||
const lines = active.map((t) => {
|
const lines = active.map((t) => {
|
||||||
const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
|
const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
|
||||||
const desc = t.description ? ` — ${t.description.slice(0, 120)}` : '';
|
const desc = t.description ? ` — ${t.description.slice(0, 120)}` : '';
|
||||||
return ` - #${t.id} [${t.status}]${owner} ${t.subject}${desc}`;
|
const deps = t.blockedBy?.length
|
||||||
|
? ` [blocked by: ${t.blockedBy.map((id) => `#${id}`).join(', ')}]`
|
||||||
|
: '';
|
||||||
|
return ` - #${t.id} [${t.status}]${owner} ${t.subject}${deps}${desc}`;
|
||||||
});
|
});
|
||||||
return `\nCurrent task board (pending/in_progress):\n${lines.join('\n')}\n`;
|
return `\nCurrent task board (pending/in_progress):\n${lines.join('\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|
@ -535,6 +553,8 @@ ${processRegistration}
|
||||||
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
|
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
|
||||||
- Prefer fewer, broader tasks over many micro-tasks.
|
- Prefer fewer, broader tasks over many micro-tasks.
|
||||||
- Avoid duplicate notifications for the same assignment.
|
- Avoid duplicate notifications for the same assignment.
|
||||||
|
- When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by.
|
||||||
|
- Use --related to connect tasks working on the same feature without blocking.
|
||||||
|
|
||||||
4) After all steps, output a short summary.
|
4) After all steps, output a short summary.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,172 @@ export class TeamTaskWriter {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addRelationship(
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
targetId: string,
|
||||||
|
type: 'blockedBy' | 'blocks' | 'related'
|
||||||
|
): Promise<void> {
|
||||||
|
if (taskId === targetId) {
|
||||||
|
throw new Error('Cannot link a task to itself');
|
||||||
|
}
|
||||||
|
|
||||||
|
// For 'blocks', delegate as reverse blockedBy
|
||||||
|
if (type === 'blocks') {
|
||||||
|
return this.addRelationship(teamName, targetId, taskId, 'blockedBy');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||||
|
const taskPath = path.join(tasksDir, `${taskId}.json`);
|
||||||
|
const targetPath = path.join(tasksDir, `${targetId}.json`);
|
||||||
|
|
||||||
|
// Lock both paths in sorted order to avoid deadlocks
|
||||||
|
const [firstPath, secondPath] =
|
||||||
|
taskPath < targetPath ? [taskPath, targetPath] : [targetPath, taskPath];
|
||||||
|
|
||||||
|
await withTaskLock(firstPath, () =>
|
||||||
|
withTaskLock(secondPath, async () => {
|
||||||
|
// Read both tasks
|
||||||
|
const taskRaw = await this.readTaskFile(taskPath, taskId);
|
||||||
|
const targetRaw = await this.readTaskFile(targetPath, targetId);
|
||||||
|
const task = JSON.parse(taskRaw) as TeamTask;
|
||||||
|
const target = JSON.parse(targetRaw) as TeamTask;
|
||||||
|
|
||||||
|
if (type === 'blockedBy') {
|
||||||
|
// Cycle detection: walk target's blockedBy chain to check if taskId is reachable
|
||||||
|
await this.checkBlockCycle(tasksDir, taskId, targetId);
|
||||||
|
|
||||||
|
// task.blockedBy += targetId
|
||||||
|
const blockedBy = task.blockedBy ?? [];
|
||||||
|
if (!blockedBy.includes(targetId)) {
|
||||||
|
task.blockedBy = [...blockedBy, targetId];
|
||||||
|
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||||
|
}
|
||||||
|
// target.blocks += taskId (reverse)
|
||||||
|
const blocks = target.blocks ?? [];
|
||||||
|
if (!blocks.includes(taskId)) {
|
||||||
|
target.blocks = [...blocks, taskId];
|
||||||
|
await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// related — bidirectional
|
||||||
|
const relA = task.related ?? [];
|
||||||
|
if (!relA.includes(targetId)) {
|
||||||
|
task.related = [...relA, targetId];
|
||||||
|
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||||
|
}
|
||||||
|
const relB = target.related ?? [];
|
||||||
|
if (!relB.includes(taskId)) {
|
||||||
|
target.related = [...relB, taskId];
|
||||||
|
await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeRelationship(
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
targetId: string,
|
||||||
|
type: 'blockedBy' | 'blocks' | 'related'
|
||||||
|
): Promise<void> {
|
||||||
|
// For 'blocks', delegate as reverse blockedBy
|
||||||
|
if (type === 'blocks') {
|
||||||
|
return this.removeRelationship(teamName, targetId, taskId, 'blockedBy');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||||
|
const taskPath = path.join(tasksDir, `${taskId}.json`);
|
||||||
|
const targetPath = path.join(tasksDir, `${targetId}.json`);
|
||||||
|
|
||||||
|
const [firstPath, secondPath] =
|
||||||
|
taskPath < targetPath ? [taskPath, targetPath] : [targetPath, taskPath];
|
||||||
|
|
||||||
|
await withTaskLock(firstPath, () =>
|
||||||
|
withTaskLock(secondPath, async () => {
|
||||||
|
// Read task (must exist)
|
||||||
|
const taskRaw = await this.readTaskFile(taskPath, taskId);
|
||||||
|
const task = JSON.parse(taskRaw) as TeamTask;
|
||||||
|
|
||||||
|
if (type === 'blockedBy') {
|
||||||
|
task.blockedBy = (task.blockedBy ?? []).filter((id) => id !== targetId);
|
||||||
|
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||||
|
|
||||||
|
// Remove reverse from target if it exists
|
||||||
|
try {
|
||||||
|
const targetRaw = await fs.promises.readFile(targetPath, 'utf8');
|
||||||
|
const target = JSON.parse(targetRaw) as TeamTask;
|
||||||
|
target.blocks = (target.blocks ?? []).filter((id) => id !== taskId);
|
||||||
|
await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||||
|
// Target doesn't exist — skip silently
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// related — remove bidirectional
|
||||||
|
task.related = (task.related ?? []).filter((id) => id !== targetId);
|
||||||
|
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targetRaw = await fs.promises.readFile(targetPath, 'utf8');
|
||||||
|
const target = JSON.parse(targetRaw) as TeamTask;
|
||||||
|
target.related = (target.related ?? []).filter((id) => id !== taskId);
|
||||||
|
await atomicWriteAsync(targetPath, JSON.stringify(target, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readTaskFile(taskPath: string, taskId: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await fs.promises.readFile(taskPath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
throw new Error(`Task not found: ${taskId}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks targetId's blockedBy chain to detect if sourceId is reachable.
|
||||||
|
* Reads are outside locks (deliberate TOCTOU trade-off — the calling method
|
||||||
|
* holds locks on both source and target, and only other tasks are read here).
|
||||||
|
*/
|
||||||
|
private async checkBlockCycle(
|
||||||
|
tasksDir: string,
|
||||||
|
sourceId: string,
|
||||||
|
targetId: string
|
||||||
|
): Promise<void> {
|
||||||
|
const visited = new Set<string>();
|
||||||
|
const stack = [targetId];
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const current = stack.pop()!;
|
||||||
|
if (current === sourceId) {
|
||||||
|
throw new Error(`Circular dependency: #${targetId} already depends on #${sourceId}`);
|
||||||
|
}
|
||||||
|
if (visited.has(current)) continue;
|
||||||
|
visited.add(current);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await fs.promises.readFile(path.join(tasksDir, `${current}.json`), 'utf8');
|
||||||
|
const task = JSON.parse(raw) as TeamTask;
|
||||||
|
if (Array.isArray(task.blockedBy)) {
|
||||||
|
for (const dep of task.blockedBy) {
|
||||||
|
stack.push(dep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Skip unreadable tasks
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async updateStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
|
async updateStatus(teamName: string, taskId: string, status: TeamTaskStatus): Promise<void> {
|
||||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,12 @@ export const CONFIG_FIND_WSL_CLAUDE_ROOTS = 'config:findWslClaudeRoots';
|
||||||
/** Open config file in external editor */
|
/** Open config file in external editor */
|
||||||
export const CONFIG_OPEN_IN_EDITOR = 'config:openInEditor';
|
export const CONFIG_OPEN_IN_EDITOR = 'config:openInEditor';
|
||||||
|
|
||||||
|
/** Add a custom project path (Select Folder persistence) */
|
||||||
|
export const CONFIG_ADD_CUSTOM_PROJECT_PATH = 'config:addCustomProjectPath';
|
||||||
|
|
||||||
|
/** Remove a custom project path */
|
||||||
|
export const CONFIG_REMOVE_CUSTOM_PROJECT_PATH = 'config:removeCustomProjectPath';
|
||||||
|
|
||||||
/** Pin a session */
|
/** Pin a session */
|
||||||
export const CONFIG_PIN_SESSION = 'config:pinSession';
|
export const CONFIG_PIN_SESSION = 'config:pinSession';
|
||||||
|
|
||||||
|
|
@ -315,6 +321,12 @@ export const TEAM_SET_TASK_CLARIFICATION = 'team:setTaskClarification';
|
||||||
/** Show native OS notification for a team message */
|
/** Show native OS notification for a team message */
|
||||||
export const TEAM_SHOW_MESSAGE_NOTIFICATION = 'team:showMessageNotification';
|
export const TEAM_SHOW_MESSAGE_NOTIFICATION = 'team:showMessageNotification';
|
||||||
|
|
||||||
|
/** Add a relationship (blockedBy/blocks/related) between two tasks */
|
||||||
|
export const TEAM_ADD_TASK_RELATIONSHIP = 'team:addTaskRelationship';
|
||||||
|
|
||||||
|
/** Remove a relationship (blockedBy/blocks/related) between two tasks */
|
||||||
|
export const TEAM_REMOVE_TASK_RELATIONSHIP = 'team:removeTaskRelationship';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// CLI Installer API Channels
|
// CLI Installer API Channels
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@ import {
|
||||||
SSH_TEST,
|
SSH_TEST,
|
||||||
TEAM_ADD_MEMBER,
|
TEAM_ADD_MEMBER,
|
||||||
TEAM_ADD_TASK_COMMENT,
|
TEAM_ADD_TASK_COMMENT,
|
||||||
|
TEAM_ADD_TASK_RELATIONSHIP,
|
||||||
TEAM_ALIVE_LIST,
|
TEAM_ALIVE_LIST,
|
||||||
TEAM_CANCEL_PROVISIONING,
|
TEAM_CANCEL_PROVISIONING,
|
||||||
TEAM_CHANGE,
|
TEAM_CHANGE,
|
||||||
|
|
@ -79,6 +80,7 @@ import {
|
||||||
TEAM_PROVISIONING_PROGRESS,
|
TEAM_PROVISIONING_PROGRESS,
|
||||||
TEAM_PROVISIONING_STATUS,
|
TEAM_PROVISIONING_STATUS,
|
||||||
TEAM_REMOVE_MEMBER,
|
TEAM_REMOVE_MEMBER,
|
||||||
|
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_RESTORE,
|
TEAM_RESTORE,
|
||||||
TEAM_RESTORE_TASK,
|
TEAM_RESTORE_TASK,
|
||||||
|
|
@ -113,6 +115,7 @@ import {
|
||||||
WINDOW_MINIMIZE,
|
WINDOW_MINIMIZE,
|
||||||
} from './constants/ipcChannels';
|
} from './constants/ipcChannels';
|
||||||
import {
|
import {
|
||||||
|
CONFIG_ADD_CUSTOM_PROJECT_PATH,
|
||||||
CONFIG_ADD_IGNORE_REGEX,
|
CONFIG_ADD_IGNORE_REGEX,
|
||||||
CONFIG_ADD_IGNORE_REPOSITORY,
|
CONFIG_ADD_IGNORE_REPOSITORY,
|
||||||
CONFIG_ADD_TRIGGER,
|
CONFIG_ADD_TRIGGER,
|
||||||
|
|
@ -125,6 +128,7 @@ import {
|
||||||
CONFIG_HIDE_SESSIONS,
|
CONFIG_HIDE_SESSIONS,
|
||||||
CONFIG_OPEN_IN_EDITOR,
|
CONFIG_OPEN_IN_EDITOR,
|
||||||
CONFIG_PIN_SESSION,
|
CONFIG_PIN_SESSION,
|
||||||
|
CONFIG_REMOVE_CUSTOM_PROJECT_PATH,
|
||||||
CONFIG_REMOVE_IGNORE_REGEX,
|
CONFIG_REMOVE_IGNORE_REGEX,
|
||||||
CONFIG_REMOVE_IGNORE_REPOSITORY,
|
CONFIG_REMOVE_IGNORE_REPOSITORY,
|
||||||
CONFIG_REMOVE_TRIGGER,
|
CONFIG_REMOVE_TRIGGER,
|
||||||
|
|
@ -442,6 +446,12 @@ const electronAPI: ElectronAPI = {
|
||||||
unhideSessions: async (projectId: string, sessionIds: string[]): Promise<void> => {
|
unhideSessions: async (projectId: string, sessionIds: string[]): Promise<void> => {
|
||||||
return invokeIpcWithResult<void>(CONFIG_UNHIDE_SESSIONS, projectId, sessionIds);
|
return invokeIpcWithResult<void>(CONFIG_UNHIDE_SESSIONS, projectId, sessionIds);
|
||||||
},
|
},
|
||||||
|
addCustomProjectPath: async (projectPath: string): Promise<void> => {
|
||||||
|
return invokeIpcWithResult<void>(CONFIG_ADD_CUSTOM_PROJECT_PATH, projectPath);
|
||||||
|
},
|
||||||
|
removeCustomProjectPath: async (projectPath: string): Promise<void> => {
|
||||||
|
return invokeIpcWithResult<void>(CONFIG_REMOVE_CUSTOM_PROJECT_PATH, projectPath);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Deep link navigation
|
// Deep link navigation
|
||||||
|
|
@ -759,6 +769,34 @@ const electronAPI: ElectronAPI = {
|
||||||
showMessageNotification: async (data: TeamMessageNotificationData) => {
|
showMessageNotification: async (data: TeamMessageNotificationData) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_SHOW_MESSAGE_NOTIFICATION, data);
|
return invokeIpcWithResult<void>(TEAM_SHOW_MESSAGE_NOTIFICATION, data);
|
||||||
},
|
},
|
||||||
|
addTaskRelationship: async (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
targetId: string,
|
||||||
|
type: 'blockedBy' | 'blocks' | 'related'
|
||||||
|
) => {
|
||||||
|
return invokeIpcWithResult<void>(
|
||||||
|
TEAM_ADD_TASK_RELATIONSHIP,
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
targetId,
|
||||||
|
type
|
||||||
|
);
|
||||||
|
},
|
||||||
|
removeTaskRelationship: async (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
targetId: string,
|
||||||
|
type: 'blockedBy' | 'blocks' | 'related'
|
||||||
|
) => {
|
||||||
|
return invokeIpcWithResult<void>(
|
||||||
|
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
targetId,
|
||||||
|
type
|
||||||
|
);
|
||||||
|
},
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
TEAM_CHANGE,
|
TEAM_CHANGE,
|
||||||
|
|
|
||||||
|
|
@ -481,6 +481,10 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
this.post('/api/config/hide-sessions', { projectId, sessionIds }),
|
this.post('/api/config/hide-sessions', { projectId, sessionIds }),
|
||||||
unhideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>
|
unhideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>
|
||||||
this.post('/api/config/unhide-sessions', { projectId, sessionIds }),
|
this.post('/api/config/unhide-sessions', { projectId, sessionIds }),
|
||||||
|
addCustomProjectPath: (projectPath: string): Promise<void> =>
|
||||||
|
this.post('/api/config/add-custom-project-path', { projectPath }),
|
||||||
|
removeCustomProjectPath: (projectPath: string): Promise<void> =>
|
||||||
|
this.post('/api/config/remove-custom-project-path', { projectPath }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -808,6 +812,22 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
showMessageNotification: async (): Promise<void> => {
|
showMessageNotification: async (): Promise<void> => {
|
||||||
// Not available via HTTP client — native notifications require Electron
|
// Not available via HTTP client — native notifications require Electron
|
||||||
},
|
},
|
||||||
|
addTaskRelationship: async (
|
||||||
|
_teamName: string,
|
||||||
|
_taskId: string,
|
||||||
|
_targetId: string,
|
||||||
|
_type: 'blockedBy' | 'blocks' | 'related'
|
||||||
|
): Promise<void> => {
|
||||||
|
throw new Error('Task relationships are not available in browser mode');
|
||||||
|
},
|
||||||
|
removeTaskRelationship: async (
|
||||||
|
_teamName: string,
|
||||||
|
_taskId: string,
|
||||||
|
_targetId: string,
|
||||||
|
_type: 'blockedBy' | 'blocks' | 'related'
|
||||||
|
): Promise<void> => {
|
||||||
|
throw new Error('Task relationships are not available in browser mode');
|
||||||
|
},
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||||
return this.addEventListener('team-change', (data: unknown) =>
|
return this.addEventListener('team-change', (data: unknown) =>
|
||||||
callback(null, data as TeamChangeEvent)
|
callback(null, data as TeamChangeEvent)
|
||||||
|
|
|
||||||
|
|
@ -413,6 +413,9 @@ const NewProjectCard = (): React.JSX.Element => {
|
||||||
|
|
||||||
// Still no match — create a synthetic group for this new folder and navigate to it.
|
// Still no match — create a synthetic group for this new folder and navigate to it.
|
||||||
// This allows launching teams in projects that don't have Claude sessions yet.
|
// This allows launching teams in projects that don't have Claude sessions yet.
|
||||||
|
// Persist the path so it survives app restarts.
|
||||||
|
await api.config.addCustomProjectPath(selectedPath);
|
||||||
|
|
||||||
const encodedId = selectedPath.replace(/[/\\]/g, '-');
|
const encodedId = selectedPath.replace(/[/\\]/g, '-');
|
||||||
const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath;
|
const folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ interface MemberBadgeProps {
|
||||||
color?: string;
|
color?: string;
|
||||||
/** Avatar + badge size variant */
|
/** Avatar + badge size variant */
|
||||||
size?: 'sm' | 'md';
|
size?: 'sm' | 'md';
|
||||||
|
/** Hide the avatar icon, show only the name badge */
|
||||||
|
hideAvatar?: boolean;
|
||||||
onClick?: (name: string) => void;
|
onClick?: (name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,6 +20,7 @@ export const MemberBadge = ({
|
||||||
name,
|
name,
|
||||||
color,
|
color,
|
||||||
size = 'sm',
|
size = 'sm',
|
||||||
|
hideAvatar,
|
||||||
onClick,
|
onClick,
|
||||||
}: MemberBadgeProps): React.JSX.Element => {
|
}: MemberBadgeProps): React.JSX.Element => {
|
||||||
const colors = getTeamColorSet(color ?? '');
|
const colors = getTeamColorSet(color ?? '');
|
||||||
|
|
@ -59,7 +62,7 @@ export const MemberBadge = ({
|
||||||
onClick(name);
|
onClick(name);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{avatar}
|
{!hideAvatar && avatar}
|
||||||
{badge}
|
{badge}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
@ -67,7 +70,7 @@ export const MemberBadge = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{avatar}
|
{!hideAvatar && avatar}
|
||||||
{badge}
|
{badge}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -317,7 +317,12 @@ export const ActivityItem = ({
|
||||||
<span style={{ color: CARD_ICON_MUTED }} className="text-[10px]">
|
<span style={{ color: CARD_ICON_MUTED }} className="text-[10px]">
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
<MemberBadge name={message.to} color={recipientColor} onClick={onMemberNameClick} />
|
<MemberBadge
|
||||||
|
name={message.to}
|
||||||
|
color={recipientColor}
|
||||||
|
hideAvatar={message.to === 'user'}
|
||||||
|
onClick={onMemberNameClick}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const MessageRowWithObserver = ({
|
||||||
memberColor,
|
memberColor,
|
||||||
recipientColor,
|
recipientColor,
|
||||||
isUnread,
|
isUnread,
|
||||||
|
isNew,
|
||||||
onMemberNameClick,
|
onMemberNameClick,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
onReply,
|
onReply,
|
||||||
|
|
@ -46,6 +47,7 @@ const MessageRowWithObserver = ({
|
||||||
memberColor?: string;
|
memberColor?: string;
|
||||||
recipientColor?: string;
|
recipientColor?: string;
|
||||||
isUnread?: boolean;
|
isUnread?: boolean;
|
||||||
|
isNew?: 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;
|
||||||
|
|
@ -83,7 +85,7 @@ const MessageRowWithObserver = ({
|
||||||
}, [onVisible]);
|
}, [onVisible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="min-h-px">
|
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}>
|
||||||
<ActivityItem
|
<ActivityItem
|
||||||
message={message}
|
message={message}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
@ -113,6 +115,11 @@ export const ActivityTimeline = ({
|
||||||
}: ActivityTimelineProps): React.JSX.Element => {
|
}: ActivityTimelineProps): React.JSX.Element => {
|
||||||
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
|
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
|
||||||
|
|
||||||
|
// --- New-message animation tracking ---
|
||||||
|
const knownKeysRef = useRef<Set<string>>(new Set<string>());
|
||||||
|
const isInitializedRef = useRef(false);
|
||||||
|
const prevVisibleCountRef = useRef(visibleCount);
|
||||||
|
|
||||||
// Track whether the user was seeing ALL messages (no hidden ones).
|
// Track whether the user was seeing ALL messages (no hidden ones).
|
||||||
// If so, auto-expand when new messages push count past the limit,
|
// If so, auto-expand when new messages push count past the limit,
|
||||||
// so previously visible messages don't silently disappear.
|
// so previously visible messages don't silently disappear.
|
||||||
|
|
@ -152,19 +159,57 @@ export const ActivityTimeline = ({
|
||||||
|
|
||||||
// Auto-expand when user was seeing all and new messages arrive — derived state sync.
|
// Auto-expand when user was seeing all and new messages arrive — derived state sync.
|
||||||
// Reading/updating ref during render is intentional (React docs: derived state sync).
|
// Reading/updating ref during render is intentional (React docs: derived state sync).
|
||||||
/* eslint-disable react-hooks/refs -- ref stores previous frame's "showing all" for derived state sync */
|
|
||||||
const wasShowingAll = wasShowingAllRef.current;
|
const wasShowingAll = wasShowingAllRef.current;
|
||||||
if (wasShowingAll && hiddenCount > 0) {
|
if (wasShowingAll && hiddenCount > 0) {
|
||||||
setVisibleCount(messages.length);
|
setVisibleCount(messages.length);
|
||||||
}
|
}
|
||||||
wasShowingAllRef.current = hiddenCount === 0;
|
wasShowingAllRef.current = hiddenCount === 0;
|
||||||
/* eslint-enable react-hooks/refs -- end of intentional ref access during render */
|
|
||||||
|
|
||||||
const visibleMessages = useMemo(
|
const visibleMessages = useMemo(
|
||||||
() => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages),
|
() => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages),
|
||||||
[messages, visibleCount, hiddenCount]
|
[messages, visibleCount, hiddenCount]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Determine which messages are "new" (should animate).
|
||||||
|
|
||||||
|
const newMessageKeys = useMemo(() => {
|
||||||
|
const getKey = (msg: InboxMessage, idx: number): string =>
|
||||||
|
`${msg.messageId ?? idx}-${msg.timestamp}-${msg.from}`;
|
||||||
|
|
||||||
|
// First render: seed known keys, no animations
|
||||||
|
if (!isInitializedRef.current) {
|
||||||
|
isInitializedRef.current = true;
|
||||||
|
for (let i = 0; i < visibleMessages.length; i++) {
|
||||||
|
knownKeysRef.current.add(getKey(visibleMessages[i], i));
|
||||||
|
}
|
||||||
|
prevVisibleCountRef.current = visibleCount;
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pagination expansion ("Show more" / "Show all"): add keys silently
|
||||||
|
const isPaginationExpansion = visibleCount > prevVisibleCountRef.current;
|
||||||
|
prevVisibleCountRef.current = visibleCount;
|
||||||
|
|
||||||
|
if (isPaginationExpansion) {
|
||||||
|
for (let i = 0; i < visibleMessages.length; i++) {
|
||||||
|
knownKeysRef.current.add(getKey(visibleMessages[i], i));
|
||||||
|
}
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal update: unknown keys are new messages
|
||||||
|
const newKeys = new Set<string>();
|
||||||
|
for (let i = 0; i < visibleMessages.length; i++) {
|
||||||
|
const key = getKey(visibleMessages[i], i);
|
||||||
|
if (!knownKeysRef.current.has(key)) {
|
||||||
|
newKeys.add(key);
|
||||||
|
knownKeysRef.current.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newKeys;
|
||||||
|
}, [visibleMessages, visibleCount]);
|
||||||
|
|
||||||
const handleShowMore = (): void => {
|
const handleShowMore = (): void => {
|
||||||
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
|
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
|
||||||
};
|
};
|
||||||
|
|
@ -203,6 +248,7 @@ export const ActivityTimeline = ({
|
||||||
memberColor={info?.color}
|
memberColor={info?.color}
|
||||||
recipientColor={recipientColor}
|
recipientColor={recipientColor}
|
||||||
isUnread={isUnread}
|
isUnread={isUnread}
|
||||||
|
isNew={newMessageKeys.has(messageKey)}
|
||||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||||
onCreateTask={onCreateTaskFromMessage}
|
onCreateTask={onCreateTaskFromMessage}
|
||||||
onReply={onReplyToMessage}
|
onReply={onReplyToMessage}
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,15 @@ export const ChangeReviewDialog = ({
|
||||||
[saveEditedFile, projectPath]
|
[saveEditedFile, projectPath]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleRestoreMissingFile = useCallback(
|
||||||
|
(filePath: string, content: string) => {
|
||||||
|
updateEditedContent(filePath, content);
|
||||||
|
// Ensure editedContents is set before saveEditedFile reads it.
|
||||||
|
void Promise.resolve().then(() => saveEditedFile(filePath, projectPath));
|
||||||
|
},
|
||||||
|
[updateEditedContent, saveEditedFile, projectPath]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDiscardFile = useCallback(
|
const handleDiscardFile = useCallback(
|
||||||
(filePath: string) => {
|
(filePath: string) => {
|
||||||
discardFileEdits(filePath);
|
discardFileEdits(filePath);
|
||||||
|
|
@ -783,6 +792,7 @@ export const ChangeReviewDialog = ({
|
||||||
onContentChanged={handleContentChanged}
|
onContentChanged={handleContentChanged}
|
||||||
onDiscard={handleDiscardFile}
|
onDiscard={handleDiscardFile}
|
||||||
onSave={handleSaveFile}
|
onSave={handleSaveFile}
|
||||||
|
onRestoreMissingFile={handleRestoreMissingFile}
|
||||||
onVisibleFileChange={handleVisibleFileChange}
|
onVisibleFileChange={handleVisibleFileChange}
|
||||||
scrollContainerRef={scrollContainerRef}
|
scrollContainerRef={scrollContainerRef}
|
||||||
editorViewMapRef={editorViewMapRef}
|
editorViewMapRef={editorViewMapRef}
|
||||||
|
|
|
||||||
|
|
@ -257,6 +257,13 @@ export const CodeMirrorDiffView = ({
|
||||||
syntaxHighlightDeletions: true,
|
syntaxHighlightDeletions: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// IMPORTANT: @codemirror/merge shows accept/reject buttons by default.
|
||||||
|
// When our UI chooses to hide merge controls (e.g. "Missing on disk" preview),
|
||||||
|
// explicitly disable them rather than relying on default behavior.
|
||||||
|
if (!showMergeControls) {
|
||||||
|
mergeConfig.mergeControls = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (collapse && !usePortionCollapse) {
|
if (collapse && !usePortionCollapse) {
|
||||||
mergeConfig.collapseUnchanged = {
|
mergeConfig.collapseUnchanged = {
|
||||||
margin,
|
margin,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ interface ContinuousScrollViewProps {
|
||||||
onContentChanged: (filePath: string, content: string) => void;
|
onContentChanged: (filePath: string, content: string) => void;
|
||||||
onDiscard: (filePath: string) => void;
|
onDiscard: (filePath: string) => void;
|
||||||
onSave: (filePath: string) => void;
|
onSave: (filePath: string) => void;
|
||||||
|
onRestoreMissingFile?: (filePath: string, content: string) => void;
|
||||||
onVisibleFileChange: (filePath: string) => void;
|
onVisibleFileChange: (filePath: string) => void;
|
||||||
scrollContainerRef: React.RefObject<HTMLDivElement>;
|
scrollContainerRef: React.RefObject<HTMLDivElement>;
|
||||||
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
|
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
|
||||||
|
|
@ -71,6 +72,7 @@ export const ContinuousScrollView = ({
|
||||||
onContentChanged,
|
onContentChanged,
|
||||||
onDiscard,
|
onDiscard,
|
||||||
onSave,
|
onSave,
|
||||||
|
onRestoreMissingFile,
|
||||||
onVisibleFileChange,
|
onVisibleFileChange,
|
||||||
scrollContainerRef,
|
scrollContainerRef,
|
||||||
editorViewMapRef,
|
editorViewMapRef,
|
||||||
|
|
@ -222,6 +224,7 @@ export const ContinuousScrollView = ({
|
||||||
onToggleCollapse={handleToggleCollapse}
|
onToggleCollapse={handleToggleCollapse}
|
||||||
onDiscard={onDiscard}
|
onDiscard={onDiscard}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
|
onRestoreMissingFile={onRestoreMissingFile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isCollapsed &&
|
{!isCollapsed &&
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,7 @@ export const FileSectionDiff = ({
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const resolvedOriginal = fileContent?.originalFullContent ?? null;
|
const resolvedOriginal = fileContent?.originalFullContent ?? null;
|
||||||
|
const isMissingOnDisk = fileContent?.contentSource === 'unavailable';
|
||||||
|
|
||||||
// Show CodeMirror only when we have a trustworthy original baseline:
|
// Show CodeMirror only when we have a trustworthy original baseline:
|
||||||
// - new files: original is legitimately empty
|
// - new files: original is legitimately empty
|
||||||
|
|
@ -114,6 +115,12 @@ export const FileSectionDiff = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
|
{isMissingOnDisk && (
|
||||||
|
<div className="border-b border-border bg-red-500/10 px-4 py-2 text-xs text-red-200">
|
||||||
|
File is missing on disk. This diff may be only a preview from agent logs. Use{' '}
|
||||||
|
<span className="font-medium text-red-100">Restore</span> to create the file on disk.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<DiffErrorBoundary
|
<DiffErrorBoundary
|
||||||
filePath={file.filePath}
|
filePath={file.filePath}
|
||||||
oldString={originalForDiff}
|
oldString={originalForDiff}
|
||||||
|
|
@ -125,7 +132,7 @@ export const FileSectionDiff = ({
|
||||||
modified={resolvedModified}
|
modified={resolvedModified}
|
||||||
fileName={file.relativePath}
|
fileName={file.relativePath}
|
||||||
readOnly={false}
|
readOnly={false}
|
||||||
showMergeControls={true}
|
showMergeControls={!isMissingOnDisk}
|
||||||
collapseUnchanged={collapseUnchanged}
|
collapseUnchanged={collapseUnchanged}
|
||||||
usePortionCollapse={true}
|
usePortionCollapse={true}
|
||||||
onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)}
|
onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { ChevronDown, ChevronRight, Loader2, Save, Undo2 } from 'lucide-react';
|
import { ChevronDown, ChevronRight, FilePlus, Loader2, Save, Undo2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
|
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
|
||||||
import type { FileChangeSummary } from '@shared/types/review';
|
import type { FileChangeSummary } from '@shared/types/review';
|
||||||
|
|
@ -11,7 +11,7 @@ const CONTENT_SOURCE_LABELS: Record<string, string> = {
|
||||||
'snippet-reconstruction': 'Reconstructed',
|
'snippet-reconstruction': 'Reconstructed',
|
||||||
'disk-current': 'Current Disk',
|
'disk-current': 'Current Disk',
|
||||||
'git-fallback': 'Git Fallback',
|
'git-fallback': 'Git Fallback',
|
||||||
unavailable: 'Unavailable',
|
unavailable: 'Missing on disk',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FileSectionHeaderProps {
|
interface FileSectionHeaderProps {
|
||||||
|
|
@ -24,6 +24,7 @@ interface FileSectionHeaderProps {
|
||||||
onToggleCollapse: (filePath: string) => void;
|
onToggleCollapse: (filePath: string) => void;
|
||||||
onDiscard: (filePath: string) => void;
|
onDiscard: (filePath: string) => void;
|
||||||
onSave: (filePath: string) => void;
|
onSave: (filePath: string) => void;
|
||||||
|
onRestoreMissingFile?: (filePath: string, content: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FileSectionHeader = ({
|
export const FileSectionHeader = ({
|
||||||
|
|
@ -36,7 +37,21 @@ export const FileSectionHeader = ({
|
||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
onDiscard,
|
onDiscard,
|
||||||
onSave,
|
onSave,
|
||||||
|
onRestoreMissingFile,
|
||||||
}: FileSectionHeaderProps): React.ReactElement => {
|
}: FileSectionHeaderProps): React.ReactElement => {
|
||||||
|
const isMissingOnDisk = fileContent?.contentSource === 'unavailable';
|
||||||
|
const restoreContent =
|
||||||
|
fileContent?.modifiedFullContent ??
|
||||||
|
(() => {
|
||||||
|
const writeSnippets = file.snippets.filter(
|
||||||
|
(s) => !s.isError && (s.type === 'write-new' || s.type === 'write-update')
|
||||||
|
);
|
||||||
|
if (writeSnippets.length === 0) return null;
|
||||||
|
return writeSnippets[writeSnippets.length - 1].newString;
|
||||||
|
})();
|
||||||
|
const canRestore =
|
||||||
|
!!onRestoreMissingFile && isMissingOnDisk && !hasEdits && restoreContent !== null;
|
||||||
|
|
||||||
const handleHeaderClick = (e: React.MouseEvent): void => {
|
const handleHeaderClick = (e: React.MouseEvent): void => {
|
||||||
// Don't collapse when clicking action buttons
|
// Don't collapse when clicking action buttons
|
||||||
if ((e.target as HTMLElement).closest('[data-no-collapse]')) return;
|
if ((e.target as HTMLElement).closest('[data-no-collapse]')) return;
|
||||||
|
|
@ -70,9 +85,44 @@ export const FileSectionHeader = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{fileContent?.contentSource && (
|
{fileContent?.contentSource && (
|
||||||
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-[10px] text-text-muted">
|
<Tooltip>
|
||||||
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
|
<TooltipTrigger asChild>
|
||||||
</span>
|
<span
|
||||||
|
className={[
|
||||||
|
'rounded px-1.5 py-0.5 text-[10px]',
|
||||||
|
fileContent.contentSource === 'unavailable'
|
||||||
|
? 'bg-red-500/20 text-red-300'
|
||||||
|
: 'bg-surface-raised text-text-muted',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" className="max-w-xs">
|
||||||
|
{fileContent.contentSource === 'unavailable' ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="font-medium text-text">File is missing on disk</div>
|
||||||
|
<div className="text-text-muted">
|
||||||
|
We can still show a preview from agent logs, but your filesystem is out of sync.
|
||||||
|
</div>
|
||||||
|
{restoreContent !== null ? (
|
||||||
|
<div className="text-text-muted">
|
||||||
|
Use <span className="font-medium text-text">Restore</span> to write the preview
|
||||||
|
content back to disk.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-text-muted">
|
||||||
|
Full file content is not available to restore automatically.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{fileDecision && (
|
{fileDecision && (
|
||||||
|
|
@ -90,6 +140,23 @@ export const FileSectionHeader = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-1.5" data-no-collapse>
|
<div className="ml-auto flex items-center gap-1.5" data-no-collapse>
|
||||||
|
{canRestore && restoreContent !== null && (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
onClick={() => onRestoreMissingFile?.(file.filePath, restoreContent)}
|
||||||
|
disabled={applying}
|
||||||
|
className="flex items-center gap-1 rounded bg-blue-500/15 px-2 py-1 text-xs text-blue-300 transition-colors hover:bg-blue-500/25 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<FilePlus className="size-3" />
|
||||||
|
Restore
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
Create/restore this file on disk from the preview
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{hasEdits && (
|
{hasEdits && (
|
||||||
<>
|
<>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -563,6 +563,21 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes message-enter {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-enter-animate {
|
||||||
|
animation: message-enter 300ms ease-out both;
|
||||||
|
}
|
||||||
|
|
||||||
.skeleton-card {
|
.skeleton-card {
|
||||||
animation: skeleton-fade-in 0.4s ease-out both;
|
animation: skeleton-fade-in 0.4s ease-out both;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,18 @@ export interface TeamSlice {
|
||||||
memberName: string,
|
memberName: string,
|
||||||
role: string | undefined
|
role: string | undefined
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
addTaskRelationship: (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
targetId: string,
|
||||||
|
type: 'blockedBy' | 'blocks' | 'related'
|
||||||
|
) => Promise<void>;
|
||||||
|
removeTaskRelationship: (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
targetId: string,
|
||||||
|
type: 'blockedBy' | 'blocks' | 'related'
|
||||||
|
) => Promise<void>;
|
||||||
setTaskNeedsClarification: (
|
setTaskNeedsClarification: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
|
|
@ -612,6 +624,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
await get().refreshTeamData(teamName);
|
await get().refreshTeamData(teamName);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addTaskRelationship: async (teamName, taskId, targetId, type) => {
|
||||||
|
await unwrapIpc('team:addTaskRelationship', () =>
|
||||||
|
api.teams.addTaskRelationship(teamName, taskId, targetId, type)
|
||||||
|
);
|
||||||
|
await get().refreshTeamData(teamName);
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTaskRelationship: async (teamName, taskId, targetId, type) => {
|
||||||
|
await unwrapIpc('team:removeTaskRelationship', () =>
|
||||||
|
api.teams.removeTaskRelationship(teamName, taskId, targetId, type)
|
||||||
|
);
|
||||||
|
await get().refreshTeamData(teamName);
|
||||||
|
},
|
||||||
|
|
||||||
setTaskNeedsClarification: async (teamName, taskId, value) => {
|
setTaskNeedsClarification: async (teamName, taskId, value) => {
|
||||||
await unwrapIpc('team:setTaskClarification', () =>
|
await unwrapIpc('team:setTaskClarification', () =>
|
||||||
api.teams.setTaskClarification(teamName, taskId, value)
|
api.teams.setTaskClarification(teamName, taskId, value)
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,10 @@ export interface ConfigAPI {
|
||||||
hideSessions: (projectId: string, sessionIds: string[]) => Promise<void>;
|
hideSessions: (projectId: string, sessionIds: string[]) => Promise<void>;
|
||||||
/** Bulk unhide sessions for a project */
|
/** Bulk unhide sessions for a project */
|
||||||
unhideSessions: (projectId: string, sessionIds: string[]) => Promise<void>;
|
unhideSessions: (projectId: string, sessionIds: string[]) => Promise<void>;
|
||||||
|
/** Add a custom project path (persisted across restarts) */
|
||||||
|
addCustomProjectPath: (projectPath: string) => Promise<void>;
|
||||||
|
/** Remove a custom project path */
|
||||||
|
removeCustomProjectPath: (projectPath: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClaudeRootInfo {
|
export interface ClaudeRootInfo {
|
||||||
|
|
@ -449,6 +453,18 @@ export interface TeamsAPI {
|
||||||
restoreTask: (teamName: string, taskId: string) => Promise<void>;
|
restoreTask: (teamName: string, taskId: string) => Promise<void>;
|
||||||
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
|
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
|
||||||
showMessageNotification: (data: TeamMessageNotificationData) => Promise<void>;
|
showMessageNotification: (data: TeamMessageNotificationData) => Promise<void>;
|
||||||
|
addTaskRelationship: (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
targetId: string,
|
||||||
|
type: 'blockedBy' | 'blocks' | 'related'
|
||||||
|
) => Promise<void>;
|
||||||
|
removeTaskRelationship: (
|
||||||
|
teamName: string,
|
||||||
|
taskId: string,
|
||||||
|
targetId: string,
|
||||||
|
type: 'blockedBy' | 'blocks' | 'related'
|
||||||
|
) => Promise<void>;
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
||||||
onProvisioningProgress: (
|
onProvisioningProgress: (
|
||||||
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
||||||
TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks',
|
TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks',
|
||||||
TEAM_SET_TASK_CLARIFICATION: 'team:setTaskClarification',
|
TEAM_SET_TASK_CLARIFICATION: 'team:setTaskClarification',
|
||||||
TEAM_SHOW_MESSAGE_NOTIFICATION: 'team:showMessageNotification',
|
TEAM_SHOW_MESSAGE_NOTIFICATION: 'team:showMessageNotification',
|
||||||
|
TEAM_ADD_TASK_RELATIONSHIP: 'team:addTaskRelationship',
|
||||||
|
TEAM_REMOVE_TASK_RELATIONSHIP: 'team:removeTaskRelationship',
|
||||||
TEAM_RESTORE: 'team:restoreTeam',
|
TEAM_RESTORE: 'team:restoreTeam',
|
||||||
TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam',
|
TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam',
|
||||||
TEAM_RESTORE_TASK: 'team:restoreTask',
|
TEAM_RESTORE_TASK: 'team:restoreTask',
|
||||||
|
|
@ -93,6 +95,8 @@ import {
|
||||||
TEAM_SET_TASK_CLARIFICATION,
|
TEAM_SET_TASK_CLARIFICATION,
|
||||||
TEAM_SOFT_DELETE_TASK,
|
TEAM_SOFT_DELETE_TASK,
|
||||||
TEAM_UPDATE_MEMBER_ROLE,
|
TEAM_UPDATE_MEMBER_ROLE,
|
||||||
|
TEAM_ADD_TASK_RELATIONSHIP,
|
||||||
|
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||||
} from '../../../src/preload/constants/ipcChannels';
|
} from '../../../src/preload/constants/ipcChannels';
|
||||||
import {
|
import {
|
||||||
initializeTeamHandlers,
|
initializeTeamHandlers,
|
||||||
|
|
@ -142,6 +146,8 @@ describe('ipc teams handlers', () => {
|
||||||
softDeleteTask: vi.fn(async () => undefined),
|
softDeleteTask: vi.fn(async () => undefined),
|
||||||
getDeletedTasks: vi.fn(async () => []),
|
getDeletedTasks: vi.fn(async () => []),
|
||||||
setTaskNeedsClarification: vi.fn(async () => undefined),
|
setTaskNeedsClarification: vi.fn(async () => undefined),
|
||||||
|
addTaskRelationship: vi.fn(async () => undefined),
|
||||||
|
removeTaskRelationship: vi.fn(async () => undefined),
|
||||||
};
|
};
|
||||||
const provisioningService = {
|
const provisioningService = {
|
||||||
prepareForProvisioning: vi.fn(async () => ({
|
prepareForProvisioning: vi.fn(async () => ({
|
||||||
|
|
@ -216,6 +222,8 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_SET_TASK_CLARIFICATION)).toBe(true);
|
expect(handlers.has(TEAM_SET_TASK_CLARIFICATION)).toBe(true);
|
||||||
expect(handlers.has(TEAM_RESTORE)).toBe(true);
|
expect(handlers.has(TEAM_RESTORE)).toBe(true);
|
||||||
expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(true);
|
expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(true);
|
||||||
|
expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(true);
|
||||||
|
expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns success false on invalid sendMessage args', async () => {
|
it('returns success false on invalid sendMessage args', async () => {
|
||||||
|
|
@ -535,5 +543,77 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_SET_TASK_CLARIFICATION)).toBe(false);
|
expect(handlers.has(TEAM_SET_TASK_CLARIFICATION)).toBe(false);
|
||||||
expect(handlers.has(TEAM_RESTORE)).toBe(false);
|
expect(handlers.has(TEAM_RESTORE)).toBe(false);
|
||||||
expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(false);
|
expect(handlers.has(TEAM_PERMANENTLY_DELETE)).toBe(false);
|
||||||
|
expect(handlers.has(TEAM_ADD_TASK_RELATIONSHIP)).toBe(false);
|
||||||
|
expect(handlers.has(TEAM_REMOVE_TASK_RELATIONSHIP)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('addTaskRelationship', () => {
|
||||||
|
it('calls service on valid input', async () => {
|
||||||
|
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', '1', '2', 'blockedBy')) as {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(service.addTaskRelationship).toHaveBeenCalledWith('my-team', '1', '2', 'blockedBy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid team name', async () => {
|
||||||
|
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!;
|
||||||
|
const result = (await handler({} as never, '../bad', '1', '2', 'blockedBy')) as {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid task id', async () => {
|
||||||
|
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', 'abc', '2', 'blockedBy')) as {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid target id', async () => {
|
||||||
|
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', '1', '', 'blockedBy')) as {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid relationship type', async () => {
|
||||||
|
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', '1', '2', 'invalid')) as {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeTaskRelationship', () => {
|
||||||
|
it('calls service on valid input', async () => {
|
||||||
|
const handler = handlers.get(TEAM_REMOVE_TASK_RELATIONSHIP)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', '1', '2', 'related')) as {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(service.removeTaskRelationship).toHaveBeenCalledWith('my-team', '1', '2', 'related');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid team name', async () => {
|
||||||
|
const handler = handlers.get(TEAM_REMOVE_TASK_RELATIONSHIP)!;
|
||||||
|
const result = (await handler({} as never, '../bad', '1', '2', 'related')) as {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid relationship type', async () => {
|
||||||
|
const handler = handlers.get(TEAM_REMOVE_TASK_RELATIONSHIP)!;
|
||||||
|
const result = (await handler({} as never, 'my-team', '1', '2', 'unknown')) as {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
296
test/main/services/team/teamctlRelationships.test.ts
Normal file
296
test/main/services/team/teamctlRelationships.test.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => {
|
||||||
|
const files = new Map<string, string>();
|
||||||
|
|
||||||
|
const norm = (p: string): string => p.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
const readFile = vi.fn(async (filePath: string) => {
|
||||||
|
const data = files.get(norm(filePath));
|
||||||
|
if (data === undefined) {
|
||||||
|
const error = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||||
|
error.code = 'ENOENT';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
const atomicWrite = vi.fn(async (filePath: string, data: string) => {
|
||||||
|
files.set(norm(filePath), data);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { files, readFile, atomicWrite, norm };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('fs', () => ({
|
||||||
|
promises: {
|
||||||
|
readFile: hoisted.readFile,
|
||||||
|
mkdir: vi.fn(async () => undefined),
|
||||||
|
access: vi.fn(async () => {
|
||||||
|
const error = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||||
|
error.code = 'ENOENT';
|
||||||
|
throw error;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
constants: { F_OK: 0 },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../src/main/utils/pathDecoder', () => ({
|
||||||
|
getTasksBasePath: () => '/mock/tasks',
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../../src/main/services/team/atomicWrite', () => ({
|
||||||
|
atomicWriteAsync: hoisted.atomicWrite,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TeamTaskWriter } from '../../../../src/main/services/team/TeamTaskWriter';
|
||||||
|
|
||||||
|
function setTask(teamName: string, id: string, data: Record<string, unknown>): void {
|
||||||
|
const taskPath = `/mock/tasks/${teamName}/${id}.json`;
|
||||||
|
hoisted.files.set(
|
||||||
|
taskPath,
|
||||||
|
JSON.stringify({ id, status: 'pending', blocks: [], blockedBy: [], ...data })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTask(teamName: string, id: string): Record<string, unknown> {
|
||||||
|
const taskPath = `/mock/tasks/${teamName}/${id}.json`;
|
||||||
|
const raw = hoisted.files.get(taskPath);
|
||||||
|
if (!raw) throw new Error(`Task ${id} not found in mock files`);
|
||||||
|
return JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TeamTaskWriter — addRelationship', () => {
|
||||||
|
const writer = new TeamTaskWriter();
|
||||||
|
const team = 'test-team';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
hoisted.files.clear();
|
||||||
|
hoisted.readFile.mockClear();
|
||||||
|
hoisted.atomicWrite.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blockedBy', () => {
|
||||||
|
it('adds blockedBy to task and blocks to target', async () => {
|
||||||
|
setTask(team, '1', { subject: 'Setup' });
|
||||||
|
setTask(team, '2', { subject: 'Build' });
|
||||||
|
|
||||||
|
await writer.addRelationship(team, '2', '1', 'blockedBy');
|
||||||
|
|
||||||
|
const task2 = getTask(team, '2');
|
||||||
|
const task1 = getTask(team, '1');
|
||||||
|
expect(task2.blockedBy).toEqual(['1']);
|
||||||
|
expect(task1.blocks).toEqual(['2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent — does not duplicate entries', async () => {
|
||||||
|
setTask(team, '1', { subject: 'Setup' });
|
||||||
|
setTask(team, '2', { subject: 'Build', blockedBy: ['1'] });
|
||||||
|
setTask(team, '1', { subject: 'Setup', blocks: ['2'] });
|
||||||
|
|
||||||
|
await writer.addRelationship(team, '2', '1', 'blockedBy');
|
||||||
|
|
||||||
|
const task2 = getTask(team, '2');
|
||||||
|
const task1 = getTask(team, '1');
|
||||||
|
expect(task2.blockedBy).toEqual(['1']);
|
||||||
|
expect(task1.blocks).toEqual(['2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blocks', () => {
|
||||||
|
it('delegates to reverse blockedBy', async () => {
|
||||||
|
setTask(team, '1', { subject: 'Setup' });
|
||||||
|
setTask(team, '2', { subject: 'Build' });
|
||||||
|
|
||||||
|
await writer.addRelationship(team, '1', '2', 'blocks');
|
||||||
|
|
||||||
|
const task1 = getTask(team, '1');
|
||||||
|
const task2 = getTask(team, '2');
|
||||||
|
expect(task1.blocks).toEqual(['2']);
|
||||||
|
expect(task2.blockedBy).toEqual(['1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('related', () => {
|
||||||
|
it('adds bidirectional related links', async () => {
|
||||||
|
setTask(team, '3', { subject: 'Frontend' });
|
||||||
|
setTask(team, '4', { subject: 'Backend' });
|
||||||
|
|
||||||
|
await writer.addRelationship(team, '3', '4', 'related');
|
||||||
|
|
||||||
|
const task3 = getTask(team, '3');
|
||||||
|
const task4 = getTask(team, '4');
|
||||||
|
expect(task3.related).toEqual(['4']);
|
||||||
|
expect(task4.related).toEqual(['3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validation', () => {
|
||||||
|
it('rejects self-reference', async () => {
|
||||||
|
setTask(team, '1', { subject: 'Task' });
|
||||||
|
|
||||||
|
await expect(writer.addRelationship(team, '1', '1', 'blockedBy')).rejects.toThrow(
|
||||||
|
'Cannot link a task to itself'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-existent task', async () => {
|
||||||
|
setTask(team, '1', { subject: 'Task' });
|
||||||
|
|
||||||
|
await expect(writer.addRelationship(team, '1', '999', 'blockedBy')).rejects.toThrow(
|
||||||
|
'Task not found: 999'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-existent source task', async () => {
|
||||||
|
setTask(team, '1', { subject: 'Task' });
|
||||||
|
|
||||||
|
await expect(writer.addRelationship(team, '999', '1', 'blockedBy')).rejects.toThrow(
|
||||||
|
'Task not found: 999'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects self-reference via blocks type (delegation preserves check)', async () => {
|
||||||
|
setTask(team, '1', { subject: 'Task' });
|
||||||
|
|
||||||
|
await expect(writer.addRelationship(team, '1', '1', 'blocks')).rejects.toThrow(
|
||||||
|
'Cannot link a task to itself'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects self-reference via related type', async () => {
|
||||||
|
setTask(team, '1', { subject: 'Task' });
|
||||||
|
|
||||||
|
await expect(writer.addRelationship(team, '1', '1', 'related')).rejects.toThrow(
|
||||||
|
'Cannot link a task to itself'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('circular dependency detection', () => {
|
||||||
|
it('detects direct cycle: A blocked-by B, then B blocked-by A', async () => {
|
||||||
|
setTask(team, '1', { subject: 'A', blockedBy: ['2'] });
|
||||||
|
setTask(team, '2', { subject: 'B', blocks: ['1'] });
|
||||||
|
|
||||||
|
await expect(writer.addRelationship(team, '2', '1', 'blockedBy')).rejects.toThrow(
|
||||||
|
'Circular dependency'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows redundant blockedBy (A→B→C, then C blocked-by A is redundant, not circular)', async () => {
|
||||||
|
// #3 already depends on #1 transitively (3→2→1)
|
||||||
|
// Adding direct #3 blockedBy #1 is redundant but NOT a cycle
|
||||||
|
setTask(team, '1', { subject: 'A' });
|
||||||
|
setTask(team, '2', { subject: 'B', blockedBy: ['1'] });
|
||||||
|
setTask(team, '3', { subject: 'C', blockedBy: ['2'] });
|
||||||
|
|
||||||
|
// Should succeed — no cycle
|
||||||
|
await writer.addRelationship(team, '3', '1', 'blockedBy');
|
||||||
|
const task3 = getTask(team, '3');
|
||||||
|
expect(task3.blockedBy).toContain('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects transitive cycle when closing the loop', async () => {
|
||||||
|
// Chain: #3 blockedBy #2, #2 blockedBy #1
|
||||||
|
// Trying: #1 blockedBy #3 — would create cycle 1→3→2→1
|
||||||
|
setTask(team, '1', { subject: 'A' });
|
||||||
|
setTask(team, '2', { subject: 'B', blockedBy: ['1'] });
|
||||||
|
setTask(team, '3', { subject: 'C', blockedBy: ['2'] });
|
||||||
|
|
||||||
|
await expect(writer.addRelationship(team, '1', '3', 'blockedBy')).rejects.toThrow(
|
||||||
|
'Circular dependency'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('TeamTaskWriter — removeRelationship', () => {
|
||||||
|
const writer = new TeamTaskWriter();
|
||||||
|
const team = 'test-team';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
hoisted.files.clear();
|
||||||
|
hoisted.readFile.mockClear();
|
||||||
|
hoisted.atomicWrite.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blockedBy', () => {
|
||||||
|
it('removes blockedBy from task and blocks from target', async () => {
|
||||||
|
setTask(team, '1', { subject: 'Setup', blocks: ['2'] });
|
||||||
|
setTask(team, '2', { subject: 'Build', blockedBy: ['1'] });
|
||||||
|
|
||||||
|
await writer.removeRelationship(team, '2', '1', 'blockedBy');
|
||||||
|
|
||||||
|
const task2 = getTask(team, '2');
|
||||||
|
const task1 = getTask(team, '1');
|
||||||
|
expect(task2.blockedBy).toEqual([]);
|
||||||
|
expect(task1.blocks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing target gracefully', async () => {
|
||||||
|
setTask(team, '2', { subject: 'Build', blockedBy: ['1'] });
|
||||||
|
|
||||||
|
await writer.removeRelationship(team, '2', '1', 'blockedBy');
|
||||||
|
|
||||||
|
const task2 = getTask(team, '2');
|
||||||
|
expect(task2.blockedBy).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blocks', () => {
|
||||||
|
it('delegates to reverse blockedBy removal', async () => {
|
||||||
|
setTask(team, '1', { subject: 'Setup', blocks: ['2'] });
|
||||||
|
setTask(team, '2', { subject: 'Build', blockedBy: ['1'] });
|
||||||
|
|
||||||
|
await writer.removeRelationship(team, '1', '2', 'blocks');
|
||||||
|
|
||||||
|
const task1 = getTask(team, '1');
|
||||||
|
const task2 = getTask(team, '2');
|
||||||
|
expect(task1.blocks).toEqual([]);
|
||||||
|
expect(task2.blockedBy).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('related', () => {
|
||||||
|
it('removes bidirectional related links', async () => {
|
||||||
|
setTask(team, '3', { subject: 'Frontend', related: ['4'] });
|
||||||
|
setTask(team, '4', { subject: 'Backend', related: ['3'] });
|
||||||
|
|
||||||
|
await writer.removeRelationship(team, '3', '4', 'related');
|
||||||
|
|
||||||
|
const task3 = getTask(team, '3');
|
||||||
|
const task4 = getTask(team, '4');
|
||||||
|
expect(task3.related).toEqual([]);
|
||||||
|
expect(task4.related).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing target gracefully', async () => {
|
||||||
|
setTask(team, '3', { subject: 'Frontend', related: ['4'] });
|
||||||
|
|
||||||
|
await writer.removeRelationship(team, '3', '4', 'related');
|
||||||
|
|
||||||
|
const task3 = getTask(team, '3');
|
||||||
|
expect(task3.related).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validation', () => {
|
||||||
|
it('rejects non-existent source task', async () => {
|
||||||
|
await expect(writer.removeRelationship(team, '999', '1', 'blockedBy')).rejects.toThrow(
|
||||||
|
'Task not found: 999'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when removing a relationship that does not exist', async () => {
|
||||||
|
setTask(team, '1', { subject: 'A', blocks: ['3'] });
|
||||||
|
setTask(team, '2', { subject: 'B' });
|
||||||
|
|
||||||
|
// Task 1 has no blockedBy referencing task 2 — should not throw
|
||||||
|
await writer.removeRelationship(team, '1', '2', 'blockedBy');
|
||||||
|
|
||||||
|
const task1 = getTask(team, '1');
|
||||||
|
expect(task1.blockedBy).toEqual([]);
|
||||||
|
expect(task1.blocks).toEqual(['3']); // other relationships preserved
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue