feat: enhance notification and task management features

- Updated notification configuration to separate settings for lead and user inbox messages, improving user control over notifications.
- Enhanced the handling of inbox message notifications to respect individual inbox settings.
- Introduced new IPC methods for managing watched directories, allowing for more granular file monitoring.
- Improved task management by implementing work intervals for task status transitions, ensuring accurate tracking of task progress.
- Added validation for task creation and configuration, ensuring proper input handling for project paths and task properties.
This commit is contained in:
iliya 2026-03-02 18:17:57 +02:00
parent 51df8847a9
commit 6aec33ae33
49 changed files with 2942 additions and 207 deletions

View file

@ -110,6 +110,7 @@
"cmdk": "1.0.4",
"date-fns": "^3.6.0",
"diff": "^8.0.3",
"dompurify": "^3.3.1",
"electron-updater": "^6.7.3",
"fastify": "^5.7.4",
"highlight.js": "^11.11.1",
@ -117,12 +118,14 @@
"isbinaryfile": "^6.0.0",
"lucide-react": "^0.562.0",
"mdast-util-to-hast": "^13.2.1",
"mermaid": "^11.12.3",
"node-diff3": "^3.2.0",
"node-pty": "^1.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"simple-git": "^3.32.3",

File diff suppressed because it is too large Load diff

View file

@ -182,19 +182,23 @@ function extractNotificationContent(text: string): { summary: string; body: stri
}
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
// Check config toggle
// Check global toggle
const config = configManager.getConfig();
if (!config.notifications.enabled || !config.notifications.notifyOnInboxMessages) return;
if (!config.notifications.enabled) return;
// detail is like "inboxes/carol.json" — extract member name
const match = /^inboxes\/(.+)\.json$/.exec(detail);
if (!match) return;
const memberName = match[1];
// Only notify for the lead's inbox (messages addressed to the human user).
// CLI doesn't set msg.to, so we filter by inbox file name instead.
// Determine inbox type and check per-inbox toggle
const leadName = teamDataService ? await teamDataService.getLeadMemberName(teamName) : null;
if (leadName !== null && memberName !== leadName && memberName !== 'user') return;
const isLeadInbox = leadName !== null && memberName === leadName;
const isUserInbox = memberName === 'user';
if (isLeadInbox && !config.notifications.notifyOnLeadInbox) return;
if (isUserInbox && !config.notifications.notifyOnUserInbox) return;
if (!isLeadInbox && !isUserInbox) return;
const key = `${teamName}:${memberName}`;
@ -400,7 +404,7 @@ function wireFileWatcherEvents(context: ServiceContext): void {
// These don't go through inbox files — they're held in-memory by TeamProvisioningService.
if (detail === 'lead-process-reply' || detail === 'lead-direct-reply') {
const cfg = configManager.getConfig();
if (cfg.notifications.enabled && cfg.notifications.notifyOnInboxMessages) {
if (cfg.notifications.enabled && cfg.notifications.notifyOnUserInbox) {
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
// Only notify for messages addressed to the human user, skip noise

View file

@ -22,6 +22,7 @@ import {
EDITOR_READ_FILE,
EDITOR_RENAME_FILE,
EDITOR_SEARCH_IN_FILES,
EDITOR_SET_WATCHED_DIRS,
EDITOR_SET_WATCHED_FILES,
EDITOR_WATCH_DIR,
EDITOR_WRITE_FILE,
@ -366,6 +367,19 @@ async function handleEditorSetWatchedFiles(
});
}
/**
* Update watched directory list (shallow, depth=0).
*/
async function handleEditorSetWatchedDirs(
_event: IpcMainInvokeEvent,
dirPaths: string[]
): Promise<IpcResult<void>> {
return wrapHandler('setWatchedDirs', async () => {
if (!activeProjectRoot) throw new Error('Editor not initialized');
editorFileWatcher.setWatchedDirs(Array.isArray(dirPaths) ? dirPaths : []);
});
}
// =============================================================================
// Registration
// =============================================================================
@ -399,6 +413,7 @@ export function registerEditorHandlers(ipcMain: IpcMain): void {
ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus);
ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir);
ipcMain.handle(EDITOR_SET_WATCHED_FILES, handleEditorSetWatchedFiles);
ipcMain.handle(EDITOR_SET_WATCHED_DIRS, handleEditorSetWatchedDirs);
}
export function removeEditorHandlers(ipcMain: IpcMain): void {
@ -418,6 +433,7 @@ export function removeEditorHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(EDITOR_GIT_STATUS);
ipcMain.removeHandler(EDITOR_WATCH_DIR);
ipcMain.removeHandler(EDITOR_SET_WATCHED_FILES);
ipcMain.removeHandler(EDITOR_SET_WATCHED_DIRS);
}
/**

View file

@ -1313,6 +1313,14 @@ async function handleCreateConfig(
if (payload.color !== undefined && typeof payload.color !== 'string') {
return { success: false, error: 'color must be a string' };
}
if (payload.cwd !== undefined) {
if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) {
return { success: false, error: 'cwd must be a non-empty string if provided' };
}
if (!path.isAbsolute(payload.cwd.trim())) {
return { success: false, error: 'cwd must be an absolute path' };
}
}
const seenNames = new Set<string>();
const members: TeamCreateConfigRequest['members'] = [];
@ -1344,6 +1352,7 @@ async function handleCreateConfig(
description: payload.description?.trim() || undefined,
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
members,
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
})
);
}
@ -1377,7 +1386,12 @@ async function handleGetLogsForTask(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
options?: { owner?: string; status?: string }
options?: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
}
): Promise<IpcResult<MemberLogSummary[]>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
@ -1392,6 +1406,17 @@ async function handleGetLogsForTask(
? {
owner: typeof options.owner === 'string' ? options.owner : undefined,
status: typeof options.status === 'string' ? options.status : undefined,
since: typeof options.since === 'string' ? options.since : undefined,
intervals: Array.isArray(options.intervals)
? (options.intervals as unknown[]).filter(
(i): i is { startedAt: string; completedAt?: string } =>
Boolean(i) &&
typeof i === 'object' &&
typeof (i as Record<string, unknown>).startedAt === 'string' &&
((i as Record<string, unknown>).completedAt === undefined ||
typeof (i as Record<string, unknown>).completedAt === 'string')
)
: undefined,
}
: undefined;
return wrapTeamHandler('getLogsForTask', () =>

View file

@ -31,6 +31,7 @@ const MAX_EMITTED_EVENTS_PER_FLUSH = 300;
export class EditorFileWatcher {
private watcher: FSWatcher | null = null;
private dirWatcher: FSWatcher | null = null;
private projectRoot: string | null = null;
private pendingEvents = new Map<string, EditorFileChangeEvent['type']>();
private flushTimer: ReturnType<typeof setTimeout> | null = null;
@ -39,6 +40,7 @@ export class EditorFileWatcher {
private readonly DEBOUNCE_MS = 350;
private ignoreChangeUntilMs = 0;
private watchedFilesKey = '';
private watchedDirsKey = '';
/**
* Initialize watcher context for a project root.
@ -51,6 +53,7 @@ export class EditorFileWatcher {
this.projectRoot = projectRoot;
this.ignoreChangeUntilMs = Date.now() + STARTUP_IGNORE_CHANGE_MS;
this.watchedFilesKey = '';
this.watchedDirsKey = '';
log.info('Starting file watcher (open files only) for:', projectRoot);
this.onChangeCallback = onChange;
@ -62,7 +65,7 @@ export class EditorFileWatcher {
*/
setWatchedFiles(filePaths: string[]): void {
if (!this.projectRoot) {
throw new Error('Watcher not initialized');
return; // Watcher not initialized yet — will sync when start() is called
}
const normalized = filePaths
@ -113,6 +116,60 @@ export class EditorFileWatcher {
});
}
/**
* Update list of watched directory paths (shallow: depth=0).
* Watches only immediate children changes (create/delete/rename) in those folders.
*/
setWatchedDirs(dirPaths: string[]): void {
if (!this.projectRoot) {
return; // Watcher not initialized yet — will sync when start() is called
}
const normalized = dirPaths
.filter((p): p is string => typeof p === 'string' && p.length > 0)
.filter((p) => isPathWithinRoot(p, this.projectRoot!));
normalized.sort();
const key = normalized.join('\n');
if (key === this.watchedDirsKey) return;
this.watchedDirsKey = key;
if (this.dirWatcher) {
void this.dirWatcher.close();
this.dirWatcher = null;
}
if (normalized.length === 0) {
return;
}
this.dirWatcher = watch(normalized, {
ignoreInitial: true,
ignorePermissionErrors: true,
followSymlinks: false,
depth: 0,
});
const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => {
if (!isPathWithinRoot(filePath, this.projectRoot!)) {
log.warn('Watcher event outside project root, ignoring:', filePath);
return;
}
this.pendingEvents.set(filePath, type);
this.scheduleFlush();
};
// For directories, we only care about structural changes.
this.dirWatcher.on('add', (p) => emitSafe('create', p));
this.dirWatcher.on('unlink', (p) => emitSafe('delete', p));
this.dirWatcher.on('addDir', (p) => emitSafe('create', p));
this.dirWatcher.on('unlinkDir', (p) => emitSafe('delete', p));
this.dirWatcher.on('error', (error) => {
log.error('Dir watcher error:', error);
});
}
/**
* Stop watching. Safe to call multiple times.
*/
@ -125,11 +182,17 @@ export class EditorFileWatcher {
this.onChangeCallback = null;
this.ignoreChangeUntilMs = 0;
this.watchedFilesKey = '';
this.watchedDirsKey = '';
if (this.watcher) {
log.info('Stopping file watcher');
void this.watcher.close();
this.watcher = null;
}
if (this.dirWatcher) {
log.info('Stopping directory watcher');
void this.dirWatcher.close();
this.dirWatcher = null;
}
this.projectRoot = null;
}

View file

@ -40,8 +40,10 @@ export interface NotificationConfig {
snoozeMinutes: number; // Default snooze duration
/** Whether to include errors from subagent sessions */
includeSubagentErrors: boolean;
/** Whether to show native OS notifications for team inbox messages */
notifyOnInboxMessages: boolean;
/** Whether to show native OS notifications when teammates send messages to the team lead */
notifyOnLeadInbox: boolean;
/** Whether to show native OS notifications when teammates send messages to you (the user) */
notifyOnUserInbox: boolean;
/** Whether to show native OS notifications when a task needs user clarification */
notifyOnClarifications: boolean;
/** Notification triggers - define when to generate notifications */
@ -249,7 +251,8 @@ const DEFAULT_CONFIG: AppConfig = {
snoozedUntil: null,
snoozeMinutes: 30,
includeSubagentErrors: true,
notifyOnInboxMessages: true,
notifyOnLeadInbox: false,
notifyOnUserInbox: true,
notifyOnClarifications: true,
triggers: DEFAULT_TRIGGERS,
},
@ -416,6 +419,7 @@ export class ConfigManager {
private mergeWithDefaults(loaded: Partial<AppConfig>): AppConfig {
const loadedNotifications = loaded.notifications ?? ({} as Partial<NotificationConfig>);
const loadedTriggers = loadedNotifications.triggers ?? [];
const mergedGeneral: GeneralConfig = {
...DEFAULT_CONFIG.general,
...(loaded.general ?? {}),

View file

@ -195,10 +195,37 @@ function writeTask(taskPath, task) {
return verify;
}
function applyWorkIntervalsForStatusTransition(task, prevStatus, nextStatus, now) {
var wasInProgress = prevStatus === 'in_progress';
var isInProgress = nextStatus === 'in_progress';
var intervals = Array.isArray(task.workIntervals) ? task.workIntervals.slice() : [];
var last = intervals.length ? intervals[intervals.length - 1] : null;
if (!wasInProgress && isInProgress) {
if (!last || typeof last.completedAt === 'string') {
intervals.push({ startedAt: now });
}
} else if (wasInProgress && !isInProgress) {
// Close the most recent open interval (if any).
for (var i = intervals.length - 1; i >= 0; i--) {
if (intervals[i] && typeof intervals[i].startedAt === 'string' && !intervals[i].completedAt) {
intervals[i].completedAt = now;
break;
}
}
}
if (intervals.length > 0) task.workIntervals = intervals;
else delete task.workIntervals;
}
function setTaskStatus(paths, taskId, status) {
const normalized = normalizeStatus(status);
if (!normalized) die('Invalid status: ' + String(status));
const { taskPath, task } = readTask(paths, taskId);
var prev = task.status;
var now = nowIso();
applyWorkIntervalsForStatusTransition(task, prev, normalized, now);
task.status = normalized;
writeTask(taskPath, task);
}
@ -244,6 +271,7 @@ function addTaskComment(paths, taskId, flags) {
id: commentId,
author: from,
text: text,
type: 'regular',
createdAt: nowIso(),
};
task.comments = existing.concat([comment]);
@ -413,7 +441,6 @@ function createTask(paths, flags) {
: '';
const owner = typeof flags.owner === 'string' && flags.owner.trim() ? flags.owner.trim() : undefined;
const explicitStatus = typeof flags.status === 'string' ? flags.status : '';
const status = normalizeStatus(explicitStatus) || (owner ? 'in_progress' : 'pending');
const activeForm =
typeof flags.activeForm === 'string'
? flags.activeForm
@ -423,6 +450,13 @@ function createTask(paths, flags) {
var blockedByIds = parseIdList(flags['blocked-by']);
var relatedIds = parseIdList(flags.related);
// Default status rule:
// - explicit --status always wins
// - tasks with dependencies should start as pending, even if assigned (owner)
// - otherwise, assigned tasks default to in_progress, unassigned to pending
const status =
normalizeStatus(explicitStatus) ||
(blockedByIds.length > 0 ? 'pending' : owner ? 'in_progress' : 'pending');
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]); }
@ -434,6 +468,7 @@ function createTask(paths, flags) {
while (true) {
nextId = getNextTaskId(paths);
taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
var createdAt = nowIso();
task = {
id: nextId,
subject,
@ -442,6 +477,8 @@ function createTask(paths, flags) {
owner,
createdBy: from,
status,
createdAt: createdAt,
workIntervals: status === 'in_progress' ? [{ startedAt: createdAt }] : undefined,
blocks: [],
blockedBy: blockedByIds,
related: relatedIds.length > 0 ? relatedIds : undefined,
@ -563,18 +600,32 @@ function sendInboxMessage(paths, teamName, flags) {
function reviewApprove(paths, teamName, taskId, flags) {
setKanbanColumn(paths, teamName, taskId, 'approved');
const notify = flags.notify === true || flags['notify-owner'] === true;
if (!notify) return;
const { task } = readTask(paths, taskId);
if (!task.owner) return;
const { taskPath, task } = readTask(paths, taskId);
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
const note = typeof flags.note === 'string' ? flags.note.trim() : '';
const text = note
// Record review comment in task.comments
var existing = Array.isArray(task.comments) ? task.comments : [];
var reviewCommentId = crypto.randomUUID
? crypto.randomUUID()
: String(Date.now()) + '-' + String(Math.random());
task.comments = existing.concat([{
id: reviewCommentId,
author: from,
text: note || 'Approved',
type: 'review_approved',
createdAt: nowIso(),
}]);
writeTask(taskPath, task);
const notify = flags.notify === true || flags['notify-owner'] === true;
if (!notify || !task.owner) return;
const inboxText = note
? 'Task #' + String(taskId) + ' approved.\n\n' + note
: 'Task #' + String(taskId) + ' approved.';
sendInboxMessage(paths, teamName, {
to: task.owner,
text,
text: inboxText,
summary: 'Approved #' + String(taskId),
from,
});
@ -585,12 +636,29 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
const { taskPath, task } = readTask(paths, taskId);
if (!task.owner) die('No owner found for task ' + String(taskId));
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
clearKanban(paths, teamName, taskId);
var now = nowIso();
applyWorkIntervalsForStatusTransition(task, task.status, 'in_progress', now);
task.status = 'in_progress';
// Record review comment in task.comments
var existing = Array.isArray(task.comments) ? task.comments : [];
var reviewCommentId = crypto.randomUUID
? crypto.randomUUID()
: String(Date.now()) + '-' + String(Math.random());
task.comments = existing.concat([{
id: reviewCommentId,
author: from,
text: comment || 'Reviewer requested changes.',
type: 'review_request',
createdAt: now,
}]);
writeTask(taskPath, task);
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
const text =
const inboxText =
'Task #' +
String(taskId) +
' needs fixes.\n\n' +
@ -599,7 +667,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
'Please fix and mark it as completed when ready.';
sendInboxMessage(paths, teamName, {
to: task.owner,
text,
text: inboxText,
summary: 'Fix request for #' + String(taskId),
from,
});

View file

@ -956,11 +956,15 @@ export class TeamDataService {
await fs.promises.mkdir(tasksDir, { recursive: true });
const joinedAt = Date.now();
const config = {
const config: Record<string, unknown> = {
name: request.displayName?.trim() || request.teamName,
description: request.description?.trim() || undefined,
color: request.color?.trim() || undefined,
};
if (request.cwd?.trim()) {
config.projectPath = request.cwd.trim();
config.projectPathHistory = [request.cwd.trim()];
}
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
await this.membersMetaStore.writeMembers(

View file

@ -110,7 +110,12 @@ export class TeamMemberLogsFinder {
async findLogsForTask(
teamName: string,
taskId: string,
options?: { owner?: string; status?: string }
options?: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
}
): Promise<MemberLogSummary[]> {
const discovery = await this.discoverProjectSessions(teamName);
if (!discovery) return [];
@ -171,6 +176,56 @@ export class TeamMemberLogsFinder {
options.owner.trim().length > 0;
if (includeOwnerSessions) {
const ownerLogs = await this.findMemberLogs(teamName, options.owner!.trim());
const TASK_LOG_INTERVAL_GRACE_MS = 10_000;
const fallbackRecentMs = 30 * 60_000; // if caller doesn't supply intervals/since, avoid pulling in old owner history
const now = Date.now();
const normalizedIntervals = Array.isArray(options?.intervals)
? options.intervals
.map((i) => {
const startMs = Date.parse(i.startedAt);
const endMsRaw =
typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN;
const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null;
return Number.isFinite(startMs) ? { startMs, endMs } : null;
})
.filter((v): v is { startMs: number; endMs: number | null } => v !== null)
: [];
// Back-compat: single since timestamp -> treat as open interval.
const sinceMsRaw = typeof options?.since === 'string' ? Date.parse(options.since) : NaN;
const sinceMs = Number.isFinite(sinceMsRaw) ? sinceMsRaw : null;
const effectiveIntervals =
normalizedIntervals.length > 0
? normalizedIntervals
: sinceMs != null
? [{ startMs: sinceMs, endMs: null }]
: [];
const overlapsAnyInterval = (logStartMs: number, logEndMs: number): boolean => {
for (const it of effectiveIntervals) {
const start = it.startMs - TASK_LOG_INTERVAL_GRACE_MS;
const end = (it.endMs ?? now) + TASK_LOG_INTERVAL_GRACE_MS;
if (logStartMs <= end && logEndMs >= start) return true;
}
return false;
};
const filteredOwnerLogs = ownerLogs.filter((log) => {
if (log.isOngoing) return true;
const startMs = new Date(log.startTime).getTime();
if (!Number.isFinite(startMs)) return false;
const durationMs =
typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0;
const endMs = startMs + durationMs;
if (effectiveIntervals.length > 0) {
return overlapsAnyInterval(startMs, endMs);
}
return startMs >= now - fallbackRecentMs;
});
const seen = new Set<string>();
for (const log of results) {
const key =
@ -179,7 +234,7 @@ export class TeamMemberLogsFinder {
: `lead:${log.sessionId}`;
seen.add(key);
}
for (const log of ownerLogs) {
for (const log of filteredOwnerLogs) {
const key =
log.kind === 'subagent'
? `subagent:${log.sessionId}:${log.subagentId}`
@ -409,12 +464,17 @@ export class TeamMemberLogsFinder {
const patterns: RegExp[] = [
new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'),
new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'),
new RegExp(`#${escaped}\\b`),
];
if (numericTaskId) {
patterns.push(
new RegExp(`"task_id"\\s*:\\s*${numericTaskId}\\b`),
new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`)
new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`),
// Support teamctl command lines (may appear in tool output).
// Example: node ".../teamctl.js" --team "t" task start 10
new RegExp(
`\\bteamctl(?:\\.js)?\\b.{0,250}\\b(?:task|review)\\b.{0,250}\\b${numericTaskId}\\b`,
'i'
)
);
}
try {

View file

@ -380,14 +380,18 @@ 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}"`,
`- Create with deps (blocked work MUST be pending): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --status pending --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.`,
`- If you set --blocked-by, create the task in pending (use --status pending). Do NOT put blocked tasks into in_progress.`,
`- Use --related to link related work (e.g. frontend + backend) without blocking.`,
`- Review tasks: Prefer NOT creating a separate "review task". Reviews apply to the work task (#X) via: review approve/request-changes #X.`,
` - If you must create a separate review reminder/assignment task, keep it pending and link it to #X with --related (and optionally --blocked-by #X if it truly cannot start yet).`,
` - Dependencies do not auto-start tasks; the owner must explicitly start it when ready.`,
`- Avoid over-specifying. Only add dependencies when execution order matters.`,
``,
`Notification policy:`,
@ -554,6 +558,13 @@ ${processRegistration}
- 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.
- If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress.
- Review guidance:
- Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
- If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task:
- Use --related to connect it to #X (non-blocking link).
- If the review truly cannot start until #X is done, ALSO add --blocked-by #X.
- There is no automatic status transition when dependencies resolve the owner must explicitly start it (task start / set-status in_progress) when ready.
- Use --related to connect tasks working on the same feature without blocking.
4) After all steps, output a short summary.
@ -1347,23 +1358,35 @@ export class TeamProvisioningService {
configParsed.leadSessionId.trim().length > 0
) {
const candidateId = configParsed.leadSessionId.trim();
const projectPath =
const storedProjectPath =
typeof configParsed.projectPath === 'string' &&
configParsed.projectPath.trim().length > 0
? configParsed.projectPath.trim()
: request.cwd;
const projectId = encodePath(projectPath);
const baseDir = extractBaseDir(projectId);
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
if (await this.pathExists(jsonlPath)) {
previousSessionId = candidateId;
: null;
// Sessions are stored per-project (~/.claude/projects/{encodePath(cwd)}/).
// If the project path changed, the old session JSONL won't be found by the CLI
// at the new project directory. Skip resume to avoid passing an invalid --resume arg.
if (storedProjectPath && path.resolve(storedProjectPath) !== path.resolve(request.cwd)) {
logger.info(
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
`[${request.teamName}] Project path changed: ${storedProjectPath}${request.cwd}. ` +
`Skipping session resume — sessions are per-project.`
);
} else {
logger.info(
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
);
const resumeProjectPath = storedProjectPath ?? request.cwd;
const projectId = encodePath(resumeProjectPath);
const baseDir = extractBaseDir(projectId);
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
if (await this.pathExists(jsonlPath)) {
previousSessionId = candidateId;
logger.info(
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
);
} else {
logger.info(
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
);
}
}
}
} catch {
@ -1377,6 +1400,11 @@ export class TeamProvisioningService {
// Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names.
await this.normalizeTeamConfigForLaunch(request.teamName, configRaw);
// Update projectPath in config IMMEDIATELY so TeamDetailView shows the correct path
// even if provisioning is interrupted or the user stops the team early.
// If launch fails, restorePrelaunchConfig() will revert to the backup (old projectPath).
await this.updateConfigProjectPath(request.teamName, request.cwd);
let claudePath: string | null;
try {
await ensureCwdExists(request.cwd);
@ -2806,6 +2834,35 @@ export class TeamProvisioningService {
return token.length > 0 ? token : null;
}
/**
* Immediately update projectPath in config.json at launch start, before CLI spawn.
* Ensures TeamDetailView shows the correct project path even if provisioning
* is interrupted. On failure, restorePrelaunchConfig() reverts to the backup.
*/
private async updateConfigProjectPath(teamName: string, cwd: string): Promise<void> {
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
try {
const raw = await fs.promises.readFile(configPath, 'utf8');
const config = JSON.parse(raw) as Record<string, unknown>;
config.projectPath = cwd;
const pathHistory = Array.isArray(config.projectPathHistory)
? (config.projectPathHistory as string[]).filter((p) => typeof p === 'string' && p !== cwd)
: [];
pathHistory.push(cwd);
config.projectPathHistory = pathHistory.slice(-500);
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
logger.info(`[${teamName}] Updated config.projectPath immediately: ${cwd}`);
} catch (error) {
// Non-fatal: updateConfigPostLaunch will update it later if provisioning succeeds.
logger.warn(
`[${teamName}] Failed to update projectPath early: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Single atomic read-mutate-write for post-launch config updates.
* Combines session history append and projectPath update to avoid

View file

@ -3,7 +3,7 @@ import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs';
import * as path from 'path';
import type { TaskComment, TeamTask } from '@shared/types';
import type { TaskComment, TaskWorkInterval, TeamTask } from '@shared/types';
const logger = createLogger('Service:TeamTaskReader');
@ -97,6 +97,21 @@ export class TeamTaskReader {
// `satisfies Record<keyof TeamTask, unknown>` ensures compile-time
// safety: if a field is added to TeamTask but not mapped here,
// TypeScript will error. This prevents silently dropping new fields.
const workIntervals: TaskWorkInterval[] | undefined = Array.isArray(parsed.workIntervals)
? (parsed.workIntervals as unknown[])
.filter(
(i): i is { startedAt: string; completedAt?: string } =>
Boolean(i) &&
typeof i === 'object' &&
typeof (i as Record<string, unknown>).startedAt === 'string' &&
((i as Record<string, unknown>).completedAt === undefined ||
typeof (i as Record<string, unknown>).completedAt === 'string')
)
.map((i) => ({
startedAt: i.startedAt,
completedAt: i.completedAt,
}))
: undefined;
const task: TeamTask = {
id:
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
@ -110,6 +125,7 @@ export class TeamTaskReader {
)
? (parsed.status as TeamTask['status'])
: 'pending',
workIntervals,
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined,
related: Array.isArray(parsed.related)
@ -119,15 +135,22 @@ export class TeamTaskReader {
updatedAt,
projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined,
comments: Array.isArray(parsed.comments)
? (parsed.comments as TaskComment[]).filter(
(c) =>
c &&
typeof c === 'object' &&
typeof c.id === 'string' &&
typeof c.author === 'string' &&
typeof c.text === 'string' &&
typeof c.createdAt === 'string'
)
? (parsed.comments as TaskComment[])
.filter(
(c) =>
c &&
typeof c === 'object' &&
typeof c.id === 'string' &&
typeof c.author === 'string' &&
typeof c.text === 'string' &&
typeof c.createdAt === 'string'
)
.map((c) => ({
...c,
type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type)
? c.type
: ('regular' as const),
}))
: undefined,
needsClarification: (['lead', 'user'] as const).includes(
parsed.needsClarification as 'lead' | 'user'

View file

@ -5,7 +5,7 @@ import * as path from 'path';
import { atomicWriteAsync } from './atomicWrite';
import type { TaskComment, TeamTask, TeamTaskStatus } from '@shared/types';
import type { TaskComment, TaskCommentType, TeamTask, TeamTaskStatus } from '@shared/types';
const taskWriteLocks = new Map<string, Promise<void>>();
@ -46,13 +46,23 @@ export class TeamTaskWriter {
// Ensure CLI-compatible format: description, blocks, blockedBy are required
// by Claude Code CLI's Zod schema validation (safeParse fails without them)
const createdAt = task.createdAt ?? new Date().toISOString();
const cliCompatibleTask: TeamTask = {
...task,
description: task.description ?? '',
blocks: task.blocks ?? [],
blockedBy: task.blockedBy ?? [],
related: task.related ?? [],
createdAt: task.createdAt ?? new Date().toISOString(),
createdAt,
workIntervals:
task.status === 'in_progress'
? // Start the first work interval on creation when task starts immediately.
[
...(Array.isArray(task.workIntervals) && task.workIntervals.length > 0
? task.workIntervals
: [{ startedAt: createdAt }]),
]
: task.workIntervals,
};
await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2));
@ -273,6 +283,29 @@ export class TeamTaskWriter {
}
const task = JSON.parse(raw) as TeamTask;
const prevStatus = task.status;
const nowIso = new Date().toISOString();
// Maintain workIntervals as periods of time where status === 'in_progress'.
const intervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
const last = intervals.length > 0 ? intervals[intervals.length - 1] : undefined;
const wasInProgress = prevStatus === 'in_progress';
const isInProgress = status === 'in_progress';
if (!wasInProgress && isInProgress) {
// Entering in_progress: open a new interval if none is open.
if (!last || typeof last.completedAt === 'string') {
intervals.push({ startedAt: nowIso });
}
} else if (wasInProgress && !isInProgress) {
// Leaving in_progress: close open interval if present.
if (last && last.completedAt === undefined) {
last.completedAt = nowIso;
}
}
task.workIntervals = intervals.length > 0 ? intervals : undefined;
task.status = status;
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
@ -323,8 +356,20 @@ export class TeamTaskWriter {
}
const task = JSON.parse(raw) as TeamTask;
const nowIso = new Date().toISOString();
// Ensure any open in_progress interval is closed on delete.
if (task.status === 'in_progress') {
const intervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
const last = intervals.length > 0 ? intervals[intervals.length - 1] : undefined;
if (last && last.completedAt === undefined) {
last.completedAt = nowIso;
}
task.workIntervals = intervals.length > 0 ? intervals : task.workIntervals;
}
task.status = 'deleted';
task.deletedAt = new Date().toISOString();
task.deletedAt = nowIso;
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
@ -417,7 +462,7 @@ export class TeamTaskWriter {
teamName: string,
taskId: string,
text: string,
options?: { id?: string; author?: string; createdAt?: string }
options?: { id?: string; author?: string; createdAt?: string; type?: TaskCommentType }
): Promise<TaskComment> {
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
const comment: TaskComment = {
@ -425,6 +470,7 @@ export class TeamTaskWriter {
author: options?.author ?? 'user',
text,
createdAt: options?.createdAt ?? new Date().toISOString(),
type: options?.type ?? 'regular',
};
await withTaskLock(taskPath, async () => {

View file

@ -461,6 +461,9 @@ export const EDITOR_WATCH_DIR = 'editor:watchDir';
/** Update list of watched file paths (open tabs) */
export const EDITOR_SET_WATCHED_FILES = 'editor:setWatchedFiles';
/** Update list of watched directories (shallow: depth=0) */
export const EDITOR_SET_WATCHED_DIRS = 'editor:setWatchedDirs';
/** Read binary file as base64 for inline preview */
export const EDITOR_READ_BINARY_PREVIEW = 'editor:readBinaryPreview';

View file

@ -24,6 +24,7 @@ import {
EDITOR_READ_FILE,
EDITOR_RENAME_FILE,
EDITOR_SEARCH_IN_FILES,
EDITOR_SET_WATCHED_DIRS,
EDITOR_SET_WATCHED_FILES,
EDITOR_WATCH_DIR,
EDITOR_WRITE_FILE,
@ -710,7 +711,12 @@ const electronAPI: ElectronAPI = {
getLogsForTask: async (
teamName: string,
taskId: string,
options?: { owner?: string; status?: string }
options?: {
owner?: string;
status?: string;
intervals?: { startedAt: string; completedAt?: string }[];
since?: string;
}
) => {
return invokeIpcWithResult<MemberLogSummary[]>(
TEAM_GET_LOGS_FOR_TASK,
@ -1035,6 +1041,8 @@ const electronAPI: ElectronAPI = {
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
setWatchedFiles: (filePaths: string[]) =>
invokeIpcWithResult<void>(EDITOR_SET_WATCHED_FILES, filePaths),
setWatchedDirs: (dirPaths: string[]) =>
invokeIpcWithResult<void>(EDITOR_SET_WATCHED_DIRS, dirPaths),
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void =>
callback(data);

View file

@ -991,6 +991,9 @@ export class HttpAPIClient implements ElectronAPI {
setWatchedFiles: async () => {
throw new Error('Editor not available in browser mode');
},
setWatchedDirs: async () => {
throw new Error('Editor not available in browser mode');
},
onEditorChange: () => {
return () => {};
},

View file

@ -23,7 +23,7 @@ import {
PROSE_TABLE_HEADER_BG,
} from '@renderer/constants/cssVariables';
import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
import { FileText } from 'lucide-react';
import remarkGfm from 'remark-gfm';
import { useShallow } from 'zustand/react/shallow';
@ -34,6 +34,8 @@ import {
type SearchContext,
} from '../searchHighlightUtils';
import { MermaidDiagram } from './MermaidDiagram';
// =============================================================================
// Types
// =============================================================================
@ -49,6 +51,95 @@ interface MarkdownViewerProps {
copyable?: boolean;
/** When true, renders without wrapper background/border (for embedding inside cards) */
bare?: boolean;
/** Base directory for resolving relative URLs (images, links) via local-resource:// protocol */
baseDir?: string;
}
// =============================================================================
// Helpers
// =============================================================================
/** Check if a URL is relative (not absolute, not data, not mailto, not hash) */
function isRelativeUrl(url: string): boolean {
return (
!!url &&
!url.startsWith('http://') &&
!url.startsWith('https://') &&
!url.startsWith('data:') &&
!url.startsWith('#') &&
!url.startsWith('mailto:')
);
}
/** Resolve a relative path to an absolute path given a base directory */
function resolveRelativePath(relativeSrc: string, baseDir: string): string {
const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc;
return `${baseDir}/${cleaned}`;
}
// =============================================================================
// LocalImage — loads images via IPC (readBinaryPreview) for local file access
// =============================================================================
interface LocalImageProps {
src: string;
alt?: string;
baseDir: string;
}
const LocalImage = React.memo(function LocalImage({
src,
alt,
baseDir,
}: LocalImageProps): React.ReactElement {
const [dataUrl, setDataUrl] = React.useState<string | null>(null);
const [error, setError] = React.useState(false);
React.useEffect(() => {
let cancelled = false;
setDataUrl(null);
setError(false);
const fullPath = resolveRelativePath(src, baseDir);
window.electronAPI.editor
.readBinaryPreview(fullPath)
.then((result) => {
if (!cancelled) {
setDataUrl(`data:${result.mimeType};base64,${result.base64}`);
}
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, [src, baseDir]);
if (error) {
return (
<span className="inline-flex items-center gap-1 text-xs text-text-muted">
[Image: {alt || src}]
</span>
);
}
if (!dataUrl) {
return (
<span className="inline-block size-4 animate-pulse rounded bg-surface-raised align-middle" />
);
}
return <img src={dataUrl} alt={alt || ''} className="my-2 max-w-full rounded" />;
});
/** Extract plain text from a hast (HTML AST) node tree */
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- hast node shape varies
function hastToText(node: any): string {
if (node.type === 'text') return node.value ?? '';
if (node.children) return node.children.map(hastToText).join('');
return '';
}
// =============================================================================
@ -180,18 +271,29 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
);
},
// Code blocks
pre: ({ children }) => (
<pre
className="my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
style={{
backgroundColor: PROSE_PRE_BG,
border: `1px solid ${PROSE_PRE_BORDER}`,
}}
>
{children}
</pre>
),
// Code blocks — intercept mermaid diagrams at the pre level
pre: ({ children, node }) => {
// Check if this pre contains a mermaid code block
const codeEl = node?.children?.[0];
if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) {
const cls = (codeEl.properties as Record<string, unknown>)?.className;
if (Array.isArray(cls) && cls.some((c) => String(c) === 'language-mermaid')) {
return <MermaidDiagram code={hastToText(codeEl)} />;
}
}
return (
<pre
className="my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
style={{
backgroundColor: PROSE_PRE_BG,
border: `1px solid ${PROSE_PRE_BORDER}`,
}}
>
{children}
</pre>
);
},
// Blockquotes
blockquote: ({ children }) => (
@ -288,6 +390,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
itemId,
copyable = false,
bare = false,
baseDir,
}) => {
const [showRaw, setShowRaw] = React.useState(false);
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
@ -435,7 +538,20 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
// Create markdown components with optional search highlighting
// When search is active, create fresh each render (match counter is stateful and must start at 0)
// useMemo would cache stale closures when parent re-renders without search deps changing
const components = searchCtx ? createViewerMarkdownComponents(searchCtx) : defaultComponents;
const baseComponents = searchCtx ? createViewerMarkdownComponents(searchCtx) : defaultComponents;
// When baseDir is set (editor preview), override img to load local files via IPC
const components = baseDir
? {
...baseComponents,
img: ({ src, alt }: { src?: string; alt?: string }) => {
if (src && isRelativeUrl(src)) {
return <LocalImage src={src} alt={alt} baseDir={baseDir} />;
}
return <img src={src} alt={alt || ''} className="my-2 max-w-full rounded" />;
},
}
: baseComponents;
return (
<div
@ -481,8 +597,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
<div className="min-w-0 break-words p-4">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={disableHighlight ? [] : REHYPE_PLUGINS}
urlTransform={(url) => url}
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
components={components}
>
{content}

View file

@ -0,0 +1,116 @@
/**
* Renders a Mermaid diagram from code string to SVG.
*
* Lazy-initializes mermaid once with dark theme.
* Each render call uses a unique ID to avoid collisions.
* SVG output is sanitized with DOMPurify before DOM insertion.
* Falls back to raw code display on parse errors.
*/
import React, { useEffect, useRef, useState } from 'react';
import { PROSE_PRE_BG, PROSE_PRE_BORDER } from '@renderer/constants/cssVariables';
import DOMPurify from 'dompurify';
import mermaid from 'mermaid';
// =============================================================================
// Mermaid initialization (once per app lifecycle)
// =============================================================================
let initialized = false;
function ensureMermaidInit(): void {
if (initialized) return;
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
themeVariables: {
darkMode: true,
background: 'transparent',
primaryColor: '#3b82f6',
primaryTextColor: '#fafafa',
primaryBorderColor: '#3b82f6',
lineColor: '#71717a',
secondaryColor: '#27272a',
tertiaryColor: '#1f1f23',
},
});
initialized = true;
}
// Monotonic counter for unique diagram IDs
let idCounter = 0;
// =============================================================================
// Component
// =============================================================================
interface MermaidDiagramProps {
code: string;
}
export const MermaidDiagram = React.memo(function MermaidDiagram({
code,
}: MermaidDiagramProps): React.ReactElement {
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!code.trim()) return;
ensureMermaidInit();
let cancelled = false;
const diagramId = `mermaid-${++idCounter}`;
mermaid
.render(diagramId, code.trim())
.then(({ svg }) => {
if (!cancelled && containerRef.current) {
const sanitized = DOMPurify.sanitize(svg, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ['foreignObject'],
});
containerRef.current.replaceChildren();
containerRef.current.insertAdjacentHTML('afterbegin', sanitized);
setError(null);
}
})
.catch((err: unknown) => {
if (!cancelled) {
const msg = err instanceof Error ? err.message : String(err);
setError(msg);
}
});
return () => {
cancelled = true;
};
}, [code]);
if (error) {
return (
<div
className="my-3 overflow-auto rounded-lg p-3 text-xs"
style={{
backgroundColor: PROSE_PRE_BG,
border: `1px solid ${PROSE_PRE_BORDER}`,
}}
>
<div className="mb-2 text-amber-400">Mermaid syntax error</div>
<pre className="whitespace-pre-wrap font-mono text-text-muted">{code}</pre>
</div>
);
}
return (
<div
ref={containerRef}
className="my-3 flex justify-center overflow-auto rounded-lg p-3"
style={{
backgroundColor: PROSE_PRE_BG,
border: `1px solid ${PROSE_PRE_BORDER}`,
}}
/>
);
});

View file

@ -42,7 +42,8 @@ export interface SafeConfig {
snoozedUntil: number | null;
snoozeMinutes: number;
includeSubagentErrors: boolean;
notifyOnInboxMessages: boolean;
notifyOnLeadInbox: boolean;
notifyOnUserInbox: boolean;
notifyOnClarifications: boolean;
triggers: AppConfig['notifications']['triggers'];
};
@ -171,7 +172,8 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null,
snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30,
includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? true,
notifyOnInboxMessages: displayConfig?.notifications?.notifyOnInboxMessages ?? true,
notifyOnLeadInbox: displayConfig?.notifications?.notifyOnLeadInbox ?? false,
notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true,
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
triggers: displayConfig?.notifications?.triggers ?? [],
},

View file

@ -287,7 +287,8 @@ export function useSettingsHandlers({
snoozedUntil: null,
snoozeMinutes: 30,
includeSubagentErrors: true,
notifyOnInboxMessages: true,
notifyOnLeadInbox: false,
notifyOnUserInbox: true,
notifyOnClarifications: true,
triggers: defaultTriggers,
},

View file

@ -36,7 +36,8 @@ interface NotificationsSectionProps {
| 'enabled'
| 'soundEnabled'
| 'includeSubagentErrors'
| 'notifyOnInboxMessages'
| 'notifyOnLeadInbox'
| 'notifyOnUserInbox'
| 'notifyOnClarifications',
value: boolean
) => void;
@ -136,12 +137,22 @@ export const NotificationsSection = ({
/>
</SettingRow>
<SettingRow
label="Team inbox notifications"
description="Show native OS notifications when teammates send messages to the lead"
label="Lead inbox notifications"
description="Notify when teammates send messages to the team lead"
>
<SettingsToggle
enabled={safeConfig.notifications.notifyOnInboxMessages}
onChange={(v) => onNotificationToggle('notifyOnInboxMessages', v)}
enabled={safeConfig.notifications.notifyOnLeadInbox}
onChange={(v) => onNotificationToggle('notifyOnLeadInbox', v)}
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>
<SettingRow
label="User inbox notifications"
description="Notify when teammates send messages to you"
>
<SettingsToggle
enabled={safeConfig.notifications.notifyOnUserInbox}
onChange={(v) => onNotificationToggle('notifyOnUserInbox', v)}
disabled={saving || !safeConfig.notifications.enabled}
/>
</SettingRow>

View file

@ -29,6 +29,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
import { getMemberColor } from '@shared/constants/memberColors';
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
import { MembersJsonEditor } from './MembersJsonEditor';
import { ProjectPathSelector } from './ProjectPathSelector';
@ -279,6 +280,7 @@ export const CreateTeamDialog = ({
const [launchTeam, setLaunchTeam] = useState(true);
const [teamColor, setTeamColor] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const [extendedContext, setExtendedContext] = useState(false);
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
const [jsonText, setJsonText] = useState('');
const [jsonError, setJsonError] = useState<string | null>(null);
@ -303,6 +305,7 @@ export const CreateTeamDialog = ({
setCustomCwd('');
setLaunchTeam(true);
setSelectedModel('');
setExtendedContext(false);
setJsonEditorOpen(false);
setJsonText('');
setJsonError(null);
@ -537,8 +540,13 @@ export const CreateTeamDialog = ({
[members]
);
const effectiveModel =
selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
const effectiveModel = useMemo(() => {
const base = selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
if (!extendedContext) return base;
// 1M context is only supported for opus and sonnet
if (base === 'haiku') return base;
return base ? `${base}[1m]` : 'sonnet[1m]';
}, [selectedModel, extendedContext]);
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
@ -623,6 +631,7 @@ export const CreateTeamDialog = ({
description: request.description,
color: request.color,
members: request.members,
cwd: effectiveCwd || undefined,
});
onOpenTeam(request.teamName, effectiveCwd || undefined);
resetFormState();
@ -909,19 +918,27 @@ export const CreateTeamDialog = ({
/>
</div>
<div className="space-y-1.5">
<Label className="label-optional">Model (optional)</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Default (account setting)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (account setting)</SelectItem>
<SelectItem value="opus">Opus 4.6</SelectItem>
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
<SelectItem value="haiku">Haiku 4.5</SelectItem>
</SelectContent>
</Select>
<div>
<div className="flex items-center gap-2.5">
<Label className="label-optional shrink-0">Model (optional)</Label>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 w-auto min-w-[180px] text-xs">
<SelectValue placeholder="Default (account setting)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">Default (account setting)</SelectItem>
<SelectItem value="opus">Opus 4.6</SelectItem>
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
<SelectItem value="haiku">Haiku 4.5</SelectItem>
</SelectContent>
</Select>
</div>
<ExtendedContextCheckbox
id="create-extended-context"
checked={extendedContext}
onCheckedChange={setExtendedContext}
disabled={selectedModel === 'haiku'}
/>
</div>
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (

View file

@ -0,0 +1,66 @@
import React from 'react';
import { Checkbox } from '@renderer/components/ui/checkbox';
import { Label } from '@renderer/components/ui/label';
import { AlertTriangle } from 'lucide-react';
interface ExtendedContextCheckboxProps {
id: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
}
export const ExtendedContextCheckbox: React.FC<ExtendedContextCheckboxProps> = ({
id,
checked,
onCheckedChange,
disabled = false,
}) => (
<>
<div className="mt-2 flex items-center gap-2">
<Checkbox
id={id}
checked={checked && !disabled}
disabled={disabled}
onCheckedChange={(value) => onCheckedChange(value === true)}
/>
<Label
htmlFor={id}
className={`flex cursor-pointer items-center gap-1.5 text-xs font-normal ${
disabled ? 'cursor-not-allowed text-text-muted opacity-50' : 'text-text-secondary'
}`}
>
Extended context (1M tokens)
{disabled && <span className="text-[10px] italic">(not available for this model)</span>}
</Label>
</div>
{checked && (
<div className="mt-1.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-amber-400" />
<div className="space-y-1 text-amber-300/90">
<p>
Beyond 200K tokens, premium pricing applies: 2x input cost, 1.5x output cost. For
subscribers, extra usage is billed separately.
</p>
<p>
Requires API tier 4+ or extra usage enabled.{' '}
<button
type="button"
className="underline underline-offset-2 hover:text-amber-200"
onClick={() =>
window.electronAPI.openExternal(
'https://platform.claude.com/docs/en/build-with-claude/context-windows#1m-token-context-window'
)
}
>
Learn more
</button>
</p>
</div>
</div>
</div>
)}
</>
);

View file

@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox';
import { Button } from '@renderer/components/ui/button';
import { Checkbox } from '@renderer/components/ui/checkbox';
import {
@ -66,6 +67,7 @@ export const LaunchTeamDialog = ({
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedModel, setSelectedModel] = useState('');
const [extendedContext, setExtendedContext] = useState(false);
const [clearContext, setClearContext] = useState(false);
const resetFormState = (): void => {
@ -78,6 +80,7 @@ export const LaunchTeamDialog = ({
setSelectedProjectPath('');
setCustomCwd('');
setSelectedModel('');
setExtendedContext(false);
setClearContext(false);
};
@ -240,7 +243,12 @@ export const LaunchTeamDialog = ({
teamName,
cwd: effectiveCwd,
prompt: promptDraft.value.trim() || undefined,
model: selectedModel || undefined,
model: (() => {
if (!extendedContext) return selectedModel || undefined;
// 1M context is only supported for opus and sonnet
if (selectedModel === 'haiku') return selectedModel;
return selectedModel ? `${selectedModel}[1m]` : 'sonnet[1m]';
})(),
clearContext: clearContext || undefined,
});
resetFormState();
@ -353,30 +361,38 @@ export const LaunchTeamDialog = ({
/>
</div>
<div className="space-y-1.5">
<Label className="label-optional">Model (optional)</Label>
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{[
{ value: '', label: 'Default' },
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'sonnet', label: 'Sonnet 4.5' },
{ value: 'haiku', label: 'Haiku 4.5' },
].map((opt) => (
<button
key={opt.value}
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
selectedModel === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => setSelectedModel(opt.value)}
>
{opt.label}
</button>
))}
<div>
<div className="flex items-center gap-2.5">
<Label className="label-optional shrink-0">Model (optional)</Label>
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
{[
{ value: '', label: 'Default' },
{ value: 'opus', label: 'Opus 4.6' },
{ value: 'sonnet', label: 'Sonnet 4.5' },
{ value: 'haiku', label: 'Haiku 4.5' },
].map((opt) => (
<button
key={opt.value}
type="button"
className={cn(
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
selectedModel === opt.value
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
)}
onClick={() => setSelectedModel(opt.value)}
>
{opt.label}
</button>
))}
</div>
</div>
<ExtendedContextCheckbox
id="launch-extended-context"
checked={extendedContext}
onCheckedChange={setExtendedContext}
disabled={selectedModel === 'haiku'}
/>
</div>
<div className="space-y-2">

View file

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import {
@ -30,6 +30,9 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { X } from 'lucide-react';
import { MarkdownViewer } from '../../chat/viewers/MarkdownViewer';
import { MemberBadge } from '../MemberBadge';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
@ -72,6 +75,7 @@ export const SendMessageDialog = ({
}: SendMessageDialogProps): React.JSX.Element => {
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
const [quoteExpanded, setQuoteExpanded] = useState(false);
const [member, setMember] = useState('');
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
const chipDraft = useChipDraftPersistence('sendMessage:chips');
@ -84,6 +88,7 @@ export const SendMessageDialog = ({
setMember(defaultRecipient ?? '');
setSummary('');
setQuote(quotedMessage);
setQuoteExpanded(false);
setPrevResult(lastResult);
if (defaultChip) {
const token = chipToken(defaultChip);
@ -117,6 +122,9 @@ export const SendMessageDialog = ({
// eslint-disable-next-line react-hooks/exhaustive-deps -- only trigger on pendingAutoClose flag
}, [pendingAutoClose]);
const QUOTE_COLLAPSE_THRESHOLD = 120;
const isQuoteLong = (quote?.text.length ?? 0) > QUOTE_COLLAPSE_THRESHOLD;
const mentionSuggestions = useMemo<MentionSuggestion[]>(
() =>
members.map((m) => ({
@ -204,47 +212,71 @@ export const SendMessageDialog = ({
</Select>
</div>
{quote ? (
<div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
onClick={() => setQuote(undefined)}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Remove quote</TooltipContent>
</Tooltip>
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]">
Replying to @{quote.from}
</span>
<p className="line-clamp-3 pr-5 text-xs text-[var(--color-text-muted)]">
{quote.text}
</p>
</div>
) : null}
<div className="grid gap-2">
<Label htmlFor="smd-message">Message</Label>
<MentionableTextarea
id="smd-message"
placeholder="Write your message..."
value={textDraft.value}
onValueChange={textDraft.setValue}
suggestions={mentionSuggestions}
chips={chipDraft.chips}
onChipRemove={handleChipRemove}
minRows={4}
maxRows={12}
footerRight={
textDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
<div className={quote ? 'flex flex-col' : 'contents'}>
{quote ? (
<div className="relative overflow-hidden rounded-t-md border border-b-0 border-blue-500/20 bg-blue-950/20 py-2 pl-3 pr-2">
{/* Decorative quotation mark */}
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[64px] leading-none text-blue-400/[0.08]">
&ldquo;
</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute right-1.5 top-1.5 z-10 rounded p-0.5 text-blue-300/40 hover:text-blue-200"
onClick={() => setQuote(undefined)}
>
<X size={12} />
</button>
</TooltipTrigger>
<TooltipContent side="left">Remove quote</TooltipContent>
</Tooltip>
<div className="mb-1 flex items-center gap-1.5">
<span className="text-[10px] text-blue-300/60">Replying to</span>
<MemberBadge name={quote.from} color={colorMap.get(quote.from)} size="sm" />
</div>
<div
className={`pr-5 opacity-50 ${quoteExpanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}
>
<MarkdownViewer
content={quote.text}
bare
maxHeight={quoteExpanded ? 'max-h-48' : 'max-h-[3.75rem]'}
/>
</div>
{isQuoteLong ? (
<button
type="button"
className="mt-0.5 text-[10px] text-blue-400/60 hover:text-blue-300"
onClick={() => setQuoteExpanded((v) => !v)}
>
{quoteExpanded ? 'less' : 'more'}
</button>
) : null}
</div>
) : null}
<MentionableTextarea
id="smd-message"
className={quote ? 'rounded-t-none' : undefined}
placeholder="Write your message..."
value={textDraft.value}
onValueChange={textDraft.setValue}
suggestions={mentionSuggestions}
chips={chipDraft.chips}
onChipRemove={handleChipRemove}
minRows={4}
maxRows={12}
footerRight={
textDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
}
/>
</div>
</div>
<div className="grid gap-2">

View file

@ -14,7 +14,16 @@ import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns';
import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react';
import {
CheckCircle2,
ChevronDown,
ChevronUp,
MessageCircleWarning,
MessageSquare,
Reply,
Send,
X,
} from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
@ -145,8 +154,23 @@ export const TaskCommentsSection = ({
) : null}
{visibleComments.map((comment) => (
<div key={comment.id} className="group p-2.5">
<div
key={comment.id}
className={`group rounded-md p-2.5 ${
comment.type === 'review_approved'
? 'bg-emerald-500/8 border border-emerald-500/15'
: comment.type === 'review_request'
? 'bg-amber-500/8 border border-amber-500/15'
: ''
}`}
>
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{comment.type === 'review_approved' && (
<CheckCircle2 size={12} className="shrink-0 text-emerald-400" />
)}
{comment.type === 'review_request' && (
<MessageCircleWarning size={12} className="shrink-0 text-amber-400" />
)}
<span
className="font-medium"
style={{
@ -158,6 +182,16 @@ export const TaskCommentsSection = ({
>
{comment.author}
</span>
{comment.type === 'review_approved' && (
<span className="rounded-full bg-emerald-500/15 px-1.5 py-px text-[9px] font-medium text-emerald-400">
Approved
</span>
)}
{comment.type === 'review_request' && (
<span className="rounded-full bg-amber-500/15 px-1.5 py-px text-[9px] font-medium text-amber-400">
Changes requested
</span>
)}
<span>
{(() => {
const date = new Date(comment.createdAt);

View file

@ -598,6 +598,7 @@ export const TaskDetailDialog = ({
taskId={currentTask.id}
taskOwner={currentTask.owner}
taskStatus={currentTask.status}
taskWorkIntervals={currentTask.workIntervals}
/>
</div>
</CollapsibleTeamSection>

View file

@ -9,7 +9,13 @@
import { useCallback, useEffect, useRef } from 'react';
import { defaultKeymap, history, historyKeymap, redo, undo } from '@codemirror/commands';
import { bracketMatching, indentOnInput, syntaxHighlighting } from '@codemirror/language';
import {
bracketMatching,
foldGutter,
foldKeymap,
indentOnInput,
syntaxHighlighting,
} from '@codemirror/language';
import { gotoLine, search, searchKeymap } from '@codemirror/search';
import { Compartment, EditorState } from '@codemirror/state';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
@ -67,6 +73,8 @@ interface CodeMirrorEditorProps {
onDraftRecovered?: (filePath: string) => void;
/** Called when text selection changes (for floating action menu) */
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
/** Called with the current document text on changes (debounced, for live preview) */
onDocChange?: (content: string) => void;
}
// =============================================================================
@ -95,6 +103,7 @@ function buildEditableExtensions(
highlightActiveLineGutter(),
bracketMatching(),
indentOnInput(),
foldGutter(),
// History
history(),
@ -129,6 +138,7 @@ function buildEditableExtensions(
...defaultKeymap,
...historyKeymap,
...searchKeymap.filter((k) => k.run !== gotoLine),
...foldKeymap,
]),
// Update listener for dirty flag + cursor position + selection
@ -220,6 +230,8 @@ function enforceDraftLimit(): void {
// Component
// =============================================================================
const DOC_CHANGE_DEBOUNCE_MS = 150;
export const CodeMirrorEditor = ({
filePath,
content,
@ -228,6 +240,7 @@ export const CodeMirrorEditor = ({
onCursorChange,
onDraftRecovered,
onSelectionChange,
onDocChange,
}: CodeMirrorEditorProps): React.ReactElement => {
const containerRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<EditorView | null>(null);
@ -241,6 +254,8 @@ export const CodeMirrorEditor = ({
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Selection debounce
const selectionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Doc change debounce (live preview)
const docChangeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const markFileModified = useStore((s) => s.markFileModified);
const discardChanges = useStore((s) => s.discardChanges);
@ -260,6 +275,9 @@ export const CodeMirrorEditor = ({
const onSelectionChangeRef = useRef(onSelectionChange);
onSelectionChangeRef.current = onSelectionChange;
const onDocChangeRef = useRef(onDocChange);
onDocChangeRef.current = onDocChange;
const lineWrapRef = useRef(lineWrap);
lineWrapRef.current = lineWrap;
@ -282,6 +300,13 @@ export const CodeMirrorEditor = ({
saveDraft(filePathRef.current, view.state.doc.toString());
}
}, AUTOSAVE_DELAY_MS);
// Live content callback for markdown preview
if (docChangeTimerRef.current) clearTimeout(docChangeTimerRef.current);
docChangeTimerRef.current = setTimeout(() => {
const view = viewRef.current;
if (view) onDocChangeRef.current?.(view.state.doc.toString());
}, DOC_CHANGE_DEBOUNCE_MS);
}, [markFileModified]);
const handleCursorMove = useCallback((line: number, col: number) => {
@ -421,6 +446,7 @@ export const CodeMirrorEditor = ({
const dirtyTimer = dirtyTimerRef;
const autosaveTimer = autosaveTimerRef;
const selectionTimer = selectionTimerRef;
const docChangeTimer = docChangeTimerRef;
return () => {
// Save scroll position before destroying
@ -433,6 +459,7 @@ export const CodeMirrorEditor = ({
if (dirtyTimer.current) clearTimeout(dirtyTimer.current);
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
if (selectionTimer.current) clearTimeout(selectionTimer.current);
if (docChangeTimer.current) clearTimeout(docChangeTimer.current);
view.destroy();
viewRef.current = null;
@ -475,5 +502,5 @@ export const CodeMirrorEditor = ({
};
}, []);
return <div ref={containerRef} className="size-full overflow-auto" />;
return <div ref={containerRef} className="size-full overflow-hidden" />;
};

View file

@ -64,6 +64,13 @@ const SHORTCUT_GROUPS: { title: string; shortcuts: ShortcutDef[] }[] = [
{ mac: '⌘ /', other: 'Ctrl+/', description: 'Toggle Comment' },
],
},
{
title: 'Markdown',
shortcuts: [
{ mac: '⌘ ⇧ M', other: 'Ctrl+Shift+M', description: 'Split Preview' },
{ mac: '⌘ ⇧ V', other: 'Ctrl+Shift+V', description: 'Full Preview' },
],
},
{
title: 'General',
shortcuts: [{ mac: 'Esc', other: 'Esc', description: 'Close Editor' }],

View file

@ -10,14 +10,32 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
import { useStore } from '@renderer/store';
import { editorBridge } from '@renderer/utils/editorBridge';
import { shortcutLabel } from '@renderer/utils/platformKeys';
import { Redo2, Save, Undo2, WrapText } from 'lucide-react';
import { Columns2, Eye, Redo2, Save, Undo2, WrapText } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
// =============================================================================
// Types
// =============================================================================
export type MdPreviewMode = 'off' | 'split' | 'preview';
// =============================================================================
// Component
// =============================================================================
export const EditorToolbar = (): React.ReactElement | null => {
interface EditorToolbarProps {
isMarkdown?: boolean;
mdPreviewMode?: MdPreviewMode;
onToggleSplit?: () => void;
onToggleFullPreview?: () => void;
}
export const EditorToolbar = ({
isMarkdown = false,
mdPreviewMode = 'off',
onToggleSplit,
onToggleFullPreview,
}: EditorToolbarProps): React.ReactElement | null => {
const { activeTabId, modifiedFiles, saving, lineWrap } = useStore(
useShallow((s) => ({
activeTabId: s.editorActiveTabId,
@ -77,6 +95,25 @@ export const EditorToolbar = (): React.ReactElement | null => {
onClick={toggleLineWrap}
active={lineWrap}
/>
{isMarkdown && (
<>
<div className="mx-1 h-4 w-px bg-border" />
<ToolbarButton
icon={<Columns2 className="size-3.5" />}
label={mdPreviewMode === 'split' ? 'Close split preview' : 'Split preview'}
shortcut={shortcutLabel('⌘ ⇧ M', 'Ctrl+Shift+M')}
onClick={onToggleSplit ?? (() => {})}
active={mdPreviewMode === 'split'}
/>
<ToolbarButton
icon={<Eye className="size-3.5" />}
label={mdPreviewMode === 'preview' ? 'Close preview' : 'Full preview'}
shortcut={shortcutLabel('⌘ ⇧ V', 'Ctrl+Shift+V')}
onClick={onToggleFullPreview ?? (() => {})}
active={mdPreviewMode === 'preview'}
/>
</>
)}
</div>
);
};

View file

@ -0,0 +1,53 @@
/**
* Scrollable markdown preview pane for the editor split view.
*
* Wraps MarkdownViewer in a scrollable container with ref access
* for external scroll synchronization (code preview).
*/
import React from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
// =============================================================================
// Types
// =============================================================================
interface MarkdownPreviewPaneProps {
content: string;
className?: string;
scrollRef?: React.RefObject<HTMLDivElement | null>;
onScroll?: () => void;
/** Base directory for resolving relative image/link URLs */
baseDir?: string;
}
// =============================================================================
// Component
// =============================================================================
export const MarkdownPreviewPane = React.memo(function MarkdownPreviewPane({
content,
className = '',
scrollRef,
onScroll,
baseDir,
}: MarkdownPreviewPaneProps): React.ReactElement {
// Callback ref to wire scrollRef (RefObject<T | null>) to the div
const setRef = React.useCallback(
(el: HTMLDivElement | null) => {
if (scrollRef && 'current' in scrollRef) {
(scrollRef as React.MutableRefObject<HTMLDivElement | null>).current = el;
}
},
[scrollRef]
);
return (
<div ref={setRef} className={`h-full overflow-y-auto ${className}`} onScroll={onScroll}>
<div className="p-4">
<MarkdownViewer content={content} bare maxHeight="" baseDir={baseDir} />
</div>
</div>
);
});

View file

@ -0,0 +1,128 @@
/**
* Right-side panel for markdown split/preview mode.
*
* In split mode: renders a drag-resizable handle + MarkdownPreviewPane.
* In preview mode: renders MarkdownPreviewPane at full width (no handle).
*
* CodeMirrorEditor is NOT rendered here it stays in ProjectEditorOverlay
* and is controlled via CSS display/width.
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useMarkdownScrollSync } from '@renderer/hooks/useMarkdownScrollSync';
import { MarkdownPreviewPane } from './MarkdownPreviewPane';
// =============================================================================
// Types
// =============================================================================
interface MarkdownSplitViewProps {
content: string;
mode: 'split' | 'preview';
splitRatio: number;
onSplitRatioChange: (ratio: number) => void;
/** Key that changes when the EditorView changes (e.g. activeTabId) — triggers scroll re-attach */
viewKey?: string | null;
/** Base directory for resolving relative image/link URLs */
baseDir?: string;
}
// =============================================================================
// Constants
// =============================================================================
const MIN_RATIO = 0.2;
const MAX_RATIO = 0.8;
const HANDLE_WIDTH = 4; // px
// =============================================================================
// Component
// =============================================================================
export const MarkdownSplitView = React.memo(function MarkdownSplitView({
content,
mode,
splitRatio,
onSplitRatioChange,
viewKey,
baseDir,
}: MarkdownSplitViewProps): React.ReactElement {
const [isResizing, setIsResizing] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Scroll sync auto-manages its own listener lifecycle via viewKey
const scrollSync = useMarkdownScrollSync(mode === 'split', viewKey);
// --- Resize drag logic ---
const handleMouseMove = useCallback(
(e: MouseEvent) => {
const parent = containerRef.current?.parentElement;
if (!parent) return;
const parentRect = parent.getBoundingClientRect();
const relativeX = e.clientX - parentRect.left;
const newRatio = Math.min(MAX_RATIO, Math.max(MIN_RATIO, relativeX / parentRect.width));
onSplitRatioChange(newRatio);
},
[onSplitRatioChange]
);
const handleMouseUp = useCallback(() => {
setIsResizing(false);
}, []);
useEffect(() => {
if (!isResizing) return;
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
}, [isResizing, handleMouseMove, handleMouseUp]);
const handleMouseDown = (e: React.MouseEvent): void => {
e.preventDefault();
setIsResizing(true);
};
// --- Preview width ---
const previewWidth =
mode === 'preview' ? '100%' : `calc(${(1 - splitRatio) * 100}% - ${HANDLE_WIDTH}px)`;
return (
<div ref={containerRef} className="flex h-full" style={{ width: previewWidth }}>
{/* Resize handle — only in split mode */}
{mode === 'split' && (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions -- resize handle
<div
className={`shrink-0 cursor-col-resize border-x border-border transition-colors ${
isResizing ? 'bg-blue-500/50' : 'hover:bg-blue-500/30'
}`}
style={{ width: HANDLE_WIDTH }}
onMouseDown={handleMouseDown}
/>
)}
{/* Preview pane */}
<div className="flex-1 overflow-hidden bg-surface">
<MarkdownPreviewPane
content={content}
scrollRef={scrollSync.previewScrollRef}
onScroll={scrollSync.handlePreviewScroll}
baseDir={baseDir}
/>
</div>
</div>
);
});

View file

@ -45,9 +45,11 @@ import { EditorStatusBar } from './EditorStatusBar';
import { EditorTabBar } from './EditorTabBar';
import { EditorToolbar } from './EditorToolbar';
import { GoToLineDialog } from './GoToLineDialog';
import { MarkdownSplitView } from './MarkdownSplitView';
import { QuickOpenDialog } from './QuickOpenDialog';
import { SearchInFilesPanel } from './SearchInFilesPanel';
import type { MdPreviewMode } from './EditorToolbar';
import type {
EditorSelectionAction,
EditorSelectionInfo,
@ -121,6 +123,18 @@ export const ProjectEditorOverlay = ({
const editorContentRef = useRef<HTMLDivElement>(null);
const [containerRect, setContainerRect] = useState<DOMRect>(() => new DOMRect());
// Markdown preview state
const [mdPreviewMode, setMdPreviewMode] = useState<MdPreviewMode>('off');
const [liveContent, setLiveContent] = useState('');
const [splitRatio, setSplitRatio] = useState(() => {
try {
const stored = localStorage.getItem('editor:mdSplitRatio');
return stored ? Math.max(0.2, Math.min(0.8, Number(stored))) : 0.5;
} catch {
return 0.5;
}
});
// Iter-4: New state
const [quickOpenVisible, setQuickOpenVisible] = useState(false);
const [searchPanelVisible, setSearchPanelVisible] = useState(false);
@ -141,6 +155,48 @@ export const ProjectEditorOverlay = ({
// Active tab metadata
const activeTab = openTabs.find((t) => t.id === activeTabId) ?? null;
const isMarkdown = activeTab?.language === 'Markdown';
// Auto-enable split preview for markdown tabs, reset for non-markdown
useEffect(() => {
if (isMarkdown) {
setMdPreviewMode((m) => (m === 'off' ? 'split' : m));
} else {
setMdPreviewMode('off');
}
}, [isMarkdown, activeTabId]);
// Persist split ratio
const handleSplitRatioChange = useCallback((ratio: number) => {
setSplitRatio(ratio);
try {
localStorage.setItem('editor:mdSplitRatio', String(ratio));
} catch {
// localStorage unavailable
}
}, []);
const handleLiveContent = useCallback((content: string) => {
setLiveContent(content);
}, []);
const toggleMdSplit = useCallback(() => {
setMdPreviewMode((m) => (m === 'split' ? 'off' : 'split'));
}, []);
const toggleMdPreview = useCallback(() => {
setMdPreviewMode((m) => (m === 'preview' ? 'off' : 'preview'));
}, []);
// Initialize live content when entering preview mode or switching files
useEffect(() => {
if (mdPreviewMode !== 'off' && fileContent?.content) {
setLiveContent(fileContent.content);
}
}, [mdPreviewMode, fileContent?.content]);
// Content for preview: use live content when available, fallback to file content
const previewContent = liveContent || fileContent?.content || '';
const loadFileContent = useCallback(
async (filePath: string) => {
@ -444,6 +500,8 @@ export const ProjectEditorOverlay = ({
onToggleGoToLine: toggleGoToLine,
onToggleSidebar: toggleSidebar,
onClose: handleCloseRequest,
onToggleMdSplit: isMarkdown ? toggleMdSplit : undefined,
onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined,
});
const projectName = projectPath.split('/').pop() ?? projectPath;
@ -580,7 +638,12 @@ export const ProjectEditorOverlay = ({
<EditorTabBar onRequestCloseTab={handleRequestCloseTab} />
{/* Toolbar */}
<EditorToolbar />
<EditorToolbar
isMarkdown={isMarkdown}
mdPreviewMode={mdPreviewMode}
onToggleSplit={toggleMdSplit}
onToggleFullPreview={toggleMdPreview}
/>
{/* Draft recovery banner */}
{draftRecoveredFile && activeTabId === draftRecoveredFile && (
@ -678,18 +741,42 @@ export const ProjectEditorOverlay = ({
)}
{fileContent && !fileContent.isBinary && activeTabId && (
<EditorErrorBoundary filePath={activeTabId} onRetry={handleRetry}>
<CodeMirrorEditor
key={`${activeTabId}-${editorResetKey}`}
filePath={activeTabId}
content={fileContent.content}
fileName={activeTabId.split('/').pop() ?? 'file'}
mtimeMs={fileContent.mtimeMs}
onCursorChange={handleCursorChange}
onDraftRecovered={handleDraftRecovered}
onSelectionChange={setSelectionInfo}
/>
</EditorErrorBoundary>
<div className="flex h-full">
{/* Code editor — always mounted, hidden via display:none in preview mode */}
<div
className="h-full overflow-hidden"
style={{
display: mdPreviewMode === 'preview' ? 'none' : 'block',
width: mdPreviewMode === 'split' ? `${splitRatio * 100}%` : '100%',
}}
>
<EditorErrorBoundary filePath={activeTabId} onRetry={handleRetry}>
<CodeMirrorEditor
key={`${activeTabId}-${editorResetKey}`}
filePath={activeTabId}
content={fileContent.content}
fileName={activeTabId.split('/').pop() ?? 'file'}
mtimeMs={fileContent.mtimeMs}
onCursorChange={handleCursorChange}
onDraftRecovered={handleDraftRecovered}
onSelectionChange={setSelectionInfo}
onDocChange={mdPreviewMode !== 'off' ? handleLiveContent : undefined}
/>
</EditorErrorBoundary>
</div>
{/* Resize handle + Preview pane */}
{mdPreviewMode !== 'off' && (
<MarkdownSplitView
content={previewContent}
mode={mdPreviewMode}
splitRatio={splitRatio}
onSplitRatioChange={handleSplitRatioChange}
viewKey={activeTabId}
baseDir={activeTabId?.substring(0, activeTabId.lastIndexOf('/'))}
/>
)}
</div>
)}
{!fileLoading && !fileError && !fileContent && !activeTabId && <EditorEmptyState />}

View file

@ -32,7 +32,13 @@ export const MemberList = ({
onAssignTask,
onOpenTask,
}: MemberListProps): React.JSX.Element => {
const activeMembers = members.filter((m) => !m.removedAt);
const activeMembers = members
.filter((m) => !m.removedAt)
.sort((a, b) => {
if (a.agentType === 'team-lead') return -1;
if (b.agentType === 'team-lead') return 1;
return 0;
});
const removedMembers = members.filter((m) => m.removedAt);
const colorMap = buildMemberColorMap(members);

View file

@ -24,6 +24,8 @@ interface MemberLogsTabProps {
/** When viewing task logs: include owner's sessions when task is in_progress */
taskOwner?: string;
taskStatus?: string;
/** Persisted work intervals for filtering owner sessions (avoid unrelated tasks) */
taskWorkIntervals?: { startedAt: string; completedAt?: string }[];
}
export const MemberLogsTab = ({
@ -32,6 +34,7 @@ export const MemberLogsTab = ({
taskId,
taskOwner,
taskStatus,
taskWorkIntervals,
}: MemberLogsTabProps): React.JSX.Element => {
const [logs, setLogs] = useState<MemberLogSummary[]>([]);
const [loading, setLoading] = useState(true);
@ -61,6 +64,7 @@ export const MemberLogsTab = ({
? await api.teams.getLogsForTask(teamName, taskId, {
owner: taskOwner,
status: taskStatus,
intervals: taskWorkIntervals,
})
: await api.teams.getMemberLogs(teamName, memberName!);
if (!cancelled) {
@ -86,7 +90,7 @@ export const MemberLogsTab = ({
cancelled = true;
if (interval) clearInterval(interval);
};
}, [teamName, memberName, taskId, taskOwner, taskStatus]);
}, [teamName, memberName, taskId, taskOwner, taskStatus, taskWorkIntervals]);
const handleExpand = useCallback(
async (log: MemberLogSummary) => {

View file

@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { indentUnit, syntaxHighlighting } from '@codemirror/language';
import { foldGutter, foldKeymap, indentUnit, syntaxHighlighting } from '@codemirror/language';
import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge';
import { Compartment, EditorState, type Extension } from '@codemirror/state';
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
@ -395,6 +395,7 @@ export const CodeMirrorDiffView = ({
...(isEffectivelyEmptyOriginal ? [emptyOriginalOverrideTheme] : []),
lineNumbers(),
syntaxHighlighting(oneDarkHighlightStyle),
foldGutter(),
EditorView.editable.of(!readOnly),
EditorState.readOnly.of(readOnly),
];
@ -405,7 +406,9 @@ export const CodeMirrorDiffView = ({
extensions.push(mergeUndoSupport);
extensions.push(mirrorEditsAfterResolve);
extensions.push(indentUnit.of(' '));
extensions.push(keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]));
extensions.push(
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...foldKeymap])
);
}
// Language placeholder — actual language injected async via compartment reconfigure
@ -426,6 +429,7 @@ export const CodeMirrorDiffView = ({
key: 'Ctrl-Alt-ArrowUp',
run: goToPreviousChunk,
},
...foldKeymap,
])
);

View file

@ -25,6 +25,8 @@ interface UseEditorKeyboardShortcutsOptions {
onToggleGoToLine: () => void;
onToggleSidebar: () => void;
onClose: () => void;
onToggleMdSplit?: () => void;
onToggleMdPreview?: () => void;
}
/** Dependencies injected into the key handler for testability. */
@ -40,6 +42,8 @@ export interface EditorKeyHandlerDeps {
onToggleGoToLine: () => void;
onToggleSidebar: () => void;
onToggleLineWrap: () => void;
onToggleMdSplit?: () => void;
onToggleMdPreview?: () => void;
getEditorView: () => { dispatch: unknown } | null;
}
@ -108,6 +112,22 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
return;
}
// Cmd+Shift+M: Toggle markdown split preview
if (key === 'm' && e.shiftKey) {
e.preventDefault();
e.stopPropagation();
deps.onToggleMdSplit?.();
return;
}
// Cmd+Shift+V: Toggle markdown full preview
if (key === 'v' && e.shiftKey) {
e.preventDefault();
e.stopPropagation();
deps.onToggleMdPreview?.();
return;
}
// Cmd+Shift+W: Toggle line wrap
if (key === 'w' && e.shiftKey && !e.altKey) {
e.preventDefault();
@ -190,6 +210,8 @@ export function useEditorKeyboardShortcuts({
onToggleGoToLine,
onToggleSidebar,
onClose: _onClose,
onToggleMdSplit,
onToggleMdPreview,
}: UseEditorKeyboardShortcutsOptions): void {
const { openTabs, activeTabId } = useStore(
useShallow((s) => ({
@ -217,6 +239,8 @@ export function useEditorKeyboardShortcuts({
onToggleGoToLine,
onToggleSidebar,
onToggleLineWrap: toggleLineWrap,
onToggleMdSplit,
onToggleMdPreview,
getEditorView: () => editorBridge.getView(),
};

View file

@ -0,0 +1,147 @@
/**
* Proportional scroll synchronization between CodeMirror and a preview pane.
*
* Uses the fraction-based approach: fraction = scrollTop / (scrollHeight - clientHeight).
* Feedback loop prevention via ref-based ignore flags reset with requestAnimationFrame.
*
* The hook auto-attaches/detaches the CodeMirror scroll listener internally:
* - Retry logic handles CodeMirror mount delay (up to 500ms)
* - `viewKey` triggers re-attachment when the EditorView changes (e.g. file switch)
* - Full cleanup on disable/unmount
*/
import { useCallback, useEffect, useRef } from 'react';
import { editorBridge } from '@renderer/utils/editorBridge';
// =============================================================================
// Constants
// =============================================================================
/** Max attempts to find CodeMirror scrollDOM before giving up */
const MAX_ATTACH_ATTEMPTS = 10;
/** Interval between retry attempts (ms) */
const ATTACH_RETRY_INTERVAL = 50;
// =============================================================================
// Types
// =============================================================================
export interface UseMarkdownScrollSyncResult {
previewScrollRef: React.RefObject<HTMLDivElement | null>;
/** Attach to preview div's onScroll */
handlePreviewScroll: () => void;
}
// =============================================================================
// Hook
// =============================================================================
/**
* Bidirectional scroll sync between CodeMirror and a preview pane.
*
* @param enabled - Whether sync is active (typically `mode === 'split'`)
* @param viewKey - Changes when the underlying EditorView changes (e.g. `activeTabId`).
* Triggers re-attachment of the code scroll listener.
*/
export function useMarkdownScrollSync(
enabled: boolean,
viewKey?: string | null
): UseMarkdownScrollSyncResult {
const previewScrollRef = useRef<HTMLDivElement | null>(null);
const ignoreCodeScroll = useRef(false);
const ignorePreviewScroll = useRef(false);
const codeRafRef = useRef(0);
const previewRafRef = useRef(0);
// Code → Preview: proportional scroll
const handleCodeScroll = useCallback(() => {
if (!enabled) return;
if (ignoreCodeScroll.current) {
ignoreCodeScroll.current = false;
return;
}
const scrollDOM = editorBridge.getView()?.scrollDOM;
const preview = previewScrollRef.current;
if (!scrollDOM || !preview) return;
const maxCode = scrollDOM.scrollHeight - scrollDOM.clientHeight;
if (maxCode <= 0) return;
const fraction = scrollDOM.scrollTop / maxCode;
const maxPreview = preview.scrollHeight - preview.clientHeight;
if (maxPreview <= 0) return;
cancelAnimationFrame(previewRafRef.current);
previewRafRef.current = requestAnimationFrame(() => {
ignorePreviewScroll.current = true;
preview.scrollTop = fraction * maxPreview;
});
}, [enabled]);
// Preview → Code: proportional scroll
const handlePreviewScroll = useCallback(() => {
if (!enabled) return;
if (ignorePreviewScroll.current) {
ignorePreviewScroll.current = false;
return;
}
const scrollDOM = editorBridge.getView()?.scrollDOM;
const preview = previewScrollRef.current;
if (!scrollDOM || !preview) return;
const maxPreview = preview.scrollHeight - preview.clientHeight;
if (maxPreview <= 0) return;
const fraction = preview.scrollTop / maxPreview;
const maxCode = scrollDOM.scrollHeight - scrollDOM.clientHeight;
if (maxCode <= 0) return;
cancelAnimationFrame(codeRafRef.current);
codeRafRef.current = requestAnimationFrame(() => {
ignoreCodeScroll.current = true;
scrollDOM.scrollTop = fraction * maxCode;
});
}, [enabled]);
// Auto-attach code scroll listener with retry on mount/viewKey change
useEffect(() => {
if (!enabled) return;
let scrollCleanup: (() => void) | undefined;
let retryTimer: ReturnType<typeof setTimeout>;
let attempts = 0;
const tryAttach = (): void => {
const scrollDOM = editorBridge.getView()?.scrollDOM;
if (!scrollDOM) {
if (attempts < MAX_ATTACH_ATTEMPTS) {
attempts++;
retryTimer = setTimeout(tryAttach, ATTACH_RETRY_INTERVAL);
}
return;
}
scrollDOM.addEventListener('scroll', handleCodeScroll, { passive: true });
scrollCleanup = () => {
scrollDOM.removeEventListener('scroll', handleCodeScroll);
};
};
tryAttach();
return () => {
clearTimeout(retryTimer);
scrollCleanup?.();
cancelAnimationFrame(codeRafRef.current);
cancelAnimationFrame(previewRafRef.current);
};
}, [enabled, viewKey, handleCodeScroll]);
return {
previewScrollRef,
handlePreviewScroll,
};
}

View file

@ -67,26 +67,58 @@ let watcherEventCounts: Record<EditorFileChangeEvent['type'], number> = {
let watchedFilesSyncTimer: ReturnType<typeof setTimeout> | null = null;
let lastWatchedFilesKey = '';
let watchedDirsSyncTimer: ReturnType<typeof setTimeout> | null = null;
let lastWatchedDirsKey = '';
const WATCHED_DIRS_DEBOUNCE_MS = 250;
const MAX_WATCHED_DIRS = 120;
function scheduleSyncWatchedFiles(get: () => AppState): void {
if (!api.editor) return;
// Editor watcher is Electron-only. In browser mode, api.editor exists but throws.
if (!window.electronAPI?.editor) return;
const state = get();
if (!state.editorWatcherEnabled) return;
if (!state.editorProjectPath) return;
const projectPath = state.editorProjectPath;
if (!projectPath) return;
const filePaths = state.editorOpenTabs.map((t) => t.filePath).filter(Boolean);
filePaths.sort();
const key = filePaths.join('\n');
const key = `${projectPath}\n${filePaths.join('\n')}`;
if (key === lastWatchedFilesKey) return;
lastWatchedFilesKey = key;
if (watchedFilesSyncTimer) clearTimeout(watchedFilesSyncTimer);
watchedFilesSyncTimer = setTimeout(() => {
watchedFilesSyncTimer = null;
void api.editor.setWatchedFiles(filePaths);
void window.electronAPI.editor.setWatchedFiles(filePaths);
}, 150);
}
function scheduleSyncWatchedDirs(get: () => AppState): void {
if (!window.electronAPI?.editor) return;
const state = get();
if (!state.editorWatcherEnabled) return;
const projectPath = state.editorProjectPath;
if (!projectPath) return;
const expanded = Object.entries(state.editorExpandedDirs)
.filter(([, v]) => v === true)
.map(([k]) => k);
// Always include root (depth=0), plus expanded folders (depth=0).
// Cap to protect chokidar from too many watched paths if user expands a lot.
const dirs = [projectPath, ...expanded].slice(0, MAX_WATCHED_DIRS);
dirs.sort();
const key = `${projectPath}\n${dirs.join('\n')}`;
if (key === lastWatchedDirsKey) return;
lastWatchedDirsKey = key;
if (watchedDirsSyncTimer) clearTimeout(watchedDirsSyncTimer);
watchedDirsSyncTimer = setTimeout(() => {
watchedDirsSyncTimer = null;
void window.electronAPI.editor.setWatchedDirs(dirs);
}, WATCHED_DIRS_DEBOUNCE_MS);
}
/**
* Open request sequence for editor initialization.
* Cancels stale async work (notably React 18 StrictMode dev effect mount/unmount).
@ -103,11 +135,16 @@ const MOVE_COOLDOWN_MS = 2000;
function scheduleIdleWork(cb: () => void): void {
// Prefer requestIdleCallback when available; fall back to a short timeout.
// This keeps editor open responsive for large repos.
// timeout ensures the callback fires within 2s even if the event loop is busy
// (without it, requestIdleCallback can be delayed indefinitely).
try {
const ric = (window as unknown as { requestIdleCallback?: (fn: () => void) => number })
.requestIdleCallback;
const ric = (
window as unknown as {
requestIdleCallback?: (fn: () => void, opts?: { timeout: number }) => number;
}
).requestIdleCallback;
if (typeof ric === 'function') {
ric(cb);
ric(cb, { timeout: 2000 });
return;
}
} catch {
@ -330,8 +367,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
scheduleIdleWork(() => {
if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return;
// TODO: temporarily disabled file watcher — re-enable when stabilized
if (watcherDesired) void get().toggleWatcher(false);
if (watcherDesired) void get().toggleWatcher(true);
// Defer initial git status a bit more — it can be expensive on large repos.
setTimeout(() => {
if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return;
@ -355,6 +391,18 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
closeEditor: () => {
// Cancel any in-flight openEditor async work
editorOpenSeq++;
// Cancel any pending watcher sync (avoid calling into main after close)
if (watchedFilesSyncTimer) {
clearTimeout(watchedFilesSyncTimer);
watchedFilesSyncTimer = null;
}
if (watchedDirsSyncTimer) {
clearTimeout(watchedDirsSyncTimer);
watchedDirsSyncTimer = null;
}
lastWatchedFilesKey = '';
lastWatchedDirsKey = '';
// Clear cooldown timestamps (no stale entries across editor sessions)
recentSaveTimestamps.clear();
recentMoveTimestamps.clear();
@ -428,6 +476,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
set({
editorExpandedDirs: { ...editorExpandedDirs, [dirPath]: true },
});
scheduleSyncWatchedDirs(get);
}
try {
@ -456,6 +505,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
collapseDirectory: (dirPath: string) => {
const { editorExpandedDirs } = get();
set({ editorExpandedDirs: omitKey(editorExpandedDirs, dirPath) });
scheduleSyncWatchedDirs(get);
},
// ═══════════════════════════════════════════════════════
@ -1027,9 +1077,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}
if (enable) {
scheduleSyncWatchedFiles(get);
scheduleSyncWatchedDirs(get);
} else {
// Ensure main process stops watching files promptly.
lastWatchedFilesKey = '';
lastWatchedDirsKey = '';
void api.editor.setWatchedFiles([]);
void api.editor.setWatchedDirs([]);
}
} catch (error) {
log.error('Failed to toggle watcher:', error);

View file

@ -14,6 +14,7 @@ export const baseEditorTheme = EditorView.theme({
color: 'var(--color-text)',
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontSize: '13px',
height: '100%',
},
'&.cm-focused': {
outline: 'none',

View file

@ -1,8 +1,15 @@
/**
* Rehype plugins for markdown rendering (used with react-markdown).
* Rehype runs after remark; rehype-highlight adds syntax highlighting to code blocks.
*
* - rehype-raw: parse and render inline HTML in markdown
* - rehype-highlight: syntax highlighting for code blocks
*/
import rehypeHighlight from 'rehype-highlight';
import rehypeRaw from 'rehype-raw';
export const REHYPE_PLUGINS = [rehypeHighlight];
/** Full plugin chain: raw HTML + syntax highlighting */
export const REHYPE_PLUGINS = [rehypeRaw, rehypeHighlight];
/** Lightweight chain: raw HTML only (used when highlighting is disabled for large content) */
export const REHYPE_PLUGINS_NO_HIGHLIGHT = [rehypeRaw];

View file

@ -426,7 +426,14 @@ export interface TeamsAPI {
getLogsForTask: (
teamName: string,
taskId: string,
options?: { owner?: string; status?: string }
options?: {
owner?: string;
status?: string;
/** Persisted work intervals (preferred for reliable owner-log attribution). */
intervals?: { startedAt: string; completedAt?: string }[];
/** Back-compat: single since timestamp (deprecated). */
since?: string;
}
) => Promise<MemberLogSummary[]>;
getMemberStats: (teamName: string, memberName: string) => Promise<MemberFullStats>;
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;

View file

@ -201,6 +201,12 @@ export interface EditorAPI {
* Intended as a performance optimization: avoids watching the whole project tree.
*/
setWatchedFiles: (filePaths: string[]) => Promise<void>;
/**
* Provide the list of directories to watch shallowly (depth=0).
* Intended to keep the explorer tree in sync with external changes without
* recursively watching the whole project.
*/
setWatchedDirs: (dirPaths: string[]) => Promise<void>;
/** Subscribe to file change events (main → renderer). Returns cleanup function. */
onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void;
}

View file

@ -247,8 +247,10 @@ export interface AppConfig {
snoozeMinutes: number;
/** Whether to include errors from subagent sessions */
includeSubagentErrors: boolean;
/** Whether to show native OS notifications for team inbox messages */
notifyOnInboxMessages: boolean;
/** Whether to show native OS notifications when teammates send messages to the team lead */
notifyOnLeadInbox: boolean;
/** Whether to show native OS notifications when teammates send messages to you (the user) */
notifyOnUserInbox: boolean;
/** Whether to show native OS notifications when a task needs user clarification */
notifyOnClarifications: boolean;
/** Notification triggers - define when to generate notifications */

View file

@ -55,11 +55,21 @@ export interface TeamSummary {
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
export interface TaskWorkInterval {
/** ISO timestamp when task entered in_progress */
startedAt: string;
/** ISO timestamp when task left in_progress (optional for active interval) */
completedAt?: string;
}
export type TaskCommentType = 'regular' | 'review_request' | 'review_approved';
export interface TaskComment {
id: string;
author: string;
text: string;
createdAt: string;
type: TaskCommentType;
}
// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record<keyof TeamTask, unknown>`.
@ -72,6 +82,11 @@ export interface TeamTask {
owner?: string;
createdBy?: string;
status: TeamTaskStatus;
/**
* One task can be worked on in multiple disjoint periods (e.g. review sends it back to in_progress).
* We persist intervals for reliable log attribution without relying on heuristics.
*/
workIntervals?: TaskWorkInterval[];
blocks?: string[];
blockedBy?: string[];
/**
@ -271,6 +286,7 @@ export interface TeamCreateConfigRequest {
description?: string;
color?: string;
members: TeamProvisioningMemberInput[];
cwd?: string;
}
export interface TeamCreateResponse {

View file

@ -45,6 +45,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
EDITOR_GIT_STATUS: 'editor:gitStatus',
EDITOR_WATCH_DIR: 'editor:watchDir',
EDITOR_SET_WATCHED_FILES: 'editor:setWatchedFiles',
EDITOR_SET_WATCHED_DIRS: 'editor:setWatchedDirs',
EDITOR_CHANGE: 'editor:change',
}));
@ -148,8 +149,8 @@ describe('Editor IPC handlers', () => {
});
describe('registration', () => {
it('registers all 16 editor channels', () => {
expect(mockIpc.handle).toHaveBeenCalledTimes(16);
it('registers all 17 editor channels', () => {
expect(mockIpc.handle).toHaveBeenCalledTimes(17);
expect(mockIpc._handlers.has('editor:open')).toBe(true);
expect(mockIpc._handlers.has('editor:close')).toBe(true);
expect(mockIpc._handlers.has('editor:readDir')).toBe(true);
@ -166,11 +167,12 @@ describe('Editor IPC handlers', () => {
expect(mockIpc._handlers.has('editor:gitStatus')).toBe(true);
expect(mockIpc._handlers.has('editor:watchDir')).toBe(true);
expect(mockIpc._handlers.has('editor:setWatchedFiles')).toBe(true);
expect(mockIpc._handlers.has('editor:setWatchedDirs')).toBe(true);
});
it('removeEditorHandlers clears all channels', () => {
removeEditorHandlers(mockIpc as unknown as IpcMain);
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(16);
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(17);
});
});

View file

@ -173,6 +173,22 @@ describe('EditorFileWatcher', () => {
});
});
describe('setWatchedFiles before start', () => {
it('returns silently when watcher not initialized', () => {
// Should NOT throw — graceful no-op when projectRoot is null
expect(() => watcher.setWatchedFiles(['/some/file.ts'])).not.toThrow();
expect(watch).not.toHaveBeenCalled();
});
});
describe('setWatchedDirs before start', () => {
it('returns silently when watcher not initialized', () => {
// Should NOT throw — graceful no-op when projectRoot is null
expect(() => watcher.setWatchedDirs(['/some/dir'])).not.toThrow();
expect(watch).not.toHaveBeenCalled();
});
});
describe('isWatching', () => {
it('returns false when not started', () => {
expect(watcher.isWatching()).toBe(false);

View file

@ -245,4 +245,186 @@ describe('TeamMemberLogsFinder', () => {
// Full file has 200 messages — must NOT be capped at 50 or 100
expect(carolLogs[0]?.messageCount).toBe(200);
});
it('findLogsForTask does not treat arbitrary "#<id>" as a task reference', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-logs-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 't4';
const projectPath = '/Users/test/proj4';
const projectId = '-Users-test-proj4';
const leadSessionId = 's4';
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'config.json'),
JSON.stringify({
name: teamName,
projectPath,
leadSessionId,
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'bob', agentType: 'general-purpose' },
],
}),
'utf8'
);
const projectRoot = path.join(tmpDir, 'projects', projectId);
await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true });
// Lead session mentions "PR #1" but NOT a task reference
await fs.writeFile(
path.join(projectRoot, `${leadSessionId}.jsonl`),
[
JSON.stringify({
timestamp: '2026-01-01T00:00:00.000Z',
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'Fix PR #1 please' }] },
}),
].join('\n') + '\n',
'utf8'
);
// Subagent session includes a structured taskId reference (should match)
await fs.writeFile(
path.join(projectRoot, leadSessionId, 'subagents', 'agent-abc111.jsonl'),
[
JSON.stringify({
timestamp: '2026-01-01T00:00:01.000Z',
type: 'user',
message: { role: 'user', content: 'You are bob, a developer on team "t4" (t4).' },
}),
JSON.stringify({
timestamp: '2026-01-01T00:00:02.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: 'TaskUpdate',
input: { taskId: '1', status: 'in_progress' },
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
const finder = new TeamMemberLogsFinder();
const logs = await finder.findLogsForTask(teamName, '1');
// Should include the subagent log, but must NOT include the lead session just because it had "PR #1"
expect(logs.some((l) => l.kind === 'lead_session')).toBe(false);
expect(logs.some((l) => l.kind === 'subagent')).toBe(true);
});
it('findLogsForTask includes only owner sessions overlapping workIntervals', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-owner-since-'));
setClaudeBasePathOverride(tmpDir);
const teamName = 't5';
const projectPath = '/Users/test/proj5';
const projectId = '-Users-test-proj5';
const leadSessionId = 's5';
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
await fs.writeFile(
path.join(tmpDir, 'teams', teamName, 'config.json'),
JSON.stringify({
name: teamName,
projectPath,
leadSessionId,
members: [
{ name: 'team-lead', agentType: 'team-lead' },
{ name: 'bob', agentType: 'general-purpose' },
{ name: 'alice', agentType: 'general-purpose' },
],
}),
'utf8'
);
const projectRoot = path.join(tmpDir, 'projects', projectId);
await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true });
// Alice file references taskId 10 via structured tool input (so results is non-empty).
await fs.writeFile(
path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice10.jsonl'),
[
JSON.stringify({
timestamp: '2026-01-01T00:00:01.000Z',
type: 'user',
message: { role: 'user', content: 'You are alice, a developer on team "t5" (t5).' },
}),
JSON.stringify({
timestamp: '2026-01-01T00:00:02.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{ type: 'tool_use', name: 'TaskUpdate', input: { taskId: '10', status: 'pending' } },
],
},
}),
].join('\n') + '\n',
'utf8'
);
// Bob has an old session (should NOT be pulled in by owner include).
await fs.writeFile(
path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-old.jsonl'),
[
JSON.stringify({
timestamp: '2025-12-31T00:00:00.000Z',
type: 'user',
message: { role: 'user', content: 'You are bob, a developer on team "t5" (t5).' },
}),
JSON.stringify({
timestamp: '2025-12-31T00:00:01.000Z',
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'Old work' }] },
}),
].join('\n') + '\n',
'utf8'
);
// Bob has a recent session within workIntervals (should be included).
await fs.writeFile(
path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-new.jsonl'),
[
JSON.stringify({
timestamp: '2026-01-01T12:00:00.000Z',
type: 'user',
message: { role: 'user', content: 'You are bob, a developer on team "t5" (t5).' },
}),
JSON.stringify({
timestamp: '2026-01-01T12:00:01.000Z',
type: 'assistant',
message: { role: 'assistant', content: [{ type: 'text', text: 'New work' }] },
}),
].join('\n') + '\n',
'utf8'
);
const finder = new TeamMemberLogsFinder();
const logs = await finder.findLogsForTask(teamName, '10', {
owner: 'bob',
status: 'in_progress',
intervals: [
{ startedAt: '2026-01-01T10:00:00.000Z', completedAt: '2026-01-01T13:00:00.000Z' },
],
});
const bobDescriptions = logs
.filter((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob')
.map((l) => l.description);
expect(bobDescriptions.some((d) => d.includes('Old'))).toBe(false);
// At least one bob log should be present (the recent one).
expect(logs.some((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob')).toBe(
true
);
});
});

View file

@ -717,7 +717,7 @@ describe('teamctl.js', () => {
});
});
it('adds a comment with valid ID and timestamp', () => {
it('adds a comment with valid ID, timestamp, and type=regular', () => {
const { stdout, exitCode } = run(claudeDir, [
'task',
'comment',
@ -735,6 +735,7 @@ describe('teamctl.js', () => {
expect(comments).toHaveLength(1);
expect(comments[0].text).toBe('Hello world');
expect(comments[0].author).toBe('alice');
expect(comments[0].type).toBe('regular');
expect(String(comments[0].id)).toMatch(UUID_RE);
expect(String(comments[0].createdAt)).toMatch(ISO_RE);
});
@ -753,7 +754,7 @@ describe('teamctl.js', () => {
expect(readInbox(claudeDir, 'bob').length).toBe(1); // still 1
});
it('multiple comments accumulate with unique IDs', () => {
it('multiple comments accumulate with unique IDs and type=regular', () => {
run(claudeDir, ['task', 'comment', '1', '--text', 'First', '--from', 'alice']);
run(claudeDir, ['task', 'comment', '1', '--text', 'Second', '--from', 'bob']);
run(claudeDir, ['task', 'comment', '1', '--text', 'Third', '--from', 'alice']);
@ -763,6 +764,7 @@ describe('teamctl.js', () => {
expect(comments.map((c) => c.text)).toEqual(['First', 'Second', 'Third']);
expect(comments.map((c) => c.author)).toEqual(['alice', 'bob', 'alice']);
expect(new Set(comments.map((c) => c.id)).size).toBe(3);
expect(comments.every((c) => c.type === 'regular')).toBe(true);
});
it('comment on task without comments array initializes it', () => {
@ -1283,6 +1285,83 @@ describe('teamctl.js', () => {
expect(exitCode).not.toBe(0);
expect(stderr).toContain('Usage');
});
it('approve records review_approved comment in task.comments', () => {
run(claudeDir, ['review', 'approve', '1', '--from', 'alice']);
const task = readTask(claudeDir, '1');
const comments = task.comments as Record<string, unknown>[];
expect(comments).toHaveLength(1);
expect(comments[0].type).toBe('review_approved');
expect(comments[0].author).toBe('alice');
expect(comments[0].text).toBe('Approved');
expect(String(comments[0].id)).toMatch(UUID_RE);
expect(String(comments[0].createdAt)).toMatch(ISO_RE);
});
it('approve records review_approved comment with --note text', () => {
run(claudeDir, [
'review',
'approve',
'1',
'--notify-owner',
'--from',
'alice',
'--note',
'Looks great!',
]);
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
expect(comments).toHaveLength(1);
expect(comments[0].type).toBe('review_approved');
expect(comments[0].text).toBe('Looks great!');
});
it('request-changes records review_request comment in task.comments', () => {
run(claudeDir, [
'review',
'request-changes',
'1',
'--comment',
'Fix the edge case',
'--from',
'alice',
]);
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
expect(comments).toHaveLength(1);
expect(comments[0].type).toBe('review_request');
expect(comments[0].author).toBe('alice');
expect(comments[0].text).toBe('Fix the edge case');
expect(String(comments[0].id)).toMatch(UUID_RE);
expect(String(comments[0].createdAt)).toMatch(ISO_RE);
});
it('request-changes without --comment records default text as review_request', () => {
run(claudeDir, ['review', 'request-changes', '1', '--from', 'alice']);
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
expect(comments).toHaveLength(1);
expect(comments[0].type).toBe('review_request');
expect(comments[0].text).toBe('Reviewer requested changes.');
});
it('review comments preserve existing task comments', () => {
// Add a regular comment first
run(claudeDir, ['task', 'comment', '1', '--text', 'Working on it', '--from', 'bob']);
// Then request changes
run(claudeDir, [
'review',
'request-changes',
'1',
'--comment',
'Needs tests',
'--from',
'alice',
]);
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
expect(comments).toHaveLength(2);
expect(comments[0].type).toBe('regular');
expect(comments[0].text).toBe('Working on it');
expect(comments[1].type).toBe('review_request');
expect(comments[1].text).toBe('Needs tests');
});
});
// =========================================================================
@ -1824,8 +1903,8 @@ describe('teamctl.js', () => {
expect(comments[0].text).toBe('Hello');
});
// --- reviewApprove without --notify-owner creates NO inbox ---
it('review approve without --notify-owner does NOT create inbox', () => {
// --- reviewApprove without --notify-owner creates NO inbox but DOES record comment ---
it('review approve without --notify-owner does NOT create inbox but records comment', () => {
writeTask(claudeDir, '1', {
id: '1',
subject: 'Feature',
@ -1835,10 +1914,14 @@ describe('teamctl.js', () => {
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
run(claudeDir, ['review', 'approve', '1']); // no --notify-owner
expect(readInbox(claudeDir, 'bob')).toEqual([]);
// Comment is still recorded
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
expect(comments).toHaveLength(1);
expect(comments[0].type).toBe('review_approved');
});
// --- request-changes: verify ALL three side effects ---
it('review request-changes: kanban cleared + status in_progress + inbox sent', () => {
// --- request-changes: verify ALL four side effects ---
it('review request-changes: kanban cleared + status in_progress + comment recorded + inbox sent', () => {
writeTask(claudeDir, '1', {
id: '1',
subject: 'PR',
@ -1859,7 +1942,13 @@ describe('teamctl.js', () => {
expect((readKanban(claudeDir).tasks as Record<string, unknown>)['1']).toBeUndefined();
// 2) Status changed to in_progress
expect(readTask(claudeDir, '1').status).toBe('in_progress');
// 3) Inbox message sent
// 3) Review comment recorded
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
expect(comments).toHaveLength(1);
expect(comments[0].type).toBe('review_request');
expect(comments[0].author).toBe('alice');
expect(comments[0].text).toBe('Missing tests');
// 4) Inbox message sent
const inbox = readInbox(claudeDir, 'bob') as Record<string, unknown>[];
expect(inbox).toHaveLength(1);
expect(inbox[0].from).toBe('alice');
@ -2249,8 +2338,8 @@ describe('teamctl.js', () => {
expect(after[0].label).toBe('server-1');
});
// --- review approve also writes to kanban (column=approved) ---
it('review approve sets kanban column to approved with movedAt', () => {
// --- review approve also writes to kanban (column=approved) + comment ---
it('review approve sets kanban column to approved with movedAt and records comment', () => {
writeTask(claudeDir, '1', {
id: '1',
subject: 'PR task',
@ -2262,6 +2351,10 @@ describe('teamctl.js', () => {
const entry = (readKanban(claudeDir).tasks as Record<string, Record<string, unknown>>)['1'];
expect(entry.column).toBe('approved');
expect(String(entry.movedAt)).toMatch(ISO_RE);
// Review comment recorded
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
expect(comments).toHaveLength(1);
expect(comments[0].type).toBe('review_approved');
});
// --- Task create without --description defaults to subject ---