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",
|
||||
"date-fns": "^3.6.0",
|
||||
"diff": "^8.0.3",
|
||||
"dompurify": "^3.3.1",
|
||||
"electron-updater": "^6.7.3",
|
||||
"fastify": "^5.7.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
|
|
@ -117,12 +118,14 @@
|
|||
"isbinaryfile": "^6.0.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mdast-util-to-hast": "^13.2.1",
|
||||
"mermaid": "^11.12.3",
|
||||
"node-diff3": "^3.2.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "^11.0.0",
|
||||
"simple-git": "^3.32.3",
|
||||
|
|
|
|||
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> {
|
||||
// Check config toggle
|
||||
// Check global toggle
|
||||
const config = configManager.getConfig();
|
||||
if (!config.notifications.enabled || !config.notifications.notifyOnInboxMessages) return;
|
||||
if (!config.notifications.enabled) return;
|
||||
|
||||
// detail is like "inboxes/carol.json" — extract member name
|
||||
const match = /^inboxes\/(.+)\.json$/.exec(detail);
|
||||
if (!match) return;
|
||||
const memberName = match[1];
|
||||
|
||||
// Only notify for the lead's inbox (messages addressed to the human user).
|
||||
// CLI doesn't set msg.to, so we filter by inbox file name instead.
|
||||
// Determine inbox type and check per-inbox toggle
|
||||
const leadName = teamDataService ? await teamDataService.getLeadMemberName(teamName) : null;
|
||||
if (leadName !== null && memberName !== leadName && memberName !== 'user') return;
|
||||
const isLeadInbox = leadName !== null && memberName === leadName;
|
||||
const isUserInbox = memberName === 'user';
|
||||
|
||||
if (isLeadInbox && !config.notifications.notifyOnLeadInbox) return;
|
||||
if (isUserInbox && !config.notifications.notifyOnUserInbox) return;
|
||||
if (!isLeadInbox && !isUserInbox) return;
|
||||
|
||||
const key = `${teamName}:${memberName}`;
|
||||
|
||||
|
|
@ -400,7 +404,7 @@ function wireFileWatcherEvents(context: ServiceContext): void {
|
|||
// These don't go through inbox files — they're held in-memory by TeamProvisioningService.
|
||||
if (detail === 'lead-process-reply' || detail === 'lead-direct-reply') {
|
||||
const cfg = configManager.getConfig();
|
||||
if (cfg.notifications.enabled && cfg.notifications.notifyOnInboxMessages) {
|
||||
if (cfg.notifications.enabled && cfg.notifications.notifyOnUserInbox) {
|
||||
const messages = teamProvisioningService.getLiveLeadProcessMessages(teamName);
|
||||
const latest = messages.length > 0 ? messages[messages.length - 1] : undefined;
|
||||
// Only notify for messages addressed to the human user, skip noise
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
EDITOR_READ_FILE,
|
||||
EDITOR_RENAME_FILE,
|
||||
EDITOR_SEARCH_IN_FILES,
|
||||
EDITOR_SET_WATCHED_DIRS,
|
||||
EDITOR_SET_WATCHED_FILES,
|
||||
EDITOR_WATCH_DIR,
|
||||
EDITOR_WRITE_FILE,
|
||||
|
|
@ -366,6 +367,19 @@ async function handleEditorSetWatchedFiles(
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update watched directory list (shallow, depth=0).
|
||||
*/
|
||||
async function handleEditorSetWatchedDirs(
|
||||
_event: IpcMainInvokeEvent,
|
||||
dirPaths: string[]
|
||||
): Promise<IpcResult<void>> {
|
||||
return wrapHandler('setWatchedDirs', async () => {
|
||||
if (!activeProjectRoot) throw new Error('Editor not initialized');
|
||||
editorFileWatcher.setWatchedDirs(Array.isArray(dirPaths) ? dirPaths : []);
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Registration
|
||||
// =============================================================================
|
||||
|
|
@ -399,6 +413,7 @@ export function registerEditorHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(EDITOR_GIT_STATUS, handleEditorGitStatus);
|
||||
ipcMain.handle(EDITOR_WATCH_DIR, handleEditorWatchDir);
|
||||
ipcMain.handle(EDITOR_SET_WATCHED_FILES, handleEditorSetWatchedFiles);
|
||||
ipcMain.handle(EDITOR_SET_WATCHED_DIRS, handleEditorSetWatchedDirs);
|
||||
}
|
||||
|
||||
export function removeEditorHandlers(ipcMain: IpcMain): void {
|
||||
|
|
@ -418,6 +433,7 @@ export function removeEditorHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(EDITOR_GIT_STATUS);
|
||||
ipcMain.removeHandler(EDITOR_WATCH_DIR);
|
||||
ipcMain.removeHandler(EDITOR_SET_WATCHED_FILES);
|
||||
ipcMain.removeHandler(EDITOR_SET_WATCHED_DIRS);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1313,6 +1313,14 @@ async function handleCreateConfig(
|
|||
if (payload.color !== undefined && typeof payload.color !== 'string') {
|
||||
return { success: false, error: 'color must be a string' };
|
||||
}
|
||||
if (payload.cwd !== undefined) {
|
||||
if (typeof payload.cwd !== 'string' || payload.cwd.trim().length === 0) {
|
||||
return { success: false, error: 'cwd must be a non-empty string if provided' };
|
||||
}
|
||||
if (!path.isAbsolute(payload.cwd.trim())) {
|
||||
return { success: false, error: 'cwd must be an absolute path' };
|
||||
}
|
||||
}
|
||||
|
||||
const seenNames = new Set<string>();
|
||||
const members: TeamCreateConfigRequest['members'] = [];
|
||||
|
|
@ -1344,6 +1352,7 @@ async function handleCreateConfig(
|
|||
description: payload.description?.trim() || undefined,
|
||||
color: typeof payload.color === 'string' ? payload.color.trim() || undefined : undefined,
|
||||
members,
|
||||
cwd: typeof payload.cwd === 'string' ? payload.cwd.trim() || undefined : undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -1377,7 +1386,12 @@ async function handleGetLogsForTask(
|
|||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
taskId: unknown,
|
||||
options?: { owner?: string; status?: string }
|
||||
options?: {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
}
|
||||
): Promise<IpcResult<MemberLogSummary[]>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) {
|
||||
|
|
@ -1392,6 +1406,17 @@ async function handleGetLogsForTask(
|
|||
? {
|
||||
owner: typeof options.owner === 'string' ? options.owner : undefined,
|
||||
status: typeof options.status === 'string' ? options.status : undefined,
|
||||
since: typeof options.since === 'string' ? options.since : undefined,
|
||||
intervals: Array.isArray(options.intervals)
|
||||
? (options.intervals as unknown[]).filter(
|
||||
(i): i is { startedAt: string; completedAt?: string } =>
|
||||
Boolean(i) &&
|
||||
typeof i === 'object' &&
|
||||
typeof (i as Record<string, unknown>).startedAt === 'string' &&
|
||||
((i as Record<string, unknown>).completedAt === undefined ||
|
||||
typeof (i as Record<string, unknown>).completedAt === 'string')
|
||||
)
|
||||
: undefined,
|
||||
}
|
||||
: undefined;
|
||||
return wrapTeamHandler('getLogsForTask', () =>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const MAX_EMITTED_EVENTS_PER_FLUSH = 300;
|
|||
|
||||
export class EditorFileWatcher {
|
||||
private watcher: FSWatcher | null = null;
|
||||
private dirWatcher: FSWatcher | null = null;
|
||||
private projectRoot: string | null = null;
|
||||
private pendingEvents = new Map<string, EditorFileChangeEvent['type']>();
|
||||
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
|
@ -39,6 +40,7 @@ export class EditorFileWatcher {
|
|||
private readonly DEBOUNCE_MS = 350;
|
||||
private ignoreChangeUntilMs = 0;
|
||||
private watchedFilesKey = '';
|
||||
private watchedDirsKey = '';
|
||||
|
||||
/**
|
||||
* Initialize watcher context for a project root.
|
||||
|
|
@ -51,6 +53,7 @@ export class EditorFileWatcher {
|
|||
this.projectRoot = projectRoot;
|
||||
this.ignoreChangeUntilMs = Date.now() + STARTUP_IGNORE_CHANGE_MS;
|
||||
this.watchedFilesKey = '';
|
||||
this.watchedDirsKey = '';
|
||||
|
||||
log.info('Starting file watcher (open files only) for:', projectRoot);
|
||||
this.onChangeCallback = onChange;
|
||||
|
|
@ -62,7 +65,7 @@ export class EditorFileWatcher {
|
|||
*/
|
||||
setWatchedFiles(filePaths: string[]): void {
|
||||
if (!this.projectRoot) {
|
||||
throw new Error('Watcher not initialized');
|
||||
return; // Watcher not initialized yet — will sync when start() is called
|
||||
}
|
||||
|
||||
const normalized = filePaths
|
||||
|
|
@ -113,6 +116,60 @@ export class EditorFileWatcher {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update list of watched directory paths (shallow: depth=0).
|
||||
* Watches only immediate children changes (create/delete/rename) in those folders.
|
||||
*/
|
||||
setWatchedDirs(dirPaths: string[]): void {
|
||||
if (!this.projectRoot) {
|
||||
return; // Watcher not initialized yet — will sync when start() is called
|
||||
}
|
||||
|
||||
const normalized = dirPaths
|
||||
.filter((p): p is string => typeof p === 'string' && p.length > 0)
|
||||
.filter((p) => isPathWithinRoot(p, this.projectRoot!));
|
||||
|
||||
normalized.sort();
|
||||
const key = normalized.join('\n');
|
||||
if (key === this.watchedDirsKey) return;
|
||||
this.watchedDirsKey = key;
|
||||
|
||||
if (this.dirWatcher) {
|
||||
void this.dirWatcher.close();
|
||||
this.dirWatcher = null;
|
||||
}
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dirWatcher = watch(normalized, {
|
||||
ignoreInitial: true,
|
||||
ignorePermissionErrors: true,
|
||||
followSymlinks: false,
|
||||
depth: 0,
|
||||
});
|
||||
|
||||
const emitSafe = (type: EditorFileChangeEvent['type'], filePath: string): void => {
|
||||
if (!isPathWithinRoot(filePath, this.projectRoot!)) {
|
||||
log.warn('Watcher event outside project root, ignoring:', filePath);
|
||||
return;
|
||||
}
|
||||
this.pendingEvents.set(filePath, type);
|
||||
this.scheduleFlush();
|
||||
};
|
||||
|
||||
// For directories, we only care about structural changes.
|
||||
this.dirWatcher.on('add', (p) => emitSafe('create', p));
|
||||
this.dirWatcher.on('unlink', (p) => emitSafe('delete', p));
|
||||
this.dirWatcher.on('addDir', (p) => emitSafe('create', p));
|
||||
this.dirWatcher.on('unlinkDir', (p) => emitSafe('delete', p));
|
||||
|
||||
this.dirWatcher.on('error', (error) => {
|
||||
log.error('Dir watcher error:', error);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching. Safe to call multiple times.
|
||||
*/
|
||||
|
|
@ -125,11 +182,17 @@ export class EditorFileWatcher {
|
|||
this.onChangeCallback = null;
|
||||
this.ignoreChangeUntilMs = 0;
|
||||
this.watchedFilesKey = '';
|
||||
this.watchedDirsKey = '';
|
||||
if (this.watcher) {
|
||||
log.info('Stopping file watcher');
|
||||
void this.watcher.close();
|
||||
this.watcher = null;
|
||||
}
|
||||
if (this.dirWatcher) {
|
||||
log.info('Stopping directory watcher');
|
||||
void this.dirWatcher.close();
|
||||
this.dirWatcher = null;
|
||||
}
|
||||
this.projectRoot = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,8 +40,10 @@ export interface NotificationConfig {
|
|||
snoozeMinutes: number; // Default snooze duration
|
||||
/** Whether to include errors from subagent sessions */
|
||||
includeSubagentErrors: boolean;
|
||||
/** Whether to show native OS notifications for team inbox messages */
|
||||
notifyOnInboxMessages: boolean;
|
||||
/** Whether to show native OS notifications when teammates send messages to the team lead */
|
||||
notifyOnLeadInbox: boolean;
|
||||
/** Whether to show native OS notifications when teammates send messages to you (the user) */
|
||||
notifyOnUserInbox: boolean;
|
||||
/** Whether to show native OS notifications when a task needs user clarification */
|
||||
notifyOnClarifications: boolean;
|
||||
/** Notification triggers - define when to generate notifications */
|
||||
|
|
@ -249,7 +251,8 @@ const DEFAULT_CONFIG: AppConfig = {
|
|||
snoozedUntil: null,
|
||||
snoozeMinutes: 30,
|
||||
includeSubagentErrors: true,
|
||||
notifyOnInboxMessages: true,
|
||||
notifyOnLeadInbox: false,
|
||||
notifyOnUserInbox: true,
|
||||
notifyOnClarifications: true,
|
||||
triggers: DEFAULT_TRIGGERS,
|
||||
},
|
||||
|
|
@ -416,6 +419,7 @@ export class ConfigManager {
|
|||
private mergeWithDefaults(loaded: Partial<AppConfig>): AppConfig {
|
||||
const loadedNotifications = loaded.notifications ?? ({} as Partial<NotificationConfig>);
|
||||
const loadedTriggers = loadedNotifications.triggers ?? [];
|
||||
|
||||
const mergedGeneral: GeneralConfig = {
|
||||
...DEFAULT_CONFIG.general,
|
||||
...(loaded.general ?? {}),
|
||||
|
|
|
|||
|
|
@ -195,10 +195,37 @@ function writeTask(taskPath, task) {
|
|||
return verify;
|
||||
}
|
||||
|
||||
function applyWorkIntervalsForStatusTransition(task, prevStatus, nextStatus, now) {
|
||||
var wasInProgress = prevStatus === 'in_progress';
|
||||
var isInProgress = nextStatus === 'in_progress';
|
||||
var intervals = Array.isArray(task.workIntervals) ? task.workIntervals.slice() : [];
|
||||
var last = intervals.length ? intervals[intervals.length - 1] : null;
|
||||
|
||||
if (!wasInProgress && isInProgress) {
|
||||
if (!last || typeof last.completedAt === 'string') {
|
||||
intervals.push({ startedAt: now });
|
||||
}
|
||||
} else if (wasInProgress && !isInProgress) {
|
||||
// Close the most recent open interval (if any).
|
||||
for (var i = intervals.length - 1; i >= 0; i--) {
|
||||
if (intervals[i] && typeof intervals[i].startedAt === 'string' && !intervals[i].completedAt) {
|
||||
intervals[i].completedAt = now;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (intervals.length > 0) task.workIntervals = intervals;
|
||||
else delete task.workIntervals;
|
||||
}
|
||||
|
||||
function setTaskStatus(paths, taskId, status) {
|
||||
const normalized = normalizeStatus(status);
|
||||
if (!normalized) die('Invalid status: ' + String(status));
|
||||
const { taskPath, task } = readTask(paths, taskId);
|
||||
var prev = task.status;
|
||||
var now = nowIso();
|
||||
applyWorkIntervalsForStatusTransition(task, prev, normalized, now);
|
||||
task.status = normalized;
|
||||
writeTask(taskPath, task);
|
||||
}
|
||||
|
|
@ -244,6 +271,7 @@ function addTaskComment(paths, taskId, flags) {
|
|||
id: commentId,
|
||||
author: from,
|
||||
text: text,
|
||||
type: 'regular',
|
||||
createdAt: nowIso(),
|
||||
};
|
||||
task.comments = existing.concat([comment]);
|
||||
|
|
@ -413,7 +441,6 @@ function createTask(paths, flags) {
|
|||
: '';
|
||||
const owner = typeof flags.owner === 'string' && flags.owner.trim() ? flags.owner.trim() : undefined;
|
||||
const explicitStatus = typeof flags.status === 'string' ? flags.status : '';
|
||||
const status = normalizeStatus(explicitStatus) || (owner ? 'in_progress' : 'pending');
|
||||
const activeForm =
|
||||
typeof flags.activeForm === 'string'
|
||||
? flags.activeForm
|
||||
|
|
@ -423,6 +450,13 @@ function createTask(paths, flags) {
|
|||
|
||||
var blockedByIds = parseIdList(flags['blocked-by']);
|
||||
var relatedIds = parseIdList(flags.related);
|
||||
// Default status rule:
|
||||
// - explicit --status always wins
|
||||
// - tasks with dependencies should start as pending, even if assigned (owner)
|
||||
// - otherwise, assigned tasks default to in_progress, unassigned to pending
|
||||
const status =
|
||||
normalizeStatus(explicitStatus) ||
|
||||
(blockedByIds.length > 0 ? 'pending' : owner ? 'in_progress' : 'pending');
|
||||
for (var v = 0; v < blockedByIds.length; v++) { if (!taskExists(paths, blockedByIds[v])) die('Blocked-by task not found: #' + blockedByIds[v]); }
|
||||
for (var w = 0; w < relatedIds.length; w++) { if (!taskExists(paths, relatedIds[w])) die('Related task not found: #' + relatedIds[w]); }
|
||||
|
||||
|
|
@ -434,6 +468,7 @@ function createTask(paths, flags) {
|
|||
while (true) {
|
||||
nextId = getNextTaskId(paths);
|
||||
taskPath = path.join(paths.tasksDir, String(nextId) + '.json');
|
||||
var createdAt = nowIso();
|
||||
task = {
|
||||
id: nextId,
|
||||
subject,
|
||||
|
|
@ -442,6 +477,8 @@ function createTask(paths, flags) {
|
|||
owner,
|
||||
createdBy: from,
|
||||
status,
|
||||
createdAt: createdAt,
|
||||
workIntervals: status === 'in_progress' ? [{ startedAt: createdAt }] : undefined,
|
||||
blocks: [],
|
||||
blockedBy: blockedByIds,
|
||||
related: relatedIds.length > 0 ? relatedIds : undefined,
|
||||
|
|
@ -563,18 +600,32 @@ function sendInboxMessage(paths, teamName, flags) {
|
|||
|
||||
function reviewApprove(paths, teamName, taskId, flags) {
|
||||
setKanbanColumn(paths, teamName, taskId, 'approved');
|
||||
const notify = flags.notify === true || flags['notify-owner'] === true;
|
||||
if (!notify) return;
|
||||
const { task } = readTask(paths, taskId);
|
||||
if (!task.owner) return;
|
||||
const { taskPath, task } = readTask(paths, taskId);
|
||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
||||
const note = typeof flags.note === 'string' ? flags.note.trim() : '';
|
||||
const text = note
|
||||
|
||||
// Record review comment in task.comments
|
||||
var existing = Array.isArray(task.comments) ? task.comments : [];
|
||||
var reviewCommentId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now()) + '-' + String(Math.random());
|
||||
task.comments = existing.concat([{
|
||||
id: reviewCommentId,
|
||||
author: from,
|
||||
text: note || 'Approved',
|
||||
type: 'review_approved',
|
||||
createdAt: nowIso(),
|
||||
}]);
|
||||
writeTask(taskPath, task);
|
||||
|
||||
const notify = flags.notify === true || flags['notify-owner'] === true;
|
||||
if (!notify || !task.owner) return;
|
||||
const inboxText = note
|
||||
? 'Task #' + String(taskId) + ' approved.\n\n' + note
|
||||
: 'Task #' + String(taskId) + ' approved.';
|
||||
sendInboxMessage(paths, teamName, {
|
||||
to: task.owner,
|
||||
text,
|
||||
text: inboxText,
|
||||
summary: 'Approved #' + String(taskId),
|
||||
from,
|
||||
});
|
||||
|
|
@ -585,12 +636,29 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
|||
const { taskPath, task } = readTask(paths, taskId);
|
||||
if (!task.owner) die('No owner found for task ' + String(taskId));
|
||||
|
||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
||||
|
||||
clearKanban(paths, teamName, taskId);
|
||||
var now = nowIso();
|
||||
applyWorkIntervalsForStatusTransition(task, task.status, 'in_progress', now);
|
||||
task.status = 'in_progress';
|
||||
|
||||
// Record review comment in task.comments
|
||||
var existing = Array.isArray(task.comments) ? task.comments : [];
|
||||
var reviewCommentId = crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: String(Date.now()) + '-' + String(Math.random());
|
||||
task.comments = existing.concat([{
|
||||
id: reviewCommentId,
|
||||
author: from,
|
||||
text: comment || 'Reviewer requested changes.',
|
||||
type: 'review_request',
|
||||
createdAt: now,
|
||||
}]);
|
||||
|
||||
writeTask(taskPath, task);
|
||||
|
||||
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : inferLeadName(paths);
|
||||
const text =
|
||||
const inboxText =
|
||||
'Task #' +
|
||||
String(taskId) +
|
||||
' needs fixes.\n\n' +
|
||||
|
|
@ -599,7 +667,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
|||
'Please fix and mark it as completed when ready.';
|
||||
sendInboxMessage(paths, teamName, {
|
||||
to: task.owner,
|
||||
text,
|
||||
text: inboxText,
|
||||
summary: 'Fix request for #' + String(taskId),
|
||||
from,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -956,11 +956,15 @@ export class TeamDataService {
|
|||
await fs.promises.mkdir(tasksDir, { recursive: true });
|
||||
|
||||
const joinedAt = Date.now();
|
||||
const config = {
|
||||
const config: Record<string, unknown> = {
|
||||
name: request.displayName?.trim() || request.teamName,
|
||||
description: request.description?.trim() || undefined,
|
||||
color: request.color?.trim() || undefined,
|
||||
};
|
||||
if (request.cwd?.trim()) {
|
||||
config.projectPath = request.cwd.trim();
|
||||
config.projectPathHistory = [request.cwd.trim()];
|
||||
}
|
||||
|
||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||
await this.membersMetaStore.writeMembers(
|
||||
|
|
|
|||
|
|
@ -110,7 +110,12 @@ export class TeamMemberLogsFinder {
|
|||
async findLogsForTask(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
options?: { owner?: string; status?: string }
|
||||
options?: {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
}
|
||||
): Promise<MemberLogSummary[]> {
|
||||
const discovery = await this.discoverProjectSessions(teamName);
|
||||
if (!discovery) return [];
|
||||
|
|
@ -171,6 +176,56 @@ export class TeamMemberLogsFinder {
|
|||
options.owner.trim().length > 0;
|
||||
if (includeOwnerSessions) {
|
||||
const ownerLogs = await this.findMemberLogs(teamName, options.owner!.trim());
|
||||
|
||||
const TASK_LOG_INTERVAL_GRACE_MS = 10_000;
|
||||
const fallbackRecentMs = 30 * 60_000; // if caller doesn't supply intervals/since, avoid pulling in old owner history
|
||||
const now = Date.now();
|
||||
|
||||
const normalizedIntervals = Array.isArray(options?.intervals)
|
||||
? options.intervals
|
||||
.map((i) => {
|
||||
const startMs = Date.parse(i.startedAt);
|
||||
const endMsRaw =
|
||||
typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN;
|
||||
const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null;
|
||||
return Number.isFinite(startMs) ? { startMs, endMs } : null;
|
||||
})
|
||||
.filter((v): v is { startMs: number; endMs: number | null } => v !== null)
|
||||
: [];
|
||||
|
||||
// Back-compat: single since timestamp -> treat as open interval.
|
||||
const sinceMsRaw = typeof options?.since === 'string' ? Date.parse(options.since) : NaN;
|
||||
const sinceMs = Number.isFinite(sinceMsRaw) ? sinceMsRaw : null;
|
||||
const effectiveIntervals =
|
||||
normalizedIntervals.length > 0
|
||||
? normalizedIntervals
|
||||
: sinceMs != null
|
||||
? [{ startMs: sinceMs, endMs: null }]
|
||||
: [];
|
||||
|
||||
const overlapsAnyInterval = (logStartMs: number, logEndMs: number): boolean => {
|
||||
for (const it of effectiveIntervals) {
|
||||
const start = it.startMs - TASK_LOG_INTERVAL_GRACE_MS;
|
||||
const end = (it.endMs ?? now) + TASK_LOG_INTERVAL_GRACE_MS;
|
||||
if (logStartMs <= end && logEndMs >= start) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const filteredOwnerLogs = ownerLogs.filter((log) => {
|
||||
if (log.isOngoing) return true;
|
||||
const startMs = new Date(log.startTime).getTime();
|
||||
if (!Number.isFinite(startMs)) return false;
|
||||
const durationMs =
|
||||
typeof log.durationMs === 'number' && log.durationMs > 0 ? log.durationMs : 0;
|
||||
const endMs = startMs + durationMs;
|
||||
|
||||
if (effectiveIntervals.length > 0) {
|
||||
return overlapsAnyInterval(startMs, endMs);
|
||||
}
|
||||
|
||||
return startMs >= now - fallbackRecentMs;
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
for (const log of results) {
|
||||
const key =
|
||||
|
|
@ -179,7 +234,7 @@ export class TeamMemberLogsFinder {
|
|||
: `lead:${log.sessionId}`;
|
||||
seen.add(key);
|
||||
}
|
||||
for (const log of ownerLogs) {
|
||||
for (const log of filteredOwnerLogs) {
|
||||
const key =
|
||||
log.kind === 'subagent'
|
||||
? `subagent:${log.sessionId}:${log.subagentId}`
|
||||
|
|
@ -409,12 +464,17 @@ export class TeamMemberLogsFinder {
|
|||
const patterns: RegExp[] = [
|
||||
new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'),
|
||||
new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'),
|
||||
new RegExp(`#${escaped}\\b`),
|
||||
];
|
||||
if (numericTaskId) {
|
||||
patterns.push(
|
||||
new RegExp(`"task_id"\\s*:\\s*${numericTaskId}\\b`),
|
||||
new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`)
|
||||
new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`),
|
||||
// Support teamctl command lines (may appear in tool output).
|
||||
// Example: node ".../teamctl.js" --team "t" task start 10
|
||||
new RegExp(
|
||||
`\\bteamctl(?:\\.js)?\\b.{0,250}\\b(?:task|review)\\b.{0,250}\\b${numericTaskId}\\b`,
|
||||
'i'
|
||||
)
|
||||
);
|
||||
}
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -380,14 +380,18 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
|
|||
`- Assign/reassign owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> <member-name> --notify --from "${leadName}"`,
|
||||
`- Clear owner: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-owner <id> clear`,
|
||||
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
|
||||
`- Create with deps: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --owner "<member>" --notify --from "${leadName}"`,
|
||||
`- Create with deps (blocked work MUST be pending): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --status pending --owner "<member>" --notify --from "${leadName}"`,
|
||||
`- Link dependency: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --blocked-by <targetId>`,
|
||||
`- Link related: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --related <targetId>`,
|
||||
`- Unlink: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task unlink <id> --blocked-by <targetId>`,
|
||||
``,
|
||||
`Dependency guidelines:`,
|
||||
`- Use --blocked-by when a task cannot start until another is done.`,
|
||||
`- If you set --blocked-by, create the task in pending (use --status pending). Do NOT put blocked tasks into in_progress.`,
|
||||
`- Use --related to link related work (e.g. frontend + backend) without blocking.`,
|
||||
`- Review tasks: Prefer NOT creating a separate "review task". Reviews apply to the work task (#X) via: review approve/request-changes #X.`,
|
||||
` - If you must create a separate review reminder/assignment task, keep it pending and link it to #X with --related (and optionally --blocked-by #X if it truly cannot start yet).`,
|
||||
` - Dependencies do not auto-start tasks; the owner must explicitly start it when ready.`,
|
||||
`- Avoid over-specifying. Only add dependencies when execution order matters.`,
|
||||
``,
|
||||
`Notification policy:`,
|
||||
|
|
@ -554,6 +558,13 @@ ${processRegistration}
|
|||
- Prefer fewer, broader tasks over many micro-tasks.
|
||||
- Avoid duplicate notifications for the same assignment.
|
||||
- When tasks have natural ordering (e.g. setup → implementation → testing), use --blocked-by.
|
||||
- If a task is blocked (uses --blocked-by), it MUST be created as pending (use --status pending). Do NOT mark blocked tasks in_progress.
|
||||
- Review guidance:
|
||||
- Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: run review approve/request-changes on the implementation task #X.
|
||||
- If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task:
|
||||
- Use --related to connect it to #X (non-blocking link).
|
||||
- If the review truly cannot start until #X is done, ALSO add --blocked-by #X.
|
||||
- There is no automatic status transition when dependencies resolve — the owner must explicitly start it (task start / set-status in_progress) when ready.
|
||||
- Use --related to connect tasks working on the same feature without blocking.
|
||||
|
||||
4) After all steps, output a short summary.
|
||||
|
|
@ -1347,23 +1358,35 @@ export class TeamProvisioningService {
|
|||
configParsed.leadSessionId.trim().length > 0
|
||||
) {
|
||||
const candidateId = configParsed.leadSessionId.trim();
|
||||
const projectPath =
|
||||
const storedProjectPath =
|
||||
typeof configParsed.projectPath === 'string' &&
|
||||
configParsed.projectPath.trim().length > 0
|
||||
? configParsed.projectPath.trim()
|
||||
: request.cwd;
|
||||
const projectId = encodePath(projectPath);
|
||||
const baseDir = extractBaseDir(projectId);
|
||||
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
|
||||
if (await this.pathExists(jsonlPath)) {
|
||||
previousSessionId = candidateId;
|
||||
: null;
|
||||
|
||||
// Sessions are stored per-project (~/.claude/projects/{encodePath(cwd)}/).
|
||||
// If the project path changed, the old session JSONL won't be found by the CLI
|
||||
// at the new project directory. Skip resume to avoid passing an invalid --resume arg.
|
||||
if (storedProjectPath && path.resolve(storedProjectPath) !== path.resolve(request.cwd)) {
|
||||
logger.info(
|
||||
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
|
||||
`[${request.teamName}] Project path changed: ${storedProjectPath} → ${request.cwd}. ` +
|
||||
`Skipping session resume — sessions are per-project.`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
|
||||
);
|
||||
const resumeProjectPath = storedProjectPath ?? request.cwd;
|
||||
const projectId = encodePath(resumeProjectPath);
|
||||
const baseDir = extractBaseDir(projectId);
|
||||
const jsonlPath = path.join(getProjectsBasePath(), baseDir, `${candidateId}.jsonl`);
|
||||
if (await this.pathExists(jsonlPath)) {
|
||||
previousSessionId = candidateId;
|
||||
logger.info(
|
||||
`[${request.teamName}] Found previous session JSONL for resume: ${candidateId}`
|
||||
);
|
||||
} else {
|
||||
logger.info(
|
||||
`[${request.teamName}] Previous session JSONL not found at ${jsonlPath}, starting fresh`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -1377,6 +1400,11 @@ export class TeamProvisioningService {
|
|||
// Normalize config.json to keep only the team-lead before spawning the CLI, so we get stable names.
|
||||
await this.normalizeTeamConfigForLaunch(request.teamName, configRaw);
|
||||
|
||||
// Update projectPath in config IMMEDIATELY so TeamDetailView shows the correct path
|
||||
// even if provisioning is interrupted or the user stops the team early.
|
||||
// If launch fails, restorePrelaunchConfig() will revert to the backup (old projectPath).
|
||||
await this.updateConfigProjectPath(request.teamName, request.cwd);
|
||||
|
||||
let claudePath: string | null;
|
||||
try {
|
||||
await ensureCwdExists(request.cwd);
|
||||
|
|
@ -2806,6 +2834,35 @@ export class TeamProvisioningService {
|
|||
return token.length > 0 ? token : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immediately update projectPath in config.json at launch start, before CLI spawn.
|
||||
* Ensures TeamDetailView shows the correct project path even if provisioning
|
||||
* is interrupted. On failure, restorePrelaunchConfig() reverts to the backup.
|
||||
*/
|
||||
private async updateConfigProjectPath(teamName: string, cwd: string): Promise<void> {
|
||||
const configPath = path.join(getTeamsBasePath(), teamName, 'config.json');
|
||||
try {
|
||||
const raw = await fs.promises.readFile(configPath, 'utf8');
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
config.projectPath = cwd;
|
||||
|
||||
const pathHistory = Array.isArray(config.projectPathHistory)
|
||||
? (config.projectPathHistory as string[]).filter((p) => typeof p === 'string' && p !== cwd)
|
||||
: [];
|
||||
pathHistory.push(cwd);
|
||||
config.projectPathHistory = pathHistory.slice(-500);
|
||||
|
||||
await atomicWriteAsync(configPath, JSON.stringify(config, null, 2));
|
||||
logger.info(`[${teamName}] Updated config.projectPath immediately: ${cwd}`);
|
||||
} catch (error) {
|
||||
// Non-fatal: updateConfigPostLaunch will update it later if provisioning succeeds.
|
||||
logger.warn(
|
||||
`[${teamName}] Failed to update projectPath early: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Single atomic read-mutate-write for post-launch config updates.
|
||||
* Combines session history append and projectPath update to avoid
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { createLogger } from '@shared/utils/logger';
|
|||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import type { TaskComment, TeamTask } from '@shared/types';
|
||||
import type { TaskComment, TaskWorkInterval, TeamTask } from '@shared/types';
|
||||
|
||||
const logger = createLogger('Service:TeamTaskReader');
|
||||
|
||||
|
|
@ -97,6 +97,21 @@ export class TeamTaskReader {
|
|||
// `satisfies Record<keyof TeamTask, unknown>` ensures compile-time
|
||||
// safety: if a field is added to TeamTask but not mapped here,
|
||||
// TypeScript will error. This prevents silently dropping new fields.
|
||||
const workIntervals: TaskWorkInterval[] | undefined = Array.isArray(parsed.workIntervals)
|
||||
? (parsed.workIntervals as unknown[])
|
||||
.filter(
|
||||
(i): i is { startedAt: string; completedAt?: string } =>
|
||||
Boolean(i) &&
|
||||
typeof i === 'object' &&
|
||||
typeof (i as Record<string, unknown>).startedAt === 'string' &&
|
||||
((i as Record<string, unknown>).completedAt === undefined ||
|
||||
typeof (i as Record<string, unknown>).completedAt === 'string')
|
||||
)
|
||||
.map((i) => ({
|
||||
startedAt: i.startedAt,
|
||||
completedAt: i.completedAt,
|
||||
}))
|
||||
: undefined;
|
||||
const task: TeamTask = {
|
||||
id:
|
||||
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||
|
|
@ -110,6 +125,7 @@ export class TeamTaskReader {
|
|||
)
|
||||
? (parsed.status as TeamTask['status'])
|
||||
: 'pending',
|
||||
workIntervals,
|
||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as string[]) : undefined,
|
||||
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as string[]) : undefined,
|
||||
related: Array.isArray(parsed.related)
|
||||
|
|
@ -119,15 +135,22 @@ export class TeamTaskReader {
|
|||
updatedAt,
|
||||
projectPath: typeof parsed.projectPath === 'string' ? parsed.projectPath : undefined,
|
||||
comments: Array.isArray(parsed.comments)
|
||||
? (parsed.comments as TaskComment[]).filter(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c === 'object' &&
|
||||
typeof c.id === 'string' &&
|
||||
typeof c.author === 'string' &&
|
||||
typeof c.text === 'string' &&
|
||||
typeof c.createdAt === 'string'
|
||||
)
|
||||
? (parsed.comments as TaskComment[])
|
||||
.filter(
|
||||
(c) =>
|
||||
c &&
|
||||
typeof c === 'object' &&
|
||||
typeof c.id === 'string' &&
|
||||
typeof c.author === 'string' &&
|
||||
typeof c.text === 'string' &&
|
||||
typeof c.createdAt === 'string'
|
||||
)
|
||||
.map((c) => ({
|
||||
...c,
|
||||
type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type)
|
||||
? c.type
|
||||
: ('regular' as const),
|
||||
}))
|
||||
: undefined,
|
||||
needsClarification: (['lead', 'user'] as const).includes(
|
||||
parsed.needsClarification as 'lead' | 'user'
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import * as path from 'path';
|
|||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
import type { TaskComment, TeamTask, TeamTaskStatus } from '@shared/types';
|
||||
import type { TaskComment, TaskCommentType, TeamTask, TeamTaskStatus } from '@shared/types';
|
||||
|
||||
const taskWriteLocks = new Map<string, Promise<void>>();
|
||||
|
||||
|
|
@ -46,13 +46,23 @@ export class TeamTaskWriter {
|
|||
|
||||
// Ensure CLI-compatible format: description, blocks, blockedBy are required
|
||||
// by Claude Code CLI's Zod schema validation (safeParse fails without them)
|
||||
const createdAt = task.createdAt ?? new Date().toISOString();
|
||||
const cliCompatibleTask: TeamTask = {
|
||||
...task,
|
||||
description: task.description ?? '',
|
||||
blocks: task.blocks ?? [],
|
||||
blockedBy: task.blockedBy ?? [],
|
||||
related: task.related ?? [],
|
||||
createdAt: task.createdAt ?? new Date().toISOString(),
|
||||
createdAt,
|
||||
workIntervals:
|
||||
task.status === 'in_progress'
|
||||
? // Start the first work interval on creation when task starts immediately.
|
||||
[
|
||||
...(Array.isArray(task.workIntervals) && task.workIntervals.length > 0
|
||||
? task.workIntervals
|
||||
: [{ startedAt: createdAt }]),
|
||||
]
|
||||
: task.workIntervals,
|
||||
};
|
||||
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(cliCompatibleTask, null, 2));
|
||||
|
|
@ -273,6 +283,29 @@ export class TeamTaskWriter {
|
|||
}
|
||||
|
||||
const task = JSON.parse(raw) as TeamTask;
|
||||
const prevStatus = task.status;
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
// Maintain workIntervals as periods of time where status === 'in_progress'.
|
||||
const intervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
|
||||
const last = intervals.length > 0 ? intervals[intervals.length - 1] : undefined;
|
||||
|
||||
const wasInProgress = prevStatus === 'in_progress';
|
||||
const isInProgress = status === 'in_progress';
|
||||
|
||||
if (!wasInProgress && isInProgress) {
|
||||
// Entering in_progress: open a new interval if none is open.
|
||||
if (!last || typeof last.completedAt === 'string') {
|
||||
intervals.push({ startedAt: nowIso });
|
||||
}
|
||||
} else if (wasInProgress && !isInProgress) {
|
||||
// Leaving in_progress: close open interval if present.
|
||||
if (last && last.completedAt === undefined) {
|
||||
last.completedAt = nowIso;
|
||||
}
|
||||
}
|
||||
|
||||
task.workIntervals = intervals.length > 0 ? intervals : undefined;
|
||||
task.status = status;
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
|
||||
|
|
@ -323,8 +356,20 @@ export class TeamTaskWriter {
|
|||
}
|
||||
|
||||
const task = JSON.parse(raw) as TeamTask;
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
// Ensure any open in_progress interval is closed on delete.
|
||||
if (task.status === 'in_progress') {
|
||||
const intervals = Array.isArray(task.workIntervals) ? [...task.workIntervals] : [];
|
||||
const last = intervals.length > 0 ? intervals[intervals.length - 1] : undefined;
|
||||
if (last && last.completedAt === undefined) {
|
||||
last.completedAt = nowIso;
|
||||
}
|
||||
task.workIntervals = intervals.length > 0 ? intervals : task.workIntervals;
|
||||
}
|
||||
|
||||
task.status = 'deleted';
|
||||
task.deletedAt = new Date().toISOString();
|
||||
task.deletedAt = nowIso;
|
||||
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||
|
||||
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
|
||||
|
|
@ -417,7 +462,7 @@ export class TeamTaskWriter {
|
|||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
options?: { id?: string; author?: string; createdAt?: string }
|
||||
options?: { id?: string; author?: string; createdAt?: string; type?: TaskCommentType }
|
||||
): Promise<TaskComment> {
|
||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||
const comment: TaskComment = {
|
||||
|
|
@ -425,6 +470,7 @@ export class TeamTaskWriter {
|
|||
author: options?.author ?? 'user',
|
||||
text,
|
||||
createdAt: options?.createdAt ?? new Date().toISOString(),
|
||||
type: options?.type ?? 'regular',
|
||||
};
|
||||
|
||||
await withTaskLock(taskPath, async () => {
|
||||
|
|
|
|||
|
|
@ -461,6 +461,9 @@ export const EDITOR_WATCH_DIR = 'editor:watchDir';
|
|||
/** Update list of watched file paths (open tabs) */
|
||||
export const EDITOR_SET_WATCHED_FILES = 'editor:setWatchedFiles';
|
||||
|
||||
/** Update list of watched directories (shallow: depth=0) */
|
||||
export const EDITOR_SET_WATCHED_DIRS = 'editor:setWatchedDirs';
|
||||
|
||||
/** Read binary file as base64 for inline preview */
|
||||
export const EDITOR_READ_BINARY_PREVIEW = 'editor:readBinaryPreview';
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
EDITOR_READ_FILE,
|
||||
EDITOR_RENAME_FILE,
|
||||
EDITOR_SEARCH_IN_FILES,
|
||||
EDITOR_SET_WATCHED_DIRS,
|
||||
EDITOR_SET_WATCHED_FILES,
|
||||
EDITOR_WATCH_DIR,
|
||||
EDITOR_WRITE_FILE,
|
||||
|
|
@ -710,7 +711,12 @@ const electronAPI: ElectronAPI = {
|
|||
getLogsForTask: async (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
options?: { owner?: string; status?: string }
|
||||
options?: {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
since?: string;
|
||||
}
|
||||
) => {
|
||||
return invokeIpcWithResult<MemberLogSummary[]>(
|
||||
TEAM_GET_LOGS_FOR_TASK,
|
||||
|
|
@ -1035,6 +1041,8 @@ const electronAPI: ElectronAPI = {
|
|||
watchDir: (enable: boolean) => invokeIpcWithResult<void>(EDITOR_WATCH_DIR, enable),
|
||||
setWatchedFiles: (filePaths: string[]) =>
|
||||
invokeIpcWithResult<void>(EDITOR_SET_WATCHED_FILES, filePaths),
|
||||
setWatchedDirs: (dirPaths: string[]) =>
|
||||
invokeIpcWithResult<void>(EDITOR_SET_WATCHED_DIRS, dirPaths),
|
||||
onEditorChange: (callback: (event: EditorFileChangeEvent) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: EditorFileChangeEvent): void =>
|
||||
callback(data);
|
||||
|
|
|
|||
|
|
@ -991,6 +991,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
setWatchedFiles: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
setWatchedDirs: async () => {
|
||||
throw new Error('Editor not available in browser mode');
|
||||
},
|
||||
onEditorChange: () => {
|
||||
return () => {};
|
||||
},
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
PROSE_TABLE_HEADER_BG,
|
||||
} from '@renderer/constants/cssVariables';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { REHYPE_PLUGINS } from '@renderer/utils/markdownPlugins';
|
||||
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
||||
import { FileText } from 'lucide-react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -34,6 +34,8 @@ import {
|
|||
type SearchContext,
|
||||
} from '../searchHighlightUtils';
|
||||
|
||||
import { MermaidDiagram } from './MermaidDiagram';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
|
@ -49,6 +51,95 @@ interface MarkdownViewerProps {
|
|||
copyable?: boolean;
|
||||
/** When true, renders without wrapper background/border (for embedding inside cards) */
|
||||
bare?: boolean;
|
||||
/** Base directory for resolving relative URLs (images, links) via local-resource:// protocol */
|
||||
baseDir?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
/** Check if a URL is relative (not absolute, not data, not mailto, not hash) */
|
||||
function isRelativeUrl(url: string): boolean {
|
||||
return (
|
||||
!!url &&
|
||||
!url.startsWith('http://') &&
|
||||
!url.startsWith('https://') &&
|
||||
!url.startsWith('data:') &&
|
||||
!url.startsWith('#') &&
|
||||
!url.startsWith('mailto:')
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a relative path to an absolute path given a base directory */
|
||||
function resolveRelativePath(relativeSrc: string, baseDir: string): string {
|
||||
const cleaned = relativeSrc.startsWith('./') ? relativeSrc.slice(2) : relativeSrc;
|
||||
return `${baseDir}/${cleaned}`;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LocalImage — loads images via IPC (readBinaryPreview) for local file access
|
||||
// =============================================================================
|
||||
|
||||
interface LocalImageProps {
|
||||
src: string;
|
||||
alt?: string;
|
||||
baseDir: string;
|
||||
}
|
||||
|
||||
const LocalImage = React.memo(function LocalImage({
|
||||
src,
|
||||
alt,
|
||||
baseDir,
|
||||
}: LocalImageProps): React.ReactElement {
|
||||
const [dataUrl, setDataUrl] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
setDataUrl(null);
|
||||
setError(false);
|
||||
|
||||
const fullPath = resolveRelativePath(src, baseDir);
|
||||
window.electronAPI.editor
|
||||
.readBinaryPreview(fullPath)
|
||||
.then((result) => {
|
||||
if (!cancelled) {
|
||||
setDataUrl(`data:${result.mimeType};base64,${result.base64}`);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [src, baseDir]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-text-muted">
|
||||
[Image: {alt || src}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dataUrl) {
|
||||
return (
|
||||
<span className="inline-block size-4 animate-pulse rounded bg-surface-raised align-middle" />
|
||||
);
|
||||
}
|
||||
|
||||
return <img src={dataUrl} alt={alt || ''} className="my-2 max-w-full rounded" />;
|
||||
});
|
||||
|
||||
/** Extract plain text from a hast (HTML AST) node tree */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- hast node shape varies
|
||||
function hastToText(node: any): string {
|
||||
if (node.type === 'text') return node.value ?? '';
|
||||
if (node.children) return node.children.map(hastToText).join('');
|
||||
return '';
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -180,18 +271,29 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
|||
);
|
||||
},
|
||||
|
||||
// Code blocks
|
||||
pre: ({ children }) => (
|
||||
<pre
|
||||
className="my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
|
||||
style={{
|
||||
backgroundColor: PROSE_PRE_BG,
|
||||
border: `1px solid ${PROSE_PRE_BORDER}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
// Code blocks — intercept mermaid diagrams at the pre level
|
||||
pre: ({ children, node }) => {
|
||||
// Check if this pre contains a mermaid code block
|
||||
const codeEl = node?.children?.[0];
|
||||
if (codeEl && 'tagName' in codeEl && codeEl.tagName === 'code' && 'properties' in codeEl) {
|
||||
const cls = (codeEl.properties as Record<string, unknown>)?.className;
|
||||
if (Array.isArray(cls) && cls.some((c) => String(c) === 'language-mermaid')) {
|
||||
return <MermaidDiagram code={hastToText(codeEl)} />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
className="my-3 max-w-full overflow-x-auto rounded-lg p-3 text-xs leading-relaxed"
|
||||
style={{
|
||||
backgroundColor: PROSE_PRE_BG,
|
||||
border: `1px solid ${PROSE_PRE_BORDER}`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
|
||||
// Blockquotes
|
||||
blockquote: ({ children }) => (
|
||||
|
|
@ -288,6 +390,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
itemId,
|
||||
copyable = false,
|
||||
bare = false,
|
||||
baseDir,
|
||||
}) => {
|
||||
const [showRaw, setShowRaw] = React.useState(false);
|
||||
const [rawLimit, setRawLimit] = React.useState(LARGE_PREVIEW_CHARS);
|
||||
|
|
@ -435,7 +538,20 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
// Create markdown components with optional search highlighting
|
||||
// When search is active, create fresh each render (match counter is stateful and must start at 0)
|
||||
// useMemo would cache stale closures when parent re-renders without search deps changing
|
||||
const components = searchCtx ? createViewerMarkdownComponents(searchCtx) : defaultComponents;
|
||||
const baseComponents = searchCtx ? createViewerMarkdownComponents(searchCtx) : defaultComponents;
|
||||
|
||||
// When baseDir is set (editor preview), override img to load local files via IPC
|
||||
const components = baseDir
|
||||
? {
|
||||
...baseComponents,
|
||||
img: ({ src, alt }: { src?: string; alt?: string }) => {
|
||||
if (src && isRelativeUrl(src)) {
|
||||
return <LocalImage src={src} alt={alt} baseDir={baseDir} />;
|
||||
}
|
||||
return <img src={src} alt={alt || ''} className="my-2 max-w-full rounded" />;
|
||||
},
|
||||
}
|
||||
: baseComponents;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -481,8 +597,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
<div className="min-w-0 break-words p-4">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={disableHighlight ? [] : REHYPE_PLUGINS}
|
||||
urlTransform={(url) => url}
|
||||
rehypePlugins={disableHighlight ? REHYPE_PLUGINS_NO_HIGHLIGHT : REHYPE_PLUGINS}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
|
|
|
|||
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;
|
||||
snoozeMinutes: number;
|
||||
includeSubagentErrors: boolean;
|
||||
notifyOnInboxMessages: boolean;
|
||||
notifyOnLeadInbox: boolean;
|
||||
notifyOnUserInbox: boolean;
|
||||
notifyOnClarifications: boolean;
|
||||
triggers: AppConfig['notifications']['triggers'];
|
||||
};
|
||||
|
|
@ -171,7 +172,8 @@ export function useSettingsConfig(): UseSettingsConfigReturn {
|
|||
snoozedUntil: displayConfig?.notifications?.snoozedUntil ?? null,
|
||||
snoozeMinutes: displayConfig?.notifications?.snoozeMinutes ?? 30,
|
||||
includeSubagentErrors: displayConfig?.notifications?.includeSubagentErrors ?? true,
|
||||
notifyOnInboxMessages: displayConfig?.notifications?.notifyOnInboxMessages ?? true,
|
||||
notifyOnLeadInbox: displayConfig?.notifications?.notifyOnLeadInbox ?? false,
|
||||
notifyOnUserInbox: displayConfig?.notifications?.notifyOnUserInbox ?? true,
|
||||
notifyOnClarifications: displayConfig?.notifications?.notifyOnClarifications ?? true,
|
||||
triggers: displayConfig?.notifications?.triggers ?? [],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -287,7 +287,8 @@ export function useSettingsHandlers({
|
|||
snoozedUntil: null,
|
||||
snoozeMinutes: 30,
|
||||
includeSubagentErrors: true,
|
||||
notifyOnInboxMessages: true,
|
||||
notifyOnLeadInbox: false,
|
||||
notifyOnUserInbox: true,
|
||||
notifyOnClarifications: true,
|
||||
triggers: defaultTriggers,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -36,7 +36,8 @@ interface NotificationsSectionProps {
|
|||
| 'enabled'
|
||||
| 'soundEnabled'
|
||||
| 'includeSubagentErrors'
|
||||
| 'notifyOnInboxMessages'
|
||||
| 'notifyOnLeadInbox'
|
||||
| 'notifyOnUserInbox'
|
||||
| 'notifyOnClarifications',
|
||||
value: boolean
|
||||
) => void;
|
||||
|
|
@ -136,12 +137,22 @@ export const NotificationsSection = ({
|
|||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="Team inbox notifications"
|
||||
description="Show native OS notifications when teammates send messages to the lead"
|
||||
label="Lead inbox notifications"
|
||||
description="Notify when teammates send messages to the team lead"
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.notifyOnInboxMessages}
|
||||
onChange={(v) => onNotificationToggle('notifyOnInboxMessages', v)}
|
||||
enabled={safeConfig.notifications.notifyOnLeadInbox}
|
||||
onChange={(v) => onNotificationToggle('notifyOnLeadInbox', v)}
|
||||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label="User inbox notifications"
|
||||
description="Notify when teammates send messages to you"
|
||||
>
|
||||
<SettingsToggle
|
||||
enabled={safeConfig.notifications.notifyOnUserInbox}
|
||||
onChange={(v) => onNotificationToggle('notifyOnUserInbox', v)}
|
||||
disabled={saving || !safeConfig.notifications.enabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { normalizePath } from '@renderer/utils/pathNormalize';
|
|||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { AlertTriangle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
|
||||
import { ExtendedContextCheckbox } from './ExtendedContextCheckbox';
|
||||
import { MembersJsonEditor } from './MembersJsonEditor';
|
||||
import { ProjectPathSelector } from './ProjectPathSelector';
|
||||
|
||||
|
|
@ -279,6 +280,7 @@ export const CreateTeamDialog = ({
|
|||
const [launchTeam, setLaunchTeam] = useState(true);
|
||||
const [teamColor, setTeamColor] = useState('');
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [extendedContext, setExtendedContext] = useState(false);
|
||||
const [jsonEditorOpen, setJsonEditorOpen] = useState(false);
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [jsonError, setJsonError] = useState<string | null>(null);
|
||||
|
|
@ -303,6 +305,7 @@ export const CreateTeamDialog = ({
|
|||
setCustomCwd('');
|
||||
setLaunchTeam(true);
|
||||
setSelectedModel('');
|
||||
setExtendedContext(false);
|
||||
setJsonEditorOpen(false);
|
||||
setJsonText('');
|
||||
setJsonError(null);
|
||||
|
|
@ -537,8 +540,13 @@ export const CreateTeamDialog = ({
|
|||
[members]
|
||||
);
|
||||
|
||||
const effectiveModel =
|
||||
selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
|
||||
const effectiveModel = useMemo(() => {
|
||||
const base = selectedModel && selectedModel !== '__default__' ? selectedModel : undefined;
|
||||
if (!extendedContext) return base;
|
||||
// 1M context is only supported for opus and sonnet
|
||||
if (base === 'haiku') return base;
|
||||
return base ? `${base}[1m]` : 'sonnet[1m]';
|
||||
}, [selectedModel, extendedContext]);
|
||||
|
||||
const sanitizedTeamName = sanitizeTeamName(teamName.trim());
|
||||
|
||||
|
|
@ -623,6 +631,7 @@ export const CreateTeamDialog = ({
|
|||
description: request.description,
|
||||
color: request.color,
|
||||
members: request.members,
|
||||
cwd: effectiveCwd || undefined,
|
||||
});
|
||||
onOpenTeam(request.teamName, effectiveCwd || undefined);
|
||||
resetFormState();
|
||||
|
|
@ -909,19 +918,27 @@ export const CreateTeamDialog = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="label-optional">Model (optional)</Label>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Default (account setting)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">Default (account setting)</SelectItem>
|
||||
<SelectItem value="opus">Opus 4.6</SelectItem>
|
||||
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
|
||||
<SelectItem value="haiku">Haiku 4.5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Label className="label-optional shrink-0">Model (optional)</Label>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-8 w-auto min-w-[180px] text-xs">
|
||||
<SelectValue placeholder="Default (account setting)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">Default (account setting)</SelectItem>
|
||||
<SelectItem value="opus">Opus 4.6</SelectItem>
|
||||
<SelectItem value="sonnet">Sonnet 4.5</SelectItem>
|
||||
<SelectItem value="haiku">Haiku 4.5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<ExtendedContextCheckbox
|
||||
id="create-extended-context"
|
||||
checked={extendedContext}
|
||||
onCheckedChange={setExtendedContext}
|
||||
disabled={selectedModel === 'haiku'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canCreate && (prepareState === 'idle' || prepareState === 'loading') ? (
|
||||
|
|
|
|||
|
|
@ -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 { api } from '@renderer/api';
|
||||
import { ExtendedContextCheckbox } from '@renderer/components/team/dialogs/ExtendedContextCheckbox';
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
import {
|
||||
|
|
@ -66,6 +67,7 @@ export const LaunchTeamDialog = ({
|
|||
const [prepareWarnings, setPrepareWarnings] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [extendedContext, setExtendedContext] = useState(false);
|
||||
const [clearContext, setClearContext] = useState(false);
|
||||
|
||||
const resetFormState = (): void => {
|
||||
|
|
@ -78,6 +80,7 @@ export const LaunchTeamDialog = ({
|
|||
setSelectedProjectPath('');
|
||||
setCustomCwd('');
|
||||
setSelectedModel('');
|
||||
setExtendedContext(false);
|
||||
setClearContext(false);
|
||||
};
|
||||
|
||||
|
|
@ -240,7 +243,12 @@ export const LaunchTeamDialog = ({
|
|||
teamName,
|
||||
cwd: effectiveCwd,
|
||||
prompt: promptDraft.value.trim() || undefined,
|
||||
model: selectedModel || undefined,
|
||||
model: (() => {
|
||||
if (!extendedContext) return selectedModel || undefined;
|
||||
// 1M context is only supported for opus and sonnet
|
||||
if (selectedModel === 'haiku') return selectedModel;
|
||||
return selectedModel ? `${selectedModel}[1m]` : 'sonnet[1m]';
|
||||
})(),
|
||||
clearContext: clearContext || undefined,
|
||||
});
|
||||
resetFormState();
|
||||
|
|
@ -353,30 +361,38 @@ export const LaunchTeamDialog = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="label-optional">Model (optional)</Label>
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{[
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'opus', label: 'Opus 4.6' },
|
||||
{ value: 'sonnet', label: 'Sonnet 4.5' },
|
||||
{ value: 'haiku', label: 'Haiku 4.5' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
selectedModel === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => setSelectedModel(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Label className="label-optional shrink-0">Model (optional)</Label>
|
||||
<div className="inline-flex rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-0.5">
|
||||
{[
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'opus', label: 'Opus 4.6' },
|
||||
{ value: 'sonnet', label: 'Sonnet 4.5' },
|
||||
{ value: 'haiku', label: 'Haiku 4.5' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'rounded-[3px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
selectedModel === opt.value
|
||||
? 'bg-[var(--color-surface-raised)] text-[var(--color-text)] shadow-sm'
|
||||
: 'text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
onClick={() => setSelectedModel(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ExtendedContextCheckbox
|
||||
id="launch-extended-context"
|
||||
checked={extendedContext}
|
||||
onCheckedChange={setExtendedContext}
|
||||
disabled={selectedModel === 'haiku'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import {
|
||||
|
|
@ -30,6 +30,9 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
|||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
import { MarkdownViewer } from '../../chat/viewers/MarkdownViewer';
|
||||
import { MemberBadge } from '../MemberBadge';
|
||||
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
||||
|
|
@ -72,6 +75,7 @@ export const SendMessageDialog = ({
|
|||
}: SendMessageDialogProps): React.JSX.Element => {
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const [quote, setQuote] = useState<QuotedMessage | undefined>(undefined);
|
||||
const [quoteExpanded, setQuoteExpanded] = useState(false);
|
||||
const [member, setMember] = useState('');
|
||||
const textDraft = useDraftPersistence({ key: 'sendMessage:text' });
|
||||
const chipDraft = useChipDraftPersistence('sendMessage:chips');
|
||||
|
|
@ -84,6 +88,7 @@ export const SendMessageDialog = ({
|
|||
setMember(defaultRecipient ?? '');
|
||||
setSummary('');
|
||||
setQuote(quotedMessage);
|
||||
setQuoteExpanded(false);
|
||||
setPrevResult(lastResult);
|
||||
if (defaultChip) {
|
||||
const token = chipToken(defaultChip);
|
||||
|
|
@ -117,6 +122,9 @@ export const SendMessageDialog = ({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps -- only trigger on pendingAutoClose flag
|
||||
}, [pendingAutoClose]);
|
||||
|
||||
const QUOTE_COLLAPSE_THRESHOLD = 120;
|
||||
const isQuoteLong = (quote?.text.length ?? 0) > QUOTE_COLLAPSE_THRESHOLD;
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
members.map((m) => ({
|
||||
|
|
@ -204,47 +212,71 @@ export const SendMessageDialog = ({
|
|||
</Select>
|
||||
</div>
|
||||
|
||||
{quote ? (
|
||||
<div className="relative rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1.5 top-1.5 rounded p-0.5 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setQuote(undefined)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Remove quote</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
Replying to @{quote.from}
|
||||
</span>
|
||||
<p className="line-clamp-3 pr-5 text-xs text-[var(--color-text-muted)]">
|
||||
{quote.text}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="smd-message">Message</Label>
|
||||
<MentionableTextarea
|
||||
id="smd-message"
|
||||
placeholder="Write your message..."
|
||||
value={textDraft.value}
|
||||
onValueChange={textDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
chips={chipDraft.chips}
|
||||
onChipRemove={handleChipRemove}
|
||||
minRows={4}
|
||||
maxRows={12}
|
||||
footerRight={
|
||||
textDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<div className={quote ? 'flex flex-col' : 'contents'}>
|
||||
{quote ? (
|
||||
<div className="relative overflow-hidden rounded-t-md border border-b-0 border-blue-500/20 bg-blue-950/20 py-2 pl-3 pr-2">
|
||||
{/* Decorative quotation mark */}
|
||||
<span className="pointer-events-none absolute -right-1 top-1/2 -translate-y-1/2 select-none font-serif text-[64px] leading-none text-blue-400/[0.08]">
|
||||
“
|
||||
</span>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1.5 top-1.5 z-10 rounded p-0.5 text-blue-300/40 hover:text-blue-200"
|
||||
onClick={() => setQuote(undefined)}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Remove quote</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="mb-1 flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-blue-300/60">Replying to</span>
|
||||
<MemberBadge name={quote.from} color={colorMap.get(quote.from)} size="sm" />
|
||||
</div>
|
||||
<div
|
||||
className={`pr-5 opacity-50 ${quoteExpanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}
|
||||
>
|
||||
<MarkdownViewer
|
||||
content={quote.text}
|
||||
bare
|
||||
maxHeight={quoteExpanded ? 'max-h-48' : 'max-h-[3.75rem]'}
|
||||
/>
|
||||
</div>
|
||||
{isQuoteLong ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mt-0.5 text-[10px] text-blue-400/60 hover:text-blue-300"
|
||||
onClick={() => setQuoteExpanded((v) => !v)}
|
||||
>
|
||||
{quoteExpanded ? 'less' : 'more'}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<MentionableTextarea
|
||||
id="smd-message"
|
||||
className={quote ? 'rounded-t-none' : undefined}
|
||||
placeholder="Write your message..."
|
||||
value={textDraft.value}
|
||||
onValueChange={textDraft.setValue}
|
||||
suggestions={mentionSuggestions}
|
||||
chips={chipDraft.chips}
|
||||
onChipRemove={handleChipRemove}
|
||||
minRows={4}
|
||||
maxRows={12}
|
||||
footerRight={
|
||||
textDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
|
|
|
|||
|
|
@ -14,7 +14,16 @@ import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
|||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ChevronDown, ChevronUp, MessageSquare, Reply, Send, X } from 'lucide-react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
MessageCircleWarning,
|
||||
MessageSquare,
|
||||
Reply,
|
||||
Send,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
|
||||
|
|
@ -145,8 +154,23 @@ export const TaskCommentsSection = ({
|
|||
) : null}
|
||||
|
||||
{visibleComments.map((comment) => (
|
||||
<div key={comment.id} className="group p-2.5">
|
||||
<div
|
||||
key={comment.id}
|
||||
className={`group rounded-md p-2.5 ${
|
||||
comment.type === 'review_approved'
|
||||
? 'bg-emerald-500/8 border border-emerald-500/15'
|
||||
: comment.type === 'review_request'
|
||||
? 'bg-amber-500/8 border border-amber-500/15'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
{comment.type === 'review_approved' && (
|
||||
<CheckCircle2 size={12} className="shrink-0 text-emerald-400" />
|
||||
)}
|
||||
{comment.type === 'review_request' && (
|
||||
<MessageCircleWarning size={12} className="shrink-0 text-amber-400" />
|
||||
)}
|
||||
<span
|
||||
className="font-medium"
|
||||
style={{
|
||||
|
|
@ -158,6 +182,16 @@ export const TaskCommentsSection = ({
|
|||
>
|
||||
{comment.author}
|
||||
</span>
|
||||
{comment.type === 'review_approved' && (
|
||||
<span className="rounded-full bg-emerald-500/15 px-1.5 py-px text-[9px] font-medium text-emerald-400">
|
||||
Approved
|
||||
</span>
|
||||
)}
|
||||
{comment.type === 'review_request' && (
|
||||
<span className="rounded-full bg-amber-500/15 px-1.5 py-px text-[9px] font-medium text-amber-400">
|
||||
Changes requested
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{(() => {
|
||||
const date = new Date(comment.createdAt);
|
||||
|
|
|
|||
|
|
@ -598,6 +598,7 @@ export const TaskDetailDialog = ({
|
|||
taskId={currentTask.id}
|
||||
taskOwner={currentTask.owner}
|
||||
taskStatus={currentTask.status}
|
||||
taskWorkIntervals={currentTask.workIntervals}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleTeamSection>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@
|
|||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { defaultKeymap, history, historyKeymap, redo, undo } from '@codemirror/commands';
|
||||
import { bracketMatching, indentOnInput, syntaxHighlighting } from '@codemirror/language';
|
||||
import {
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
foldKeymap,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from '@codemirror/language';
|
||||
import { gotoLine, search, searchKeymap } from '@codemirror/search';
|
||||
import { Compartment, EditorState } from '@codemirror/state';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
|
|
@ -67,6 +73,8 @@ interface CodeMirrorEditorProps {
|
|||
onDraftRecovered?: (filePath: string) => void;
|
||||
/** Called when text selection changes (for floating action menu) */
|
||||
onSelectionChange?: (info: EditorSelectionInfo | null) => void;
|
||||
/** Called with the current document text on changes (debounced, for live preview) */
|
||||
onDocChange?: (content: string) => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
@ -95,6 +103,7 @@ function buildEditableExtensions(
|
|||
highlightActiveLineGutter(),
|
||||
bracketMatching(),
|
||||
indentOnInput(),
|
||||
foldGutter(),
|
||||
|
||||
// History
|
||||
history(),
|
||||
|
|
@ -129,6 +138,7 @@ function buildEditableExtensions(
|
|||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...searchKeymap.filter((k) => k.run !== gotoLine),
|
||||
...foldKeymap,
|
||||
]),
|
||||
|
||||
// Update listener for dirty flag + cursor position + selection
|
||||
|
|
@ -220,6 +230,8 @@ function enforceDraftLimit(): void {
|
|||
// Component
|
||||
// =============================================================================
|
||||
|
||||
const DOC_CHANGE_DEBOUNCE_MS = 150;
|
||||
|
||||
export const CodeMirrorEditor = ({
|
||||
filePath,
|
||||
content,
|
||||
|
|
@ -228,6 +240,7 @@ export const CodeMirrorEditor = ({
|
|||
onCursorChange,
|
||||
onDraftRecovered,
|
||||
onSelectionChange,
|
||||
onDocChange,
|
||||
}: CodeMirrorEditorProps): React.ReactElement => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
|
@ -241,6 +254,8 @@ export const CodeMirrorEditor = ({
|
|||
const autosaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Selection debounce
|
||||
const selectionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Doc change debounce (live preview)
|
||||
const docChangeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const markFileModified = useStore((s) => s.markFileModified);
|
||||
const discardChanges = useStore((s) => s.discardChanges);
|
||||
|
|
@ -260,6 +275,9 @@ export const CodeMirrorEditor = ({
|
|||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
|
||||
const onDocChangeRef = useRef(onDocChange);
|
||||
onDocChangeRef.current = onDocChange;
|
||||
|
||||
const lineWrapRef = useRef(lineWrap);
|
||||
lineWrapRef.current = lineWrap;
|
||||
|
||||
|
|
@ -282,6 +300,13 @@ export const CodeMirrorEditor = ({
|
|||
saveDraft(filePathRef.current, view.state.doc.toString());
|
||||
}
|
||||
}, AUTOSAVE_DELAY_MS);
|
||||
|
||||
// Live content callback for markdown preview
|
||||
if (docChangeTimerRef.current) clearTimeout(docChangeTimerRef.current);
|
||||
docChangeTimerRef.current = setTimeout(() => {
|
||||
const view = viewRef.current;
|
||||
if (view) onDocChangeRef.current?.(view.state.doc.toString());
|
||||
}, DOC_CHANGE_DEBOUNCE_MS);
|
||||
}, [markFileModified]);
|
||||
|
||||
const handleCursorMove = useCallback((line: number, col: number) => {
|
||||
|
|
@ -421,6 +446,7 @@ export const CodeMirrorEditor = ({
|
|||
const dirtyTimer = dirtyTimerRef;
|
||||
const autosaveTimer = autosaveTimerRef;
|
||||
const selectionTimer = selectionTimerRef;
|
||||
const docChangeTimer = docChangeTimerRef;
|
||||
|
||||
return () => {
|
||||
// Save scroll position before destroying
|
||||
|
|
@ -433,6 +459,7 @@ export const CodeMirrorEditor = ({
|
|||
if (dirtyTimer.current) clearTimeout(dirtyTimer.current);
|
||||
if (autosaveTimer.current) clearTimeout(autosaveTimer.current);
|
||||
if (selectionTimer.current) clearTimeout(selectionTimer.current);
|
||||
if (docChangeTimer.current) clearTimeout(docChangeTimer.current);
|
||||
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
|
|
@ -475,5 +502,5 @@ export const CodeMirrorEditor = ({
|
|||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={containerRef} className="size-full overflow-auto" />;
|
||||
return <div ref={containerRef} className="size-full overflow-hidden" />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -64,6 +64,13 @@ const SHORTCUT_GROUPS: { title: string; shortcuts: ShortcutDef[] }[] = [
|
|||
{ mac: '⌘ /', other: 'Ctrl+/', description: 'Toggle Comment' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Markdown',
|
||||
shortcuts: [
|
||||
{ mac: '⌘ ⇧ M', other: 'Ctrl+Shift+M', description: 'Split Preview' },
|
||||
{ mac: '⌘ ⇧ V', other: 'Ctrl+Shift+V', description: 'Full Preview' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'General',
|
||||
shortcuts: [{ mac: 'Esc', other: 'Esc', description: 'Close Editor' }],
|
||||
|
|
|
|||
|
|
@ -10,14 +10,32 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui
|
|||
import { useStore } from '@renderer/store';
|
||||
import { editorBridge } from '@renderer/utils/editorBridge';
|
||||
import { shortcutLabel } from '@renderer/utils/platformKeys';
|
||||
import { Redo2, Save, Undo2, WrapText } from 'lucide-react';
|
||||
import { Columns2, Eye, Redo2, Save, Undo2, WrapText } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type MdPreviewMode = 'off' | 'split' | 'preview';
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export const EditorToolbar = (): React.ReactElement | null => {
|
||||
interface EditorToolbarProps {
|
||||
isMarkdown?: boolean;
|
||||
mdPreviewMode?: MdPreviewMode;
|
||||
onToggleSplit?: () => void;
|
||||
onToggleFullPreview?: () => void;
|
||||
}
|
||||
|
||||
export const EditorToolbar = ({
|
||||
isMarkdown = false,
|
||||
mdPreviewMode = 'off',
|
||||
onToggleSplit,
|
||||
onToggleFullPreview,
|
||||
}: EditorToolbarProps): React.ReactElement | null => {
|
||||
const { activeTabId, modifiedFiles, saving, lineWrap } = useStore(
|
||||
useShallow((s) => ({
|
||||
activeTabId: s.editorActiveTabId,
|
||||
|
|
@ -77,6 +95,25 @@ export const EditorToolbar = (): React.ReactElement | null => {
|
|||
onClick={toggleLineWrap}
|
||||
active={lineWrap}
|
||||
/>
|
||||
{isMarkdown && (
|
||||
<>
|
||||
<div className="mx-1 h-4 w-px bg-border" />
|
||||
<ToolbarButton
|
||||
icon={<Columns2 className="size-3.5" />}
|
||||
label={mdPreviewMode === 'split' ? 'Close split preview' : 'Split preview'}
|
||||
shortcut={shortcutLabel('⌘ ⇧ M', 'Ctrl+Shift+M')}
|
||||
onClick={onToggleSplit ?? (() => {})}
|
||||
active={mdPreviewMode === 'split'}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Eye className="size-3.5" />}
|
||||
label={mdPreviewMode === 'preview' ? 'Close preview' : 'Full preview'}
|
||||
shortcut={shortcutLabel('⌘ ⇧ V', 'Ctrl+Shift+V')}
|
||||
onClick={onToggleFullPreview ?? (() => {})}
|
||||
active={mdPreviewMode === 'preview'}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
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 { EditorToolbar } from './EditorToolbar';
|
||||
import { GoToLineDialog } from './GoToLineDialog';
|
||||
import { MarkdownSplitView } from './MarkdownSplitView';
|
||||
import { QuickOpenDialog } from './QuickOpenDialog';
|
||||
import { SearchInFilesPanel } from './SearchInFilesPanel';
|
||||
|
||||
import type { MdPreviewMode } from './EditorToolbar';
|
||||
import type {
|
||||
EditorSelectionAction,
|
||||
EditorSelectionInfo,
|
||||
|
|
@ -121,6 +123,18 @@ export const ProjectEditorOverlay = ({
|
|||
const editorContentRef = useRef<HTMLDivElement>(null);
|
||||
const [containerRect, setContainerRect] = useState<DOMRect>(() => new DOMRect());
|
||||
|
||||
// Markdown preview state
|
||||
const [mdPreviewMode, setMdPreviewMode] = useState<MdPreviewMode>('off');
|
||||
const [liveContent, setLiveContent] = useState('');
|
||||
const [splitRatio, setSplitRatio] = useState(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('editor:mdSplitRatio');
|
||||
return stored ? Math.max(0.2, Math.min(0.8, Number(stored))) : 0.5;
|
||||
} catch {
|
||||
return 0.5;
|
||||
}
|
||||
});
|
||||
|
||||
// Iter-4: New state
|
||||
const [quickOpenVisible, setQuickOpenVisible] = useState(false);
|
||||
const [searchPanelVisible, setSearchPanelVisible] = useState(false);
|
||||
|
|
@ -141,6 +155,48 @@ export const ProjectEditorOverlay = ({
|
|||
|
||||
// Active tab metadata
|
||||
const activeTab = openTabs.find((t) => t.id === activeTabId) ?? null;
|
||||
const isMarkdown = activeTab?.language === 'Markdown';
|
||||
|
||||
// Auto-enable split preview for markdown tabs, reset for non-markdown
|
||||
useEffect(() => {
|
||||
if (isMarkdown) {
|
||||
setMdPreviewMode((m) => (m === 'off' ? 'split' : m));
|
||||
} else {
|
||||
setMdPreviewMode('off');
|
||||
}
|
||||
}, [isMarkdown, activeTabId]);
|
||||
|
||||
// Persist split ratio
|
||||
const handleSplitRatioChange = useCallback((ratio: number) => {
|
||||
setSplitRatio(ratio);
|
||||
try {
|
||||
localStorage.setItem('editor:mdSplitRatio', String(ratio));
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleLiveContent = useCallback((content: string) => {
|
||||
setLiveContent(content);
|
||||
}, []);
|
||||
|
||||
const toggleMdSplit = useCallback(() => {
|
||||
setMdPreviewMode((m) => (m === 'split' ? 'off' : 'split'));
|
||||
}, []);
|
||||
|
||||
const toggleMdPreview = useCallback(() => {
|
||||
setMdPreviewMode((m) => (m === 'preview' ? 'off' : 'preview'));
|
||||
}, []);
|
||||
|
||||
// Initialize live content when entering preview mode or switching files
|
||||
useEffect(() => {
|
||||
if (mdPreviewMode !== 'off' && fileContent?.content) {
|
||||
setLiveContent(fileContent.content);
|
||||
}
|
||||
}, [mdPreviewMode, fileContent?.content]);
|
||||
|
||||
// Content for preview: use live content when available, fallback to file content
|
||||
const previewContent = liveContent || fileContent?.content || '';
|
||||
|
||||
const loadFileContent = useCallback(
|
||||
async (filePath: string) => {
|
||||
|
|
@ -444,6 +500,8 @@ export const ProjectEditorOverlay = ({
|
|||
onToggleGoToLine: toggleGoToLine,
|
||||
onToggleSidebar: toggleSidebar,
|
||||
onClose: handleCloseRequest,
|
||||
onToggleMdSplit: isMarkdown ? toggleMdSplit : undefined,
|
||||
onToggleMdPreview: isMarkdown ? toggleMdPreview : undefined,
|
||||
});
|
||||
|
||||
const projectName = projectPath.split('/').pop() ?? projectPath;
|
||||
|
|
@ -580,7 +638,12 @@ export const ProjectEditorOverlay = ({
|
|||
<EditorTabBar onRequestCloseTab={handleRequestCloseTab} />
|
||||
|
||||
{/* Toolbar */}
|
||||
<EditorToolbar />
|
||||
<EditorToolbar
|
||||
isMarkdown={isMarkdown}
|
||||
mdPreviewMode={mdPreviewMode}
|
||||
onToggleSplit={toggleMdSplit}
|
||||
onToggleFullPreview={toggleMdPreview}
|
||||
/>
|
||||
|
||||
{/* Draft recovery banner */}
|
||||
{draftRecoveredFile && activeTabId === draftRecoveredFile && (
|
||||
|
|
@ -678,18 +741,42 @@ export const ProjectEditorOverlay = ({
|
|||
)}
|
||||
|
||||
{fileContent && !fileContent.isBinary && activeTabId && (
|
||||
<EditorErrorBoundary filePath={activeTabId} onRetry={handleRetry}>
|
||||
<CodeMirrorEditor
|
||||
key={`${activeTabId}-${editorResetKey}`}
|
||||
filePath={activeTabId}
|
||||
content={fileContent.content}
|
||||
fileName={activeTabId.split('/').pop() ?? 'file'}
|
||||
mtimeMs={fileContent.mtimeMs}
|
||||
onCursorChange={handleCursorChange}
|
||||
onDraftRecovered={handleDraftRecovered}
|
||||
onSelectionChange={setSelectionInfo}
|
||||
/>
|
||||
</EditorErrorBoundary>
|
||||
<div className="flex h-full">
|
||||
{/* Code editor — always mounted, hidden via display:none in preview mode */}
|
||||
<div
|
||||
className="h-full overflow-hidden"
|
||||
style={{
|
||||
display: mdPreviewMode === 'preview' ? 'none' : 'block',
|
||||
width: mdPreviewMode === 'split' ? `${splitRatio * 100}%` : '100%',
|
||||
}}
|
||||
>
|
||||
<EditorErrorBoundary filePath={activeTabId} onRetry={handleRetry}>
|
||||
<CodeMirrorEditor
|
||||
key={`${activeTabId}-${editorResetKey}`}
|
||||
filePath={activeTabId}
|
||||
content={fileContent.content}
|
||||
fileName={activeTabId.split('/').pop() ?? 'file'}
|
||||
mtimeMs={fileContent.mtimeMs}
|
||||
onCursorChange={handleCursorChange}
|
||||
onDraftRecovered={handleDraftRecovered}
|
||||
onSelectionChange={setSelectionInfo}
|
||||
onDocChange={mdPreviewMode !== 'off' ? handleLiveContent : undefined}
|
||||
/>
|
||||
</EditorErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* Resize handle + Preview pane */}
|
||||
{mdPreviewMode !== 'off' && (
|
||||
<MarkdownSplitView
|
||||
content={previewContent}
|
||||
mode={mdPreviewMode}
|
||||
splitRatio={splitRatio}
|
||||
onSplitRatioChange={handleSplitRatioChange}
|
||||
viewKey={activeTabId}
|
||||
baseDir={activeTabId?.substring(0, activeTabId.lastIndexOf('/'))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fileLoading && !fileError && !fileContent && !activeTabId && <EditorEmptyState />}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,13 @@ export const MemberList = ({
|
|||
onAssignTask,
|
||||
onOpenTask,
|
||||
}: MemberListProps): React.JSX.Element => {
|
||||
const activeMembers = members.filter((m) => !m.removedAt);
|
||||
const activeMembers = members
|
||||
.filter((m) => !m.removedAt)
|
||||
.sort((a, b) => {
|
||||
if (a.agentType === 'team-lead') return -1;
|
||||
if (b.agentType === 'team-lead') return 1;
|
||||
return 0;
|
||||
});
|
||||
const removedMembers = members.filter((m) => m.removedAt);
|
||||
const colorMap = buildMemberColorMap(members);
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ interface MemberLogsTabProps {
|
|||
/** When viewing task logs: include owner's sessions when task is in_progress */
|
||||
taskOwner?: string;
|
||||
taskStatus?: string;
|
||||
/** Persisted work intervals for filtering owner sessions (avoid unrelated tasks) */
|
||||
taskWorkIntervals?: { startedAt: string; completedAt?: string }[];
|
||||
}
|
||||
|
||||
export const MemberLogsTab = ({
|
||||
|
|
@ -32,6 +34,7 @@ export const MemberLogsTab = ({
|
|||
taskId,
|
||||
taskOwner,
|
||||
taskStatus,
|
||||
taskWorkIntervals,
|
||||
}: MemberLogsTabProps): React.JSX.Element => {
|
||||
const [logs, setLogs] = useState<MemberLogSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -61,6 +64,7 @@ export const MemberLogsTab = ({
|
|||
? await api.teams.getLogsForTask(teamName, taskId, {
|
||||
owner: taskOwner,
|
||||
status: taskStatus,
|
||||
intervals: taskWorkIntervals,
|
||||
})
|
||||
: await api.teams.getMemberLogs(teamName, memberName!);
|
||||
if (!cancelled) {
|
||||
|
|
@ -86,7 +90,7 @@ export const MemberLogsTab = ({
|
|||
cancelled = true;
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [teamName, memberName, taskId, taskOwner, taskStatus]);
|
||||
}, [teamName, memberName, taskId, taskOwner, taskStatus, taskWorkIntervals]);
|
||||
|
||||
const handleExpand = useCallback(
|
||||
async (log: MemberLogSummary) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||
import { indentUnit, syntaxHighlighting } from '@codemirror/language';
|
||||
import { foldGutter, foldKeymap, indentUnit, syntaxHighlighting } from '@codemirror/language';
|
||||
import { goToNextChunk, goToPreviousChunk, unifiedMergeView } from '@codemirror/merge';
|
||||
import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
|
|
@ -395,6 +395,7 @@ export const CodeMirrorDiffView = ({
|
|||
...(isEffectivelyEmptyOriginal ? [emptyOriginalOverrideTheme] : []),
|
||||
lineNumbers(),
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
foldGutter(),
|
||||
EditorView.editable.of(!readOnly),
|
||||
EditorState.readOnly.of(readOnly),
|
||||
];
|
||||
|
|
@ -405,7 +406,9 @@ export const CodeMirrorDiffView = ({
|
|||
extensions.push(mergeUndoSupport);
|
||||
extensions.push(mirrorEditsAfterResolve);
|
||||
extensions.push(indentUnit.of(' '));
|
||||
extensions.push(keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]));
|
||||
extensions.push(
|
||||
keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...foldKeymap])
|
||||
);
|
||||
}
|
||||
|
||||
// Language placeholder — actual language injected async via compartment reconfigure
|
||||
|
|
@ -426,6 +429,7 @@ export const CodeMirrorDiffView = ({
|
|||
key: 'Ctrl-Alt-ArrowUp',
|
||||
run: goToPreviousChunk,
|
||||
},
|
||||
...foldKeymap,
|
||||
])
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ interface UseEditorKeyboardShortcutsOptions {
|
|||
onToggleGoToLine: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
onClose: () => void;
|
||||
onToggleMdSplit?: () => void;
|
||||
onToggleMdPreview?: () => void;
|
||||
}
|
||||
|
||||
/** Dependencies injected into the key handler for testability. */
|
||||
|
|
@ -40,6 +42,8 @@ export interface EditorKeyHandlerDeps {
|
|||
onToggleGoToLine: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
onToggleLineWrap: () => void;
|
||||
onToggleMdSplit?: () => void;
|
||||
onToggleMdPreview?: () => void;
|
||||
getEditorView: () => { dispatch: unknown } | null;
|
||||
}
|
||||
|
||||
|
|
@ -108,6 +112,22 @@ export function createEditorKeyHandler(deps: EditorKeyHandlerDeps): (e: Keyboard
|
|||
return;
|
||||
}
|
||||
|
||||
// Cmd+Shift+M: Toggle markdown split preview
|
||||
if (key === 'm' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
deps.onToggleMdSplit?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+Shift+V: Toggle markdown full preview
|
||||
if (key === 'v' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
deps.onToggleMdPreview?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+Shift+W: Toggle line wrap
|
||||
if (key === 'w' && e.shiftKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
|
|
@ -190,6 +210,8 @@ export function useEditorKeyboardShortcuts({
|
|||
onToggleGoToLine,
|
||||
onToggleSidebar,
|
||||
onClose: _onClose,
|
||||
onToggleMdSplit,
|
||||
onToggleMdPreview,
|
||||
}: UseEditorKeyboardShortcutsOptions): void {
|
||||
const { openTabs, activeTabId } = useStore(
|
||||
useShallow((s) => ({
|
||||
|
|
@ -217,6 +239,8 @@ export function useEditorKeyboardShortcuts({
|
|||
onToggleGoToLine,
|
||||
onToggleSidebar,
|
||||
onToggleLineWrap: toggleLineWrap,
|
||||
onToggleMdSplit,
|
||||
onToggleMdPreview,
|
||||
getEditorView: () => editorBridge.getView(),
|
||||
};
|
||||
|
||||
|
|
|
|||
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 lastWatchedFilesKey = '';
|
||||
let watchedDirsSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastWatchedDirsKey = '';
|
||||
const WATCHED_DIRS_DEBOUNCE_MS = 250;
|
||||
const MAX_WATCHED_DIRS = 120;
|
||||
|
||||
function scheduleSyncWatchedFiles(get: () => AppState): void {
|
||||
if (!api.editor) return;
|
||||
// Editor watcher is Electron-only. In browser mode, api.editor exists but throws.
|
||||
if (!window.electronAPI?.editor) return;
|
||||
const state = get();
|
||||
if (!state.editorWatcherEnabled) return;
|
||||
if (!state.editorProjectPath) return;
|
||||
const projectPath = state.editorProjectPath;
|
||||
if (!projectPath) return;
|
||||
|
||||
const filePaths = state.editorOpenTabs.map((t) => t.filePath).filter(Boolean);
|
||||
filePaths.sort();
|
||||
const key = filePaths.join('\n');
|
||||
const key = `${projectPath}\n${filePaths.join('\n')}`;
|
||||
if (key === lastWatchedFilesKey) return;
|
||||
lastWatchedFilesKey = key;
|
||||
|
||||
if (watchedFilesSyncTimer) clearTimeout(watchedFilesSyncTimer);
|
||||
watchedFilesSyncTimer = setTimeout(() => {
|
||||
watchedFilesSyncTimer = null;
|
||||
void api.editor.setWatchedFiles(filePaths);
|
||||
void window.electronAPI.editor.setWatchedFiles(filePaths);
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function scheduleSyncWatchedDirs(get: () => AppState): void {
|
||||
if (!window.electronAPI?.editor) return;
|
||||
const state = get();
|
||||
if (!state.editorWatcherEnabled) return;
|
||||
const projectPath = state.editorProjectPath;
|
||||
if (!projectPath) return;
|
||||
|
||||
const expanded = Object.entries(state.editorExpandedDirs)
|
||||
.filter(([, v]) => v === true)
|
||||
.map(([k]) => k);
|
||||
|
||||
// Always include root (depth=0), plus expanded folders (depth=0).
|
||||
// Cap to protect chokidar from too many watched paths if user expands a lot.
|
||||
const dirs = [projectPath, ...expanded].slice(0, MAX_WATCHED_DIRS);
|
||||
dirs.sort();
|
||||
const key = `${projectPath}\n${dirs.join('\n')}`;
|
||||
if (key === lastWatchedDirsKey) return;
|
||||
lastWatchedDirsKey = key;
|
||||
|
||||
if (watchedDirsSyncTimer) clearTimeout(watchedDirsSyncTimer);
|
||||
watchedDirsSyncTimer = setTimeout(() => {
|
||||
watchedDirsSyncTimer = null;
|
||||
void window.electronAPI.editor.setWatchedDirs(dirs);
|
||||
}, WATCHED_DIRS_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open request sequence for editor initialization.
|
||||
* Cancels stale async work (notably React 18 StrictMode dev effect mount/unmount).
|
||||
|
|
@ -103,11 +135,16 @@ const MOVE_COOLDOWN_MS = 2000;
|
|||
function scheduleIdleWork(cb: () => void): void {
|
||||
// Prefer requestIdleCallback when available; fall back to a short timeout.
|
||||
// This keeps editor open responsive for large repos.
|
||||
// timeout ensures the callback fires within 2s even if the event loop is busy
|
||||
// (without it, requestIdleCallback can be delayed indefinitely).
|
||||
try {
|
||||
const ric = (window as unknown as { requestIdleCallback?: (fn: () => void) => number })
|
||||
.requestIdleCallback;
|
||||
const ric = (
|
||||
window as unknown as {
|
||||
requestIdleCallback?: (fn: () => void, opts?: { timeout: number }) => number;
|
||||
}
|
||||
).requestIdleCallback;
|
||||
if (typeof ric === 'function') {
|
||||
ric(cb);
|
||||
ric(cb, { timeout: 2000 });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -330,8 +367,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
|
||||
scheduleIdleWork(() => {
|
||||
if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return;
|
||||
// TODO: temporarily disabled file watcher — re-enable when stabilized
|
||||
if (watcherDesired) void get().toggleWatcher(false);
|
||||
if (watcherDesired) void get().toggleWatcher(true);
|
||||
// Defer initial git status a bit more — it can be expensive on large repos.
|
||||
setTimeout(() => {
|
||||
if (editorOpenSeq !== openSeq || get().editorProjectPath !== projectPath) return;
|
||||
|
|
@ -355,6 +391,18 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
closeEditor: () => {
|
||||
// Cancel any in-flight openEditor async work
|
||||
editorOpenSeq++;
|
||||
// Cancel any pending watcher sync (avoid calling into main after close)
|
||||
if (watchedFilesSyncTimer) {
|
||||
clearTimeout(watchedFilesSyncTimer);
|
||||
watchedFilesSyncTimer = null;
|
||||
}
|
||||
if (watchedDirsSyncTimer) {
|
||||
clearTimeout(watchedDirsSyncTimer);
|
||||
watchedDirsSyncTimer = null;
|
||||
}
|
||||
lastWatchedFilesKey = '';
|
||||
lastWatchedDirsKey = '';
|
||||
|
||||
// Clear cooldown timestamps (no stale entries across editor sessions)
|
||||
recentSaveTimestamps.clear();
|
||||
recentMoveTimestamps.clear();
|
||||
|
|
@ -428,6 +476,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
set({
|
||||
editorExpandedDirs: { ...editorExpandedDirs, [dirPath]: true },
|
||||
});
|
||||
scheduleSyncWatchedDirs(get);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -456,6 +505,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
collapseDirectory: (dirPath: string) => {
|
||||
const { editorExpandedDirs } = get();
|
||||
set({ editorExpandedDirs: omitKey(editorExpandedDirs, dirPath) });
|
||||
scheduleSyncWatchedDirs(get);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
|
|
@ -1027,9 +1077,13 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
|
|||
}
|
||||
if (enable) {
|
||||
scheduleSyncWatchedFiles(get);
|
||||
scheduleSyncWatchedDirs(get);
|
||||
} else {
|
||||
// Ensure main process stops watching files promptly.
|
||||
lastWatchedFilesKey = '';
|
||||
lastWatchedDirsKey = '';
|
||||
void api.editor.setWatchedFiles([]);
|
||||
void api.editor.setWatchedDirs([]);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Failed to toggle watcher:', error);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export const baseEditorTheme = EditorView.theme({
|
|||
color: 'var(--color-text)',
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
|
||||
fontSize: '13px',
|
||||
height: '100%',
|
||||
},
|
||||
'&.cm-focused': {
|
||||
outline: 'none',
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
/**
|
||||
* Rehype plugins for markdown rendering (used with react-markdown).
|
||||
* Rehype runs after remark; rehype-highlight adds syntax highlighting to code blocks.
|
||||
*
|
||||
* - rehype-raw: parse and render inline HTML in markdown
|
||||
* - rehype-highlight: syntax highlighting for code blocks
|
||||
*/
|
||||
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
||||
export const REHYPE_PLUGINS = [rehypeHighlight];
|
||||
/** Full plugin chain: raw HTML + syntax highlighting */
|
||||
export const REHYPE_PLUGINS = [rehypeRaw, rehypeHighlight];
|
||||
|
||||
/** Lightweight chain: raw HTML only (used when highlighting is disabled for large content) */
|
||||
export const REHYPE_PLUGINS_NO_HIGHLIGHT = [rehypeRaw];
|
||||
|
|
|
|||
|
|
@ -426,7 +426,14 @@ export interface TeamsAPI {
|
|||
getLogsForTask: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
options?: { owner?: string; status?: string }
|
||||
options?: {
|
||||
owner?: string;
|
||||
status?: string;
|
||||
/** Persisted work intervals (preferred for reliable owner-log attribution). */
|
||||
intervals?: { startedAt: string; completedAt?: string }[];
|
||||
/** Back-compat: single since timestamp (deprecated). */
|
||||
since?: string;
|
||||
}
|
||||
) => Promise<MemberLogSummary[]>;
|
||||
getMemberStats: (teamName: string, memberName: string) => Promise<MemberFullStats>;
|
||||
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
|
||||
|
|
|
|||
|
|
@ -201,6 +201,12 @@ export interface EditorAPI {
|
|||
* Intended as a performance optimization: avoids watching the whole project tree.
|
||||
*/
|
||||
setWatchedFiles: (filePaths: string[]) => Promise<void>;
|
||||
/**
|
||||
* Provide the list of directories to watch shallowly (depth=0).
|
||||
* Intended to keep the explorer tree in sync with external changes without
|
||||
* recursively watching the whole project.
|
||||
*/
|
||||
setWatchedDirs: (dirPaths: string[]) => Promise<void>;
|
||||
/** Subscribe to file change events (main → renderer). Returns cleanup function. */
|
||||
onEditorChange: (callback: (event: EditorFileChangeEvent) => void) => () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -247,8 +247,10 @@ export interface AppConfig {
|
|||
snoozeMinutes: number;
|
||||
/** Whether to include errors from subagent sessions */
|
||||
includeSubagentErrors: boolean;
|
||||
/** Whether to show native OS notifications for team inbox messages */
|
||||
notifyOnInboxMessages: boolean;
|
||||
/** Whether to show native OS notifications when teammates send messages to the team lead */
|
||||
notifyOnLeadInbox: boolean;
|
||||
/** Whether to show native OS notifications when teammates send messages to you (the user) */
|
||||
notifyOnUserInbox: boolean;
|
||||
/** Whether to show native OS notifications when a task needs user clarification */
|
||||
notifyOnClarifications: boolean;
|
||||
/** Notification triggers - define when to generate notifications */
|
||||
|
|
|
|||
|
|
@ -55,11 +55,21 @@ export interface TeamSummary {
|
|||
|
||||
export type TeamTaskStatus = 'pending' | 'in_progress' | 'completed' | 'deleted';
|
||||
|
||||
export interface TaskWorkInterval {
|
||||
/** ISO timestamp when task entered in_progress */
|
||||
startedAt: string;
|
||||
/** ISO timestamp when task left in_progress (optional for active interval) */
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export type TaskCommentType = 'regular' | 'review_request' | 'review_approved';
|
||||
|
||||
export interface TaskComment {
|
||||
id: string;
|
||||
author: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
type: TaskCommentType;
|
||||
}
|
||||
|
||||
// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record<keyof TeamTask, unknown>`.
|
||||
|
|
@ -72,6 +82,11 @@ export interface TeamTask {
|
|||
owner?: string;
|
||||
createdBy?: string;
|
||||
status: TeamTaskStatus;
|
||||
/**
|
||||
* One task can be worked on in multiple disjoint periods (e.g. review sends it back to in_progress).
|
||||
* We persist intervals for reliable log attribution without relying on heuristics.
|
||||
*/
|
||||
workIntervals?: TaskWorkInterval[];
|
||||
blocks?: string[];
|
||||
blockedBy?: string[];
|
||||
/**
|
||||
|
|
@ -271,6 +286,7 @@ export interface TeamCreateConfigRequest {
|
|||
description?: string;
|
||||
color?: string;
|
||||
members: TeamProvisioningMemberInput[];
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface TeamCreateResponse {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
|||
EDITOR_GIT_STATUS: 'editor:gitStatus',
|
||||
EDITOR_WATCH_DIR: 'editor:watchDir',
|
||||
EDITOR_SET_WATCHED_FILES: 'editor:setWatchedFiles',
|
||||
EDITOR_SET_WATCHED_DIRS: 'editor:setWatchedDirs',
|
||||
EDITOR_CHANGE: 'editor:change',
|
||||
}));
|
||||
|
||||
|
|
@ -148,8 +149,8 @@ describe('Editor IPC handlers', () => {
|
|||
});
|
||||
|
||||
describe('registration', () => {
|
||||
it('registers all 16 editor channels', () => {
|
||||
expect(mockIpc.handle).toHaveBeenCalledTimes(16);
|
||||
it('registers all 17 editor channels', () => {
|
||||
expect(mockIpc.handle).toHaveBeenCalledTimes(17);
|
||||
expect(mockIpc._handlers.has('editor:open')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:close')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:readDir')).toBe(true);
|
||||
|
|
@ -166,11 +167,12 @@ describe('Editor IPC handlers', () => {
|
|||
expect(mockIpc._handlers.has('editor:gitStatus')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:watchDir')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:setWatchedFiles')).toBe(true);
|
||||
expect(mockIpc._handlers.has('editor:setWatchedDirs')).toBe(true);
|
||||
});
|
||||
|
||||
it('removeEditorHandlers clears all channels', () => {
|
||||
removeEditorHandlers(mockIpc as unknown as IpcMain);
|
||||
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(16);
|
||||
expect(mockIpc.removeHandler).toHaveBeenCalledTimes(17);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -173,6 +173,22 @@ describe('EditorFileWatcher', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('setWatchedFiles before start', () => {
|
||||
it('returns silently when watcher not initialized', () => {
|
||||
// Should NOT throw — graceful no-op when projectRoot is null
|
||||
expect(() => watcher.setWatchedFiles(['/some/file.ts'])).not.toThrow();
|
||||
expect(watch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setWatchedDirs before start', () => {
|
||||
it('returns silently when watcher not initialized', () => {
|
||||
// Should NOT throw — graceful no-op when projectRoot is null
|
||||
expect(() => watcher.setWatchedDirs(['/some/dir'])).not.toThrow();
|
||||
expect(watch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWatching', () => {
|
||||
it('returns false when not started', () => {
|
||||
expect(watcher.isWatching()).toBe(false);
|
||||
|
|
|
|||
|
|
@ -245,4 +245,186 @@ describe('TeamMemberLogsFinder', () => {
|
|||
// Full file has 200 messages — must NOT be capped at 50 or 100
|
||||
expect(carolLogs[0]?.messageCount).toBe(200);
|
||||
});
|
||||
|
||||
it('findLogsForTask does not treat arbitrary "#<id>" as a task reference', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-logs-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
||||
const teamName = 't4';
|
||||
const projectPath = '/Users/test/proj4';
|
||||
const projectId = '-Users-test-proj4';
|
||||
const leadSessionId = 's4';
|
||||
|
||||
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'teams', teamName, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath,
|
||||
leadSessionId,
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'bob', agentType: 'general-purpose' },
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const projectRoot = path.join(tmpDir, 'projects', projectId);
|
||||
await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true });
|
||||
|
||||
// Lead session mentions "PR #1" but NOT a task reference
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, `${leadSessionId}.jsonl`),
|
||||
[
|
||||
JSON.stringify({
|
||||
timestamp: '2026-01-01T00:00:00.000Z',
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Fix PR #1 please' }] },
|
||||
}),
|
||||
].join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Subagent session includes a structured taskId reference (should match)
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, leadSessionId, 'subagents', 'agent-abc111.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'You are bob, a developer on team "t4" (t4).' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-01-01T00:00:02.000Z',
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'TaskUpdate',
|
||||
input: { taskId: '1', status: 'in_progress' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
].join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const finder = new TeamMemberLogsFinder();
|
||||
const logs = await finder.findLogsForTask(teamName, '1');
|
||||
|
||||
// Should include the subagent log, but must NOT include the lead session just because it had "PR #1"
|
||||
expect(logs.some((l) => l.kind === 'lead_session')).toBe(false);
|
||||
expect(logs.some((l) => l.kind === 'subagent')).toBe(true);
|
||||
});
|
||||
|
||||
it('findLogsForTask includes only owner sessions overlapping workIntervals', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-owner-since-'));
|
||||
setClaudeBasePathOverride(tmpDir);
|
||||
|
||||
const teamName = 't5';
|
||||
const projectPath = '/Users/test/proj5';
|
||||
const projectId = '-Users-test-proj5';
|
||||
const leadSessionId = 's5';
|
||||
|
||||
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'teams', teamName, 'config.json'),
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath,
|
||||
leadSessionId,
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'bob', agentType: 'general-purpose' },
|
||||
{ name: 'alice', agentType: 'general-purpose' },
|
||||
],
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const projectRoot = path.join(tmpDir, 'projects', projectId);
|
||||
await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true });
|
||||
|
||||
// Alice file references taskId 10 via structured tool input (so results is non-empty).
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice10.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
timestamp: '2026-01-01T00:00:01.000Z',
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'You are alice, a developer on team "t5" (t5).' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-01-01T00:00:02.000Z',
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', name: 'TaskUpdate', input: { taskId: '10', status: 'pending' } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
].join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Bob has an old session (should NOT be pulled in by owner include).
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-old.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
timestamp: '2025-12-31T00:00:00.000Z',
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'You are bob, a developer on team "t5" (t5).' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2025-12-31T00:00:01.000Z',
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'Old work' }] },
|
||||
}),
|
||||
].join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
// Bob has a recent session within workIntervals (should be included).
|
||||
await fs.writeFile(
|
||||
path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-new.jsonl'),
|
||||
[
|
||||
JSON.stringify({
|
||||
timestamp: '2026-01-01T12:00:00.000Z',
|
||||
type: 'user',
|
||||
message: { role: 'user', content: 'You are bob, a developer on team "t5" (t5).' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-01-01T12:00:01.000Z',
|
||||
type: 'assistant',
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'New work' }] },
|
||||
}),
|
||||
].join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const finder = new TeamMemberLogsFinder();
|
||||
const logs = await finder.findLogsForTask(teamName, '10', {
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
intervals: [
|
||||
{ startedAt: '2026-01-01T10:00:00.000Z', completedAt: '2026-01-01T13:00:00.000Z' },
|
||||
],
|
||||
});
|
||||
|
||||
const bobDescriptions = logs
|
||||
.filter((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob')
|
||||
.map((l) => l.description);
|
||||
|
||||
expect(bobDescriptions.some((d) => d.includes('Old'))).toBe(false);
|
||||
// At least one bob log should be present (the recent one).
|
||||
expect(logs.some((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -717,7 +717,7 @@ describe('teamctl.js', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('adds a comment with valid ID and timestamp', () => {
|
||||
it('adds a comment with valid ID, timestamp, and type=regular', () => {
|
||||
const { stdout, exitCode } = run(claudeDir, [
|
||||
'task',
|
||||
'comment',
|
||||
|
|
@ -735,6 +735,7 @@ describe('teamctl.js', () => {
|
|||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].text).toBe('Hello world');
|
||||
expect(comments[0].author).toBe('alice');
|
||||
expect(comments[0].type).toBe('regular');
|
||||
expect(String(comments[0].id)).toMatch(UUID_RE);
|
||||
expect(String(comments[0].createdAt)).toMatch(ISO_RE);
|
||||
});
|
||||
|
|
@ -753,7 +754,7 @@ describe('teamctl.js', () => {
|
|||
expect(readInbox(claudeDir, 'bob').length).toBe(1); // still 1
|
||||
});
|
||||
|
||||
it('multiple comments accumulate with unique IDs', () => {
|
||||
it('multiple comments accumulate with unique IDs and type=regular', () => {
|
||||
run(claudeDir, ['task', 'comment', '1', '--text', 'First', '--from', 'alice']);
|
||||
run(claudeDir, ['task', 'comment', '1', '--text', 'Second', '--from', 'bob']);
|
||||
run(claudeDir, ['task', 'comment', '1', '--text', 'Third', '--from', 'alice']);
|
||||
|
|
@ -763,6 +764,7 @@ describe('teamctl.js', () => {
|
|||
expect(comments.map((c) => c.text)).toEqual(['First', 'Second', 'Third']);
|
||||
expect(comments.map((c) => c.author)).toEqual(['alice', 'bob', 'alice']);
|
||||
expect(new Set(comments.map((c) => c.id)).size).toBe(3);
|
||||
expect(comments.every((c) => c.type === 'regular')).toBe(true);
|
||||
});
|
||||
|
||||
it('comment on task without comments array initializes it', () => {
|
||||
|
|
@ -1283,6 +1285,83 @@ describe('teamctl.js', () => {
|
|||
expect(exitCode).not.toBe(0);
|
||||
expect(stderr).toContain('Usage');
|
||||
});
|
||||
|
||||
it('approve records review_approved comment in task.comments', () => {
|
||||
run(claudeDir, ['review', 'approve', '1', '--from', 'alice']);
|
||||
const task = readTask(claudeDir, '1');
|
||||
const comments = task.comments as Record<string, unknown>[];
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].type).toBe('review_approved');
|
||||
expect(comments[0].author).toBe('alice');
|
||||
expect(comments[0].text).toBe('Approved');
|
||||
expect(String(comments[0].id)).toMatch(UUID_RE);
|
||||
expect(String(comments[0].createdAt)).toMatch(ISO_RE);
|
||||
});
|
||||
|
||||
it('approve records review_approved comment with --note text', () => {
|
||||
run(claudeDir, [
|
||||
'review',
|
||||
'approve',
|
||||
'1',
|
||||
'--notify-owner',
|
||||
'--from',
|
||||
'alice',
|
||||
'--note',
|
||||
'Looks great!',
|
||||
]);
|
||||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].type).toBe('review_approved');
|
||||
expect(comments[0].text).toBe('Looks great!');
|
||||
});
|
||||
|
||||
it('request-changes records review_request comment in task.comments', () => {
|
||||
run(claudeDir, [
|
||||
'review',
|
||||
'request-changes',
|
||||
'1',
|
||||
'--comment',
|
||||
'Fix the edge case',
|
||||
'--from',
|
||||
'alice',
|
||||
]);
|
||||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].type).toBe('review_request');
|
||||
expect(comments[0].author).toBe('alice');
|
||||
expect(comments[0].text).toBe('Fix the edge case');
|
||||
expect(String(comments[0].id)).toMatch(UUID_RE);
|
||||
expect(String(comments[0].createdAt)).toMatch(ISO_RE);
|
||||
});
|
||||
|
||||
it('request-changes without --comment records default text as review_request', () => {
|
||||
run(claudeDir, ['review', 'request-changes', '1', '--from', 'alice']);
|
||||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].type).toBe('review_request');
|
||||
expect(comments[0].text).toBe('Reviewer requested changes.');
|
||||
});
|
||||
|
||||
it('review comments preserve existing task comments', () => {
|
||||
// Add a regular comment first
|
||||
run(claudeDir, ['task', 'comment', '1', '--text', 'Working on it', '--from', 'bob']);
|
||||
// Then request changes
|
||||
run(claudeDir, [
|
||||
'review',
|
||||
'request-changes',
|
||||
'1',
|
||||
'--comment',
|
||||
'Needs tests',
|
||||
'--from',
|
||||
'alice',
|
||||
]);
|
||||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||||
expect(comments).toHaveLength(2);
|
||||
expect(comments[0].type).toBe('regular');
|
||||
expect(comments[0].text).toBe('Working on it');
|
||||
expect(comments[1].type).toBe('review_request');
|
||||
expect(comments[1].text).toBe('Needs tests');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -1824,8 +1903,8 @@ describe('teamctl.js', () => {
|
|||
expect(comments[0].text).toBe('Hello');
|
||||
});
|
||||
|
||||
// --- reviewApprove without --notify-owner creates NO inbox ---
|
||||
it('review approve without --notify-owner does NOT create inbox', () => {
|
||||
// --- reviewApprove without --notify-owner creates NO inbox but DOES record comment ---
|
||||
it('review approve without --notify-owner does NOT create inbox but records comment', () => {
|
||||
writeTask(claudeDir, '1', {
|
||||
id: '1',
|
||||
subject: 'Feature',
|
||||
|
|
@ -1835,10 +1914,14 @@ describe('teamctl.js', () => {
|
|||
run(claudeDir, ['kanban', 'set-column', '1', 'review']);
|
||||
run(claudeDir, ['review', 'approve', '1']); // no --notify-owner
|
||||
expect(readInbox(claudeDir, 'bob')).toEqual([]);
|
||||
// Comment is still recorded
|
||||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].type).toBe('review_approved');
|
||||
});
|
||||
|
||||
// --- request-changes: verify ALL three side effects ---
|
||||
it('review request-changes: kanban cleared + status in_progress + inbox sent', () => {
|
||||
// --- request-changes: verify ALL four side effects ---
|
||||
it('review request-changes: kanban cleared + status in_progress + comment recorded + inbox sent', () => {
|
||||
writeTask(claudeDir, '1', {
|
||||
id: '1',
|
||||
subject: 'PR',
|
||||
|
|
@ -1859,7 +1942,13 @@ describe('teamctl.js', () => {
|
|||
expect((readKanban(claudeDir).tasks as Record<string, unknown>)['1']).toBeUndefined();
|
||||
// 2) Status changed to in_progress
|
||||
expect(readTask(claudeDir, '1').status).toBe('in_progress');
|
||||
// 3) Inbox message sent
|
||||
// 3) Review comment recorded
|
||||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].type).toBe('review_request');
|
||||
expect(comments[0].author).toBe('alice');
|
||||
expect(comments[0].text).toBe('Missing tests');
|
||||
// 4) Inbox message sent
|
||||
const inbox = readInbox(claudeDir, 'bob') as Record<string, unknown>[];
|
||||
expect(inbox).toHaveLength(1);
|
||||
expect(inbox[0].from).toBe('alice');
|
||||
|
|
@ -2249,8 +2338,8 @@ describe('teamctl.js', () => {
|
|||
expect(after[0].label).toBe('server-1');
|
||||
});
|
||||
|
||||
// --- review approve also writes to kanban (column=approved) ---
|
||||
it('review approve sets kanban column to approved with movedAt', () => {
|
||||
// --- review approve also writes to kanban (column=approved) + comment ---
|
||||
it('review approve sets kanban column to approved with movedAt and records comment', () => {
|
||||
writeTask(claudeDir, '1', {
|
||||
id: '1',
|
||||
subject: 'PR task',
|
||||
|
|
@ -2262,6 +2351,10 @@ describe('teamctl.js', () => {
|
|||
const entry = (readKanban(claudeDir).tasks as Record<string, Record<string, unknown>>)['1'];
|
||||
expect(entry.column).toBe('approved');
|
||||
expect(String(entry.movedAt)).toMatch(ISO_RE);
|
||||
// Review comment recorded
|
||||
const comments = readTask(claudeDir, '1').comments as Record<string, unknown>[];
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].type).toBe('review_approved');
|
||||
});
|
||||
|
||||
// --- Task create without --description defaults to subject ---
|
||||
|
|
|
|||
Loading…
Reference in a new issue