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/pin-session - Pin 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/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
|
||||
app.post('/api/config/select-folders', async (): Promise<ConfigResult<string[]>> => {
|
||||
return { success: true, data: [] };
|
||||
|
|
|
|||
|
|
@ -126,6 +126,61 @@ async function resolveTeamDisplayName(teamName: string): Promise<string> {
|
|||
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> {
|
||||
// Check config toggle
|
||||
const config = configManager.getConfig();
|
||||
|
|
@ -145,10 +200,11 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
|
||||
try {
|
||||
const messages = await teamInboxReader.getMessagesFor(teamName, memberName);
|
||||
const isFirstLoad = !inboxMessageCounts.has(key);
|
||||
const prevCount = inboxMessageCounts.get(key) ?? 0;
|
||||
|
||||
if (prevCount === 0) {
|
||||
// First load — seed count, don't notify
|
||||
if (isFirstLoad) {
|
||||
// First load — seed count, don't notify for pre-existing messages
|
||||
inboxMessageCounts.set(key, messages.length);
|
||||
return;
|
||||
}
|
||||
|
|
@ -167,14 +223,17 @@ async function notifyNewInboxMessages(teamName: string, detail: string): Promise
|
|||
for (const msg of newMessages) {
|
||||
// Skip messages sent from our own UI
|
||||
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 summary = msg.summary || msg.text.slice(0, 60);
|
||||
const extracted = extractNotificationContent(msg.text);
|
||||
const summary = msg.summary || extracted.summary;
|
||||
|
||||
showTeamNativeNotification({
|
||||
title: teamDisplayName,
|
||||
subtitle: `${fromLabel}: ${summary}`,
|
||||
body: msg.text,
|
||||
body: extracted.body,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -344,16 +403,17 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
if (cfg.notifications.enabled && cfg.notifications.notifyOnInboxMessages) {
|
||||
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
|
||||
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
// Only notify for messages addressed to the human user
|
||||
if (latest?.to === 'user') {
|
||||
// Only notify for messages addressed to the human user, skip noise
|
||||
if (latest?.to === 'user' && !isInboxNoiseMessage(latest.text)) {
|
||||
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)
|
||||
.then((displayName) => {
|
||||
showTeamNativeNotification({
|
||||
title: displayName,
|
||||
subtitle: `${fromLabel}: ${summary}`,
|
||||
body: latest.text,
|
||||
body: extracted.body,
|
||||
});
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
|
|
|||
|
|
@ -114,6 +114,10 @@ export function registerConfigHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle('config:getClaudeRootInfo', handleGetClaudeRootInfo);
|
||||
ipcMain.handle('config:findWslClaudeRoots', handleFindWslClaudeRoots);
|
||||
|
||||
// Custom project path handlers
|
||||
ipcMain.handle('config:addCustomProjectPath', handleAddCustomProjectPath);
|
||||
ipcMain.handle('config:removeCustomProjectPath', handleRemoveCustomProjectPath);
|
||||
|
||||
// Editor handlers
|
||||
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.
|
||||
* 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:hideSessions');
|
||||
ipcMain.removeHandler('config:unhideSessions');
|
||||
ipcMain.removeHandler('config:addCustomProjectPath');
|
||||
ipcMain.removeHandler('config:removeCustomProjectPath');
|
||||
ipcMain.removeHandler('config:selectFolders');
|
||||
ipcMain.removeHandler('config:selectClaudeRootFolder');
|
||||
ipcMain.removeHandler('config:getClaudeRootInfo');
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { getAppIconPath } from '@main/utils/appIcon';
|
|||
import {
|
||||
TEAM_ADD_MEMBER,
|
||||
TEAM_ADD_TASK_COMMENT,
|
||||
TEAM_ADD_TASK_RELATIONSHIP,
|
||||
TEAM_ALIVE_LIST,
|
||||
TEAM_CANCEL_PROVISIONING,
|
||||
TEAM_CREATE,
|
||||
|
|
@ -29,6 +30,7 @@ import {
|
|||
TEAM_PROVISIONING_PROGRESS,
|
||||
TEAM_PROVISIONING_STATUS,
|
||||
TEAM_REMOVE_MEMBER,
|
||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_RESTORE,
|
||||
TEAM_RESTORE_TASK,
|
||||
|
|
@ -216,6 +218,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
||||
ipcMain.handle(TEAM_SET_TASK_CLARIFICATION, handleSetTaskClarification);
|
||||
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');
|
||||
}
|
||||
|
||||
|
|
@ -262,6 +266,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
||||
ipcMain.removeHandler(TEAM_SET_TASK_CLARIFICATION);
|
||||
ipcMain.removeHandler(TEAM_SHOW_MESSAGE_NOTIFICATION);
|
||||
ipcMain.removeHandler(TEAM_ADD_TASK_RELATIONSHIP);
|
||||
ipcMain.removeHandler(TEAM_REMOVE_TASK_RELATIONSHIP);
|
||||
}
|
||||
|
||||
function getTeamDataService(): TeamDataService {
|
||||
|
|
@ -1715,14 +1721,21 @@ export function showTeamNativeNotification(opts: {
|
|||
body: string;
|
||||
}): void {
|
||||
const config = ConfigManager.getInstance().getConfig();
|
||||
if (!config.notifications.enabled) return;
|
||||
if (config.notifications.snoozedUntil && Date.now() < config.notifications.snoozedUntil) return;
|
||||
if (!config.notifications.enabled) {
|
||||
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 (
|
||||
typeof Notification === 'undefined' ||
|
||||
typeof Notification.isSupported !== 'function' ||
|
||||
!Notification.isSupported()
|
||||
) {
|
||||
logger.warn('[native-notification] skipped: Notification not supported on this platform');
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -1766,3 +1787,66 @@ async function handleAddTaskComment(
|
|||
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
|
||||
const projects = await this.scan();
|
||||
|
||||
if (projects.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 2. Convert each project to a simple RepositoryGroup (git resolution disabled)
|
||||
// Git identity resolution is bypassed to avoid blocking I/O on startup.
|
||||
// Each project becomes a single-worktree group.
|
||||
|
|
@ -223,6 +219,40 @@ export class ProjectScanner {
|
|||
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)
|
||||
groups.sort((a, b) => (b.mostRecentSession ?? 0) - (a.mostRecentSession ?? 0));
|
||||
|
||||
|
|
|
|||
|
|
@ -188,6 +188,8 @@ export interface GeneralConfig {
|
|||
agentLanguage: string;
|
||||
autoExpandAIGroups: boolean;
|
||||
useNativeTitleBar: boolean;
|
||||
/** Paths manually added via "Select Folder" that persist across app restarts */
|
||||
customProjectPaths: string[];
|
||||
}
|
||||
|
||||
export interface DisplayConfig {
|
||||
|
|
@ -260,6 +262,7 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
agentLanguage: 'system',
|
||||
autoExpandAIGroups: false,
|
||||
useNativeTitleBar: false,
|
||||
customProjectPaths: [],
|
||||
},
|
||||
display: {
|
||||
showTimestamps: true,
|
||||
|
|
@ -845,6 +848,58 @@ export class ConfigManager {
|
|||
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
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -305,6 +305,103 @@ function updateHighwatermark(paths, 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) {
|
||||
const subject = typeof flags.subject === 'string' ? flags.subject.trim() : '';
|
||||
if (!subject) die('Missing --subject');
|
||||
|
|
@ -324,6 +421,11 @@ function createTask(paths, flags) {
|
|||
? flags['active-form']
|
||||
: 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);
|
||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined;
|
||||
let nextId;
|
||||
|
|
@ -341,7 +443,8 @@ function createTask(paths, flags) {
|
|||
createdBy: from,
|
||||
status,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
blockedBy: blockedByIds,
|
||||
related: relatedIds.length > 0 ? relatedIds : undefined,
|
||||
};
|
||||
try {
|
||||
const fd = fs.openSync(taskPath, 'wx');
|
||||
|
|
@ -356,6 +459,20 @@ function createTask(paths, flags) {
|
|||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -638,6 +755,9 @@ function taskBriefing(paths, teamName, flags) {
|
|||
if (t.description && t.description !== t.subject) {
|
||||
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) {
|
||||
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 complete <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 comment <id> --text "..." [--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);
|
||||
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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -783,6 +783,24 @@ export class TeamDataService {
|
|||
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> {
|
||||
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:
|
||||
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.
|
||||
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.`);
|
||||
}
|
||||
|
||||
|
|
@ -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}"`,
|
||||
`- 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>`,
|
||||
`- 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:`,
|
||||
`- 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 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`;
|
||||
}
|
||||
|
|
@ -460,7 +475,10 @@ function buildTaskBoardSnapshot(tasks: TeamTask[]): string {
|
|||
const lines = active.map((t) => {
|
||||
const owner = t.owner ? ` (owner: ${t.owner})` : ' (unassigned)';
|
||||
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`;
|
||||
}
|
||||
|
|
@ -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.
|
||||
- Prefer fewer, broader tasks over many micro-tasks.
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
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 */
|
||||
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 */
|
||||
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 */
|
||||
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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ import {
|
|||
SSH_TEST,
|
||||
TEAM_ADD_MEMBER,
|
||||
TEAM_ADD_TASK_COMMENT,
|
||||
TEAM_ADD_TASK_RELATIONSHIP,
|
||||
TEAM_ALIVE_LIST,
|
||||
TEAM_CANCEL_PROVISIONING,
|
||||
TEAM_CHANGE,
|
||||
|
|
@ -79,6 +80,7 @@ import {
|
|||
TEAM_PROVISIONING_PROGRESS,
|
||||
TEAM_PROVISIONING_STATUS,
|
||||
TEAM_REMOVE_MEMBER,
|
||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||
TEAM_REQUEST_REVIEW,
|
||||
TEAM_RESTORE,
|
||||
TEAM_RESTORE_TASK,
|
||||
|
|
@ -113,6 +115,7 @@ import {
|
|||
WINDOW_MINIMIZE,
|
||||
} from './constants/ipcChannels';
|
||||
import {
|
||||
CONFIG_ADD_CUSTOM_PROJECT_PATH,
|
||||
CONFIG_ADD_IGNORE_REGEX,
|
||||
CONFIG_ADD_IGNORE_REPOSITORY,
|
||||
CONFIG_ADD_TRIGGER,
|
||||
|
|
@ -125,6 +128,7 @@ import {
|
|||
CONFIG_HIDE_SESSIONS,
|
||||
CONFIG_OPEN_IN_EDITOR,
|
||||
CONFIG_PIN_SESSION,
|
||||
CONFIG_REMOVE_CUSTOM_PROJECT_PATH,
|
||||
CONFIG_REMOVE_IGNORE_REGEX,
|
||||
CONFIG_REMOVE_IGNORE_REPOSITORY,
|
||||
CONFIG_REMOVE_TRIGGER,
|
||||
|
|
@ -442,6 +446,12 @@ const electronAPI: ElectronAPI = {
|
|||
unhideSessions: async (projectId: string, sessionIds: string[]): Promise<void> => {
|
||||
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
|
||||
|
|
@ -759,6 +769,34 @@ const electronAPI: ElectronAPI = {
|
|||
showMessageNotification: async (data: TeamMessageNotificationData) => {
|
||||
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) => {
|
||||
ipcRenderer.on(
|
||||
TEAM_CHANGE,
|
||||
|
|
|
|||
|
|
@ -481,6 +481,10 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
this.post('/api/config/hide-sessions', { projectId, sessionIds }),
|
||||
unhideSessions: (projectId: string, sessionIds: string[]): Promise<void> =>
|
||||
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> => {
|
||||
// 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) => {
|
||||
return this.addEventListener('team-change', (data: unknown) =>
|
||||
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.
|
||||
// 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 folderName = selectedPath.split(/[/\\]/).filter(Boolean).pop() ?? selectedPath;
|
||||
const now = Date.now();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ interface MemberBadgeProps {
|
|||
color?: string;
|
||||
/** Avatar + badge size variant */
|
||||
size?: 'sm' | 'md';
|
||||
/** Hide the avatar icon, show only the name badge */
|
||||
hideAvatar?: boolean;
|
||||
onClick?: (name: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -18,6 +20,7 @@ export const MemberBadge = ({
|
|||
name,
|
||||
color,
|
||||
size = 'sm',
|
||||
hideAvatar,
|
||||
onClick,
|
||||
}: MemberBadgeProps): React.JSX.Element => {
|
||||
const colors = getTeamColorSet(color ?? '');
|
||||
|
|
@ -59,7 +62,7 @@ export const MemberBadge = ({
|
|||
onClick(name);
|
||||
}}
|
||||
>
|
||||
{avatar}
|
||||
{!hideAvatar && avatar}
|
||||
{badge}
|
||||
</button>
|
||||
);
|
||||
|
|
@ -67,7 +70,7 @@ export const MemberBadge = ({
|
|||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{avatar}
|
||||
{!hideAvatar && avatar}
|
||||
{badge}
|
||||
</span>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -317,7 +317,12 @@ export const ActivityItem = ({
|
|||
<span style={{ color: CARD_ICON_MUTED }} className="text-[10px]">
|
||||
→
|
||||
</span>
|
||||
<MemberBadge name={message.to} color={recipientColor} onClick={onMemberNameClick} />
|
||||
<MemberBadge
|
||||
name={message.to}
|
||||
color={recipientColor}
|
||||
hideAvatar={message.to === 'user'}
|
||||
onClick={onMemberNameClick}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const MessageRowWithObserver = ({
|
|||
memberColor,
|
||||
recipientColor,
|
||||
isUnread,
|
||||
isNew,
|
||||
onMemberNameClick,
|
||||
onCreateTask,
|
||||
onReply,
|
||||
|
|
@ -46,6 +47,7 @@ const MessageRowWithObserver = ({
|
|||
memberColor?: string;
|
||||
recipientColor?: string;
|
||||
isUnread?: boolean;
|
||||
isNew?: boolean;
|
||||
onMemberNameClick?: (name: string) => void;
|
||||
onCreateTask?: (subject: string, description: string) => void;
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
|
|
@ -83,7 +85,7 @@ const MessageRowWithObserver = ({
|
|||
}, [onVisible]);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="min-h-px">
|
||||
<div ref={ref} className={isNew ? 'message-enter-animate min-h-px' : 'min-h-px'}>
|
||||
<ActivityItem
|
||||
message={message}
|
||||
teamName={teamName}
|
||||
|
|
@ -113,6 +115,11 @@ export const ActivityTimeline = ({
|
|||
}: ActivityTimelineProps): React.JSX.Element => {
|
||||
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).
|
||||
// If so, auto-expand when new messages push count past the limit,
|
||||
// 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.
|
||||
// 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;
|
||||
if (wasShowingAll && hiddenCount > 0) {
|
||||
setVisibleCount(messages.length);
|
||||
}
|
||||
wasShowingAllRef.current = hiddenCount === 0;
|
||||
/* eslint-enable react-hooks/refs -- end of intentional ref access during render */
|
||||
|
||||
const visibleMessages = useMemo(
|
||||
() => (hiddenCount > 0 ? messages.slice(0, visibleCount) : messages),
|
||||
[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 => {
|
||||
setVisibleCount((prev) => prev + MESSAGES_PAGE_SIZE);
|
||||
};
|
||||
|
|
@ -203,6 +248,7 @@ export const ActivityTimeline = ({
|
|||
memberColor={info?.color}
|
||||
recipientColor={recipientColor}
|
||||
isUnread={isUnread}
|
||||
isNew={newMessageKeys.has(messageKey)}
|
||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||
onCreateTask={onCreateTaskFromMessage}
|
||||
onReply={onReplyToMessage}
|
||||
|
|
|
|||
|
|
@ -247,6 +247,15 @@ export const ChangeReviewDialog = ({
|
|||
[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(
|
||||
(filePath: string) => {
|
||||
discardFileEdits(filePath);
|
||||
|
|
@ -783,6 +792,7 @@ export const ChangeReviewDialog = ({
|
|||
onContentChanged={handleContentChanged}
|
||||
onDiscard={handleDiscardFile}
|
||||
onSave={handleSaveFile}
|
||||
onRestoreMissingFile={handleRestoreMissingFile}
|
||||
onVisibleFileChange={handleVisibleFileChange}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
editorViewMapRef={editorViewMapRef}
|
||||
|
|
|
|||
|
|
@ -257,6 +257,13 @@ export const CodeMirrorDiffView = ({
|
|||
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) {
|
||||
mergeConfig.collapseUnchanged = {
|
||||
margin,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ interface ContinuousScrollViewProps {
|
|||
onContentChanged: (filePath: string, content: string) => void;
|
||||
onDiscard: (filePath: string) => void;
|
||||
onSave: (filePath: string) => void;
|
||||
onRestoreMissingFile?: (filePath: string, content: string) => void;
|
||||
onVisibleFileChange: (filePath: string) => void;
|
||||
scrollContainerRef: React.RefObject<HTMLDivElement>;
|
||||
editorViewMapRef: React.MutableRefObject<Map<string, EditorView>>;
|
||||
|
|
@ -71,6 +72,7 @@ export const ContinuousScrollView = ({
|
|||
onContentChanged,
|
||||
onDiscard,
|
||||
onSave,
|
||||
onRestoreMissingFile,
|
||||
onVisibleFileChange,
|
||||
scrollContainerRef,
|
||||
editorViewMapRef,
|
||||
|
|
@ -222,6 +224,7 @@ export const ContinuousScrollView = ({
|
|||
onToggleCollapse={handleToggleCollapse}
|
||||
onDiscard={onDiscard}
|
||||
onSave={onSave}
|
||||
onRestoreMissingFile={onRestoreMissingFile}
|
||||
/>
|
||||
|
||||
{!isCollapsed &&
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ export const FileSectionDiff = ({
|
|||
})();
|
||||
|
||||
const resolvedOriginal = fileContent?.originalFullContent ?? null;
|
||||
const isMissingOnDisk = fileContent?.contentSource === 'unavailable';
|
||||
|
||||
// Show CodeMirror only when we have a trustworthy original baseline:
|
||||
// - new files: original is legitimately empty
|
||||
|
|
@ -114,6 +115,12 @@ export const FileSectionDiff = ({
|
|||
|
||||
return (
|
||||
<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
|
||||
filePath={file.filePath}
|
||||
oldString={originalForDiff}
|
||||
|
|
@ -125,7 +132,7 @@ export const FileSectionDiff = ({
|
|||
modified={resolvedModified}
|
||||
fileName={file.relativePath}
|
||||
readOnly={false}
|
||||
showMergeControls={true}
|
||||
showMergeControls={!isMissingOnDisk}
|
||||
collapseUnchanged={collapseUnchanged}
|
||||
usePortionCollapse={true}
|
||||
onHunkAccepted={(idx) => onHunkAccepted(file.filePath, idx)}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
|
||||
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 { FileChangeSummary } from '@shared/types/review';
|
||||
|
|
@ -11,7 +11,7 @@ const CONTENT_SOURCE_LABELS: Record<string, string> = {
|
|||
'snippet-reconstruction': 'Reconstructed',
|
||||
'disk-current': 'Current Disk',
|
||||
'git-fallback': 'Git Fallback',
|
||||
unavailable: 'Unavailable',
|
||||
unavailable: 'Missing on disk',
|
||||
};
|
||||
|
||||
interface FileSectionHeaderProps {
|
||||
|
|
@ -24,6 +24,7 @@ interface FileSectionHeaderProps {
|
|||
onToggleCollapse: (filePath: string) => void;
|
||||
onDiscard: (filePath: string) => void;
|
||||
onSave: (filePath: string) => void;
|
||||
onRestoreMissingFile?: (filePath: string, content: string) => void;
|
||||
}
|
||||
|
||||
export const FileSectionHeader = ({
|
||||
|
|
@ -36,7 +37,21 @@ export const FileSectionHeader = ({
|
|||
onToggleCollapse,
|
||||
onDiscard,
|
||||
onSave,
|
||||
onRestoreMissingFile,
|
||||
}: 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 => {
|
||||
// Don't collapse when clicking action buttons
|
||||
if ((e.target as HTMLElement).closest('[data-no-collapse]')) return;
|
||||
|
|
@ -70,9 +85,44 @@ export const FileSectionHeader = ({
|
|||
)}
|
||||
|
||||
{fileContent?.contentSource && (
|
||||
<span className="rounded bg-surface-raised px-1.5 py-0.5 text-[10px] text-text-muted">
|
||||
{CONTENT_SOURCE_LABELS[fileContent.contentSource] ?? fileContent.contentSource}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<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 && (
|
||||
|
|
@ -90,6 +140,23 @@ export const FileSectionHeader = ({
|
|||
)}
|
||||
|
||||
<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 && (
|
||||
<>
|
||||
<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 {
|
||||
animation: skeleton-fade-in 0.4s ease-out both;
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -175,6 +175,18 @@ export interface TeamSlice {
|
|||
memberName: string,
|
||||
role: string | undefined
|
||||
) => 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: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
|
|
@ -612,6 +624,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
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) => {
|
||||
await unwrapIpc('team:setTaskClarification', () =>
|
||||
api.teams.setTaskClarification(teamName, taskId, value)
|
||||
|
|
|
|||
|
|
@ -184,6 +184,10 @@ export interface ConfigAPI {
|
|||
hideSessions: (projectId: string, sessionIds: string[]) => Promise<void>;
|
||||
/** Bulk unhide sessions for a project */
|
||||
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 {
|
||||
|
|
@ -449,6 +453,18 @@ export interface TeamsAPI {
|
|||
restoreTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
|
||||
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;
|
||||
onProvisioningProgress: (
|
||||
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
|||
TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks',
|
||||
TEAM_SET_TASK_CLARIFICATION: 'team:setTaskClarification',
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION: 'team:showMessageNotification',
|
||||
TEAM_ADD_TASK_RELATIONSHIP: 'team:addTaskRelationship',
|
||||
TEAM_REMOVE_TASK_RELATIONSHIP: 'team:removeTaskRelationship',
|
||||
TEAM_RESTORE: 'team:restoreTeam',
|
||||
TEAM_PERMANENTLY_DELETE: 'team:permanentlyDeleteTeam',
|
||||
TEAM_RESTORE_TASK: 'team:restoreTask',
|
||||
|
|
@ -93,6 +95,8 @@ import {
|
|||
TEAM_SET_TASK_CLARIFICATION,
|
||||
TEAM_SOFT_DELETE_TASK,
|
||||
TEAM_UPDATE_MEMBER_ROLE,
|
||||
TEAM_ADD_TASK_RELATIONSHIP,
|
||||
TEAM_REMOVE_TASK_RELATIONSHIP,
|
||||
} from '../../../src/preload/constants/ipcChannels';
|
||||
import {
|
||||
initializeTeamHandlers,
|
||||
|
|
@ -142,6 +146,8 @@ describe('ipc teams handlers', () => {
|
|||
softDeleteTask: vi.fn(async () => undefined),
|
||||
getDeletedTasks: vi.fn(async () => []),
|
||||
setTaskNeedsClarification: vi.fn(async () => undefined),
|
||||
addTaskRelationship: vi.fn(async () => undefined),
|
||||
removeTaskRelationship: vi.fn(async () => undefined),
|
||||
};
|
||||
const provisioningService = {
|
||||
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_RESTORE)).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 () => {
|
||||
|
|
@ -535,5 +543,77 @@ describe('ipc teams handlers', () => {
|
|||
expect(handlers.has(TEAM_SET_TASK_CLARIFICATION)).toBe(false);
|
||||
expect(handlers.has(TEAM_RESTORE)).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