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:
parent
51df8847a9
commit
6aec33ae33
49 changed files with 2942 additions and 207 deletions
|
|
@ -110,6 +110,7 @@
|
||||||
"cmdk": "1.0.4",
|
"cmdk": "1.0.4",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
|
"dompurify": "^3.3.1",
|
||||||
"electron-updater": "^6.7.3",
|
"electron-updater": "^6.7.3",
|
||||||
"fastify": "^5.7.4",
|
"fastify": "^5.7.4",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
|
@ -117,12 +118,14 @@
|
||||||
"isbinaryfile": "^6.0.0",
|
"isbinaryfile": "^6.0.0",
|
||||||
"lucide-react": "^0.562.0",
|
"lucide-react": "^0.562.0",
|
||||||
"mdast-util-to-hast": "^13.2.1",
|
"mdast-util-to-hast": "^13.2.1",
|
||||||
|
"mermaid": "^11.12.3",
|
||||||
"node-diff3": "^3.2.0",
|
"node-diff3": "^3.2.0",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"remark-parse": "^11.0.0",
|
"remark-parse": "^11.0.0",
|
||||||
"simple-git": "^3.32.3",
|
"simple-git": "^3.32.3",
|
||||||
|
|
|
||||||
1031
pnpm-lock.yaml
1031
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
|
|
@ -182,19 +182,23 @@ function extractNotificationContent(text: string): { summary: string; body: stri
|
||||||
}
|
}
|
||||||
|
|
||||||
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
|
async function notifyNewInboxMessages(teamName: string, detail: string): Promise<void> {
|
||||||
// Check config toggle
|
// Check global toggle
|
||||||
const config = configManager.getConfig();
|
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
|
// detail is like "inboxes/carol.json" — extract member name
|
||||||
const match = /^inboxes\/(.+)\.json$/.exec(detail);
|
const match = /^inboxes\/(.+)\.json$/.exec(detail);
|
||||||
if (!match) return;
|
if (!match) return;
|
||||||
const memberName = match[1];
|
const memberName = match[1];
|
||||||
|
|
||||||
// Only notify for the lead's inbox (messages addressed to the human user).
|
// Determine inbox type and check per-inbox toggle
|
||||||
// CLI doesn't set msg.to, so we filter by inbox file name instead.
|
|
||||||
const leadName = teamDataService ? await teamDataService.getLeadMemberName(teamName) : null;
|
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}`;
|
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.
|
// These don't go through inbox files — they're held in-memory by TeamProvisioningService.
|
||||||
if (detail === 'lead-process-reply' || detail === 'lead-direct-reply') {
|
if (detail === 'lead-process-reply' || detail === 'lead-direct-reply') {
|
||||||
const cfg = configManager.getConfig();
|
const cfg = configManager.getConfig();
|
||||||
if (cfg.notifications.enabled && cfg.notifications.notifyOnInboxMessages) {
|
if (cfg.notifications.enabled && cfg.notifications.notifyOnUserInbox) {
|
||||||
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
|
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
|
||||||
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||||
// Only notify for messages addressed to the human user, skip noise
|
// Only notify for messages addressed to the human user, skip noise
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
EDITOR_READ_FILE,
|
EDITOR_READ_FILE,
|
||||||
EDITOR_RENAME_FILE,
|
EDITOR_RENAME_FILE,
|
||||||
EDITOR_SEARCH_IN_FILES,
|
EDITOR_SEARCH_IN_FILES,
|
||||||
|
EDITOR_SET_WATCHED_DIRS,
|
||||||
EDITOR_SET_WATCHED_FILES,
|
EDITOR_SET_WATCHED_FILES,
|
||||||
EDITOR_WATCH_DIR,
|
EDITOR_WATCH_DIR,
|
||||||
EDITOR_WRITE_FILE,
|
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
|
// Registration
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -399,6 +413,7 @@ export function registerEditorHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus);
|
ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus);
|
||||||
ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir);
|
ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir);
|
||||||
ipcMain.handle(EDITOR_SET_WATCHED_FILES, handleEditorSetWatchedFiles);
|
ipcMain.handle(EDITOR_SET_WATCHED_FILES, handleEditorSetWatchedFiles);
|
||||||
|
ipcMain.handle(EDITOR_SET_WATCHED_DIRS, handleEditorSetWatchedDirs);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeEditorHandlers(ipcMain: IpcMain): void {
|
export function removeEditorHandlers(ipcMain: IpcMain): void {
|
||||||
|
|
@ -418,6 +433,7 @@ export function removeEditorHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(EDITOR_GIT_STATUS);
|
ipcMain.removeHandler(EDITOR_GIT_STATUS);
|
||||||
ipcMain.removeHandler(EDITOR_WATCH_DIR);
|
ipcMain.removeHandler(EDITOR_WATCH_DIR);
|
||||||
ipcMain.removeHandler(EDITOR_SET_WATCHED_FILES);
|
ipcMain.removeHandler(EDITOR_SET_WATCHED_FILES);
|
||||||
|
ipcMain.removeHandler(EDITOR_SET_WATCHED_DIRS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1313,6 +1313,14 @@ async function handleCreateConfig(
|
||||||
if (payload.color !== undefined && typeof payload.color !== 'string') {
|
if (payload.color !== undefined && typeof payload.color !== 'string') {
|
||||||
return { success: false, error: 'color must be a 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 seenNames = new Set<string>();
|
||||||
const members: TeamCreateConfigRequest['members'] = [];
|
const members: TeamCreateConfigRequest['members'] = [];
|
||||||
|
|
@ -1344,6 +1352,7 @@ async function handleCreateConfig(
|
||||||
description: payload.description?.trim() || undefined,
|
description: payload.description?.trim() || undefined,
|
||||||
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
|
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
|
||||||
members,
|
members,
|
||||||
|
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1377,7 +1386,12 @@ async function handleGetLogsForTask(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown,
|
teamName: unknown,
|
||||||
taskId: unknown,
|
taskId: unknown,
|
||||||
options?: { owner?: string; status?: string }
|
options?: {
|
||||||
|
owner?: string;
|
||||||
|
status?: string;
|
||||||
|
intervals?: { startedAt: string; completedAt?: string }[];
|
||||||
|
since?: string;
|
||||||
|
}
|
||||||
): Promise<IpcResult<MemberLogSummary[]>> {
|
): Promise<IpcResult<MemberLogSummary[]>> {
|
||||||
const vTeam = validateTeamName(teamName);
|
const vTeam = validateTeamName(teamName);
|
||||||
if (!vTeam.valid) {
|
if (!vTeam.valid) {
|
||||||
|
|
@ -1392,6 +1406,17 @@ async function handleGetLogsForTask(
|
||||||
? {
|
? {
|
||||||
owner: typeof options.owner === 'string' ? options.owner : undefined,
|
owner: typeof options.owner === 'string' ? options.owner : undefined,
|
||||||
status: typeof options.status === 'string' ? options.status : 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;
|
: undefined;
|
||||||
return wrapTeamHandler('getLogsForTask', () =>
|
return wrapTeamHandler('getLogsForTask', () =>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ const MAX_EMITTED_EVENTS_PER_FLUSH = 300;
|
||||||
|
|
||||||
export class EditorFileWatcher {
|
export class EditorFileWatcher {
|
||||||
private watcher: FSWatcher | null = null;
|
private watcher: FSWatcher | null = null;
|
||||||
|
private dirWatcher: FSWatcher | null = null;
|
||||||
private projectRoot: string | null = null;
|
private projectRoot: string | null = null;
|
||||||
private pendingEvents = new Map<string, EditorFileChangeEvent['type']>();
|
private pendingEvents = new Map<string, EditorFileChangeEvent['type']>();
|
||||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
@ -39,6 +40,7 @@ export class EditorFileWatcher {
|
||||||
private readonly DEBOUNCE_MS = 350;
|
private readonly DEBOUNCE_MS = 350;
|
||||||
private ignoreChangeUntilMs = 0;
|
private ignoreChangeUntilMs = 0;
|
||||||
private watchedFilesKey = '';
|
private watchedFilesKey = '';
|
||||||
|
private watchedDirsKey = '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize watcher context for a project root.
|
* Initialize watcher context for a project root.
|
||||||
|
|
@ -51,6 +53,7 @@ export class EditorFileWatcher {
|
||||||
this.projectRoot = projectRoot;
|
this.projectRoot = projectRoot;
|
||||||
this.ignoreChangeUntilMs = Date.now() + STARTUP_IGNORE_CHANGE_MS;
|
this.ignoreChangeUntilMs = Date.now() + STARTUP_IGNORE_CHANGE_MS;
|
||||||
this.watchedFilesKey = '';
|
this.watchedFilesKey = '';
|
||||||
|
this.watchedDirsKey = '';
|
||||||
|
|
||||||
log.info('Starting file watcher (open files only) for:', projectRoot);
|
log.info('Starting file watcher (open files only) for:', projectRoot);
|
||||||
this.onChangeCallback = onChange;
|
this.onChangeCallback = onChange;
|
||||||
|
|
@ -62,7 +65,7 @@ export class EditorFileWatcher {
|
||||||
*/
|
*/
|
||||||
setWatchedFiles(filePaths: string[]): void {
|
setWatchedFiles(filePaths: string[]): void {
|
||||||
if (!this.projectRoot) {
|
if (!this.projectRoot) {
|
||||||
throw new Error('Watcher not initialized');
|
return; // Watcher not initialized yet — will sync when start() is called
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = filePaths
|
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.
|
* Stop watching. Safe to call multiple times.
|
||||||
*/
|
*/
|
||||||
|
|
@ -125,11 +182,17 @@ export class EditorFileWatcher {
|
||||||
this.onChangeCallback = null;
|
this.onChangeCallback = null;
|
||||||
this.ignoreChangeUntilMs = 0;
|
this.ignoreChangeUntilMs = 0;
|
||||||
this.watchedFilesKey = '';
|
this.watchedFilesKey = '';
|
||||||
|
this.watchedDirsKey = '';
|
||||||
if (this.watcher) {
|
if (this.watcher) {
|
||||||
log.info('Stopping file watcher');
|
log.info('Stopping file watcher');
|
||||||
void this.watcher.close();
|
void this.watcher.close();
|
||||||
this.watcher = null;
|
this.watcher = null;
|
||||||
}
|
}
|
||||||
|
if (this.dirWatcher) {
|
||||||
|
log.info('Stopping directory watcher');
|
||||||
|
void this.dirWatcher.close();
|
||||||
|
this.dirWatcher = null;
|
||||||
|
}
|
||||||
this.projectRoot = null;
|
this.projectRoot = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,10 @@ export interface NotificationConfig {
|
||||||
snoozeMinutes: number; // Default snooze duration
|
snoozeMinutes: number; // Default snooze duration
|
||||||
/** Whether to include errors from subagent sessions */
|
/** Whether to include errors from subagent sessions */
|
||||||
includeSubagentErrors: boolean;
|
includeSubagentErrors: boolean;
|
||||||
/** Whether to show native OS notifications for team inbox messages */
|
/** Whether to show native OS notifications when teammates send messages to the team lead */
|
||||||
notifyOnInboxMessages: boolean;
|
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 */
|
/** Whether to show native OS notifications when a task needs user clarification */
|
||||||
notifyOnClarifications: boolean;
|
notifyOnClarifications: boolean;
|
||||||
/** Notification triggers - define when to generate notifications */
|
/** Notification triggers - define when to generate notifications */
|
||||||
|
|
@ -249,7 +251,8 @@ const DEFAULT_CONFIG: AppConfig = {
|
||||||
snoozedUntil: null,
|
snoozedUntil: null,
|
||||||
snoozeMinutes: 30,
|
snoozeMinutes: 30,
|
||||||
includeSubagentErrors: true,
|
includeSubagentErrors: true,
|
||||||
notifyOnInboxMessages: true,
|
notifyOnLeadInbox: false,
|
||||||
|
notifyOnUserInbox: true,
|
||||||
notifyOnClarifications: true,
|
notifyOnClarifications: true,
|
||||||
triggers: DEFAULT_TRIGGERS,
|
triggers: DEFAULT_TRIGGERS,
|
||||||
},
|
},
|
||||||
|
|
@ -416,6 +419,7 @@ export class ConfigManager {
|
||||||
private mergeWithDefaults(loaded: Partial<AppConfig>): AppConfig {
|
private mergeWithDefaults(loaded: Partial<AppConfig>): AppConfig {
|
||||||
const loadedNotifications = loaded.notifications ?? ({} as Partial<NotificationConfig>);
|
const loadedNotifications = loaded.notifications ?? ({} as Partial<NotificationConfig>);
|
||||||
const loadedTriggers = loadedNotifications.triggers ?? [];
|
const loadedTriggers = loadedNotifications.triggers ?? [];
|
||||||
|
|
||||||
const mergedGeneral: GeneralConfig = {
|
const mergedGeneral: GeneralConfig = {
|
||||||
...DEFAULT_CONFIG.general,
|
...DEFAULT_CONFIG.general,
|
||||||
...(loaded.general ?? {}),
|
...(loaded.general ?? {}),
|
||||||
|
|
|
||||||
|
|
@ -195,10 +195,37 @@ function writeTask(taskPath, task) {
|
||||||
return verify;
|
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) {
|
function setTaskStatus(paths, taskId, status) {
|
||||||
const normalized = normalizeStatus(status);
|
const normalized = normalizeStatus(status);
|
||||||
if (!normalized) die('Invalid status: ' + String(status));
|
if (!normalized) die('Invalid status: ' + String(status));
|
||||||
const { taskPath, task } = readTask(paths, taskId);
|
const { taskPath, task } = readTask(paths, taskId);
|
||||||
|
var prev = task.status;
|
||||||
|
var now = nowIso();
|
||||||
|
applyWorkIntervalsForStatusTransition(task, prev, normalized, now);
|
||||||
task.status = normalized;
|
task.status = normalized;
|
||||||
writeTask(taskPath, task);
|
writeTask(taskPath, task);
|
||||||
}
|
}
|
||||||
|
|
@ -244,6 +271,7 @@ function addTaskComment(paths, taskId, flags) {
|
||||||
id: commentId,
|
id: commentId,
|
||||||
author: from,
|
author: from,
|
||||||
text: text,
|
text: text,
|
||||||
|
type: 'regular',
|
||||||
createdAt: nowIso(),
|
createdAt: nowIso(),
|
||||||
};
|
};
|
||||||
task.comments = existing.concat([comment]);
|
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 owner = typeof flags.owner === 'string' && flags.owner.trim() ? flags.owner.trim() : undefined;
|
||||||
const explicitStatus = typeof flags.status === 'string' ? flags.status : '';
|
const explicitStatus = typeof flags.status === 'string' ? flags.status : '';
|
||||||
const status = normalizeStatus(explicitStatus) || (owner ? 'in_progress' : 'pending');
|
|
||||||
const activeForm =
|
const activeForm =
|
||||||
typeof flags.activeForm === 'string'
|
typeof flags.activeForm === 'string'
|
||||||
? flags.activeForm
|
? flags.activeForm
|
||||||
|
|
@ -423,6 +450,13 @@ function createTask(paths, flags) {
|
||||||
|
|
||||||
var blockedByIds = parseIdList(flags['blocked-by']);
|
var blockedByIds = parseIdList(flags['blocked-by']);
|
||||||
var relatedIds = parseIdList(flags.related);
|
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 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]); }
|
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) {
|
while (true) {
|
||||||
nextId = getNextTaskId(paths);
|
nextId = getNextTaskId(paths);
|
||||||
taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
|
taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
|
||||||
|
var createdAt = nowIso();
|
||||||
task = {
|
task = {
|
||||||
id: nextId,
|
id: nextId,
|
||||||
subject,
|
subject,
|
||||||
|
|
@ -442,6 +477,8 @@ function createTask(paths, flags) {
|
||||||
owner,
|
owner,
|
||||||
createdBy: from,
|
createdBy: from,
|
||||||
status,
|
status,
|
||||||
|
createdAt: createdAt,
|
||||||
|
workIntervals: status === 'in_progress' ? [{ startedAt: createdAt }] : undefined,
|
||||||
blocks: [],
|
blocks: [],
|
||||||
blockedBy: blockedByIds,
|
blockedBy: blockedByIds,
|
||||||
related: relatedIds.length > 0 ? relatedIds : undefined,
|
related: relatedIds.length > 0 ? relatedIds : undefined,
|
||||||
|
|
@ -563,18 +600,32 @@ function sendInboxMessage(paths, teamName, flags) {
|
||||||
|
|
||||||
function reviewApprove(paths, teamName, taskId, flags) {
|
function reviewApprove(paths, teamName, taskId, flags) {
|
||||||
setKanbanColumn(paths, teamName, taskId, 'approved');
|
setKanbanColumn(paths, teamName, taskId, 'approved');
|
||||||
const notify = flags.notify === true || flags['notify-owner'] === true;
|
const { taskPath, task } = readTask(paths, taskId);
|
||||||
if (!notify) return;
|
|
||||||
const { task } = readTask(paths, taskId);
|
|
||||||
if (!task.owner) return;
|
|
||||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
||||||
const note = typeof flags.note === 'string' ? flags.note.trim() : '';
|
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.\n\n' + note
|
||||||
: 'Task #' + String(taskId) + ' approved.';
|
: 'Task #' + String(taskId) + ' approved.';
|
||||||
sendInboxMessage(paths, teamName, {
|
sendInboxMessage(paths, teamName, {
|
||||||
to: task.owner,
|
to: task.owner,
|
||||||
text,
|
text: inboxText,
|
||||||
summary: 'Approved #' + String(taskId),
|
summary: 'Approved #' + String(taskId),
|
||||||
from,
|
from,
|
||||||
});
|
});
|
||||||
|
|
@ -585,12 +636,29 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
||||||
const { taskPath, task } = readTask(paths, taskId);
|
const { taskPath, task } = readTask(paths, taskId);
|
||||||
if (!task.owner) die('No owner found for task ' + String(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);
|
clearKanban(paths, teamName, taskId);
|
||||||
|
var now = nowIso();
|
||||||
|
applyWorkIntervalsForStatusTransition(task, task.status, 'in_progress', now);
|
||||||
task.status = 'in_progress';
|
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);
|
writeTask(taskPath, task);
|
||||||
|
|
||||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
const inboxText =
|
||||||
const text =
|
|
||||||
'Task #' +
|
'Task #' +
|
||||||
String(taskId) +
|
String(taskId) +
|
||||||
' needs fixes.\n\n' +
|
' needs fixes.\n\n' +
|
||||||
|
|
@ -599,7 +667,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
||||||
'Please fix and mark it as completed when ready.';
|
'Please fix and mark it as completed when ready.';
|
||||||
sendInboxMessage(paths, teamName, {
|
sendInboxMessage(paths, teamName, {
|
||||||
to: task.owner,
|
to: task.owner,
|
||||||
text,
|
text: inboxText,
|
||||||
summary: 'Fix request for #' + String(taskId),
|
summary: 'Fix request for #' + String(taskId),
|
||||||
from,
|
from,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -956,11 +956,15 @@ export class TeamDataService {
|
||||||
await fs.promises.mkdir(tasksDir, { recursive: true });
|
await fs.promises.mkdir(tasksDir, { recursive: true });
|
||||||
|
|
||||||
const joinedAt = Date.now();
|
const joinedAt = Date.now();
|
||||||
const config = {
|
const config: Record<string, unknown> = {
|
||||||
name: request.displayName?.trim() || request.teamName,
|
name: request.displayName?.trim() || request.teamName,
|
||||||
description: request.description?.trim() || undefined,
|
description: request.description?.trim() || undefined,
|
||||||
color: request.color?.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 atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||||
await this.membersMetaStore.writeMembers(
|
await this.membersMetaStore.writeMembers(
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,12 @@ export class TeamMemberLogsFinder {
|
||||||
async findLogsForTask(
|
async findLogsForTask(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
options?: { owner?: string; status?: string }
|
options?: {
|
||||||
|
owner?: string;
|
||||||
|
status?: string;
|
||||||
|
intervals?: { startedAt: string; completedAt?: string }[];
|
||||||
|
since?: string;
|
||||||
|
}
|
||||||
): Promise<MemberLogSummary[]> {
|
): Promise<MemberLogSummary[]> {
|
||||||
const discovery = await this.discoverProjectSessions(teamName);
|
const discovery = await this.discoverProjectSessions(teamName);
|
||||||
if (!discovery) return [];
|
if (!discovery) return [];
|
||||||
|
|
@ -171,6 +176,56 @@ export class TeamMemberLogsFinder {
|
||||||
options.owner.trim().length > 0;
|
options.owner.trim().length > 0;
|
||||||
if (includeOwnerSessions) {
|
if (includeOwnerSessions) {
|
||||||
const ownerLogs = await this.findMemberLogs(teamName, options.owner!.trim());
|
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>();
|
const seen = new Set<string>();
|
||||||
for (const log of results) {
|
for (const log of results) {
|
||||||
const key =
|
const key =
|
||||||
|
|
@ -179,7 +234,7 @@ export class TeamMemberLogsFinder {
|
||||||
: `lead:${log.sessionId}`;
|
: `lead:${log.sessionId}`;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
}
|
}
|
||||||
for (const log of ownerLogs) {
|
for (const log of filteredOwnerLogs) {
|
||||||
const key =
|
const key =
|
||||||
log.kind === 'subagent'
|
log.kind === 'subagent'
|
||||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
? `subagent:${log.sessionId}:${log.subagentId}`
|
||||||
|
|
@ -409,12 +464,17 @@ export class TeamMemberLogsFinder {
|
||||||
const patterns: RegExp[] = [
|
const patterns: RegExp[] = [
|
||||||
new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'),
|
new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'),
|
||||||
new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'),
|
new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'),
|
||||||
new RegExp(`#${escaped}\\b`),
|
|
||||||
];
|
];
|
||||||
if (numericTaskId) {
|
if (numericTaskId) {
|
||||||
patterns.push(
|
patterns.push(
|
||||||
new RegExp(`"task_id"\\s*:\\s*${numericTaskId}\\b`),
|
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 {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -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}"`,
|
`- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> <member-name> --notify --from "${leadName}"`,
|
||||||
`- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> clear`,
|
`- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> clear`,
|
||||||
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
|
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
|
||||||
`- Create with deps: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --owner "<member>" --notify --from "${leadName}"`,
|
`- 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 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>`,
|
`- 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>`,
|
`- Unlink: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task unlink <id> --blocked-by <targetId>`,
|
||||||
``,
|
``,
|
||||||
`Dependency guidelines:`,
|
`Dependency guidelines:`,
|
||||||
`- Use --blocked-by when a task cannot start until another is done.`,
|
`- 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.`,
|
`- 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.`,
|
`- Avoid over-specifying. Only add dependencies when execution order matters.`,
|
||||||
``,
|
``,
|
||||||
`Notification policy:`,
|
`Notification policy:`,
|
||||||
|
|
@ -554,6 +558,13 @@ ${processRegistration}
|
||||||
- Prefer fewer, broader tasks over many micro-tasks.
|
- Prefer fewer, broader tasks over many micro-tasks.
|
||||||
- Avoid duplicate notifications for the same assignment.
|
- Avoid duplicate notifications for the same assignment.
|
||||||
- When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by.
|
- 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.
|
- Use --related to connect tasks working on the same feature without blocking.
|
||||||
|
|
||||||
4) After all steps, output a short summary.
|
4) After all steps, output a short summary.
|
||||||
|
|
@ -1347,23 +1358,35 @@ export class TeamProvisioningService {
|
||||||
configParsed.leadSessionId.trim().length > 0
|
configParsed.leadSessionId.trim().length > 0
|
||||||
) {
|
) {
|
||||||
const candidateId = configParsed.leadSessionId.trim();
|
const candidateId = configParsed.leadSessionId.trim();
|
||||||
const projectPath =
|
const storedProjectPath =
|
||||||
typeof configParsed.projectPath === 'string' &&
|
typeof configParsed.projectPath === 'string' &&
|
||||||
configParsed.projectPath.trim().length > 0
|
configParsed.projectPath.trim().length > 0
|
||||||
? configParsed.projectPath.trim()
|
? configParsed.projectPath.trim()
|
||||||
: request.cwd;
|
: null;
|
||||||
const projectId = encodePath(projectPath);
|
|
||||||
const baseDir = extractBaseDir(projectId);
|
// Sessions are stored per-project (~/.claude/projects/{encodePath(cwd)}/).
|
||||||
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
|
// If the project path changed, the old session JSONL won't be found by the CLI
|
||||||
if (await this.pathExists(jsonlPath)) {
|
// at the new project directory. Skip resume to avoid passing an invalid --resume arg.
|
||||||
previousSessionId = candidateId;
|
if (storedProjectPath && path.resolve(storedProjectPath) !== path.resolve(request.cwd)) {
|
||||||
logger.info(
|
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 {
|
} else {
|
||||||
logger.info(
|
const resumeProjectPath = storedProjectPath ?? request.cwd;
|
||||||
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
|
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 {
|
} 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.
|
// Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names.
|
||||||
await this.normalizeTeamConfigForLaunch(request.teamName, configRaw);
|
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;
|
let claudePath: string | null;
|
||||||
try {
|
try {
|
||||||
await ensureCwdExists(request.cwd);
|
await ensureCwdExists(request.cwd);
|
||||||
|
|
@ -2806,6 +2834,35 @@ export class TeamProvisioningService {
|
||||||
return token.length > 0 ? token : null;
|
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.
|
* Single atomic read-mutate-write for post-launch config updates.
|
||||||
* Combines session history append and projectPath update to avoid
|
* Combines session history append and projectPath update to avoid
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
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');
|
const logger = createLogger('Service:TeamTaskReader');
|
||||||
|
|
||||||
|
|
@ -97,6 +97,21 @@ export class TeamTaskReader {
|
||||||
// `satisfies Record<keyof TeamTask, unknown>` ensures compile-time
|
// `satisfies Record<keyof TeamTask, unknown>` ensures compile-time
|
||||||
// safety: if a field is added to TeamTask but not mapped here,
|
// safety: if a field is added to TeamTask but not mapped here,
|
||||||
// TypeScript will error. This prevents silently dropping new fields.
|
// 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 = {
|
const task: TeamTask = {
|
||||||
id:
|
id:
|
||||||
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||||
|
|
@ -110,6 +125,7 @@ export class TeamTaskReader {
|
||||||
)
|
)
|
||||||
? (parsed.status as TeamTask['status'])
|
? (parsed.status as TeamTask['status'])
|
||||||
: 'pending',
|
: 'pending',
|
||||||
|
workIntervals,
|
||||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
|
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
|
||||||
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined,
|
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined,
|
||||||
related: Array.isArray(parsed.related)
|
related: Array.isArray(parsed.related)
|
||||||
|
|
@ -119,15 +135,22 @@ export class TeamTaskReader {
|
||||||
updatedAt,
|
updatedAt,
|
||||||
projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined,
|
projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined,
|
||||||
comments: Array.isArray(parsed.comments)
|
comments: Array.isArray(parsed.comments)
|
||||||
? (parsed.comments as TaskComment[]).filter(
|
? (parsed.comments as TaskComment[])
|
||||||
(c) =>
|
.filter(
|
||||||
c &&
|
(c) =>
|
||||||
typeof c === 'object' &&
|
c &&
|
||||||
typeof c.id === 'string' &&
|
typeof c === 'object' &&
|
||||||
typeof c.author === 'string' &&
|
typeof c.id === 'string' &&
|
||||||
typeof c.text === 'string' &&
|
typeof c.author === 'string' &&
|
||||||
typeof c.createdAt === '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,
|
: undefined,
|
||||||
needsClarification: (['lead', 'user'] as const).includes(
|
needsClarification: (['lead', 'user'] as const).includes(
|
||||||
parsed.needsClarification as 'lead' | 'user'
|
parsed.needsClarification as 'lead' | 'user'
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import * as path from 'path';
|
||||||
|
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
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>>();
|
const taskWriteLocks = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
|
@ -46,13 +46,23 @@ export class TeamTaskWriter {
|
||||||
|
|
||||||
// Ensure CLI-compatible format: description, blocks, blockedBy are required
|
// Ensure CLI-compatible format: description, blocks, blockedBy are required
|
||||||
// by Claude Code CLI's Zod schema validation (safeParse fails without them)
|
// by Claude Code CLI's Zod schema validation (safeParse fails without them)
|
||||||
|
const createdAt = task.createdAt ?? new Date().toISOString();
|
||||||
const cliCompatibleTask: TeamTask = {
|
const cliCompatibleTask: TeamTask = {
|
||||||
...task,
|
...task,
|
||||||
description: task.description ?? '',
|
description: task.description ?? '',
|
||||||
blocks: task.blocks ?? [],
|
blocks: task.blocks ?? [],
|
||||||
blockedBy: task.blockedBy ?? [],
|
blockedBy: task.blockedBy ?? [],
|
||||||
related: task.related ?? [],
|
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));
|
await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2));
|
||||||
|
|
@ -273,6 +283,29 @@ export class TeamTaskWriter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = JSON.parse(raw) as TeamTask;
|
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;
|
task.status = status;
|
||||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||||
|
|
||||||
|
|
@ -323,8 +356,20 @@ export class TeamTaskWriter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = JSON.parse(raw) as TeamTask;
|
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.status = 'deleted';
|
||||||
task.deletedAt = new Date().toISOString();
|
task.deletedAt = nowIso;
|
||||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||||
|
|
||||||
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
|
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
|
||||||
|
|
@ -417,7 +462,7 @@ export class TeamTaskWriter {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
text: string,
|
text: string,
|
||||||
options?: { id?: string; author?: string; createdAt?: string }
|
options?: { id?: string; author?: string; createdAt?: string; type?: TaskCommentType }
|
||||||
): Promise<TaskComment> {
|
): Promise<TaskComment> {
|
||||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||||
const comment: TaskComment = {
|
const comment: TaskComment = {
|
||||||
|
|
@ -425,6 +470,7 @@ export class TeamTaskWriter {
|
||||||
author: options?.author ?? 'user',
|
author: options?.author ?? 'user',
|
||||||
text,
|
text,
|
||||||
createdAt: options?.createdAt ?? new Date().toISOString(),
|
createdAt: options?.createdAt ?? new Date().toISOString(),
|
||||||
|
type: options?.type ?? 'regular',
|
||||||
};
|
};
|
||||||
|
|
||||||
await withTaskLock(taskPath, async () => {
|
await withTaskLock(taskPath, async () => {
|
||||||
|
|
|
||||||
|
|
@ -461,6 +461,9 @@ export const EDITOR_WATCH_DIR = 'editor:watchDir';
|
||||||
/** Update list of watched file paths (open tabs) */
|
/** Update list of watched file paths (open tabs) */
|
||||||
export const EDITOR_SET_WATCHED_FILES = 'editor:setWatchedFiles';
|
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 */
|
/** Read binary file as base64 for inline preview */
|
||||||
export const EDITOR_READ_BINARY_PREVIEW = 'editor:readBinaryPreview';
|
export const EDITOR_READ_BINARY_PREVIEW = 'editor:readBinaryPreview';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
EDITOR_READ_FILE,
|
EDITOR_READ_FILE,
|
||||||
EDITOR_RENAME_FILE,
|
EDITOR_RENAME_FILE,
|
||||||
EDITOR_SEARCH_IN_FILES,
|
EDITOR_SEARCH_IN_FILES,
|
||||||
|
EDITOR_SET_WATCHED_DIRS,
|
||||||
EDITOR_SET_WATCHED_FILES,
|
EDITOR_SET_WATCHED_FILES,
|
||||||
EDITOR_WATCH_DIR,
|
EDITOR_WATCH_DIR,
|
||||||
EDITOR_WRITE_FILE,
|
EDITOR_WRITE_FILE,
|
||||||
|
|
@ -710,7 +711,12 @@ const electronAPI: ElectronAPI = {
|
||||||
getLogsForTask: async (
|
getLogsForTask: async (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
options?: { owner?: string; status?: string }
|
options?: {
|
||||||
|
owner?: string;
|
||||||
|
status?: string;
|
||||||
|
intervals?: { startedAt: string; completedAt?: string }[];
|
||||||
|
since?: string;
|
||||||
|
}
|
||||||
) => {
|
) => {
|
||||||
return invokeIpcWithResult<MemberLogSummary[]>(
|
return invokeIpcWithResult<MemberLogSummary[]>(
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
TEAM_GET_LOGS_FOR_TASK,
|
||||||
|
|
@ -1035,6 +1041,8 @@ const electronAPI: ElectronAPI = {
|
||||||
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
|
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
|
||||||
setWatchedFiles: (filePaths: string[]) =>
|
setWatchedFiles: (filePaths: string[]) =>
|
||||||
invokeIpcWithResult<void>(EDITOR_SET_WATCHED_FILES, filePaths),
|
invokeIpcWithResult<void>(EDITOR_SET_WATCHED_FILES, filePaths),
|
||||||
|
setWatchedDirs: (dirPaths: string[]) =>
|
||||||
|
invokeIpcWithResult<void>(EDITOR_SET_WATCHED_DIRS, dirPaths),
|
||||||
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {
|
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void =>
|
const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void =>
|
||||||
callback(data);
|
callback(data);
|
||||||
|
|
|
||||||
|
|
@ -991,6 +991,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
setWatchedFiles: async () => {
|
setWatchedFiles: async () => {
|
||||||
throw new Error('Editor not available in browser mode');
|
throw new Error('Editor not available in browser mode');
|
||||||
},
|
},
|
||||||
|
setWatchedDirs: async () => {
|
||||||
|
throw new Error('Editor not available in browser mode');
|
||||||
|
},
|
||||||
onEditorChange: () => {
|
onEditorChange: () => {
|
||||||
return () => {};
|
return () => {};
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
PROSE_TABLE_HEADER_BG,
|
PROSE_TABLE_HEADER_BG,
|
||||||
} from '@renderer/constants/cssVariables';
|
} from '@renderer/constants/cssVariables';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
||||||
import { FileText } from 'lucide-react';
|
import { FileText } from 'lucide-react';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -34,6 +34,8 @@ import {
|
||||||
type SearchContext,
|
type SearchContext,
|
||||||
} from '../searchHighlightUtils';
|
} from '../searchHighlightUtils';
|
||||||
|
|
||||||
|
import { MermaidDiagram } from './MermaidDiagram';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Types
|
// Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -49,6 +51,95 @@ interface MarkdownViewerProps {
|
||||||
copyable?: boolean;
|
copyable?: boolean;
|
||||||
/** When true, renders without wrapper background/border (for embedding inside cards) */
|
/** When true, renders without wrapper background/border (for embedding inside cards) */
|
||||||
bare?: boolean;
|
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
|
// Code blocks — intercept mermaid diagrams at the pre level
|
||||||
pre: ({ children }) => (
|
pre: ({ children, node }) => {
|
||||||
<pre
|
// Check if this pre contains a mermaid code block
|
||||||
className="my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
|
const codeEl = node?.children?.[0];
|
||||||
style={{
|
if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) {
|
||||||
backgroundColor: PROSE_PRE_BG,
|
const cls = (codeEl.properties as Record<string, unknown>)?.className;
|
||||||
border: `1px solid ${PROSE_PRE_BORDER}`,
|
if (Array.isArray(cls) && cls.some((c) => String(c) === 'language-mermaid')) {
|
||||||
}}
|
return <MermaidDiagram code={hastToText(codeEl)} />;
|
||||||
>
|
}
|
||||||
{children}
|
}
|
||||||
</pre>
|
|
||||||
),
|
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
|
// Blockquotes
|
||||||
blockquote: ({ children }) => (
|
blockquote: ({ children }) => (
|
||||||
|
|
@ -288,6 +390,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
itemId,
|
itemId,
|
||||||
copyable = false,
|
copyable = false,
|
||||||
bare = false,
|
bare = false,
|
||||||
|
baseDir,
|
||||||
}) => {
|
}) => {
|
||||||
const [showRaw, setShowRaw] = React.useState(false);
|
const [showRaw, setShowRaw] = React.useState(false);
|
||||||
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
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
|
// Create markdown components with optional search highlighting
|
||||||
// When search is active, create fresh each render (match counter is stateful and must start at 0)
|
// 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
|
// 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -481,8 +597,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
<div className="min-w-0 break-words p-4">
|
<div className="min-w-0 break-words p-4">
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={disableHighlight ? [] : REHYPE_PLUGINS}
|
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
|
||||||
urlTransform={(url) => url}
|
|
||||||
components={components}
|
components={components}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|
|
||||||
116
src/renderer/components/chat/viewers/MermaidDiagram.tsx
Normal file
116
src/renderer/components/chat/viewers/MermaidDiagram.tsx
Normal 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}`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -42,7 +42,8 @@ export interface SafeConfig {
|
||||||
snoozedUntil: number | null;
|
snoozedUntil: number | null;
|
||||||
snoozeMinutes: number;
|
snoozeMinutes: number;
|
||||||
includeSubagentErrors: boolean;
|
includeSubagentErrors: boolean;
|
||||||
notifyOnInboxMessages: boolean;
|
notifyOnLeadInbox: boolean;
|
||||||
|
notifyOnUserInbox: boolean;
|
||||||
notifyOnClarifications: boolean;
|
notifyOnClarifications: boolean;
|
||||||
triggers: AppConfig['notifications']['triggers'];
|
triggers: AppConfig['notifications']['triggers'];
|
||||||
};
|
};
|
||||||
|
|
@ -171,7 +172,8 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
||||||
snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null,
|
snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null,
|
||||||
snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30,
|
snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30,
|
||||||
includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? true,
|
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,
|
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
||||||
triggers: displayConfig?.notifications?.triggers ?? [],
|
triggers: displayConfig?.notifications?.triggers ?? [],
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -287,7 +287,8 @@ export function useSettingsHandlers({
|
||||||
snoozedUntil: null,
|
snoozedUntil: null,
|
||||||
snoozeMinutes: 30,
|
snoozeMinutes: 30,
|
||||||
includeSubagentErrors: true,
|
includeSubagentErrors: true,
|
||||||
notifyOnInboxMessages: true,
|
notifyOnLeadInbox: false,
|
||||||
|
notifyOnUserInbox: true,
|
||||||
notifyOnClarifications: true,
|
notifyOnClarifications: true,
|
||||||
triggers: defaultTriggers,
|
triggers: defaultTriggers,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ interface NotificationsSectionProps {
|
||||||
| 'enabled'
|
| 'enabled'
|
||||||
| 'soundEnabled'
|
| 'soundEnabled'
|
||||||
| 'includeSubagentErrors'
|
| 'includeSubagentErrors'
|
||||||
| 'notifyOnInboxMessages'
|
| 'notifyOnLeadInbox'
|
||||||
|
| 'notifyOnUserInbox'
|
||||||
| 'notifyOnClarifications',
|
| 'notifyOnClarifications',
|
||||||
value: boolean
|
value: boolean
|
||||||
) => void;
|
) => void;
|
||||||
|
|
@ -136,12 +137,22 @@ export const NotificationsSection = ({
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
label="Team inbox notifications"
|
label="Lead inbox notifications"
|
||||||
description="Show native OS notifications when teammates send messages to the lead"
|
description="Notify when teammates send messages to the team lead"
|
||||||
>
|
>
|
||||||
<SettingsToggle
|
<SettingsToggle
|
||||||
enabled={safeConfig.notifications.notifyOnInboxMessages}
|
enabled={safeConfig.notifications.notifyOnLeadInbox}
|
||||||
onChange={(v) => onNotificationToggle('notifyOnInboxMessages', v)}
|
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}
|
disabled={saving || !safeConfig.notifications.enabled}
|
||||||
/>
|
/>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
|
||||||
import { MembersJsonEditor } from './MembersJsonEditor';
|
import { MembersJsonEditor } from './MembersJsonEditor';
|
||||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||||
|
|
||||||
|
|
@ -279,6 +280,7 @@ export const CreateTeamDialog = ({
|
||||||
const [launchTeam, setLaunchTeam] = useState(true);
|
const [launchTeam, setLaunchTeam] = useState(true);
|
||||||
const [teamColor, setTeamColor] = useState('');
|
const [teamColor, setTeamColor] = useState('');
|
||||||
const [selectedModel, setSelectedModel] = useState('');
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
|
const [extendedContext, setExtendedContext] = useState(false);
|
||||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||||
const [jsonText, setJsonText] = useState('');
|
const [jsonText, setJsonText] = useState('');
|
||||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||||
|
|
@ -303,6 +305,7 @@ export const CreateTeamDialog = ({
|
||||||
setCustomCwd('');
|
setCustomCwd('');
|
||||||
setLaunchTeam(true);
|
setLaunchTeam(true);
|
||||||
setSelectedModel('');
|
setSelectedModel('');
|
||||||
|
setExtendedContext(false);
|
||||||
setJsonEditorOpen(false);
|
setJsonEditorOpen(false);
|
||||||
setJsonText('');
|
setJsonText('');
|
||||||
setJsonError(null);
|
setJsonError(null);
|
||||||
|
|
@ -537,8 +540,13 @@ export const CreateTeamDialog = ({
|
||||||
[members]
|
[members]
|
||||||
);
|
);
|
||||||
|
|
||||||
const effectiveModel =
|
const effectiveModel = useMemo(() => {
|
||||||
selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
|
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());
|
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
||||||
|
|
||||||
|
|
@ -623,6 +631,7 @@ export const CreateTeamDialog = ({
|
||||||
description: request.description,
|
description: request.description,
|
||||||
color: request.color,
|
color: request.color,
|
||||||
members: request.members,
|
members: request.members,
|
||||||
|
cwd: effectiveCwd || undefined,
|
||||||
});
|
});
|
||||||
onOpenTeam(request.teamName, effectiveCwd || undefined);
|
onOpenTeam(request.teamName, effectiveCwd || undefined);
|
||||||
resetFormState();
|
resetFormState();
|
||||||
|
|
@ -909,19 +918,27 @@ export const CreateTeamDialog = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div>
|
||||||
<Label className="label-optional">Model (optional)</Label>
|
<div className="flex items-center gap-2.5">
|
||||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
<Label className="label-optional shrink-0">Model (optional)</Label>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||||
<SelectValue placeholder="Default (account setting)" />
|
<SelectTrigger className="h-8 w-auto min-w-[180px] text-xs">
|
||||||
</SelectTrigger>
|
<SelectValue placeholder="Default (account setting)" />
|
||||||
<SelectContent>
|
</SelectTrigger>
|
||||||
<SelectItem value="__default__">Default (account setting)</SelectItem>
|
<SelectContent>
|
||||||
<SelectItem value="opus">Opus 4.6</SelectItem>
|
<SelectItem value="__default__">Default (account setting)</SelectItem>
|
||||||
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
|
<SelectItem value="opus">Opus 4.6</SelectItem>
|
||||||
<SelectItem value="haiku">Haiku 4.5</SelectItem>
|
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
|
||||||
</SelectContent>
|
<SelectItem value="haiku">Haiku 4.5</SelectItem>
|
||||||
</Select>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<ExtendedContextCheckbox
|
||||||
|
id="create-extended-context"
|
||||||
|
checked={extendedContext}
|
||||||
|
onCheckedChange={setExtendedContext}
|
||||||
|
disabled={selectedModel === 'haiku'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (
|
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||||
import {
|
import {
|
||||||
|
|
@ -66,6 +67,7 @@ export const LaunchTeamDialog = ({
|
||||||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState('');
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
|
const [extendedContext, setExtendedContext] = useState(false);
|
||||||
const [clearContext, setClearContext] = useState(false);
|
const [clearContext, setClearContext] = useState(false);
|
||||||
|
|
||||||
const resetFormState = (): void => {
|
const resetFormState = (): void => {
|
||||||
|
|
@ -78,6 +80,7 @@ export const LaunchTeamDialog = ({
|
||||||
setSelectedProjectPath('');
|
setSelectedProjectPath('');
|
||||||
setCustomCwd('');
|
setCustomCwd('');
|
||||||
setSelectedModel('');
|
setSelectedModel('');
|
||||||
|
setExtendedContext(false);
|
||||||
setClearContext(false);
|
setClearContext(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -240,7 +243,12 @@ export const LaunchTeamDialog = ({
|
||||||
teamName,
|
teamName,
|
||||||
cwd: effectiveCwd,
|
cwd: effectiveCwd,
|
||||||
prompt: promptDraft.value.trim() || undefined,
|
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,
|
clearContext: clearContext || undefined,
|
||||||
});
|
});
|
||||||
resetFormState();
|
resetFormState();
|
||||||
|
|
@ -353,30 +361,38 @@ export const LaunchTeamDialog = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div>
|
||||||
<Label className="label-optional">Model (optional)</Label>
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.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: '', label: 'Default' },
|
||||||
{ value: 'sonnet', label: 'Sonnet 4.5' },
|
{ value: 'opus', label: 'Opus 4.6' },
|
||||||
{ value: 'haiku', label: 'Haiku 4.5' },
|
{ value: 'sonnet', label: 'Sonnet 4.5' },
|
||||||
].map((opt) => (
|
{ value: 'haiku', label: 'Haiku 4.5' },
|
||||||
<button
|
].map((opt) => (
|
||||||
key={opt.value}
|
<button
|
||||||
type="button"
|
key={opt.value}
|
||||||
className={cn(
|
type="button"
|
||||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
className={cn(
|
||||||
selectedModel === opt.value
|
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
selectedModel === opt.value
|
||||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
? '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)}
|
)}
|
||||||
>
|
onClick={() => setSelectedModel(opt.value)}
|
||||||
{opt.label}
|
>
|
||||||
</button>
|
{opt.label}
|
||||||
))}
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ExtendedContextCheckbox
|
||||||
|
id="launch-extended-context"
|
||||||
|
checked={extendedContext}
|
||||||
|
onCheckedChange={setExtendedContext}
|
||||||
|
disabled={selectedModel === 'haiku'}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -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 { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
|
|
@ -30,6 +30,9 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { X } from 'lucide-react';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { MarkdownViewer } from '../../chat/viewers/MarkdownViewer';
|
||||||
|
import { MemberBadge } from '../MemberBadge';
|
||||||
|
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
||||||
|
|
@ -72,6 +75,7 @@ export const SendMessageDialog = ({
|
||||||
}: SendMessageDialogProps): React.JSX.Element => {
|
}: SendMessageDialogProps): React.JSX.Element => {
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
||||||
|
const [quoteExpanded, setQuoteExpanded] = useState(false);
|
||||||
const [member, setMember] = useState('');
|
const [member, setMember] = useState('');
|
||||||
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
|
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
|
||||||
const chipDraft = useChipDraftPersistence('sendMessage:chips');
|
const chipDraft = useChipDraftPersistence('sendMessage:chips');
|
||||||
|
|
@ -84,6 +88,7 @@ export const SendMessageDialog = ({
|
||||||
setMember(defaultRecipient ?? '');
|
setMember(defaultRecipient ?? '');
|
||||||
setSummary('');
|
setSummary('');
|
||||||
setQuote(quotedMessage);
|
setQuote(quotedMessage);
|
||||||
|
setQuoteExpanded(false);
|
||||||
setPrevResult(lastResult);
|
setPrevResult(lastResult);
|
||||||
if (defaultChip) {
|
if (defaultChip) {
|
||||||
const token = chipToken(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
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- only trigger on pendingAutoClose flag
|
||||||
}, [pendingAutoClose]);
|
}, [pendingAutoClose]);
|
||||||
|
|
||||||
|
const QUOTE_COLLAPSE_THRESHOLD = 120;
|
||||||
|
const isQuoteLong = (quote?.text.length ?? 0) > QUOTE_COLLAPSE_THRESHOLD;
|
||||||
|
|
||||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||||
() =>
|
() =>
|
||||||
members.map((m) => ({
|
members.map((m) => ({
|
||||||
|
|
@ -204,47 +212,71 @@ export const SendMessageDialog = ({
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="smd-message">Message</Label>
|
<Label htmlFor="smd-message">Message</Label>
|
||||||
<MentionableTextarea
|
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||||
id="smd-message"
|
{quote ? (
|
||||||
placeholder="Write your message..."
|
<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">
|
||||||
value={textDraft.value}
|
{/* Decorative quotation mark */}
|
||||||
onValueChange={textDraft.setValue}
|
<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]">
|
||||||
suggestions={mentionSuggestions}
|
“
|
||||||
chips={chipDraft.chips}
|
</span>
|
||||||
onChipRemove={handleChipRemove}
|
|
||||||
minRows={4}
|
<Tooltip>
|
||||||
maxRows={12}
|
<TooltipTrigger asChild>
|
||||||
footerRight={
|
<button
|
||||||
textDraft.isSaved ? (
|
type="button"
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
className="absolute right-1.5 top-1.5 z-10 rounded p-0.5 text-blue-300/40 hover:text-blue-200"
|
||||||
) : null
|
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>
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,16 @@ import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
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 { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
|
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
|
||||||
|
|
@ -145,8 +154,23 @@ export const TaskCommentsSection = ({
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{visibleComments.map((comment) => (
|
{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)]">
|
<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
|
<span
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -158,6 +182,16 @@ export const TaskCommentsSection = ({
|
||||||
>
|
>
|
||||||
{comment.author}
|
{comment.author}
|
||||||
</span>
|
</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>
|
<span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const date = new Date(comment.createdAt);
|
const date = new Date(comment.createdAt);
|
||||||
|
|
|
||||||
|
|
@ -598,6 +598,7 @@ export const TaskDetailDialog = ({
|
||||||
taskId={currentTask.id}
|
taskId={currentTask.id}
|
||||||
taskOwner={currentTask.owner}
|
taskOwner={currentTask.owner}
|
||||||
taskStatus={currentTask.status}
|
taskStatus={currentTask.status}
|
||||||
|
taskWorkIntervals={currentTask.workIntervals}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,13 @@
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { defaultKeymap, history, historyKeymap, redo, undo } from '@codemirror/commands';
|
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 { gotoLine, search, searchKeymap } from '@codemirror/search';
|
||||||
import { Compartment, EditorState } from '@codemirror/state';
|
import { Compartment, EditorState } from '@codemirror/state';
|
||||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||||
|
|
@ -67,6 +73,8 @@ interface CodeMirrorEditorProps {
|
||||||
onDraftRecovered?: (filePath: string) => void;
|
onDraftRecovered?: (filePath: string) => void;
|
||||||
/** Called when text selection changes (for floating action menu) */
|
/** Called when text selection changes (for floating action menu) */
|
||||||
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
|
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(),
|
highlightActiveLineGutter(),
|
||||||
bracketMatching(),
|
bracketMatching(),
|
||||||
indentOnInput(),
|
indentOnInput(),
|
||||||
|
foldGutter(),
|
||||||
|
|
||||||
// History
|
// History
|
||||||
history(),
|
history(),
|
||||||
|
|
@ -129,6 +138,7 @@ function buildEditableExtensions(
|
||||||
...defaultKeymap,
|
...defaultKeymap,
|
||||||
...historyKeymap,
|
...historyKeymap,
|
||||||
...searchKeymap.filter((k) => k.run !== gotoLine),
|
...searchKeymap.filter((k) => k.run !== gotoLine),
|
||||||
|
...foldKeymap,
|
||||||
]),
|
]),
|
||||||
|
|
||||||
// Update listener for dirty flag + cursor position + selection
|
// Update listener for dirty flag + cursor position + selection
|
||||||
|
|
@ -220,6 +230,8 @@ function enforceDraftLimit(): void {
|
||||||
// Component
|
// Component
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
const DOC_CHANGE_DEBOUNCE_MS = 150;
|
||||||
|
|
||||||
export const CodeMirrorEditor = ({
|
export const CodeMirrorEditor = ({
|
||||||
filePath,
|
filePath,
|
||||||
content,
|
content,
|
||||||
|
|
@ -228,6 +240,7 @@ export const CodeMirrorEditor = ({
|
||||||
onCursorChange,
|
onCursorChange,
|
||||||
onDraftRecovered,
|
onDraftRecovered,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
|
onDocChange,
|
||||||
}: CodeMirrorEditorProps): React.ReactElement => {
|
}: CodeMirrorEditorProps): React.ReactElement => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const viewRef = useRef<EditorView | null>(null);
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
|
@ -241,6 +254,8 @@ export const CodeMirrorEditor = ({
|
||||||
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
// Selection debounce
|
// Selection debounce
|
||||||
const selectionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
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 markFileModified = useStore((s) => s.markFileModified);
|
||||||
const discardChanges = useStore((s) => s.discardChanges);
|
const discardChanges = useStore((s) => s.discardChanges);
|
||||||
|
|
@ -260,6 +275,9 @@ export const CodeMirrorEditor = ({
|
||||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||||
onSelectionChangeRef.current = onSelectionChange;
|
onSelectionChangeRef.current = onSelectionChange;
|
||||||
|
|
||||||
|
const onDocChangeRef = useRef(onDocChange);
|
||||||
|
onDocChangeRef.current = onDocChange;
|
||||||
|
|
||||||
const lineWrapRef = useRef(lineWrap);
|
const lineWrapRef = useRef(lineWrap);
|
||||||
lineWrapRef.current = lineWrap;
|
lineWrapRef.current = lineWrap;
|
||||||
|
|
||||||
|
|
@ -282,6 +300,13 @@ export const CodeMirrorEditor = ({
|
||||||
saveDraft(filePathRef.current, view.state.doc.toString());
|
saveDraft(filePathRef.current, view.state.doc.toString());
|
||||||
}
|
}
|
||||||
}, AUTOSAVE_DELAY_MS);
|
}, 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]);
|
}, [markFileModified]);
|
||||||
|
|
||||||
const handleCursorMove = useCallback((line: number, col: number) => {
|
const handleCursorMove = useCallback((line: number, col: number) => {
|
||||||
|
|
@ -421,6 +446,7 @@ export const CodeMirrorEditor = ({
|
||||||
const dirtyTimer = dirtyTimerRef;
|
const dirtyTimer = dirtyTimerRef;
|
||||||
const autosaveTimer = autosaveTimerRef;
|
const autosaveTimer = autosaveTimerRef;
|
||||||
const selectionTimer = selectionTimerRef;
|
const selectionTimer = selectionTimerRef;
|
||||||
|
const docChangeTimer = docChangeTimerRef;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Save scroll position before destroying
|
// Save scroll position before destroying
|
||||||
|
|
@ -433,6 +459,7 @@ export const CodeMirrorEditor = ({
|
||||||
if (dirtyTimer.current) clearTimeout(dirtyTimer.current);
|
if (dirtyTimer.current) clearTimeout(dirtyTimer.current);
|
||||||
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
|
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
|
||||||
if (selectionTimer.current) clearTimeout(selectionTimer.current);
|
if (selectionTimer.current) clearTimeout(selectionTimer.current);
|
||||||
|
if (docChangeTimer.current) clearTimeout(docChangeTimer.current);
|
||||||
|
|
||||||
view.destroy();
|
view.destroy();
|
||||||
viewRef.current = null;
|
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" />;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,13 @@ const SHORTCUT_GROUPS: { title: string; shortcuts: ShortcutDef[] }[] = [
|
||||||
{ mac: '⌘ /', other: 'Ctrl+/', description: 'Toggle Comment' },
|
{ 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',
|
title: 'General',
|
||||||
shortcuts: [{ mac: 'Esc', other: 'Esc', description: 'Close Editor' }],
|
shortcuts: [{ mac: 'Esc', other: 'Esc', description: 'Close Editor' }],
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,32 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { editorBridge } from '@renderer/utils/editorBridge';
|
import { editorBridge } from '@renderer/utils/editorBridge';
|
||||||
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
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';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Types
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export type MdPreviewMode = 'off' | 'split' | 'preview';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Component
|
// 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(
|
const { activeTabId, modifiedFiles, saving, lineWrap } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
activeTabId: s.editorActiveTabId,
|
activeTabId: s.editorActiveTabId,
|
||||||
|
|
@ -77,6 +95,25 @@ export const EditorToolbar = (): React.ReactElement | null => {
|
||||||
onClick={toggleLineWrap}
|
onClick={toggleLineWrap}
|
||||||
active={lineWrap}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
53
src/renderer/components/team/editor/MarkdownPreviewPane.tsx
Normal file
53
src/renderer/components/team/editor/MarkdownPreviewPane.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
128
src/renderer/components/team/editor/MarkdownSplitView.tsx
Normal file
128
src/renderer/components/team/editor/MarkdownSplitView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -45,9 +45,11 @@ import { EditorStatusBar } from './EditorStatusBar';
|
||||||
import { EditorTabBar } from './EditorTabBar';
|
import { EditorTabBar } from './EditorTabBar';
|
||||||
import { EditorToolbar } from './EditorToolbar';
|
import { EditorToolbar } from './EditorToolbar';
|
||||||
import { GoToLineDialog } from './GoToLineDialog';
|
import { GoToLineDialog } from './GoToLineDialog';
|
||||||
|
import { MarkdownSplitView } from './MarkdownSplitView';
|
||||||
import { QuickOpenDialog } from './QuickOpenDialog';
|
import { QuickOpenDialog } from './QuickOpenDialog';
|
||||||
import { SearchInFilesPanel } from './SearchInFilesPanel';
|
import { SearchInFilesPanel } from './SearchInFilesPanel';
|
||||||
|
|
||||||
|
import type { MdPreviewMode } from './EditorToolbar';
|
||||||
import type {
|
import type {
|
||||||
EditorSelectionAction,
|
EditorSelectionAction,
|
||||||
EditorSelectionInfo,
|
EditorSelectionInfo,
|
||||||
|
|
@ -121,6 +123,18 @@ export const ProjectEditorOverlay = ({
|
||||||
const editorContentRef = useRef<HTMLDivElement>(null);
|
const editorContentRef = useRef<HTMLDivElement>(null);
|
||||||
const [containerRect, setContainerRect] = useState<DOMRect>(() => new DOMRect());
|
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
|
// Iter-4: New state
|
||||||
const [quickOpenVisible, setQuickOpenVisible] = useState(false);
|
const [quickOpenVisible, setQuickOpenVisible] = useState(false);
|
||||||
const [searchPanelVisible, setSearchPanelVisible] = useState(false);
|
const [searchPanelVisible, setSearchPanelVisible] = useState(false);
|
||||||
|
|
@ -141,6 +155,48 @@ export const ProjectEditorOverlay = ({
|
||||||
|
|
||||||
// Active tab metadata
|
// Active tab metadata
|
||||||
const activeTab = openTabs.find((t) => t.id === activeTabId) ?? null;
|
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(
|
const loadFileContent = useCallback(
|
||||||
async (filePath: string) => {
|
async (filePath: string) => {
|
||||||
|
|
@ -444,6 +500,8 @@ export const ProjectEditorOverlay = ({
|
||||||
onToggleGoToLine: toggleGoToLine,
|
onToggleGoToLine: toggleGoToLine,
|
||||||
onToggleSidebar: toggleSidebar,
|
onToggleSidebar: toggleSidebar,
|
||||||
onClose: handleCloseRequest,
|
onClose: handleCloseRequest,
|
||||||
|
onToggleMdSplit: isMarkdown ? toggleMdSplit : undefined,
|
||||||
|
onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const projectName = projectPath.split('/').pop() ?? projectPath;
|
const projectName = projectPath.split('/').pop() ?? projectPath;
|
||||||
|
|
@ -580,7 +638,12 @@ export const ProjectEditorOverlay = ({
|
||||||
<EditorTabBar onRequestCloseTab={handleRequestCloseTab} />
|
<EditorTabBar onRequestCloseTab={handleRequestCloseTab} />
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<EditorToolbar />
|
<EditorToolbar
|
||||||
|
isMarkdown={isMarkdown}
|
||||||
|
mdPreviewMode={mdPreviewMode}
|
||||||
|
onToggleSplit={toggleMdSplit}
|
||||||
|
onToggleFullPreview={toggleMdPreview}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Draft recovery banner */}
|
{/* Draft recovery banner */}
|
||||||
{draftRecoveredFile && activeTabId === draftRecoveredFile && (
|
{draftRecoveredFile && activeTabId === draftRecoveredFile && (
|
||||||
|
|
@ -678,18 +741,42 @@ export const ProjectEditorOverlay = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{fileContent && !fileContent.isBinary && activeTabId && (
|
{fileContent && !fileContent.isBinary && activeTabId && (
|
||||||
<EditorErrorBoundary filePath={activeTabId} onRetry={handleRetry}>
|
<div className="flex h-full">
|
||||||
<CodeMirrorEditor
|
{/* Code editor — always mounted, hidden via display:none in preview mode */}
|
||||||
key={`${activeTabId}-${editorResetKey}`}
|
<div
|
||||||
filePath={activeTabId}
|
className="h-full overflow-hidden"
|
||||||
content={fileContent.content}
|
style={{
|
||||||
fileName={activeTabId.split('/').pop() ?? 'file'}
|
display: mdPreviewMode === 'preview' ? 'none' : 'block',
|
||||||
mtimeMs={fileContent.mtimeMs}
|
width: mdPreviewMode === 'split' ? `${splitRatio * 100}%` : '100%',
|
||||||
onCursorChange={handleCursorChange}
|
}}
|
||||||
onDraftRecovered={handleDraftRecovered}
|
>
|
||||||
onSelectionChange={setSelectionInfo}
|
<EditorErrorBoundary filePath={activeTabId} onRetry={handleRetry}>
|
||||||
/>
|
<CodeMirrorEditor
|
||||||
</EditorErrorBoundary>
|
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 />}
|
{!fileLoading && !fileError && !fileContent && !activeTabId && <EditorEmptyState />}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,13 @@ export const MemberList = ({
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
}: MemberListProps): React.JSX.Element => {
|
}: 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 removedMembers = members.filter((m) => m.removedAt);
|
||||||
const colorMap = buildMemberColorMap(members);
|
const colorMap = buildMemberColorMap(members);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ interface MemberLogsTabProps {
|
||||||
/** When viewing task logs: include owner's sessions when task is in_progress */
|
/** When viewing task logs: include owner's sessions when task is in_progress */
|
||||||
taskOwner?: string;
|
taskOwner?: string;
|
||||||
taskStatus?: string;
|
taskStatus?: string;
|
||||||
|
/** Persisted work intervals for filtering owner sessions (avoid unrelated tasks) */
|
||||||
|
taskWorkIntervals?: { startedAt: string; completedAt?: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberLogsTab = ({
|
export const MemberLogsTab = ({
|
||||||
|
|
@ -32,6 +34,7 @@ export const MemberLogsTab = ({
|
||||||
taskId,
|
taskId,
|
||||||
taskOwner,
|
taskOwner,
|
||||||
taskStatus,
|
taskStatus,
|
||||||
|
taskWorkIntervals,
|
||||||
}: MemberLogsTabProps): React.JSX.Element => {
|
}: MemberLogsTabProps): React.JSX.Element => {
|
||||||
const [logs, setLogs] = useState<MemberLogSummary[]>([]);
|
const [logs, setLogs] = useState<MemberLogSummary[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -61,6 +64,7 @@ export const MemberLogsTab = ({
|
||||||
? await api.teams.getLogsForTask(teamName, taskId, {
|
? await api.teams.getLogsForTask(teamName, taskId, {
|
||||||
owner: taskOwner,
|
owner: taskOwner,
|
||||||
status: taskStatus,
|
status: taskStatus,
|
||||||
|
intervals: taskWorkIntervals,
|
||||||
})
|
})
|
||||||
: await api.teams.getMemberLogs(teamName, memberName!);
|
: await api.teams.getMemberLogs(teamName, memberName!);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
|
|
@ -86,7 +90,7 @@ export const MemberLogsTab = ({
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (interval) clearInterval(interval);
|
if (interval) clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [teamName, memberName, taskId, taskOwner, taskStatus]);
|
}, [teamName, memberName, taskId, taskOwner, taskStatus, taskWorkIntervals]);
|
||||||
|
|
||||||
const handleExpand = useCallback(
|
const handleExpand = useCallback(
|
||||||
async (log: MemberLogSummary) => {
|
async (log: MemberLogSummary) => {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
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 { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge';
|
||||||
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
||||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||||
|
|
@ -395,6 +395,7 @@ export const CodeMirrorDiffView = ({
|
||||||
...(isEffectivelyEmptyOriginal ? [emptyOriginalOverrideTheme] : []),
|
...(isEffectivelyEmptyOriginal ? [emptyOriginalOverrideTheme] : []),
|
||||||
lineNumbers(),
|
lineNumbers(),
|
||||||
syntaxHighlighting(oneDarkHighlightStyle),
|
syntaxHighlighting(oneDarkHighlightStyle),
|
||||||
|
foldGutter(),
|
||||||
EditorView.editable.of(!readOnly),
|
EditorView.editable.of(!readOnly),
|
||||||
EditorState.readOnly.of(readOnly),
|
EditorState.readOnly.of(readOnly),
|
||||||
];
|
];
|
||||||
|
|
@ -405,7 +406,9 @@ export const CodeMirrorDiffView = ({
|
||||||
extensions.push(mergeUndoSupport);
|
extensions.push(mergeUndoSupport);
|
||||||
extensions.push(mirrorEditsAfterResolve);
|
extensions.push(mirrorEditsAfterResolve);
|
||||||
extensions.push(indentUnit.of(' '));
|
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
|
// Language placeholder — actual language injected async via compartment reconfigure
|
||||||
|
|
@ -426,6 +429,7 @@ export const CodeMirrorDiffView = ({
|
||||||
key: 'Ctrl-Alt-ArrowUp',
|
key: 'Ctrl-Alt-ArrowUp',
|
||||||
run: goToPreviousChunk,
|
run: goToPreviousChunk,
|
||||||
},
|
},
|
||||||
|
...foldKeymap,
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ interface UseEditorKeyboardShortcutsOptions {
|
||||||
onToggleGoToLine: () => void;
|
onToggleGoToLine: () => void;
|
||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
onToggleMdSplit?: () => void;
|
||||||
|
onToggleMdPreview?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dependencies injected into the key handler for testability. */
|
/** Dependencies injected into the key handler for testability. */
|
||||||
|
|
@ -40,6 +42,8 @@ export interface EditorKeyHandlerDeps {
|
||||||
onToggleGoToLine: () => void;
|
onToggleGoToLine: () => void;
|
||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
onToggleLineWrap: () => void;
|
onToggleLineWrap: () => void;
|
||||||
|
onToggleMdSplit?: () => void;
|
||||||
|
onToggleMdPreview?: () => void;
|
||||||
getEditorView: () => { dispatch: unknown } | null;
|
getEditorView: () => { dispatch: unknown } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,6 +112,22 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
||||||
return;
|
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
|
// Cmd+Shift+W: Toggle line wrap
|
||||||
if (key === 'w' && e.shiftKey && !e.altKey) {
|
if (key === 'w' && e.shiftKey && !e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -190,6 +210,8 @@ export function useEditorKeyboardShortcuts({
|
||||||
onToggleGoToLine,
|
onToggleGoToLine,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
onClose: _onClose,
|
onClose: _onClose,
|
||||||
|
onToggleMdSplit,
|
||||||
|
onToggleMdPreview,
|
||||||
}: UseEditorKeyboardShortcutsOptions): void {
|
}: UseEditorKeyboardShortcutsOptions): void {
|
||||||
const { openTabs, activeTabId } = useStore(
|
const { openTabs, activeTabId } = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
|
|
@ -217,6 +239,8 @@ export function useEditorKeyboardShortcuts({
|
||||||
onToggleGoToLine,
|
onToggleGoToLine,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
onToggleLineWrap: toggleLineWrap,
|
onToggleLineWrap: toggleLineWrap,
|
||||||
|
onToggleMdSplit,
|
||||||
|
onToggleMdPreview,
|
||||||
getEditorView: () => editorBridge.getView(),
|
getEditorView: () => editorBridge.getView(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
147
src/renderer/hooks/useMarkdownScrollSync.ts
Normal file
147
src/renderer/hooks/useMarkdownScrollSync.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -67,26 +67,58 @@ let watcherEventCounts: Record<EditorFileChangeEvent['type'], number> = {
|
||||||
|
|
||||||
let watchedFilesSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
let watchedFilesSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let lastWatchedFilesKey = '';
|
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 {
|
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();
|
const state = get();
|
||||||
if (!state.editorWatcherEnabled) return;
|
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);
|
const filePaths = state.editorOpenTabs.map((t) => t.filePath).filter(Boolean);
|
||||||
filePaths.sort();
|
filePaths.sort();
|
||||||
const key = filePaths.join('\n');
|
const key = `${projectPath}\n${filePaths.join('\n')}`;
|
||||||
if (key === lastWatchedFilesKey) return;
|
if (key === lastWatchedFilesKey) return;
|
||||||
lastWatchedFilesKey = key;
|
lastWatchedFilesKey = key;
|
||||||
|
|
||||||
if (watchedFilesSyncTimer) clearTimeout(watchedFilesSyncTimer);
|
if (watchedFilesSyncTimer) clearTimeout(watchedFilesSyncTimer);
|
||||||
watchedFilesSyncTimer = setTimeout(() => {
|
watchedFilesSyncTimer = setTimeout(() => {
|
||||||
watchedFilesSyncTimer = null;
|
watchedFilesSyncTimer = null;
|
||||||
void api.editor.setWatchedFiles(filePaths);
|
void window.electronAPI.editor.setWatchedFiles(filePaths);
|
||||||
}, 150);
|
}, 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.
|
* Open request sequence for editor initialization.
|
||||||
* Cancels stale async work (notably React 18 StrictMode dev effect mount/unmount).
|
* 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 {
|
function scheduleIdleWork(cb: () => void): void {
|
||||||
// Prefer requestIdleCallback when available; fall back to a short timeout.
|
// Prefer requestIdleCallback when available; fall back to a short timeout.
|
||||||
// This keeps editor open responsive for large repos.
|
// 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 {
|
try {
|
||||||
const ric = (window as unknown as { requestIdleCallback?: (fn: () => void) => number })
|
const ric = (
|
||||||
.requestIdleCallback;
|
window as unknown as {
|
||||||
|
requestIdleCallback?: (fn: () => void, opts?: { timeout: number }) => number;
|
||||||
|
}
|
||||||
|
).requestIdleCallback;
|
||||||
if (typeof ric === 'function') {
|
if (typeof ric === 'function') {
|
||||||
ric(cb);
|
ric(cb, { timeout: 2000 });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -330,8 +367,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
|
|
||||||
scheduleIdleWork(() => {
|
scheduleIdleWork(() => {
|
||||||
if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return;
|
if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return;
|
||||||
// TODO: temporarily disabled file watcher — re-enable when stabilized
|
if (watcherDesired) void get().toggleWatcher(true);
|
||||||
if (watcherDesired) void get().toggleWatcher(false);
|
|
||||||
// Defer initial git status a bit more — it can be expensive on large repos.
|
// Defer initial git status a bit more — it can be expensive on large repos.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return;
|
if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return;
|
||||||
|
|
@ -355,6 +391,18 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
closeEditor: () => {
|
closeEditor: () => {
|
||||||
// Cancel any in-flight openEditor async work
|
// Cancel any in-flight openEditor async work
|
||||||
editorOpenSeq++;
|
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)
|
// Clear cooldown timestamps (no stale entries across editor sessions)
|
||||||
recentSaveTimestamps.clear();
|
recentSaveTimestamps.clear();
|
||||||
recentMoveTimestamps.clear();
|
recentMoveTimestamps.clear();
|
||||||
|
|
@ -428,6 +476,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
set({
|
set({
|
||||||
editorExpandedDirs: { ...editorExpandedDirs, [dirPath]: true },
|
editorExpandedDirs: { ...editorExpandedDirs, [dirPath]: true },
|
||||||
});
|
});
|
||||||
|
scheduleSyncWatchedDirs(get);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -456,6 +505,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
collapseDirectory: (dirPath: string) => {
|
collapseDirectory: (dirPath: string) => {
|
||||||
const { editorExpandedDirs } = get();
|
const { editorExpandedDirs } = get();
|
||||||
set({ editorExpandedDirs: omitKey(editorExpandedDirs, dirPath) });
|
set({ editorExpandedDirs: omitKey(editorExpandedDirs, dirPath) });
|
||||||
|
scheduleSyncWatchedDirs(get);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════
|
||||||
|
|
@ -1027,9 +1077,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
||||||
}
|
}
|
||||||
if (enable) {
|
if (enable) {
|
||||||
scheduleSyncWatchedFiles(get);
|
scheduleSyncWatchedFiles(get);
|
||||||
|
scheduleSyncWatchedDirs(get);
|
||||||
} else {
|
} else {
|
||||||
// Ensure main process stops watching files promptly.
|
// Ensure main process stops watching files promptly.
|
||||||
lastWatchedFilesKey = '';
|
lastWatchedFilesKey = '';
|
||||||
|
lastWatchedDirsKey = '';
|
||||||
|
void api.editor.setWatchedFiles([]);
|
||||||
|
void api.editor.setWatchedDirs([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Failed to toggle watcher:', error);
|
log.error('Failed to toggle watcher:', error);
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export const baseEditorTheme = EditorView.theme({
|
||||||
color: 'var(--color-text)',
|
color: 'var(--color-text)',
|
||||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||||
fontSize: '13px',
|
fontSize: '13px',
|
||||||
|
height: '100%',
|
||||||
},
|
},
|
||||||
'&.cm-focused': {
|
'&.cm-focused': {
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Rehype plugins for markdown rendering (used with react-markdown).
|
* 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 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];
|
||||||
|
|
|
||||||
|
|
@ -426,7 +426,14 @@ export interface TeamsAPI {
|
||||||
getLogsForTask: (
|
getLogsForTask: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: 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[]>;
|
) => Promise<MemberLogSummary[]>;
|
||||||
getMemberStats: (teamName: string, memberName: string) => Promise<MemberFullStats>;
|
getMemberStats: (teamName: string, memberName: string) => Promise<MemberFullStats>;
|
||||||
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
|
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,12 @@ export interface EditorAPI {
|
||||||
* Intended as a performance optimization: avoids watching the whole project tree.
|
* Intended as a performance optimization: avoids watching the whole project tree.
|
||||||
*/
|
*/
|
||||||
setWatchedFiles: (filePaths: string[]) => Promise<void>;
|
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. */
|
/** Subscribe to file change events (main → renderer). Returns cleanup function. */
|
||||||
onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void;
|
onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -247,8 +247,10 @@ export interface AppConfig {
|
||||||
snoozeMinutes: number;
|
snoozeMinutes: number;
|
||||||
/** Whether to include errors from subagent sessions */
|
/** Whether to include errors from subagent sessions */
|
||||||
includeSubagentErrors: boolean;
|
includeSubagentErrors: boolean;
|
||||||
/** Whether to show native OS notifications for team inbox messages */
|
/** Whether to show native OS notifications when teammates send messages to the team lead */
|
||||||
notifyOnInboxMessages: boolean;
|
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 */
|
/** Whether to show native OS notifications when a task needs user clarification */
|
||||||
notifyOnClarifications: boolean;
|
notifyOnClarifications: boolean;
|
||||||
/** Notification triggers - define when to generate notifications */
|
/** Notification triggers - define when to generate notifications */
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,21 @@ export interface TeamSummary {
|
||||||
|
|
||||||
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
|
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 {
|
export interface TaskComment {
|
||||||
id: string;
|
id: string;
|
||||||
author: string;
|
author: string;
|
||||||
text: string;
|
text: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
type: TaskCommentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record<keyof TeamTask, unknown>`.
|
// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record<keyof TeamTask, unknown>`.
|
||||||
|
|
@ -72,6 +82,11 @@ export interface TeamTask {
|
||||||
owner?: string;
|
owner?: string;
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
status: TeamTaskStatus;
|
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[];
|
blocks?: string[];
|
||||||
blockedBy?: string[];
|
blockedBy?: string[];
|
||||||
/**
|
/**
|
||||||
|
|
@ -271,6 +286,7 @@ export interface TeamCreateConfigRequest {
|
||||||
description?: string;
|
description?: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
members: TeamProvisioningMemberInput[];
|
members: TeamProvisioningMemberInput[];
|
||||||
|
cwd?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TeamCreateResponse {
|
export interface TeamCreateResponse {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
||||||
EDITOR_GIT_STATUS: 'editor:gitStatus',
|
EDITOR_GIT_STATUS: 'editor:gitStatus',
|
||||||
EDITOR_WATCH_DIR: 'editor:watchDir',
|
EDITOR_WATCH_DIR: 'editor:watchDir',
|
||||||
EDITOR_SET_WATCHED_FILES: 'editor:setWatchedFiles',
|
EDITOR_SET_WATCHED_FILES: 'editor:setWatchedFiles',
|
||||||
|
EDITOR_SET_WATCHED_DIRS: 'editor:setWatchedDirs',
|
||||||
EDITOR_CHANGE: 'editor:change',
|
EDITOR_CHANGE: 'editor:change',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -148,8 +149,8 @@ describe('Editor IPC handlers', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('registration', () => {
|
describe('registration', () => {
|
||||||
it('registers all 16 editor channels', () => {
|
it('registers all 17 editor channels', () => {
|
||||||
expect(mockIpc.handle).toHaveBeenCalledTimes(16);
|
expect(mockIpc.handle).toHaveBeenCalledTimes(17);
|
||||||
expect(mockIpc._handlers.has('editor:open')).toBe(true);
|
expect(mockIpc._handlers.has('editor:open')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:close')).toBe(true);
|
expect(mockIpc._handlers.has('editor:close')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:readDir')).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:gitStatus')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:watchDir')).toBe(true);
|
expect(mockIpc._handlers.has('editor:watchDir')).toBe(true);
|
||||||
expect(mockIpc._handlers.has('editor:setWatchedFiles')).toBe(true);
|
expect(mockIpc._handlers.has('editor:setWatchedFiles')).toBe(true);
|
||||||
|
expect(mockIpc._handlers.has('editor:setWatchedDirs')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removeEditorHandlers clears all channels', () => {
|
it('removeEditorHandlers clears all channels', () => {
|
||||||
removeEditorHandlers(mockIpc as unknown as IpcMain);
|
removeEditorHandlers(mockIpc as unknown as IpcMain);
|
||||||
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(16);
|
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(17);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
describe('isWatching', () => {
|
||||||
it('returns false when not started', () => {
|
it('returns false when not started', () => {
|
||||||
expect(watcher.isWatching()).toBe(false);
|
expect(watcher.isWatching()).toBe(false);
|
||||||
|
|
|
||||||
|
|
@ -245,4 +245,186 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
// Full file has 200 messages — must NOT be capped at 50 or 100
|
// Full file has 200 messages — must NOT be capped at 50 or 100
|
||||||
expect(carolLogs[0]?.messageCount).toBe(200);
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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, [
|
const { stdout, exitCode } = run(claudeDir, [
|
||||||
'task',
|
'task',
|
||||||
'comment',
|
'comment',
|
||||||
|
|
@ -735,6 +735,7 @@ describe('teamctl.js', () => {
|
||||||
expect(comments).toHaveLength(1);
|
expect(comments).toHaveLength(1);
|
||||||
expect(comments[0].text).toBe('Hello world');
|
expect(comments[0].text).toBe('Hello world');
|
||||||
expect(comments[0].author).toBe('alice');
|
expect(comments[0].author).toBe('alice');
|
||||||
|
expect(comments[0].type).toBe('regular');
|
||||||
expect(String(comments[0].id)).toMatch(UUID_RE);
|
expect(String(comments[0].id)).toMatch(UUID_RE);
|
||||||
expect(String(comments[0].createdAt)).toMatch(ISO_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
|
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', 'First', '--from', 'alice']);
|
||||||
run(claudeDir, ['task', 'comment', '1', '--text', 'Second', '--from', 'bob']);
|
run(claudeDir, ['task', 'comment', '1', '--text', 'Second', '--from', 'bob']);
|
||||||
run(claudeDir, ['task', 'comment', '1', '--text', 'Third', '--from', 'alice']);
|
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.text)).toEqual(['First', 'Second', 'Third']);
|
||||||
expect(comments.map((c) => c.author)).toEqual(['alice', 'bob', 'alice']);
|
expect(comments.map((c) => c.author)).toEqual(['alice', 'bob', 'alice']);
|
||||||
expect(new Set(comments.map((c) => c.id)).size).toBe(3);
|
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', () => {
|
it('comment on task without comments array initializes it', () => {
|
||||||
|
|
@ -1283,6 +1285,83 @@ describe('teamctl.js', () => {
|
||||||
expect(exitCode).not.toBe(0);
|
expect(exitCode).not.toBe(0);
|
||||||
expect(stderr).toContain('Usage');
|
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');
|
expect(comments[0].text).toBe('Hello');
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- reviewApprove without --notify-owner creates NO inbox ---
|
// --- reviewApprove without --notify-owner creates NO inbox but DOES record comment ---
|
||||||
it('review approve without --notify-owner does NOT create inbox', () => {
|
it('review approve without --notify-owner does NOT create inbox but records comment', () => {
|
||||||
writeTask(claudeDir, '1', {
|
writeTask(claudeDir, '1', {
|
||||||
id: '1',
|
id: '1',
|
||||||
subject: 'Feature',
|
subject: 'Feature',
|
||||||
|
|
@ -1835,10 +1914,14 @@ describe('teamctl.js', () => {
|
||||||
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
||||||
run(claudeDir, ['review', 'approve', '1']); // no --notify-owner
|
run(claudeDir, ['review', 'approve', '1']); // no --notify-owner
|
||||||
expect(readInbox(claudeDir, 'bob')).toEqual([]);
|
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 ---
|
// --- request-changes: verify ALL four side effects ---
|
||||||
it('review request-changes: kanban cleared + status in_progress + inbox sent', () => {
|
it('review request-changes: kanban cleared + status in_progress + comment recorded + inbox sent', () => {
|
||||||
writeTask(claudeDir, '1', {
|
writeTask(claudeDir, '1', {
|
||||||
id: '1',
|
id: '1',
|
||||||
subject: 'PR',
|
subject: 'PR',
|
||||||
|
|
@ -1859,7 +1942,13 @@ describe('teamctl.js', () => {
|
||||||
expect((readKanban(claudeDir).tasks as Record<string, unknown>)['1']).toBeUndefined();
|
expect((readKanban(claudeDir).tasks as Record<string, unknown>)['1']).toBeUndefined();
|
||||||
// 2) Status changed to in_progress
|
// 2) Status changed to in_progress
|
||||||
expect(readTask(claudeDir, '1').status).toBe('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>[];
|
const inbox = readInbox(claudeDir, 'bob') as Record<string, unknown>[];
|
||||||
expect(inbox).toHaveLength(1);
|
expect(inbox).toHaveLength(1);
|
||||||
expect(inbox[0].from).toBe('alice');
|
expect(inbox[0].from).toBe('alice');
|
||||||
|
|
@ -2249,8 +2338,8 @@ describe('teamctl.js', () => {
|
||||||
expect(after[0].label).toBe('server-1');
|
expect(after[0].label).toBe('server-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- review approve also writes to kanban (column=approved) ---
|
// --- review approve also writes to kanban (column=approved) + comment ---
|
||||||
it('review approve sets kanban column to approved with movedAt', () => {
|
it('review approve sets kanban column to approved with movedAt and records comment', () => {
|
||||||
writeTask(claudeDir, '1', {
|
writeTask(claudeDir, '1', {
|
||||||
id: '1',
|
id: '1',
|
||||||
subject: 'PR task',
|
subject: 'PR task',
|
||||||
|
|
@ -2262,6 +2351,10 @@ describe('teamctl.js', () => {
|
||||||
const entry = (readKanban(claudeDir).tasks as Record<string, Record<string, unknown>>)['1'];
|
const entry = (readKanban(claudeDir).tasks as Record<string, Record<string, unknown>>)['1'];
|
||||||
expect(entry.column).toBe('approved');
|
expect(entry.column).toBe('approved');
|
||||||
expect(String(entry.movedAt)).toMatch(ISO_RE);
|
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 ---
|
// --- Task create without --description defaults to subject ---
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue