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:
iliya 2026-02-25 16:54:53 +02:00
parent 0df816bba6
commit 877f214113
31 changed files with 877 additions and 133 deletions

View file

@ -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(

View file

@ -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,

View file

@ -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 = {

View file

@ -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));
}

View file

@ -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).

View file

@ -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.

View 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;
}
}

View file

@ -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';

View file

@ -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';

View file

@ -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';
},

View file

@ -346,6 +346,7 @@ export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={REHYPE_PLUGINS}
urlTransform={(url) => url}
components={components}
>
{content}

View file

@ -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>

View file

@ -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

View file

@ -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>
);
};

View 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>
);
};

View file

@ -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}

View file

@ -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 */}

View file

@ -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>
}
/>
);
};

View file

@ -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>

View file

@ -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}
/>
)}

View file

@ -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 };

View file

@ -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) {

View file

@ -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);
}

View file

@ -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}

View file

@ -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>
);
};

View file

@ -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,
];
}

View file

@ -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);

View file

@ -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) => {

View file

@ -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,

View file

@ -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: (

View file

@ -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;
}