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:
iliya 2026-03-01 20:42:17 +02:00
parent a0764e39d1
commit 4c5f0c3cc2
27 changed files with 1353 additions and 30 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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]">
&rarr; &rarr;
</span> </span>
<MemberBadge name={message.to} color={recipientColor} onClick={onMemberNameClick} /> <MemberBadge
name={message.to}
color={recipientColor}
hideAvatar={message.to === 'user'}
onClick={onMemberNameClick}
/>
</> </>
) : null} ) : null}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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