feat: implement process management features for team services
- Added process registration and health tracking capabilities in TeamDataService, allowing teams to monitor and manage background processes effectively. - Introduced new CLI commands for registering, unregistering, and listing processes, enhancing the command-line tool's functionality. - Implemented periodic health checks for registered processes, automatically updating their status and notifying the UI of changes. - Enhanced the FileWatcher to emit team change events for process updates, ensuring real-time synchronization with the UI. - Updated the team detail view to display active CLI processes, improving visibility into team operations. - Added documentation for new process management protocols and CLI commands to assist users in managing background processes.
This commit is contained in:
parent
0df816bba6
commit
877f214113
31 changed files with 877 additions and 133 deletions
|
|
@ -51,6 +51,8 @@ import {
|
||||||
UpdaterService,
|
UpdaterService,
|
||||||
} from './services';
|
} from './services';
|
||||||
|
|
||||||
|
import type { TeamChangeEvent } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('App');
|
const logger = createLogger('App');
|
||||||
|
|
||||||
// Window icon path for non-mac platforms.
|
// Window icon path for non-mac platforms.
|
||||||
|
|
@ -320,12 +322,17 @@ function initializeServices(): void {
|
||||||
httpServer = new HttpServer();
|
httpServer = new HttpServer();
|
||||||
|
|
||||||
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
|
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
|
||||||
teamProvisioningService.setTeamChangeEmitter((event) => {
|
const teamChangeEmitter = (event: TeamChangeEvent): void => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.webContents.send(TEAM_CHANGE, event);
|
mainWindow.webContents.send(TEAM_CHANGE, event);
|
||||||
}
|
}
|
||||||
httpServer?.broadcast('team-change', event);
|
httpServer?.broadcast('team-change', event);
|
||||||
});
|
};
|
||||||
|
teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter);
|
||||||
|
|
||||||
|
// Start periodic health checks for registered CLI processes (every 2s).
|
||||||
|
// Dead processes get stoppedAt written to processes.json → FileWatcher picks it up.
|
||||||
|
teamDataService.startProcessHealthPolling();
|
||||||
|
|
||||||
// Initialize IPC handlers with registry
|
// Initialize IPC handlers with registry
|
||||||
initializeIpcHandlers(
|
initializeIpcHandlers(
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_GET_PROJECT_BRANCH,
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
|
TEAM_KILL_PROCESS,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
TEAM_LIST,
|
TEAM_LIST,
|
||||||
|
|
@ -194,6 +195,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
|
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
|
||||||
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
||||||
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
||||||
|
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
|
||||||
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
||||||
logger.info('Team handlers registered');
|
logger.info('Team handlers registered');
|
||||||
}
|
}
|
||||||
|
|
@ -231,6 +233,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
|
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
|
||||||
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
||||||
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
||||||
|
ipcMain.removeHandler(TEAM_KILL_PROCESS);
|
||||||
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1415,6 +1418,19 @@ async function handleUpdateMemberRole(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleKillProcess(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
pid: unknown
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
const vTeam = validateTeamName(teamName);
|
||||||
|
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||||
|
if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) {
|
||||||
|
return { success: false, error: 'pid must be a positive integer' };
|
||||||
|
}
|
||||||
|
return wrapTeamHandler('killProcess', () => getTeamDataService().killProcess(vTeam.value!, pid));
|
||||||
|
}
|
||||||
|
|
||||||
async function handleAddTaskComment(
|
async function handleAddTaskComment(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown,
|
teamName: unknown,
|
||||||
|
|
|
||||||
|
|
@ -920,6 +920,12 @@ export class FileWatcher extends EventEmitter {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (relative === 'processes.json') {
|
||||||
|
const event: TeamChangeEvent = { type: 'process', teamName, detail: relative };
|
||||||
|
this.emit('team-change', event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Classify only the paths we care about in iteration 02.
|
// Classify only the paths we care about in iteration 02.
|
||||||
if (normalized.includes('inboxes') || relative === 'sentMessages.json') {
|
if (normalized.includes('inboxes') || relative === 'sentMessages.json') {
|
||||||
const event: TeamChangeEvent = {
|
const event: TeamChangeEvent = {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import * as path from 'path';
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
|
||||||
const TOOL_FILE_NAME = 'teamctl.js';
|
const TOOL_FILE_NAME = 'teamctl.js';
|
||||||
const TOOL_VERSION = 5;
|
const TOOL_VERSION = 6;
|
||||||
|
|
||||||
function buildTeamCtlScript(): string {
|
function buildTeamCtlScript(): string {
|
||||||
const script = String.raw`#!/usr/bin/env node
|
const script = String.raw`#!/usr/bin/env node
|
||||||
|
|
@ -166,7 +166,8 @@ function getPaths(flags, teamName) {
|
||||||
const teamDir = path.join(claudeDir, 'teams', teamName);
|
const teamDir = path.join(claudeDir, 'teams', teamName);
|
||||||
const tasksDir = path.join(claudeDir, 'tasks', teamName);
|
const tasksDir = path.join(claudeDir, 'tasks', teamName);
|
||||||
const kanbanPath = path.join(teamDir, 'kanban-state.json');
|
const kanbanPath = path.join(teamDir, 'kanban-state.json');
|
||||||
return { claudeDir, teamDir, tasksDir, kanbanPath };
|
const processesPath = path.join(teamDir, 'processes.json');
|
||||||
|
return { claudeDir, teamDir, tasksDir, kanbanPath, processesPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferLeadName(paths) {
|
function inferLeadName(paths) {
|
||||||
|
|
@ -421,6 +422,89 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readProcessesSafe(filePath) {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isProcessAlive(pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code === 'EPERM') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processRegister(paths, flags) {
|
||||||
|
const pid = Number(flags.pid);
|
||||||
|
if (!Number.isInteger(pid) || pid <= 0) die('Invalid --pid (must be > 0)');
|
||||||
|
const label = typeof flags.label === 'string' ? flags.label.trim() : '';
|
||||||
|
if (!label) die('Missing --label');
|
||||||
|
|
||||||
|
const rawPort = flags.port != null ? Number(flags.port) : undefined;
|
||||||
|
const port = rawPort != null && Number.isInteger(rawPort) && rawPort >= 1 && rawPort <= 65535 ? rawPort : undefined;
|
||||||
|
const url = typeof flags.url === 'string' && flags.url.trim() ? flags.url.trim() : undefined;
|
||||||
|
|
||||||
|
const claudeProcessId = typeof flags['claude-process-id'] === 'string' ? flags['claude-process-id'].trim() : undefined;
|
||||||
|
const from = typeof flags.from === 'string' && flags.from.trim() ? flags.from.trim() : undefined;
|
||||||
|
const command = typeof flags.command === 'string' ? flags.command.trim() : undefined;
|
||||||
|
|
||||||
|
const list = readProcessesSafe(paths.processesPath);
|
||||||
|
const existingIdx = list.findIndex(function (p) { return p.pid === pid; });
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
id: existingIdx >= 0 ? list[existingIdx].id : (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random())),
|
||||||
|
port: port,
|
||||||
|
url: url,
|
||||||
|
label: label,
|
||||||
|
pid: pid,
|
||||||
|
claudeProcessId: claudeProcessId,
|
||||||
|
registeredBy: from,
|
||||||
|
command: command,
|
||||||
|
registeredAt: existingIdx >= 0 ? list[existingIdx].registeredAt : nowIso(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
list[existingIdx] = entry;
|
||||||
|
} else {
|
||||||
|
list.push(entry);
|
||||||
|
}
|
||||||
|
atomicWrite(paths.processesPath, JSON.stringify(list, null, 2));
|
||||||
|
var portStr = port ? ' port=' + String(port) : '';
|
||||||
|
process.stdout.write('OK process registered pid=' + String(pid) + portStr + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function processUnregister(paths, flags) {
|
||||||
|
const list = readProcessesSafe(paths.processesPath);
|
||||||
|
const pid = flags.pid ? Number(flags.pid) : undefined;
|
||||||
|
const id = typeof flags.id === 'string' ? flags.id.trim() : undefined;
|
||||||
|
if (!pid && !id) die('Missing --pid or --id');
|
||||||
|
|
||||||
|
const idx = list.findIndex(function (p) {
|
||||||
|
if (pid) return p.pid === pid;
|
||||||
|
return p.id === id;
|
||||||
|
});
|
||||||
|
if (idx < 0) die('Process not found');
|
||||||
|
const removed = list.splice(idx, 1)[0];
|
||||||
|
atomicWrite(paths.processesPath, JSON.stringify(list, null, 2));
|
||||||
|
process.stdout.write('OK process unregistered pid=' + String(removed.pid) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function processList(paths) {
|
||||||
|
const list = readProcessesSafe(paths.processesPath);
|
||||||
|
const result = list.map(function (p) {
|
||||||
|
return Object.assign({}, p, { alive: isProcessAlive(p.pid) });
|
||||||
|
});
|
||||||
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
function printHelp() {
|
function printHelp() {
|
||||||
const inferred = inferTeamNameFromScriptPath();
|
const inferred = inferTeamNameFromScriptPath();
|
||||||
const teamHint = inferred ? ' (inferred team: ' + String(inferred) + ')' : '';
|
const teamHint = inferred ? ' (inferred team: ' + String(inferred) + ')' : '';
|
||||||
|
|
@ -439,6 +523,10 @@ function printHelp() {
|
||||||
' node teamctl.js review approve <id> [--notify-owner --from "member" --note "..."] [--team <team>]',
|
' node teamctl.js review approve <id> [--notify-owner --from "member" --note "..."] [--team <team>]',
|
||||||
' node teamctl.js review request-changes <id> --comment "..." [--from "member"] [--team <team>]',
|
' node teamctl.js review request-changes <id> --comment "..." [--from "member"] [--team <team>]',
|
||||||
' node teamctl.js message send --to "member" --text "..." [--summary "..."] [--from "member"] [--team <team>]',
|
' node teamctl.js message send --to "member" --text "..." [--summary "..."] [--from "member"] [--team <team>]',
|
||||||
|
' node teamctl.js process register --pid <pid> --label <label> [--port <port>] [--url <url>] [--claude-process-id <id>] [--from <member>] [--command <cmd>] [--team <team>]',
|
||||||
|
' node teamctl.js process unregister --pid <pid> [--team <team>]',
|
||||||
|
' node teamctl.js process unregister --id <uuid> [--team <team>]',
|
||||||
|
' node teamctl.js process list [--team <team>]',
|
||||||
'',
|
'',
|
||||||
'Options:',
|
'Options:',
|
||||||
' --team <name> Team name (if not under ~/.claude/teams/<team>/tools)',
|
' --team <name> Team name (if not under ~/.claude/teams/<team>/tools)',
|
||||||
|
|
@ -627,6 +715,22 @@ async function main() {
|
||||||
die('Unknown message action: ' + String(action));
|
die('Unknown message action: ' + String(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (domain === 'process') {
|
||||||
|
if (action === 'register') {
|
||||||
|
processRegister(paths, args.flags);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'unregister' || action === 'remove') {
|
||||||
|
processUnregister(paths, args.flags);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'list') {
|
||||||
|
processList(paths);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
die('Unknown process action: ' + String(action));
|
||||||
|
}
|
||||||
|
|
||||||
die('Unknown domain: ' + String(domain));
|
die('Unknown domain: ' + String(domain));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
getTasksBasePath,
|
getTasksBasePath,
|
||||||
getTeamsBasePath,
|
getTeamsBasePath,
|
||||||
} from '@main/utils/pathDecoder';
|
} from '@main/utils/pathDecoder';
|
||||||
|
import { isProcessAlive } from '@main/utils/processHealth';
|
||||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColor } from '@shared/constants/memberColors';
|
import { getMemberColor } from '@shared/constants/memberColors';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
|
@ -43,6 +44,7 @@ import type {
|
||||||
TeamCreateConfigRequest,
|
TeamCreateConfigRequest,
|
||||||
TeamData,
|
TeamData,
|
||||||
TeamMember,
|
TeamMember,
|
||||||
|
TeamProcess,
|
||||||
TeamSummary,
|
TeamSummary,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
|
|
@ -54,8 +56,12 @@ const logger = createLogger('Service:TeamDataService');
|
||||||
|
|
||||||
const MIN_TEXT_LENGTH = 30;
|
const MIN_TEXT_LENGTH = 30;
|
||||||
const MAX_LEAD_TEXTS = 50;
|
const MAX_LEAD_TEXTS = 50;
|
||||||
|
const PROCESS_HEALTH_INTERVAL_MS = 2_000;
|
||||||
|
|
||||||
export class TeamDataService {
|
export class TeamDataService {
|
||||||
|
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
private processHealthTeams = new Set<string>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||||
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
||||||
|
|
@ -252,6 +258,21 @@ export class TeamDataService {
|
||||||
return { ...task, kanbanColumn };
|
return { ...task, kanbanColumn };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let processes: TeamProcess[] = [];
|
||||||
|
try {
|
||||||
|
processes = await this.readProcesses(teamName);
|
||||||
|
} catch {
|
||||||
|
warnings.push('Processes failed to load');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-track teams with alive processes for periodic health checks
|
||||||
|
const hasAlive = processes.some((p) => !p.stoppedAt);
|
||||||
|
if (hasAlive) {
|
||||||
|
this.processHealthTeams.add(teamName);
|
||||||
|
} else {
|
||||||
|
this.processHealthTeams.delete(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
teamName,
|
teamName,
|
||||||
config,
|
config,
|
||||||
|
|
@ -259,10 +280,163 @@ export class TeamDataService {
|
||||||
members,
|
members,
|
||||||
messages,
|
messages,
|
||||||
kanbanState,
|
kanbanState,
|
||||||
|
processes,
|
||||||
warnings: warnings.length > 0 ? warnings : undefined,
|
warnings: warnings.length > 0 ? warnings : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startProcessHealthPolling(): void {
|
||||||
|
if (this.processHealthTimer) return;
|
||||||
|
this.processHealthTimer = setInterval(() => {
|
||||||
|
void this.processHealthTick();
|
||||||
|
}, PROCESS_HEALTH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopProcessHealthPolling(): void {
|
||||||
|
if (this.processHealthTimer) {
|
||||||
|
clearInterval(this.processHealthTimer);
|
||||||
|
this.processHealthTimer = null;
|
||||||
|
}
|
||||||
|
this.processHealthTeams.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
trackProcessHealthForTeam(teamName: string): void {
|
||||||
|
this.processHealthTeams.add(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
untrackProcessHealthForTeam(teamName: string): void {
|
||||||
|
this.processHealthTeams.delete(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processHealthTick(): Promise<void> {
|
||||||
|
for (const teamName of this.processHealthTeams) {
|
||||||
|
try {
|
||||||
|
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
|
||||||
|
let raw: unknown[];
|
||||||
|
try {
|
||||||
|
const content = await fs.promises.readFile(processesPath, 'utf8');
|
||||||
|
const parsed: unknown = JSON.parse(content);
|
||||||
|
raw = Array.isArray(parsed) ? (parsed as unknown[]) : [];
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processes = raw.filter(
|
||||||
|
(p): p is TeamProcess =>
|
||||||
|
!!p &&
|
||||||
|
typeof p === 'object' &&
|
||||||
|
'pid' in p &&
|
||||||
|
typeof (p as TeamProcess).pid === 'number' &&
|
||||||
|
(p as TeamProcess).pid > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
let dirty = false;
|
||||||
|
for (const proc of processes) {
|
||||||
|
if (!proc.stoppedAt && !isProcessAlive(proc.pid)) {
|
||||||
|
proc.stoppedAt = new Date().toISOString();
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirty) {
|
||||||
|
await atomicWriteAsync(processesPath, JSON.stringify(processes, null, 2));
|
||||||
|
// atomicWrite triggers FileWatcher → team-change 'process' → UI refresh
|
||||||
|
// No need to emit manually — FileWatcher handles it.
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort per team
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async readProcesses(teamName: string): Promise<TeamProcess[]> {
|
||||||
|
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
|
||||||
|
let raw: unknown[];
|
||||||
|
try {
|
||||||
|
const content = await fs.promises.readFile(processesPath, 'utf8');
|
||||||
|
const parsed: unknown = JSON.parse(content);
|
||||||
|
raw = Array.isArray(parsed) ? (parsed as unknown[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const processes = raw.filter(
|
||||||
|
(p): p is TeamProcess =>
|
||||||
|
!!p &&
|
||||||
|
typeof p === 'object' &&
|
||||||
|
'pid' in p &&
|
||||||
|
typeof (p as TeamProcess).pid === 'number' &&
|
||||||
|
(p as TeamProcess).pid > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
let dirty = false;
|
||||||
|
for (const proc of processes) {
|
||||||
|
if (!proc.stoppedAt && !isProcessAlive(proc.pid)) {
|
||||||
|
proc.stoppedAt = new Date().toISOString();
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirty) {
|
||||||
|
try {
|
||||||
|
await atomicWriteAsync(processesPath, JSON.stringify(processes, null, 2));
|
||||||
|
} catch {
|
||||||
|
// best-effort write-back
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill a registered CLI process by PID (SIGTERM) and mark it as stopped in processes.json.
|
||||||
|
*/
|
||||||
|
async killProcess(teamName: string, pid: number): Promise<void> {
|
||||||
|
const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json');
|
||||||
|
|
||||||
|
// Try to kill the process
|
||||||
|
try {
|
||||||
|
process.kill(pid, 'SIGTERM');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// ESRCH = process not found — still mark as stopped below
|
||||||
|
if (
|
||||||
|
err instanceof Error &&
|
||||||
|
'code' in err &&
|
||||||
|
(err as NodeJS.ErrnoException).code !== 'ESRCH'
|
||||||
|
) {
|
||||||
|
throw new Error(`Failed to kill process ${pid}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update processes.json to set stoppedAt
|
||||||
|
let raw: unknown[];
|
||||||
|
try {
|
||||||
|
const content = await fs.promises.readFile(processesPath, 'utf8');
|
||||||
|
const parsed: unknown = JSON.parse(content);
|
||||||
|
raw = Array.isArray(parsed) ? (parsed as unknown[]) : [];
|
||||||
|
} catch {
|
||||||
|
return; // No processes file — nothing to update
|
||||||
|
}
|
||||||
|
|
||||||
|
let dirty = false;
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (
|
||||||
|
entry &&
|
||||||
|
typeof entry === 'object' &&
|
||||||
|
'pid' in entry &&
|
||||||
|
(entry as TeamProcess).pid === pid &&
|
||||||
|
!(entry as TeamProcess).stoppedAt
|
||||||
|
) {
|
||||||
|
(entry as TeamProcess).stoppedAt = new Date().toISOString();
|
||||||
|
dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dirty) {
|
||||||
|
await atomicWriteAsync(processesPath, JSON.stringify(raw, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enriches members with gitBranch when their cwd differs from the lead's.
|
* Enriches members with gitBranch when their cwd differs from the lead's.
|
||||||
* Mutates members in-place for efficiency (called right after resolveMembers).
|
* Mutates members in-place for efficiency (called right after resolveMembers).
|
||||||
|
|
|
||||||
|
|
@ -302,6 +302,19 @@ function buildTaskStatusProtocol(teamName: string): string {
|
||||||
Failure to follow this protocol means the task board will show incorrect status.`);
|
Failure to follow this protocol means the task board will show incorrect status.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildProcessRegistrationProtocol(teamName: string): string {
|
||||||
|
return wrapInAgentBlock(`BACKGROUND PROCESS REGISTRATION — when you start a background process (dev server, watcher, database, etc.):
|
||||||
|
1. Launch with & to get PID:
|
||||||
|
pnpm dev &
|
||||||
|
2. Register immediately (--port and --url are optional, use when the process listens on a port):
|
||||||
|
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" process register --pid $! --label "<description>" --from "<your-name>" [--port <PORT> --url "http://localhost:<PORT>"]
|
||||||
|
3. VERIFY registration succeeded (MANDATORY — never skip this step):
|
||||||
|
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" process list
|
||||||
|
4. When stopping a process:
|
||||||
|
node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" process unregister --pid <PID>
|
||||||
|
If verification in step 3 fails or the process is missing from the list, re-register it.`);
|
||||||
|
}
|
||||||
|
|
||||||
function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string {
|
function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string {
|
||||||
return wrapInAgentBlock(
|
return wrapInAgentBlock(
|
||||||
[
|
[
|
||||||
|
|
@ -360,6 +373,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
||||||
const description = request.description?.trim() || 'No description';
|
const description = request.description?.trim() || 'No description';
|
||||||
const members = buildMembersPrompt(request.members);
|
const members = buildMembersPrompt(request.members);
|
||||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||||
|
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
||||||
const languageInstruction = getAgentLanguageInstruction();
|
const languageInstruction = getAgentLanguageInstruction();
|
||||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||||
const userPromptBlock = request.prompt?.trim()
|
const userPromptBlock = request.prompt?.trim()
|
||||||
|
|
@ -416,6 +430,8 @@ Steps (execute in this exact order):
|
||||||
|
|
||||||
${taskProtocol}
|
${taskProtocol}
|
||||||
|
|
||||||
|
${processRegistration}
|
||||||
|
|
||||||
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
|
3) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
|
||||||
- Prefer fewer, broader tasks over many micro-tasks.
|
- Prefer fewer, broader tasks over many micro-tasks.
|
||||||
- Avoid duplicate notifications for the same assignment.
|
- Avoid duplicate notifications for the same assignment.
|
||||||
|
|
@ -436,6 +452,7 @@ function buildLaunchPrompt(
|
||||||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||||
: '';
|
: '';
|
||||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||||
|
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
||||||
const languageInstruction = getAgentLanguageInstruction();
|
const languageInstruction = getAgentLanguageInstruction();
|
||||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||||
|
|
||||||
|
|
@ -489,6 +506,8 @@ Steps (execute in this exact order):
|
||||||
|
|
||||||
${taskProtocol}
|
${taskProtocol}
|
||||||
|
|
||||||
|
${processRegistration}
|
||||||
|
|
||||||
4) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
|
4) If user instructions explicitly ask to create tasks OR describe substantial/assigned work that should be tracked — create tasks on the team board.
|
||||||
- Prefer fewer, broader tasks over many micro-tasks.
|
- Prefer fewer, broader tasks over many micro-tasks.
|
||||||
- Avoid duplicate notifications for the same assignment.
|
- Avoid duplicate notifications for the same assignment.
|
||||||
|
|
|
||||||
11
src/main/utils/processHealth.ts
Normal file
11
src/main/utils/processHealth.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export function isProcessAlive(pid: number): boolean {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (err instanceof Error && 'code' in err) {
|
||||||
|
if ((err as NodeJS.ErrnoException).code === 'EPERM') return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -285,6 +285,9 @@ export const TEAM_UPDATE_MEMBER_ROLE = 'team:updateMemberRole';
|
||||||
/** Get attachment data for a message */
|
/** Get attachment data for a message */
|
||||||
export const TEAM_GET_ATTACHMENTS = 'team:getAttachments';
|
export const TEAM_GET_ATTACHMENTS = 'team:getAttachments';
|
||||||
|
|
||||||
|
/** Kill a registered CLI process by PID */
|
||||||
|
export const TEAM_KILL_PROCESS = 'team:killProcess';
|
||||||
|
|
||||||
/** Get lead process activity state (active/idle/offline) */
|
/** Get lead process activity state (active/idle/offline) */
|
||||||
export const TEAM_LEAD_ACTIVITY = 'team:leadActivity';
|
export const TEAM_LEAD_ACTIVITY = 'team:leadActivity';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ import {
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_GET_PROJECT_BRANCH,
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
|
TEAM_KILL_PROCESS,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
TEAM_LIST,
|
TEAM_LIST,
|
||||||
|
|
@ -664,6 +665,9 @@ const electronAPI: ElectronAPI = {
|
||||||
getAttachments: async (teamName: string, messageId: string) => {
|
getAttachments: async (teamName: string, messageId: string) => {
|
||||||
return invokeIpcWithResult<AttachmentFileData[]>(TEAM_GET_ATTACHMENTS, teamName, messageId);
|
return invokeIpcWithResult<AttachmentFileData[]>(TEAM_GET_ATTACHMENTS, teamName, messageId);
|
||||||
},
|
},
|
||||||
|
killProcess: async (teamName: string, pid: number) => {
|
||||||
|
return invokeIpcWithResult<void>(TEAM_KILL_PROCESS, teamName, pid);
|
||||||
|
},
|
||||||
getLeadActivity: async (teamName: string) => {
|
getLeadActivity: async (teamName: string) => {
|
||||||
const result = await invokeIpcWithResult<string>(TEAM_LEAD_ACTIVITY, teamName);
|
const result = await invokeIpcWithResult<string>(TEAM_LEAD_ACTIVITY, teamName);
|
||||||
return result as 'active' | 'idle' | 'offline';
|
return result as 'active' | 'idle' | 'offline';
|
||||||
|
|
|
||||||
|
|
@ -761,6 +761,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
): Promise<AttachmentFileData[]> => {
|
): Promise<AttachmentFileData[]> => {
|
||||||
return [];
|
return [];
|
||||||
},
|
},
|
||||||
|
killProcess: async (_teamName: string, _pid: number): Promise<void> => {
|
||||||
|
// Not available via HTTP client — no-op
|
||||||
|
},
|
||||||
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
|
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
|
||||||
return 'offline';
|
return 'offline';
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={REHYPE_PLUGINS}
|
rehypePlugins={REHYPE_PLUGINS}
|
||||||
|
urlTransform={(url) => url}
|
||||||
components={components}
|
components={components}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { UpdateBanner } from '../common/UpdateBanner';
|
||||||
import { UpdateDialog } from '../common/UpdateDialog';
|
import { UpdateDialog } from '../common/UpdateDialog';
|
||||||
import { WorkspaceIndicator } from '../common/WorkspaceIndicator';
|
import { WorkspaceIndicator } from '../common/WorkspaceIndicator';
|
||||||
import { CommandPalette } from '../search/CommandPalette';
|
import { CommandPalette } from '../search/CommandPalette';
|
||||||
|
import { GlobalTaskDetailDialog } from '../team/dialogs/GlobalTaskDetailDialog';
|
||||||
|
|
||||||
import { CustomTitleBar } from './CustomTitleBar';
|
import { CustomTitleBar } from './CustomTitleBar';
|
||||||
import { PaneContainer } from './PaneContainer';
|
import { PaneContainer } from './PaneContainer';
|
||||||
|
|
@ -50,6 +51,7 @@ export const TabbedLayout = (): React.JSX.Element => {
|
||||||
{/* Multi-pane content area */}
|
{/* Multi-pane content area */}
|
||||||
<PaneContainer />
|
<PaneContainer />
|
||||||
</div>
|
</div>
|
||||||
|
<GlobalTaskDetailDialog />
|
||||||
<UpdateDialog />
|
<UpdateDialog />
|
||||||
<WorkspaceIndicator />
|
<WorkspaceIndicator />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ export const SidebarTaskItem = ({
|
||||||
task,
|
task,
|
||||||
hideTeamName,
|
hideTeamName,
|
||||||
}: SidebarTaskItemProps): React.JSX.Element => {
|
}: SidebarTaskItemProps): React.JSX.Element => {
|
||||||
const openTeamTab = useStore((s) => s.openTeamTab);
|
const openGlobalTaskDetail = useStore((s) => s.openGlobalTaskDetail);
|
||||||
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
const unreadCount = useUnreadCommentCount(task.teamName, task.id, task.comments);
|
||||||
const cfg =
|
const cfg =
|
||||||
task.kanbanColumn === 'approved'
|
task.kanbanColumn === 'approved'
|
||||||
|
|
@ -70,7 +70,7 @@ export const SidebarTaskItem = ({
|
||||||
type="button"
|
type="button"
|
||||||
className="flex h-[48px] w-full cursor-pointer flex-col justify-center border-b px-3 py-2 text-left transition-colors hover:bg-surface-raised"
|
className="flex h-[48px] w-full cursor-pointer flex-col justify-center border-b px-3 py-2 text-left transition-colors hover:bg-surface-raised"
|
||||||
style={{ borderColor: 'var(--color-border)' }}
|
style={{ borderColor: 'var(--color-border)' }}
|
||||||
onClick={() => openTeamTab(task.teamName, undefined, task.id)}
|
onClick={() => openGlobalTaskDetail(task.teamName, task.id)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center gap-1.5 overflow-hidden">
|
<div className="flex w-full items-center gap-1.5 overflow-hidden">
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ export const CollapsibleTeamSection = ({
|
||||||
const isOpen = forceOpen ? true : open;
|
const isOpen = forceOpen ? true : open;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border-b border-[var(--color-border)] pb-3 last:border-b-0">
|
<section className="min-w-0 overflow-hidden border-b border-[var(--color-border)] pb-3 last:border-b-0">
|
||||||
<div className="relative -mx-4 flex min-h-10 w-full items-stretch py-3">
|
<div className="relative -mx-4 flex min-h-10 w-full items-stretch py-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -65,7 +65,7 @@ export const CollapsibleTeamSection = ({
|
||||||
</div>
|
</div>
|
||||||
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
|
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
|
||||||
</div>
|
</div>
|
||||||
{isOpen && <div className="mt-2">{children}</div>}
|
{isOpen && <div className="mt-2 min-w-0 overflow-hidden">{children}</div>}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
112
src/renderer/components/team/ProcessesSection.tsx
Normal file
112
src/renderer/components/team/ProcessesSection.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { formatDistanceToNowStrict } from 'date-fns';
|
||||||
|
import { ExternalLink, Square, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { TeamProcess } from '@shared/types';
|
||||||
|
|
||||||
|
interface ProcessesSectionProps {
|
||||||
|
teamName: string;
|
||||||
|
processes: TeamProcess[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortTime(date: Date): string {
|
||||||
|
const distance = formatDistanceToNowStrict(date, { addSuffix: false });
|
||||||
|
return distance
|
||||||
|
.replace(' seconds', 's')
|
||||||
|
.replace(' second', 's')
|
||||||
|
.replace(' minutes', 'm')
|
||||||
|
.replace(' minute', 'm')
|
||||||
|
.replace(' hours', 'h')
|
||||||
|
.replace(' hour', 'h')
|
||||||
|
.replace(' days', 'd')
|
||||||
|
.replace(' day', 'd')
|
||||||
|
.replace(' weeks', 'w')
|
||||||
|
.replace(' week', 'w')
|
||||||
|
.replace(' months', 'mo')
|
||||||
|
.replace(' month', 'mo')
|
||||||
|
.replace(' years', 'y')
|
||||||
|
.replace(' year', 'y');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ProcessesSection = ({
|
||||||
|
teamName,
|
||||||
|
processes,
|
||||||
|
}: ProcessesSectionProps): React.JSX.Element => {
|
||||||
|
const sorted = [...processes].sort((a, b) => {
|
||||||
|
const aAlive = !a.stoppedAt;
|
||||||
|
const bAlive = !b.stoppedAt;
|
||||||
|
if (aAlive !== bAlive) return aAlive ? -1 : 1;
|
||||||
|
return Date.parse(b.registeredAt) - Date.parse(a.registeredAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{sorted.map((proc) => {
|
||||||
|
const alive = !proc.stoppedAt;
|
||||||
|
const timeStr = alive
|
||||||
|
? `${formatShortTime(new Date(proc.registeredAt))} ago`
|
||||||
|
: `stopped ${formatShortTime(new Date(proc.stoppedAt!))} ago`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={proc.id}
|
||||||
|
className={`flex items-center gap-2 rounded px-2 py-1.5 text-xs transition-colors hover:bg-[var(--color-surface-raised)] ${!alive ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
{/* Status indicator */}
|
||||||
|
<span
|
||||||
|
className={`inline-block size-2 shrink-0 rounded-full ${alive ? 'bg-emerald-400' : 'bg-zinc-500'}`}
|
||||||
|
title={alive ? 'Running' : 'Stopped'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Icon + label — takes available space */}
|
||||||
|
<Terminal size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||||
|
<span
|
||||||
|
className="min-w-0 truncate font-medium text-[var(--color-text)]"
|
||||||
|
title={proc.label}
|
||||||
|
>
|
||||||
|
{proc.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Port + URL inline — only when present */}
|
||||||
|
{(proc.port != null || proc.url) && (
|
||||||
|
<span className="min-w-0 truncate text-[var(--color-text-secondary)]">
|
||||||
|
{proc.port != null && `:${proc.port}`}
|
||||||
|
{proc.port != null && proc.url && ' '}
|
||||||
|
{proc.url}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Right-aligned group: Kill button, Open button, author, time */}
|
||||||
|
<span className="ml-auto flex shrink-0 items-center gap-2">
|
||||||
|
{alive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-red-400 transition-colors hover:bg-red-500/10"
|
||||||
|
onClick={() => void window.electronAPI.teams.killProcess(teamName, proc.pid)}
|
||||||
|
title="Stop process (SIGTERM)"
|
||||||
|
>
|
||||||
|
<Square size={8} className="fill-current" />
|
||||||
|
Kill
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{alive && proc.url && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] text-blue-400 transition-colors hover:bg-blue-500/10"
|
||||||
|
onClick={() => void window.electronAPI.openExternal(proc.url!)}
|
||||||
|
title="Open in browser"
|
||||||
|
>
|
||||||
|
<ExternalLink size={10} />
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{proc.registeredBy && (
|
||||||
|
<span className="text-[var(--color-text-muted)]">{proc.registeredBy}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[var(--color-text-muted)]">{timeStr}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -54,6 +54,7 @@ import { MessageComposer } from './messages/MessageComposer';
|
||||||
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
|
import { MessagesFilterPopover } from './messages/MessagesFilterPopover';
|
||||||
import { ChangeReviewDialog } from './review/ChangeReviewDialog';
|
import { ChangeReviewDialog } from './review/ChangeReviewDialog';
|
||||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||||
|
import { ProcessesSection } from './ProcessesSection';
|
||||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||||
|
|
||||||
|
|
@ -993,6 +994,16 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
{(data.processes?.length ?? 0) > 0 && (
|
||||||
|
<CollapsibleTeamSection
|
||||||
|
title="CLI Processes"
|
||||||
|
badge={data.processes.filter((p) => !p.stoppedAt).length}
|
||||||
|
defaultOpen
|
||||||
|
>
|
||||||
|
<ProcessesSection teamName={teamName} processes={data.processes} />
|
||||||
|
</CollapsibleTeamSection>
|
||||||
|
)}
|
||||||
|
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
title="Messages"
|
title="Messages"
|
||||||
badge={filteredMessages.length}
|
badge={filteredMessages.length}
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,28 @@ function linkifyTaskIdsInMarkdown(text: string): string {
|
||||||
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
|
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Render `#<digits>` in plain text as clickable inline elements. */
|
||||||
|
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
|
||||||
|
return text.split(/(#\d+)/g).map((part, i) => {
|
||||||
|
const match = /^#(\d+)$/.exec(part);
|
||||||
|
if (!match) return <span key={i}>{part}</span>;
|
||||||
|
const taskId = match[1];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
type="button"
|
||||||
|
className="cursor-pointer font-medium text-blue-400 hover:underline"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick(taskId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const ActivityItem = ({
|
export const ActivityItem = ({
|
||||||
message,
|
message,
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -299,7 +321,7 @@ export const ActivityItem = ({
|
||||||
|
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
|
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
|
||||||
{summaryText}
|
{onTaskIdClick ? linkifyTaskIds(summaryText, onTaskIdClick) : summaryText}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Timestamp + reply + create task */}
|
{/* Timestamp + reply + create task */}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useStore } from '@renderer/store';
|
||||||
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
|
import { TaskDetailDialog } from './TaskDetailDialog';
|
||||||
|
|
||||||
|
import type { TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global wrapper around TaskDetailDialog.
|
||||||
|
* Mounted at layout level so it can be opened from anywhere (e.g. sidebar)
|
||||||
|
* without navigating to the team page first.
|
||||||
|
*/
|
||||||
|
export const GlobalTaskDetailDialog = (): React.JSX.Element | null => {
|
||||||
|
const {
|
||||||
|
globalTaskDetail,
|
||||||
|
closeGlobalTaskDetail,
|
||||||
|
selectedTeamData,
|
||||||
|
selectedTeamLoading,
|
||||||
|
openTeamTab,
|
||||||
|
} = useStore(
|
||||||
|
useShallow((s) => ({
|
||||||
|
globalTaskDetail: s.globalTaskDetail,
|
||||||
|
closeGlobalTaskDetail: s.closeGlobalTaskDetail,
|
||||||
|
selectedTeamData: s.selectedTeamData,
|
||||||
|
selectedTeamLoading: s.selectedTeamLoading,
|
||||||
|
openTeamTab: s.openTeamTab,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const taskMap = useMemo(() => {
|
||||||
|
const map = new Map<string, TeamTaskWithKanban>();
|
||||||
|
if (!selectedTeamData) return map;
|
||||||
|
for (const t of selectedTeamData.tasks) map.set(t.id, t);
|
||||||
|
return map;
|
||||||
|
}, [selectedTeamData]);
|
||||||
|
|
||||||
|
const activeMembers = useMemo(
|
||||||
|
() => selectedTeamData?.members.filter((m) => !m.removedAt) ?? [],
|
||||||
|
[selectedTeamData]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!globalTaskDetail) return null;
|
||||||
|
|
||||||
|
const { teamName, taskId } = globalTaskDetail;
|
||||||
|
const task = taskMap.get(taskId) ?? null;
|
||||||
|
const kanbanTaskState = selectedTeamData?.kanbanState.tasks[taskId];
|
||||||
|
|
||||||
|
const handleOpenTeam = (): void => {
|
||||||
|
closeGlobalTaskDetail();
|
||||||
|
openTeamTab(teamName, undefined, taskId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TaskDetailDialog
|
||||||
|
open
|
||||||
|
task={selectedTeamLoading ? null : task}
|
||||||
|
teamName={teamName}
|
||||||
|
kanbanTaskState={kanbanTaskState}
|
||||||
|
taskMap={taskMap}
|
||||||
|
members={activeMembers}
|
||||||
|
onClose={closeGlobalTaskDetail}
|
||||||
|
onOwnerChange={undefined}
|
||||||
|
footerExtra={
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-md border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||||
|
onClick={handleOpenTeam}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} />
|
||||||
|
Open team
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -57,6 +57,8 @@ interface TaskDetailDialogProps {
|
||||||
onScrollToTask?: (taskId: string) => void;
|
onScrollToTask?: (taskId: string) => void;
|
||||||
onOwnerChange?: (taskId: string, owner: string | null) => void;
|
onOwnerChange?: (taskId: string, owner: string | null) => void;
|
||||||
onViewChanges?: (taskId: string, filePath?: string) => void;
|
onViewChanges?: (taskId: string, filePath?: string) => void;
|
||||||
|
/** Extra content rendered in the dialog footer (e.g. "Open team" button). */
|
||||||
|
footerExtra?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TaskDetailDialog = ({
|
export const TaskDetailDialog = ({
|
||||||
|
|
@ -70,6 +72,7 @@ export const TaskDetailDialog = ({
|
||||||
onScrollToTask,
|
onScrollToTask,
|
||||||
onOwnerChange,
|
onOwnerChange,
|
||||||
onViewChanges,
|
onViewChanges,
|
||||||
|
footerExtra,
|
||||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
||||||
|
|
@ -262,7 +265,7 @@ export const TaskDetailDialog = ({
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
title="Changes"
|
title="Changes"
|
||||||
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
||||||
defaultOpen
|
defaultOpen={!!taskChangesFiles && taskChangesFiles.length > 0}
|
||||||
>
|
>
|
||||||
{changeSetLoading && !taskChangesFiles ? (
|
{changeSetLoading && !taskChangesFiles ? (
|
||||||
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
|
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
|
||||||
|
|
@ -457,9 +460,18 @@ export const TaskDetailDialog = ({
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
|
{footerExtra ? (
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
{footerExtra}
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { isLastChunkInFile, useDiffNavigation } from '@renderer/hooks/useDiffNav
|
||||||
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
|
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { REVIEW_INSTANT_APPLY } from '@renderer/store/slices/changeReviewSlice';
|
||||||
import { ChevronDown, Clock, X } from 'lucide-react';
|
import { ChevronDown, Clock, X } from 'lucide-react';
|
||||||
|
|
||||||
import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils';
|
import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils';
|
||||||
|
|
@ -62,6 +63,7 @@ export const ChangeReviewDialog = ({
|
||||||
acceptAllFile,
|
acceptAllFile,
|
||||||
rejectAllFile,
|
rejectAllFile,
|
||||||
applyReview,
|
applyReview,
|
||||||
|
applySingleFileDecision,
|
||||||
editedContents,
|
editedContents,
|
||||||
updateEditedContent,
|
updateEditedContent,
|
||||||
discardFileEdits,
|
discardFileEdits,
|
||||||
|
|
@ -166,8 +168,11 @@ export const ChangeReviewDialog = ({
|
||||||
const handleHunkRejected = useCallback(
|
const handleHunkRejected = useCallback(
|
||||||
(filePath: string, hunkIndex: number) => {
|
(filePath: string, hunkIndex: number) => {
|
||||||
setHunkDecision(filePath, hunkIndex, 'rejected');
|
setHunkDecision(filePath, hunkIndex, 'rejected');
|
||||||
|
if (REVIEW_INSTANT_APPLY) {
|
||||||
|
void applySingleFileDecision(teamName, filePath, taskId, memberName);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[setHunkDecision]
|
[setHunkDecision, applySingleFileDecision, teamName, taskId, memberName]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleContentChanged = useCallback(
|
const handleContentChanged = useCallback(
|
||||||
|
|
@ -347,17 +352,11 @@ export const ChangeReviewDialog = ({
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h2 className="text-sm font-medium text-text">{title}</h2>
|
<h2 className="text-sm font-medium text-text">{title}</h2>
|
||||||
{activeChangeSet && (
|
{activeChangeSet && (
|
||||||
<>
|
|
||||||
<span className="text-xs text-text-muted">
|
|
||||||
{activeChangeSet.totalFiles} files, +{activeChangeSet.totalLinesAdded} -
|
|
||||||
{activeChangeSet.totalLinesRemoved}
|
|
||||||
</span>
|
|
||||||
<ViewedProgressBar
|
<ViewedProgressBar
|
||||||
viewed={viewedCount}
|
viewed={viewedCount}
|
||||||
total={viewedTotalCount}
|
total={viewedTotalCount}
|
||||||
progress={viewedProgress}
|
progress={viewedProgress}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -391,6 +390,7 @@ export const ChangeReviewDialog = ({
|
||||||
onRejectAll={handleRejectAll}
|
onRejectAll={handleRejectAll}
|
||||||
onApply={handleApply}
|
onApply={handleApply}
|
||||||
onCollapseUnchangedChange={setCollapseUnchanged}
|
onCollapseUnchangedChange={setCollapseUnchanged}
|
||||||
|
instantApply={REVIEW_INSTANT_APPLY}
|
||||||
editedCount={editedCount}
|
editedCount={editedCount}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@ import {
|
||||||
acceptChunk,
|
acceptChunk,
|
||||||
getChunks,
|
getChunks,
|
||||||
getOriginalDoc,
|
getOriginalDoc,
|
||||||
|
originalDocChangeEffect,
|
||||||
rejectChunk,
|
rejectChunk,
|
||||||
updateOriginalDoc,
|
updateOriginalDoc,
|
||||||
} from '@codemirror/merge';
|
} from '@codemirror/merge';
|
||||||
import { ChangeSet, type ChangeSpec, type StateEffect } from '@codemirror/state';
|
import { ChangeSet, type ChangeSpec, EditorState, type StateEffect } from '@codemirror/state';
|
||||||
import { type EditorView } from '@codemirror/view';
|
import { type EditorView } from '@codemirror/view';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -72,4 +73,22 @@ export function rejectAllChunks(view: EditorView): boolean {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After all diff chunks are accepted, mirrors user edits to the original doc
|
||||||
|
* so no new diffs appear. Makes editing feel like a regular editor (Cursor-like).
|
||||||
|
*/
|
||||||
|
export const mirrorEditsAfterResolve = EditorState.transactionExtender.of((tr) => {
|
||||||
|
if (!tr.docChanged) return null;
|
||||||
|
|
||||||
|
// Skip if transaction already updates original (undo/redo inverse, explicit accept)
|
||||||
|
if (tr.effects.some((e) => e.is(updateOriginalDoc))) return null;
|
||||||
|
|
||||||
|
// Only mirror when ALL chunks are resolved
|
||||||
|
const result = getChunks(tr.startState);
|
||||||
|
if (!result || result.chunks.length > 0) return null;
|
||||||
|
|
||||||
|
// Mirror edit to original doc (same ChangeSet applies because original === modified)
|
||||||
|
return { effects: originalDocChangeEffect(tr.startState, tr.changes) };
|
||||||
|
});
|
||||||
|
|
||||||
export { acceptChunk, getChunks, rejectChunk };
|
export { acceptChunk, getChunks, rejectChunk };
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,13 @@ import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
||||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||||
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
|
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
|
||||||
|
|
||||||
import { acceptChunk, getChunks, mergeUndoSupport, rejectChunk } from './CodeMirrorDiffUtils';
|
import {
|
||||||
|
acceptChunk,
|
||||||
|
getChunks,
|
||||||
|
mergeUndoSupport,
|
||||||
|
mirrorEditsAfterResolve,
|
||||||
|
rejectChunk,
|
||||||
|
} from './CodeMirrorDiffUtils';
|
||||||
import { portionCollapseExtension } from './portionCollapse';
|
import { portionCollapseExtension } from './portionCollapse';
|
||||||
|
|
||||||
interface CodeMirrorDiffViewProps {
|
interface CodeMirrorDiffViewProps {
|
||||||
|
|
@ -119,16 +125,15 @@ function getAsyncLanguageDesc(fileName: string): LanguageDescription | null {
|
||||||
return LanguageDescription.matchFilename(languages, fileName);
|
return LanguageDescription.matchFilename(languages, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compute hunk index for the chunk at a given position */
|
/** Compute hunk index for the chunk at a given position (B-side / modified doc) */
|
||||||
function computeHunkIndexAtPos(state: EditorState, pos: number): number {
|
function computeHunkIndexAtPos(state: EditorState, pos: number): number {
|
||||||
const chunks = getChunks(state);
|
const chunks = getChunks(state);
|
||||||
if (!chunks) return 0;
|
if (!chunks) return 0;
|
||||||
|
|
||||||
let index = 0;
|
let index = 0;
|
||||||
for (const chunk of chunks.chunks) {
|
for (const chunk of chunks.chunks) {
|
||||||
if (pos >= chunk.fromA && pos <= chunk.toA) {
|
// Only check B-side (modified doc) — editor positions correspond to B-side in unified view.
|
||||||
return index;
|
// A-side (fromA/toA) positions refer to the original document and are not editor positions.
|
||||||
}
|
|
||||||
if (pos >= chunk.fromB && pos <= chunk.toB) {
|
if (pos >= chunk.fromB && pos <= chunk.toB) {
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
@ -467,6 +472,7 @@ export const CodeMirrorDiffView = ({
|
||||||
if (!readOnly) {
|
if (!readOnly) {
|
||||||
extensions.push(history());
|
extensions.push(history());
|
||||||
extensions.push(mergeUndoSupport);
|
extensions.push(mergeUndoSupport);
|
||||||
|
extensions.push(mirrorEditsAfterResolve);
|
||||||
extensions.push(indentUnit.of(' '));
|
extensions.push(indentUnit.of(' '));
|
||||||
extensions.push(keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]));
|
extensions.push(keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]));
|
||||||
}
|
}
|
||||||
|
|
@ -474,32 +480,13 @@ export const CodeMirrorDiffView = ({
|
||||||
// Language placeholder — actual language injected async via compartment reconfigure
|
// Language placeholder — actual language injected async via compartment reconfigure
|
||||||
extensions.push(langCompartment.current.of([]));
|
extensions.push(langCompartment.current.of([]));
|
||||||
|
|
||||||
// Keyboard shortcuts for chunk navigation and accept/reject
|
// Keyboard shortcuts for chunk navigation (within single editor).
|
||||||
|
// NOTE: Mod-y, Mod-n, Alt-j are intentionally NOT here — they are handled by
|
||||||
|
// useDiffNavigation's document handler (cross-file aware) and IPC handler (Cmd+N on macOS).
|
||||||
|
// Registering them in CM keymap would call event.preventDefault(), blocking the
|
||||||
|
// document handler's cross-file logic.
|
||||||
extensions.push(
|
extensions.push(
|
||||||
keymap.of([
|
keymap.of([
|
||||||
{
|
|
||||||
key: 'Mod-y',
|
|
||||||
run: (view) => {
|
|
||||||
acceptChunk(view);
|
|
||||||
requestAnimationFrame(() => goToNextChunk(view));
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'Mod-n',
|
|
||||||
run: (view) => {
|
|
||||||
rejectChunk(view);
|
|
||||||
requestAnimationFrame(() => goToNextChunk(view));
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'Alt-j',
|
|
||||||
run: (view) => {
|
|
||||||
goToNextChunk(view);
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'Ctrl-Alt-ArrowDown',
|
key: 'Ctrl-Alt-ArrowDown',
|
||||||
run: goToNextChunk,
|
run: goToNextChunk,
|
||||||
|
|
@ -710,6 +697,7 @@ export const CodeMirrorDiffView = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
clearTimeout(debounceTimer.current);
|
||||||
view.destroy();
|
view.destroy();
|
||||||
viewRef.current = null;
|
viewRef.current = null;
|
||||||
if (extRef) {
|
if (extRef) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import React, { useCallback, useMemo } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent';
|
import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent';
|
||||||
import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection';
|
import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection';
|
||||||
|
|
||||||
|
import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils';
|
||||||
import { FileSectionDiff } from './FileSectionDiff';
|
import { FileSectionDiff } from './FileSectionDiff';
|
||||||
import { FileSectionHeader } from './FileSectionHeader';
|
import { FileSectionHeader } from './FileSectionHeader';
|
||||||
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
|
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
|
||||||
|
|
@ -99,10 +100,30 @@ export const ContinuousScrollView = ({
|
||||||
[registerFileSectionRef, registerLazyRef]
|
[registerFileSectionRef, registerLazyRef]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Ref to avoid stale closure — fileDecisions changes frequently
|
||||||
|
const fileDecisionsRef = useRef(fileDecisions);
|
||||||
|
useEffect(() => {
|
||||||
|
fileDecisionsRef.current = fileDecisions;
|
||||||
|
});
|
||||||
|
|
||||||
const handleEditorViewReady = useCallback(
|
const handleEditorViewReady = useCallback(
|
||||||
(filePath: string, view: EditorView | null) => {
|
(filePath: string, view: EditorView | null) => {
|
||||||
if (view) {
|
if (view) {
|
||||||
editorViewMapRef.current.set(filePath, view);
|
editorViewMapRef.current.set(filePath, view);
|
||||||
|
|
||||||
|
// Sync pre-existing "Accept All" / "Reject All" decisions to newly mounted editors.
|
||||||
|
// When Accept All runs, store is updated for ALL files, but CM only updates mounted ones.
|
||||||
|
// Lazily-loaded files mount later and need their CM state synced with the store.
|
||||||
|
const decision = fileDecisionsRef.current[filePath];
|
||||||
|
if (decision === 'accepted' || decision === 'rejected') {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (decision === 'accepted') {
|
||||||
|
acceptAllChunks(view);
|
||||||
|
} else {
|
||||||
|
rejectAllChunks(view);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
editorViewMapRef.current.delete(filePath);
|
editorViewMapRef.current.delete(filePath);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ const TreeItem = ({
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'min-w-0 flex-1 truncate',
|
'min-w-0 flex-1 truncate',
|
||||||
viewedSet?.has(node.file.filePath) && 'text-text-muted line-through'
|
status === 'rejected' && 'text-text-muted line-through'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{node.name}
|
{node.name}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ interface ReviewToolbarProps {
|
||||||
collapseUnchanged: boolean;
|
collapseUnchanged: boolean;
|
||||||
applying: boolean;
|
applying: boolean;
|
||||||
autoViewed: boolean;
|
autoViewed: boolean;
|
||||||
|
instantApply?: boolean;
|
||||||
onAutoViewedChange: (auto: boolean) => void;
|
onAutoViewedChange: (auto: boolean) => void;
|
||||||
onAcceptAll: () => void;
|
onAcceptAll: () => void;
|
||||||
onRejectAll: () => void;
|
onRejectAll: () => void;
|
||||||
|
|
@ -41,6 +42,7 @@ export const ReviewToolbar = ({
|
||||||
onRejectAll,
|
onRejectAll,
|
||||||
onApply,
|
onApply,
|
||||||
onCollapseUnchangedChange,
|
onCollapseUnchangedChange,
|
||||||
|
instantApply = false,
|
||||||
editedCount = 0,
|
editedCount = 0,
|
||||||
}: ReviewToolbarProps): React.ReactElement => {
|
}: ReviewToolbarProps): React.ReactElement => {
|
||||||
const hasRejected = stats.rejected > 0;
|
const hasRejected = stats.rejected > 0;
|
||||||
|
|
@ -173,6 +175,7 @@ export const ReviewToolbar = ({
|
||||||
<TooltipContent side="bottom">Reject all changes across all files</TooltipContent>
|
<TooltipContent side="bottom">Reject all changes across all files</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
|
{!instantApply && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
|
|
@ -195,6 +198,7 @@ export const ReviewToolbar = ({
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">Apply review decisions across all files</TooltipContent>
|
<TooltipContent side="bottom">Apply review decisions across all files</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { updateOriginalDoc } from '@codemirror/merge';
|
import { updateOriginalDoc } from '@codemirror/merge';
|
||||||
import { type Extension, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
|
import { type Extension, Facet, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
|
||||||
import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
|
import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
|
||||||
|
|
||||||
import { getChunks } from './CodeMirrorDiffUtils';
|
import { getChunks } from './CodeMirrorDiffUtils';
|
||||||
|
|
@ -14,6 +14,20 @@ interface PortionCollapseConfig {
|
||||||
portionSize?: number;
|
portionSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ResolvedPortionConfig {
|
||||||
|
margin: number;
|
||||||
|
minSize: number;
|
||||||
|
portionSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Configuration Facet ───
|
||||||
|
// Compartment controls this facet value. The StateField reads config from here,
|
||||||
|
// so reconfiguring the compartment does NOT recreate the field (preserving expanded state).
|
||||||
|
|
||||||
|
const portionCollapseConfigFacet = Facet.define<ResolvedPortionConfig, ResolvedPortionConfig>({
|
||||||
|
combine: (values) => values[0] ?? { margin: 3, minSize: 4, portionSize: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
// ─── State Effects ───
|
// ─── State Effects ───
|
||||||
|
|
||||||
export const expandPortion = StateEffect.define<{ pos: number; count: number }>({
|
export const expandPortion = StateEffect.define<{ pos: number; count: number }>({
|
||||||
|
|
@ -111,6 +125,11 @@ function buildPortionRanges(
|
||||||
if (!result) return Decoration.none;
|
if (!result) return Decoration.none;
|
||||||
|
|
||||||
const chunks = result.chunks;
|
const chunks = result.chunks;
|
||||||
|
|
||||||
|
// After all diff chunks are accepted/resolved, chunks is empty.
|
||||||
|
// Don't collapse the entire file — there's nothing to review.
|
||||||
|
if (chunks.length === 0) return Decoration.none;
|
||||||
|
|
||||||
const builder = new RangeSetBuilder<Decoration>();
|
const builder = new RangeSetBuilder<Decoration>();
|
||||||
|
|
||||||
let prevLine = 1;
|
let prevLine = 1;
|
||||||
|
|
@ -257,26 +276,27 @@ const portionCollapseTheme = EditorView.theme({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Extension ───
|
// ─── Singleton StateField ───
|
||||||
|
// Defined at MODULE level so compartment.reconfigure() reuses the same field instance.
|
||||||
|
// CM recognizes it's the same field → keeps accumulated state (expanded regions) → no create() call.
|
||||||
|
// Config is read from portionCollapseConfigFacet (controlled by compartment).
|
||||||
|
|
||||||
export function portionCollapseExtension(config?: PortionCollapseConfig): Extension {
|
const portionCollapseField = StateField.define<DecorationSet>({
|
||||||
const margin = config?.margin ?? 3;
|
|
||||||
const minSize = config?.minSize ?? 4;
|
|
||||||
const portionSize = config?.portionSize ?? 100;
|
|
||||||
|
|
||||||
const field = StateField.define<DecorationSet>({
|
|
||||||
create(state: EditorState): DecorationSet {
|
create(state: EditorState): DecorationSet {
|
||||||
return buildPortionRanges(state, margin, minSize, portionSize);
|
const cfg = state.facet(portionCollapseConfigFacet);
|
||||||
|
return buildPortionRanges(state, cfg.margin, cfg.minSize, cfg.portionSize);
|
||||||
},
|
},
|
||||||
|
|
||||||
update(deco: DecorationSet, tr: Transaction): DecorationSet {
|
update(deco: DecorationSet, tr: Transaction): DecorationSet {
|
||||||
|
const cfg = tr.state.facet(portionCollapseConfigFacet);
|
||||||
|
|
||||||
// 1. Expand effects
|
// 1. Expand effects
|
||||||
let result = deco;
|
let result = deco;
|
||||||
let hasExpandEffect = false;
|
let hasExpandEffect = false;
|
||||||
for (const effect of tr.effects) {
|
for (const effect of tr.effects) {
|
||||||
if (effect.is(expandPortion)) {
|
if (effect.is(expandPortion)) {
|
||||||
hasExpandEffect = true;
|
hasExpandEffect = true;
|
||||||
result = handleExpandPortion(result, effect.value, tr.state, minSize, portionSize);
|
result = handleExpandPortion(result, effect.value, tr.state, cfg.minSize, cfg.portionSize);
|
||||||
}
|
}
|
||||||
if (effect.is(expandAllAtPos)) {
|
if (effect.is(expandAllAtPos)) {
|
||||||
hasExpandEffect = true;
|
hasExpandEffect = true;
|
||||||
|
|
@ -285,21 +305,25 @@ export function portionCollapseExtension(config?: PortionCollapseConfig): Extens
|
||||||
}
|
}
|
||||||
if (hasExpandEffect) return result;
|
if (hasExpandEffect) return result;
|
||||||
|
|
||||||
// 2. Accept chunk (updateOriginalDoc) → full rebuild
|
// 2. Accept chunk (updateOriginalDoc) — editor doc unchanged, keep decorations.
|
||||||
if (tr.effects.some((e) => e.is(updateOriginalDoc))) {
|
// Full rebuild here would destroy user's expanded state (Expand All / Expand N).
|
||||||
return buildPortionRanges(tr.state, margin, minSize, portionSize);
|
// The chunk boundaries shift but editor positions stay valid since doc didn't change.
|
||||||
|
// When mirrorEditsAfterResolve adds updateOriginalDoc to a docChanged transaction,
|
||||||
|
// we must NOT short-circuit — fall through to docChanged handler for proper rebuild.
|
||||||
|
if (tr.effects.some((e) => e.is(updateOriginalDoc)) && !tr.docChanged) {
|
||||||
|
return deco;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Document changed (reject, user edit) → full rebuild
|
// 3. Document changed (reject, user edit) → full rebuild
|
||||||
if (tr.docChanged) {
|
if (tr.docChanged) {
|
||||||
return buildPortionRanges(tr.state, margin, minSize, portionSize);
|
return buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Lazy init
|
// 4. Lazy init
|
||||||
if (deco === Decoration.none) {
|
if (deco === Decoration.none) {
|
||||||
const chunks = getChunks(tr.state);
|
const chunks = getChunks(tr.state);
|
||||||
if (chunks) {
|
if (chunks) {
|
||||||
return buildPortionRanges(tr.state, margin, minSize, portionSize);
|
return buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -309,7 +333,21 @@ export function portionCollapseExtension(config?: PortionCollapseConfig): Extens
|
||||||
provide(f) {
|
provide(f) {
|
||||||
return EditorView.decorations.from(f);
|
return EditorView.decorations.from(f);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return [field, portionCollapseTheme];
|
// ─── Extension ───
|
||||||
|
|
||||||
|
export function portionCollapseExtension(config?: PortionCollapseConfig): Extension {
|
||||||
|
const margin = config?.margin ?? 3;
|
||||||
|
const minSize = config?.minSize ?? 4;
|
||||||
|
const portionSize = config?.portionSize ?? 100;
|
||||||
|
|
||||||
|
// Returns the SAME portionCollapseField reference every time.
|
||||||
|
// CM sees it's the same StateField → keeps state across compartment reconfigurations.
|
||||||
|
// Only the facet value (config) is new — the field reads it dynamically.
|
||||||
|
return [
|
||||||
|
portionCollapseConfigFacet.of({ margin, minSize, portionSize }),
|
||||||
|
portionCollapseField,
|
||||||
|
portionCollapseTheme,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -174,14 +174,19 @@ export function useDiffNavigation(
|
||||||
const nextFilePath = files[currentIdx + 1].filePath;
|
const nextFilePath = files[currentIdx + 1].filePath;
|
||||||
continuousOptionsRef.current.scrollToFile(nextFilePath);
|
continuousOptionsRef.current.scrollToFile(nextFilePath);
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
// Retry until EditorView appears (lazy-loaded files may not have it yet)
|
||||||
|
let attempts = 0;
|
||||||
|
const tryNavigate = (): void => {
|
||||||
const opts = continuousOptionsRef.current;
|
const opts = continuousOptionsRef.current;
|
||||||
const nextView = opts?.editorViewMapRef.current.get(nextFilePath);
|
const nextView = opts?.editorViewMapRef.current.get(nextFilePath);
|
||||||
if (nextView) {
|
if (nextView) {
|
||||||
nextView.dispatch({ selection: { anchor: 0 } });
|
nextView.dispatch({ selection: { anchor: 0 } });
|
||||||
goToNextChunk(nextView);
|
goToNextChunk(nextView);
|
||||||
|
} else if (++attempts < 15) {
|
||||||
|
requestAnimationFrame(tryNavigate);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
requestAnimationFrame(tryNavigate);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
goToNextChunk(view);
|
goToNextChunk(view);
|
||||||
|
|
@ -206,15 +211,19 @@ export function useDiffNavigation(
|
||||||
const prevFilePath = files[currentIdx - 1].filePath;
|
const prevFilePath = files[currentIdx - 1].filePath;
|
||||||
continuousOptionsRef.current.scrollToFile(prevFilePath);
|
continuousOptionsRef.current.scrollToFile(prevFilePath);
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
let attempts = 0;
|
||||||
|
const tryNavigate = (): void => {
|
||||||
const opts = continuousOptionsRef.current;
|
const opts = continuousOptionsRef.current;
|
||||||
const prevView = opts?.editorViewMapRef.current.get(prevFilePath);
|
const prevView = opts?.editorViewMapRef.current.get(prevFilePath);
|
||||||
if (prevView) {
|
if (prevView) {
|
||||||
const docLength = prevView.state.doc.length;
|
const docLength = prevView.state.doc.length;
|
||||||
prevView.dispatch({ selection: { anchor: docLength } });
|
prevView.dispatch({ selection: { anchor: docLength } });
|
||||||
goToPreviousChunk(prevView);
|
goToPreviousChunk(prevView);
|
||||||
|
} else if (++attempts < 15) {
|
||||||
|
requestAnimationFrame(tryNavigate);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
requestAnimationFrame(tryNavigate);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
goToPreviousChunk(view);
|
goToPreviousChunk(view);
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ import type { StateCreator } from 'zustand';
|
||||||
|
|
||||||
const logger = createLogger('changeReviewSlice');
|
const logger = createLogger('changeReviewSlice');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When true, rejected hunks are immediately applied to disk (no need for "Apply All Changes").
|
||||||
|
* When false, decisions are batched and applied manually via "Apply All Changes" button.
|
||||||
|
*/
|
||||||
|
export const REVIEW_INSTANT_APPLY = true;
|
||||||
|
|
||||||
function mapReviewError(error: unknown): string {
|
function mapReviewError(error: unknown): string {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
if (message.includes('conflict')) return 'File has been modified since agent changes.';
|
if (message.includes('conflict')) return 'File has been modified since agent changes.';
|
||||||
|
|
@ -74,6 +80,12 @@ export interface ChangeReviewSlice {
|
||||||
filePath: string
|
filePath: string
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
applyReview: (teamName: string, taskId?: string, memberName?: string) => Promise<void>;
|
applyReview: (teamName: string, taskId?: string, memberName?: string) => Promise<void>;
|
||||||
|
applySingleFileDecision: (
|
||||||
|
teamName: string,
|
||||||
|
filePath: string,
|
||||||
|
taskId?: string,
|
||||||
|
memberName?: string
|
||||||
|
) => Promise<void>;
|
||||||
invalidateChangeStats: (teamName: string) => void;
|
invalidateChangeStats: (teamName: string) => void;
|
||||||
|
|
||||||
// Editable diff actions
|
// Editable diff actions
|
||||||
|
|
@ -372,6 +384,41 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
applySingleFileDecision: async (
|
||||||
|
teamName: string,
|
||||||
|
filePath: string,
|
||||||
|
taskId?: string,
|
||||||
|
memberName?: string
|
||||||
|
) => {
|
||||||
|
const { hunkDecisions, fileDecisions, activeChangeSet } = get();
|
||||||
|
if (!activeChangeSet) return;
|
||||||
|
|
||||||
|
const file = activeChangeSet.files.find((f) => f.filePath === filePath);
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const fileDecision = fileDecisions[filePath] ?? 'pending';
|
||||||
|
const hunkDecs: Record<number, HunkDecision> = {};
|
||||||
|
for (let i = 0; i < file.snippets.length; i++) {
|
||||||
|
hunkDecs[i] = hunkDecisions[`${filePath}:${i}`] ?? 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRejected =
|
||||||
|
fileDecision === 'rejected' || Object.values(hunkDecs).some((d) => d === 'rejected');
|
||||||
|
if (!hasRejected) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.review.applyDecisions({
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
memberName,
|
||||||
|
decisions: [{ filePath, fileDecision, hunkDecisions: hunkDecs }],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('applySingleFileDecision error:', error);
|
||||||
|
set({ applyError: mapReviewError(error) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ── Editable diff actions ──
|
// ── Editable diff actions ──
|
||||||
|
|
||||||
updateEditedContent: (filePath: string, content: string) => {
|
updateEditedContent: (filePath: string, content: string) => {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,11 @@ function mapReviewError(error: unknown): string {
|
||||||
return message || 'Failed to perform review action';
|
return message || 'Failed to perform review action';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GlobalTaskDetailState {
|
||||||
|
teamName: string;
|
||||||
|
taskId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TeamSlice {
|
export interface TeamSlice {
|
||||||
teams: TeamSummary[];
|
teams: TeamSummary[];
|
||||||
teamsLoading: boolean;
|
teamsLoading: boolean;
|
||||||
|
|
@ -53,6 +58,9 @@ export interface TeamSlice {
|
||||||
globalTasks: GlobalTask[];
|
globalTasks: GlobalTask[];
|
||||||
globalTasksLoading: boolean;
|
globalTasksLoading: boolean;
|
||||||
globalTasksError: string | null;
|
globalTasksError: string | null;
|
||||||
|
globalTaskDetail: GlobalTaskDetailState | null;
|
||||||
|
openGlobalTaskDetail: (teamName: string, taskId: string) => void;
|
||||||
|
closeGlobalTaskDetail: () => void;
|
||||||
selectedTeamName: string | null;
|
selectedTeamName: string | null;
|
||||||
selectedTeamData: TeamData | null;
|
selectedTeamData: TeamData | null;
|
||||||
selectedTeamLoading: boolean;
|
selectedTeamLoading: boolean;
|
||||||
|
|
@ -126,6 +134,16 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
activeProvisioningRunId: null,
|
activeProvisioningRunId: null,
|
||||||
provisioningError: null,
|
provisioningError: null,
|
||||||
kanbanFilterQuery: null,
|
kanbanFilterQuery: null,
|
||||||
|
globalTaskDetail: null,
|
||||||
|
openGlobalTaskDetail: (teamName: string, taskId: string) => {
|
||||||
|
set({ globalTaskDetail: { teamName, taskId } });
|
||||||
|
// Ensure team data is loaded for the dialog
|
||||||
|
const state = get();
|
||||||
|
if (state.selectedTeamName !== teamName || !state.selectedTeamData) {
|
||||||
|
void state.selectTeam(teamName);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
closeGlobalTaskDetail: () => set({ globalTaskDetail: null }),
|
||||||
addingComment: false,
|
addingComment: false,
|
||||||
addCommentError: null,
|
addCommentError: null,
|
||||||
provisioningProgressUnsubscribe: null,
|
provisioningProgressUnsubscribe: null,
|
||||||
|
|
|
||||||
|
|
@ -426,6 +426,7 @@ export interface TeamsAPI {
|
||||||
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||||
getProjectBranch: (projectPath: string) => Promise<string | null>;
|
getProjectBranch: (projectPath: string) => Promise<string | null>;
|
||||||
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
||||||
|
killProcess: (teamName: string, pid: number) => Promise<void>;
|
||||||
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
|
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
||||||
onProvisioningProgress: (
|
onProvisioningProgress: (
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,19 @@ export interface ResolvedTeamMember {
|
||||||
removedAt?: number;
|
removedAt?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TeamProcess {
|
||||||
|
id: string;
|
||||||
|
port?: number;
|
||||||
|
url?: string;
|
||||||
|
label: string;
|
||||||
|
pid: number;
|
||||||
|
claudeProcessId?: string;
|
||||||
|
registeredBy?: string;
|
||||||
|
command?: string;
|
||||||
|
registeredAt: string;
|
||||||
|
stoppedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TeamData {
|
export interface TeamData {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
config: TeamConfig;
|
config: TeamConfig;
|
||||||
|
|
@ -179,6 +192,7 @@ export interface TeamData {
|
||||||
members: ResolvedTeamMember[];
|
members: ResolvedTeamMember[];
|
||||||
messages: InboxMessage[];
|
messages: InboxMessage[];
|
||||||
kanbanState: KanbanState;
|
kanbanState: KanbanState;
|
||||||
|
processes: TeamProcess[];
|
||||||
warnings?: string[];
|
warnings?: string[];
|
||||||
isAlive?: boolean;
|
isAlive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
@ -207,7 +221,7 @@ export interface CreateTaskRequest {
|
||||||
export type LeadActivityState = 'active' | 'idle' | 'offline';
|
export type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||||
|
|
||||||
export interface TeamChangeEvent {
|
export interface TeamChangeEvent {
|
||||||
type: 'config' | 'inbox' | 'task' | 'lead-activity';
|
type: 'config' | 'inbox' | 'task' | 'lead-activity' | 'process';
|
||||||
teamName: string;
|
teamName: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue