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,
|
||||
} from './services';
|
||||
|
||||
import type { TeamChangeEvent } from '@shared/types';
|
||||
|
||||
const logger = createLogger('App');
|
||||
|
||||
// Window icon path for non-mac platforms.
|
||||
|
|
@ -320,12 +322,17 @@ function initializeServices(): void {
|
|||
httpServer = new HttpServer();
|
||||
|
||||
// Allow TeamProvisioningService to trigger team refresh events (e.g. live lead replies).
|
||||
teamProvisioningService.setTeamChangeEmitter((event) => {
|
||||
const teamChangeEmitter = (event: TeamChangeEvent): void => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send(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
|
||||
initializeIpcHandlers(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_PROJECT_BRANCH,
|
||||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LIST,
|
||||
|
|
@ -194,6 +195,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_UPDATE_MEMBER_ROLE, handleUpdateMemberRole);
|
||||
ipcMain.handle(TEAM_GET_PROJECT_BRANCH, handleGetProjectBranch);
|
||||
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
||||
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
|
||||
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
||||
logger.info('Team handlers registered');
|
||||
}
|
||||
|
|
@ -231,6 +233,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_UPDATE_MEMBER_ROLE);
|
||||
ipcMain.removeHandler(TEAM_GET_PROJECT_BRANCH);
|
||||
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
||||
ipcMain.removeHandler(TEAM_KILL_PROCESS);
|
||||
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(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
|
|
|
|||
|
|
@ -920,6 +920,12 @@ export class FileWatcher extends EventEmitter {
|
|||
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.
|
||||
if (normalized.includes('inboxes') || relative === 'sentMessages.json') {
|
||||
const event: TeamChangeEvent = {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import * as path from 'path';
|
|||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
const TOOL_FILE_NAME = 'teamctl.js';
|
||||
const TOOL_VERSION = 5;
|
||||
const TOOL_VERSION = 6;
|
||||
|
||||
function buildTeamCtlScript(): string {
|
||||
const script = String.raw`#!/usr/bin/env node
|
||||
|
|
@ -166,7 +166,8 @@ function getPaths(flags, teamName) {
|
|||
const teamDir = path.join(claudeDir, 'teams', teamName);
|
||||
const tasksDir = path.join(claudeDir, 'tasks', teamName);
|
||||
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) {
|
||||
|
|
@ -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() {
|
||||
const inferred = inferTeamNameFromScriptPath();
|
||||
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 request-changes <id> --comment "..." [--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:',
|
||||
' --team <name> Team name (if not under ~/.claude/teams/<team>/tools)',
|
||||
|
|
@ -627,6 +715,22 @@ async function main() {
|
|||
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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { isProcessAlive } from '@main/utils/processHealth';
|
||||
import { AGENT_BLOCK_CLOSE, AGENT_BLOCK_OPEN } from '@shared/constants/agentBlocks';
|
||||
import { getMemberColor } from '@shared/constants/memberColors';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
|
|
@ -43,6 +44,7 @@ import type {
|
|||
TeamCreateConfigRequest,
|
||||
TeamData,
|
||||
TeamMember,
|
||||
TeamProcess,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
|
|
@ -54,8 +56,12 @@ const logger = createLogger('Service:TeamDataService');
|
|||
|
||||
const MIN_TEXT_LENGTH = 30;
|
||||
const MAX_LEAD_TEXTS = 50;
|
||||
const PROCESS_HEALTH_INTERVAL_MS = 2_000;
|
||||
|
||||
export class TeamDataService {
|
||||
private processHealthTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private processHealthTeams = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
||||
|
|
@ -252,6 +258,21 @@ export class TeamDataService {
|
|||
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 {
|
||||
teamName,
|
||||
config,
|
||||
|
|
@ -259,10 +280,163 @@ export class TeamDataService {
|
|||
members,
|
||||
messages,
|
||||
kanbanState,
|
||||
processes,
|
||||
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.
|
||||
* 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.`);
|
||||
}
|
||||
|
||||
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 {
|
||||
return wrapInAgentBlock(
|
||||
[
|
||||
|
|
@ -360,6 +373,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
|||
const description = request.description?.trim() || 'No description';
|
||||
const members = buildMembersPrompt(request.members);
|
||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
||||
const languageInstruction = getAgentLanguageInstruction();
|
||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||
const userPromptBlock = request.prompt?.trim()
|
||||
|
|
@ -416,6 +430,8 @@ Steps (execute in this exact order):
|
|||
|
||||
${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.
|
||||
- Prefer fewer, broader tasks over many micro-tasks.
|
||||
- Avoid duplicate notifications for the same assignment.
|
||||
|
|
@ -436,6 +452,7 @@ function buildLaunchPrompt(
|
|||
? `\nAdditional instructions from the user:\n${request.prompt.trim()}\n`
|
||||
: '';
|
||||
const taskProtocol = buildTaskStatusProtocol(request.teamName);
|
||||
const processRegistration = buildProcessRegistrationProtocol(request.teamName);
|
||||
const languageInstruction = getAgentLanguageInstruction();
|
||||
const agentBlockPolicy = buildAgentBlockUsagePolicy();
|
||||
|
||||
|
|
@ -489,6 +506,8 @@ Steps (execute in this exact order):
|
|||
|
||||
${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.
|
||||
- Prefer fewer, broader tasks over many micro-tasks.
|
||||
- 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 */
|
||||
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) */
|
||||
export const TEAM_LEAD_ACTIVITY = 'team:leadActivity';
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
TEAM_GET_MEMBER_LOGS,
|
||||
TEAM_GET_MEMBER_STATS,
|
||||
TEAM_GET_PROJECT_BRANCH,
|
||||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LIST,
|
||||
|
|
@ -664,6 +665,9 @@ const electronAPI: ElectronAPI = {
|
|||
getAttachments: async (teamName: string, messageId: string) => {
|
||||
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) => {
|
||||
const result = await invokeIpcWithResult<string>(TEAM_LEAD_ACTIVITY, teamName);
|
||||
return result as 'active' | 'idle' | 'offline';
|
||||
|
|
|
|||
|
|
@ -761,6 +761,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
): Promise<AttachmentFileData[]> => {
|
||||
return [];
|
||||
},
|
||||
killProcess: async (_teamName: string, _pid: number): Promise<void> => {
|
||||
// Not available via HTTP client — no-op
|
||||
},
|
||||
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
|
||||
return 'offline';
|
||||
},
|
||||
|
|
|
|||
|
|
@ -346,6 +346,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
|
|||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={REHYPE_PLUGINS}
|
||||
urlTransform={(url) => url}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { UpdateBanner } from '../common/UpdateBanner';
|
|||
import { UpdateDialog } from '../common/UpdateDialog';
|
||||
import { WorkspaceIndicator } from '../common/WorkspaceIndicator';
|
||||
import { CommandPalette } from '../search/CommandPalette';
|
||||
import { GlobalTaskDetailDialog } from '../team/dialogs/GlobalTaskDetailDialog';
|
||||
|
||||
import { CustomTitleBar } from './CustomTitleBar';
|
||||
import { PaneContainer } from './PaneContainer';
|
||||
|
|
@ -50,6 +51,7 @@ export const TabbedLayout = (): React.JSX.Element => {
|
|||
{/* Multi-pane content area */}
|
||||
<PaneContainer />
|
||||
</div>
|
||||
<GlobalTaskDetailDialog />
|
||||
<UpdateDialog />
|
||||
<WorkspaceIndicator />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export const SidebarTaskItem = ({
|
|||
task,
|
||||
hideTeamName,
|
||||
}: 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 cfg =
|
||||
task.kanbanColumn === 'approved'
|
||||
|
|
@ -70,7 +70,7 @@ export const SidebarTaskItem = ({
|
|||
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"
|
||||
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">
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const CollapsibleTeamSection = ({
|
|||
const isOpen = forceOpen ? true : open;
|
||||
|
||||
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">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -65,7 +65,7 @@ export const CollapsibleTeamSection = ({
|
|||
</div>
|
||||
{action && <div className="relative z-10 flex shrink-0 items-center">{action}</div>}
|
||||
</div>
|
||||
{isOpen && <div className="mt-2">{children}</div>}
|
||||
{isOpen && <div className="mt-2 min-w-0 overflow-hidden">{children}</div>}
|
||||
</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 { ChangeReviewDialog } from './review/ChangeReviewDialog';
|
||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||
import { ProcessesSection } from './ProcessesSection';
|
||||
import { TeamProvisioningBanner } from './TeamProvisioningBanner';
|
||||
import { TeamSessionsSection } from './TeamSessionsSection';
|
||||
|
||||
|
|
@ -993,6 +994,16 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
/>
|
||||
</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
|
||||
title="Messages"
|
||||
badge={filteredMessages.length}
|
||||
|
|
|
|||
|
|
@ -132,6 +132,28 @@ function linkifyTaskIdsInMarkdown(text: string): string {
|
|||
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 = ({
|
||||
message,
|
||||
teamName,
|
||||
|
|
@ -299,7 +321,7 @@ export const ActivityItem = ({
|
|||
|
||||
{/* Summary */}
|
||||
<span className="flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
{summaryText}
|
||||
{onTaskIdClick ? linkifyTaskIds(summaryText, onTaskIdClick) : summaryText}
|
||||
</span>
|
||||
|
||||
{/* 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;
|
||||
onOwnerChange?: (taskId: string, owner: string | null) => 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 = ({
|
||||
|
|
@ -70,6 +72,7 @@ export const TaskDetailDialog = ({
|
|||
onScrollToTask,
|
||||
onOwnerChange,
|
||||
onViewChanges,
|
||||
footerExtra,
|
||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
|
||||
|
|
@ -262,7 +265,7 @@ export const TaskDetailDialog = ({
|
|||
<CollapsibleTeamSection
|
||||
title="Changes"
|
||||
badge={taskChangesFiles ? taskChangesFiles.length : undefined}
|
||||
defaultOpen
|
||||
defaultOpen={!!taskChangesFiles && taskChangesFiles.length > 0}
|
||||
>
|
||||
{changeSetLoading && !taskChangesFiles ? (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-[var(--color-text-muted)]">
|
||||
|
|
@ -457,9 +460,18 @@ export const TaskDetailDialog = ({
|
|||
</CollapsibleTeamSection>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{footerExtra ? (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{footerExtra}
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { isLastChunkInFile, useDiffNavigation } from '@renderer/hooks/useDiffNav
|
|||
import { useViewedFiles } from '@renderer/hooks/useViewedFiles';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { REVIEW_INSTANT_APPLY } from '@renderer/store/slices/changeReviewSlice';
|
||||
import { ChevronDown, Clock, X } from 'lucide-react';
|
||||
|
||||
import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils';
|
||||
|
|
@ -62,6 +63,7 @@ export const ChangeReviewDialog = ({
|
|||
acceptAllFile,
|
||||
rejectAllFile,
|
||||
applyReview,
|
||||
applySingleFileDecision,
|
||||
editedContents,
|
||||
updateEditedContent,
|
||||
discardFileEdits,
|
||||
|
|
@ -166,8 +168,11 @@ export const ChangeReviewDialog = ({
|
|||
const handleHunkRejected = useCallback(
|
||||
(filePath: string, hunkIndex: number) => {
|
||||
setHunkDecision(filePath, hunkIndex, 'rejected');
|
||||
if (REVIEW_INSTANT_APPLY) {
|
||||
void applySingleFileDecision(teamName, filePath, taskId, memberName);
|
||||
}
|
||||
},
|
||||
[setHunkDecision]
|
||||
[setHunkDecision, applySingleFileDecision, teamName, taskId, memberName]
|
||||
);
|
||||
|
||||
const handleContentChanged = useCallback(
|
||||
|
|
@ -347,17 +352,11 @@ export const ChangeReviewDialog = ({
|
|||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-sm font-medium text-text">{title}</h2>
|
||||
{activeChangeSet && (
|
||||
<>
|
||||
<span className="text-xs text-text-muted">
|
||||
{activeChangeSet.totalFiles} files, +{activeChangeSet.totalLinesAdded} -
|
||||
{activeChangeSet.totalLinesRemoved}
|
||||
</span>
|
||||
<ViewedProgressBar
|
||||
viewed={viewedCount}
|
||||
total={viewedTotalCount}
|
||||
progress={viewedProgress}
|
||||
/>
|
||||
</>
|
||||
<ViewedProgressBar
|
||||
viewed={viewedCount}
|
||||
total={viewedTotalCount}
|
||||
progress={viewedProgress}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -391,6 +390,7 @@ export const ChangeReviewDialog = ({
|
|||
onRejectAll={handleRejectAll}
|
||||
onApply={handleApply}
|
||||
onCollapseUnchangedChange={setCollapseUnchanged}
|
||||
instantApply={REVIEW_INSTANT_APPLY}
|
||||
editedCount={editedCount}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import {
|
|||
acceptChunk,
|
||||
getChunks,
|
||||
getOriginalDoc,
|
||||
originalDocChangeEffect,
|
||||
rejectChunk,
|
||||
updateOriginalDoc,
|
||||
} 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';
|
||||
|
||||
/**
|
||||
|
|
@ -72,4 +73,22 @@ export function rejectAllChunks(view: EditorView): boolean {
|
|||
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 };
|
||||
|
|
|
|||
|
|
@ -24,7 +24,13 @@ import { Compartment, EditorState, type Extension } from '@codemirror/state';
|
|||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
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';
|
||||
|
||||
interface CodeMirrorDiffViewProps {
|
||||
|
|
@ -119,16 +125,15 @@ function getAsyncLanguageDesc(fileName: string): LanguageDescription | null {
|
|||
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 {
|
||||
const chunks = getChunks(state);
|
||||
if (!chunks) return 0;
|
||||
|
||||
let index = 0;
|
||||
for (const chunk of chunks.chunks) {
|
||||
if (pos >= chunk.fromA && pos <= chunk.toA) {
|
||||
return index;
|
||||
}
|
||||
// Only check B-side (modified doc) — editor positions correspond to B-side in unified view.
|
||||
// A-side (fromA/toA) positions refer to the original document and are not editor positions.
|
||||
if (pos >= chunk.fromB && pos <= chunk.toB) {
|
||||
return index;
|
||||
}
|
||||
|
|
@ -467,6 +472,7 @@ export const CodeMirrorDiffView = ({
|
|||
if (!readOnly) {
|
||||
extensions.push(history());
|
||||
extensions.push(mergeUndoSupport);
|
||||
extensions.push(mirrorEditsAfterResolve);
|
||||
extensions.push(indentUnit.of(' '));
|
||||
extensions.push(keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap]));
|
||||
}
|
||||
|
|
@ -474,32 +480,13 @@ export const CodeMirrorDiffView = ({
|
|||
// Language placeholder — actual language injected async via compartment reconfigure
|
||||
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(
|
||||
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',
|
||||
run: goToNextChunk,
|
||||
|
|
@ -710,6 +697,7 @@ export const CodeMirrorDiffView = ({
|
|||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(debounceTimer.current);
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
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 { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection';
|
||||
|
||||
import { acceptAllChunks, rejectAllChunks } from './CodeMirrorDiffUtils';
|
||||
import { FileSectionDiff } from './FileSectionDiff';
|
||||
import { FileSectionHeader } from './FileSectionHeader';
|
||||
import { FileSectionPlaceholder } from './FileSectionPlaceholder';
|
||||
|
|
@ -99,10 +100,30 @@ export const ContinuousScrollView = ({
|
|||
[registerFileSectionRef, registerLazyRef]
|
||||
);
|
||||
|
||||
// Ref to avoid stale closure — fileDecisions changes frequently
|
||||
const fileDecisionsRef = useRef(fileDecisions);
|
||||
useEffect(() => {
|
||||
fileDecisionsRef.current = fileDecisions;
|
||||
});
|
||||
|
||||
const handleEditorViewReady = useCallback(
|
||||
(filePath: string, view: EditorView | null) => {
|
||||
if (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 {
|
||||
editorViewMapRef.current.delete(filePath);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ const TreeItem = ({
|
|||
<span
|
||||
className={cn(
|
||||
'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}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ interface ReviewToolbarProps {
|
|||
collapseUnchanged: boolean;
|
||||
applying: boolean;
|
||||
autoViewed: boolean;
|
||||
instantApply?: boolean;
|
||||
onAutoViewedChange: (auto: boolean) => void;
|
||||
onAcceptAll: () => void;
|
||||
onRejectAll: () => void;
|
||||
|
|
@ -41,6 +42,7 @@ export const ReviewToolbar = ({
|
|||
onRejectAll,
|
||||
onApply,
|
||||
onCollapseUnchangedChange,
|
||||
instantApply = false,
|
||||
editedCount = 0,
|
||||
}: ReviewToolbarProps): React.ReactElement => {
|
||||
const hasRejected = stats.rejected > 0;
|
||||
|
|
@ -173,28 +175,30 @@ export const ReviewToolbar = ({
|
|||
<TooltipContent side="bottom">Reject all changes across all files</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onApply}
|
||||
disabled={!canApply}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||
canApply
|
||||
? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30'
|
||||
: 'cursor-not-allowed bg-zinc-500/10 text-zinc-600'
|
||||
)}
|
||||
>
|
||||
{applying ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<GitMerge className="size-3" />
|
||||
)}
|
||||
{applying ? 'Applying...' : 'Apply All Changes'}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Apply review decisions across all files</TooltipContent>
|
||||
</Tooltip>
|
||||
{!instantApply && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={onApply}
|
||||
disabled={!canApply}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||
canApply
|
||||
? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30'
|
||||
: 'cursor-not-allowed bg-zinc-500/10 text-zinc-600'
|
||||
)}
|
||||
>
|
||||
{applying ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : (
|
||||
<GitMerge className="size-3" />
|
||||
)}
|
||||
{applying ? 'Applying...' : 'Apply All Changes'}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">Apply review decisions across all files</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { getChunks } from './CodeMirrorDiffUtils';
|
||||
|
|
@ -14,6 +14,20 @@ interface PortionCollapseConfig {
|
|||
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 ───
|
||||
|
||||
export const expandPortion = StateEffect.define<{ pos: number; count: number }>({
|
||||
|
|
@ -111,6 +125,11 @@ function buildPortionRanges(
|
|||
if (!result) return Decoration.none;
|
||||
|
||||
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>();
|
||||
|
||||
let prevLine = 1;
|
||||
|
|
@ -257,6 +276,65 @@ const portionCollapseTheme = EditorView.theme({
|
|||
},
|
||||
});
|
||||
|
||||
// ─── 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).
|
||||
|
||||
const portionCollapseField = StateField.define<DecorationSet>({
|
||||
create(state: EditorState): DecorationSet {
|
||||
const cfg = state.facet(portionCollapseConfigFacet);
|
||||
return buildPortionRanges(state, cfg.margin, cfg.minSize, cfg.portionSize);
|
||||
},
|
||||
|
||||
update(deco: DecorationSet, tr: Transaction): DecorationSet {
|
||||
const cfg = tr.state.facet(portionCollapseConfigFacet);
|
||||
|
||||
// 1. Expand effects
|
||||
let result = deco;
|
||||
let hasExpandEffect = false;
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(expandPortion)) {
|
||||
hasExpandEffect = true;
|
||||
result = handleExpandPortion(result, effect.value, tr.state, cfg.minSize, cfg.portionSize);
|
||||
}
|
||||
if (effect.is(expandAllAtPos)) {
|
||||
hasExpandEffect = true;
|
||||
result = handleExpandAll(result, effect.value);
|
||||
}
|
||||
}
|
||||
if (hasExpandEffect) return result;
|
||||
|
||||
// 2. Accept chunk (updateOriginalDoc) — editor doc unchanged, keep decorations.
|
||||
// Full rebuild here would destroy user's expanded state (Expand All / Expand N).
|
||||
// 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
|
||||
if (tr.docChanged) {
|
||||
return buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize);
|
||||
}
|
||||
|
||||
// 4. Lazy init
|
||||
if (deco === Decoration.none) {
|
||||
const chunks = getChunks(tr.state);
|
||||
if (chunks) {
|
||||
return buildPortionRanges(tr.state, cfg.margin, cfg.minSize, cfg.portionSize);
|
||||
}
|
||||
}
|
||||
|
||||
return deco;
|
||||
},
|
||||
|
||||
provide(f) {
|
||||
return EditorView.decorations.from(f);
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Extension ───
|
||||
|
||||
export function portionCollapseExtension(config?: PortionCollapseConfig): Extension {
|
||||
|
|
@ -264,52 +342,12 @@ export function portionCollapseExtension(config?: PortionCollapseConfig): Extens
|
|||
const minSize = config?.minSize ?? 4;
|
||||
const portionSize = config?.portionSize ?? 100;
|
||||
|
||||
const field = StateField.define<DecorationSet>({
|
||||
create(state: EditorState): DecorationSet {
|
||||
return buildPortionRanges(state, margin, minSize, portionSize);
|
||||
},
|
||||
|
||||
update(deco: DecorationSet, tr: Transaction): DecorationSet {
|
||||
// 1. Expand effects
|
||||
let result = deco;
|
||||
let hasExpandEffect = false;
|
||||
for (const effect of tr.effects) {
|
||||
if (effect.is(expandPortion)) {
|
||||
hasExpandEffect = true;
|
||||
result = handleExpandPortion(result, effect.value, tr.state, minSize, portionSize);
|
||||
}
|
||||
if (effect.is(expandAllAtPos)) {
|
||||
hasExpandEffect = true;
|
||||
result = handleExpandAll(result, effect.value);
|
||||
}
|
||||
}
|
||||
if (hasExpandEffect) return result;
|
||||
|
||||
// 2. Accept chunk (updateOriginalDoc) → full rebuild
|
||||
if (tr.effects.some((e) => e.is(updateOriginalDoc))) {
|
||||
return buildPortionRanges(tr.state, margin, minSize, portionSize);
|
||||
}
|
||||
|
||||
// 3. Document changed (reject, user edit) → full rebuild
|
||||
if (tr.docChanged) {
|
||||
return buildPortionRanges(tr.state, margin, minSize, portionSize);
|
||||
}
|
||||
|
||||
// 4. Lazy init
|
||||
if (deco === Decoration.none) {
|
||||
const chunks = getChunks(tr.state);
|
||||
if (chunks) {
|
||||
return buildPortionRanges(tr.state, margin, minSize, portionSize);
|
||||
}
|
||||
}
|
||||
|
||||
return deco;
|
||||
},
|
||||
|
||||
provide(f) {
|
||||
return EditorView.decorations.from(f);
|
||||
},
|
||||
});
|
||||
|
||||
return [field, portionCollapseTheme];
|
||||
// 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;
|
||||
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 nextView = opts?.editorViewMapRef.current.get(nextFilePath);
|
||||
if (nextView) {
|
||||
nextView.dispatch({ selection: { anchor: 0 } });
|
||||
goToNextChunk(nextView);
|
||||
} else if (++attempts < 15) {
|
||||
requestAnimationFrame(tryNavigate);
|
||||
}
|
||||
});
|
||||
};
|
||||
requestAnimationFrame(tryNavigate);
|
||||
}
|
||||
} else {
|
||||
goToNextChunk(view);
|
||||
|
|
@ -206,15 +211,19 @@ export function useDiffNavigation(
|
|||
const prevFilePath = files[currentIdx - 1].filePath;
|
||||
continuousOptionsRef.current.scrollToFile(prevFilePath);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
let attempts = 0;
|
||||
const tryNavigate = (): void => {
|
||||
const opts = continuousOptionsRef.current;
|
||||
const prevView = opts?.editorViewMapRef.current.get(prevFilePath);
|
||||
if (prevView) {
|
||||
const docLength = prevView.state.doc.length;
|
||||
prevView.dispatch({ selection: { anchor: docLength } });
|
||||
goToPreviousChunk(prevView);
|
||||
} else if (++attempts < 15) {
|
||||
requestAnimationFrame(tryNavigate);
|
||||
}
|
||||
});
|
||||
};
|
||||
requestAnimationFrame(tryNavigate);
|
||||
}
|
||||
} else {
|
||||
goToPreviousChunk(view);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ import type { StateCreator } from 'zustand';
|
|||
|
||||
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 {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('conflict')) return 'File has been modified since agent changes.';
|
||||
|
|
@ -74,6 +80,12 @@ export interface ChangeReviewSlice {
|
|||
filePath: 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;
|
||||
|
||||
// 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 ──
|
||||
|
||||
updateEditedContent: (filePath: string, content: string) => {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,11 @@ function mapReviewError(error: unknown): string {
|
|||
return message || 'Failed to perform review action';
|
||||
}
|
||||
|
||||
export interface GlobalTaskDetailState {
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export interface TeamSlice {
|
||||
teams: TeamSummary[];
|
||||
teamsLoading: boolean;
|
||||
|
|
@ -53,6 +58,9 @@ export interface TeamSlice {
|
|||
globalTasks: GlobalTask[];
|
||||
globalTasksLoading: boolean;
|
||||
globalTasksError: string | null;
|
||||
globalTaskDetail: GlobalTaskDetailState | null;
|
||||
openGlobalTaskDetail: (teamName: string, taskId: string) => void;
|
||||
closeGlobalTaskDetail: () => void;
|
||||
selectedTeamName: string | null;
|
||||
selectedTeamData: TeamData | null;
|
||||
selectedTeamLoading: boolean;
|
||||
|
|
@ -126,6 +134,16 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
activeProvisioningRunId: null,
|
||||
provisioningError: 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,
|
||||
addCommentError: null,
|
||||
provisioningProgressUnsubscribe: null,
|
||||
|
|
|
|||
|
|
@ -426,6 +426,7 @@ export interface TeamsAPI {
|
|||
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||
getProjectBranch: (projectPath: string) => Promise<string | null>;
|
||||
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
||||
killProcess: (teamName: string, pid: number) => Promise<void>;
|
||||
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
|
||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
||||
onProvisioningProgress: (
|
||||
|
|
|
|||
|
|
@ -172,6 +172,19 @@ export interface ResolvedTeamMember {
|
|||
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 {
|
||||
teamName: string;
|
||||
config: TeamConfig;
|
||||
|
|
@ -179,6 +192,7 @@ export interface TeamData {
|
|||
members: ResolvedTeamMember[];
|
||||
messages: InboxMessage[];
|
||||
kanbanState: KanbanState;
|
||||
processes: TeamProcess[];
|
||||
warnings?: string[];
|
||||
isAlive?: boolean;
|
||||
}
|
||||
|
|
@ -207,7 +221,7 @@ export interface CreateTaskRequest {
|
|||
export type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||
|
||||
export interface TeamChangeEvent {
|
||||
type: 'config' | 'inbox' | 'task' | 'lead-activity';
|
||||
type: 'config' | 'inbox' | 'task' | 'lead-activity' | 'process';
|
||||
teamName: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue