feat: implement soft delete and retrieval of deleted tasks in team management
- Added functionality for soft-deleting tasks, allowing tasks to be marked as deleted without permanent removal. - Introduced new IPC channels for soft-deleting tasks and retrieving deleted tasks, enhancing task management capabilities. - Updated TeamDataService and TeamTaskWriter to handle soft delete operations and maintain task integrity. - Enhanced the UI components to support viewing and managing deleted tasks, improving user experience and task visibility. - Implemented a TrashDialog for displaying deleted tasks, allowing users to review and manage their deleted items effectively.
This commit is contained in:
parent
0ab2b8d769
commit
1c6b7c4ef1
25 changed files with 672 additions and 77 deletions
|
|
@ -12,6 +12,7 @@ import {
|
||||||
TEAM_GET_ALL_TASKS,
|
TEAM_GET_ALL_TASKS,
|
||||||
TEAM_GET_ATTACHMENTS,
|
TEAM_GET_ATTACHMENTS,
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
|
TEAM_GET_DELETED_TASKS,
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
TEAM_GET_LOGS_FOR_TASK,
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
|
|
@ -28,6 +29,7 @@ import {
|
||||||
TEAM_REMOVE_MEMBER,
|
TEAM_REMOVE_MEMBER,
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
|
TEAM_SOFT_DELETE_TASK,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_STOP,
|
TEAM_STOP,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
|
|
@ -197,6 +199,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
||||||
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
|
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
|
||||||
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
||||||
|
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
||||||
|
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
||||||
logger.info('Team handlers registered');
|
logger.info('Team handlers registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -235,6 +239,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
||||||
ipcMain.removeHandler(TEAM_KILL_PROCESS);
|
ipcMain.removeHandler(TEAM_KILL_PROCESS);
|
||||||
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
||||||
|
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
||||||
|
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTeamDataService(): TeamDataService {
|
function getTeamDataService(): TeamDataService {
|
||||||
|
|
@ -1069,6 +1075,40 @@ async function handleUpdateTaskStatus(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleSoftDeleteTask(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
taskId: unknown
|
||||||
|
): Promise<IpcResult<void>> {
|
||||||
|
const validatedTeamName = validateTeamName(teamName);
|
||||||
|
if (!validatedTeamName.valid) {
|
||||||
|
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validatedTaskId = validateTaskId(taskId);
|
||||||
|
if (!validatedTaskId.valid) {
|
||||||
|
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapTeamHandler('softDeleteTask', () =>
|
||||||
|
getTeamDataService().softDeleteTask(validatedTeamName.value!, validatedTaskId.value!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetDeletedTasks(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown
|
||||||
|
): Promise<IpcResult<TeamTask[]>> {
|
||||||
|
const validatedTeamName = validateTeamName(teamName);
|
||||||
|
if (!validatedTeamName.valid) {
|
||||||
|
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrapTeamHandler('getDeletedTasks', () =>
|
||||||
|
getTeamDataService().getDeletedTasks(validatedTeamName.value!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUpdateTaskOwner(
|
async function handleUpdateTaskOwner(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown,
|
teamName: unknown,
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,9 @@ export class MemberStatsComputer {
|
||||||
const stats: MemberFullStats = {
|
const stats: MemberFullStats = {
|
||||||
linesAdded,
|
linesAdded,
|
||||||
linesRemoved,
|
linesRemoved,
|
||||||
filesTouched: [...filesTouchedSet].sort((a, b) => a.localeCompare(b)),
|
filesTouched: [...filesTouchedSet]
|
||||||
|
.filter((f) => f && f !== 'null' && f !== 'undefined')
|
||||||
|
.sort((a, b) => a.localeCompare(b)),
|
||||||
toolUsage,
|
toolUsage,
|
||||||
inputTokens,
|
inputTokens,
|
||||||
outputTokens,
|
outputTokens,
|
||||||
|
|
@ -308,8 +310,9 @@ export function estimateBashLinesChanged(command: string): BashLinesResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Echo / printf with redirect: echo "..." > /path OR printf "..." > /path
|
// 2. Echo / printf with redirect: echo "..." > /path OR printf "..." > /path
|
||||||
|
|
||||||
const echoPattern =
|
const echoPattern =
|
||||||
/(?:echo|printf)\s+(?:-[a-zA-Z]+\s+)?(?:"([^"]*)"|'([^']*)')\s*>{1,2}\s*(\S+)/g;
|
/(?:echo|printf)\s+(?:-[a-zA-Z]+\s+)?(?:"([^"]*)"|'([^']*)')\s*>{1,2}\s*(\S+)/g; // eslint-disable-line security/detect-unsafe-regex -- Fixed alternation, short command strings only
|
||||||
let echoMatch: RegExpExecArray | null;
|
let echoMatch: RegExpExecArray | null;
|
||||||
while ((echoMatch = echoPattern.exec(command)) !== null) {
|
while ((echoMatch = echoPattern.exec(command)) !== null) {
|
||||||
const content = echoMatch[1] ?? echoMatch[2] ?? '';
|
const content = echoMatch[1] ?? echoMatch[2] ?? '';
|
||||||
|
|
@ -349,7 +352,7 @@ export function estimateBashLinesChanged(command: string): BashLinesResult {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. tee: ... | tee /path/to/file
|
// 5. tee: ... | tee /path/to/file
|
||||||
const teePattern = /\btee\s+(?:-a\s+)?(\/\S+)/g;
|
const teePattern = /\btee\s+(?:-a\s+)?(\/\S+)/g; // eslint-disable-line security/detect-unsafe-regex -- Simple pattern on short command strings
|
||||||
let teeMatch: RegExpExecArray | null;
|
let teeMatch: RegExpExecArray | null;
|
||||||
while ((teeMatch = teePattern.exec(command)) !== null) {
|
while ((teeMatch = teePattern.exec(command)) !== null) {
|
||||||
const filePath = teeMatch[1];
|
const filePath = teeMatch[1];
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import * as path from 'path';
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
|
|
||||||
const TOOL_FILE_NAME = 'teamctl.js';
|
const TOOL_FILE_NAME = 'teamctl.js';
|
||||||
const TOOL_VERSION = 6;
|
const TOOL_VERSION = 7;
|
||||||
|
|
||||||
function buildTeamCtlScript(): string {
|
function buildTeamCtlScript(): string {
|
||||||
const script = String.raw`#!/usr/bin/env node
|
const script = String.raw`#!/usr/bin/env node
|
||||||
|
|
@ -505,6 +505,124 @@ function processList(paths) {
|
||||||
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function taskBriefing(paths, teamName, flags) {
|
||||||
|
var forMember = typeof flags['for'] === 'string' ? flags['for'].trim() : '';
|
||||||
|
if (!forMember) die('Missing --for <member-name>');
|
||||||
|
|
||||||
|
var kanban = readKanbanState(paths, teamName);
|
||||||
|
var ids = listTaskIds(paths.tasksDir);
|
||||||
|
|
||||||
|
var allTasks = [];
|
||||||
|
for (var i = 0; i < ids.length; i++) {
|
||||||
|
try {
|
||||||
|
var taskPath = path.join(paths.tasksDir, ids[i] + '.json');
|
||||||
|
var t = readJson(taskPath, null);
|
||||||
|
if (t && !String(t.id).startsWith('_internal') && !(t.metadata && t.metadata._internal === true)) {
|
||||||
|
try { t._mtime = fs.statSync(taskPath).mtime.toISOString(); } catch (_e) { t._mtime = ''; }
|
||||||
|
allTasks.push(t);
|
||||||
|
}
|
||||||
|
} catch (e) { /* skip unreadable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectiveColumn(task) {
|
||||||
|
var ks = kanban.tasks[String(task.id)];
|
||||||
|
if (ks) return ks.column;
|
||||||
|
if (task.status === 'pending') return 'todo';
|
||||||
|
if (task.status === 'in_progress') return 'in_progress';
|
||||||
|
if (task.status === 'completed') return 'done';
|
||||||
|
return task.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
var relevant = allTasks.filter(function (t) {
|
||||||
|
var col = getEffectiveColumn(t);
|
||||||
|
return col !== 'approved' && t.status !== 'deleted';
|
||||||
|
});
|
||||||
|
|
||||||
|
var myTasks = { todo: [], in_progress: [], done: [], review: [] };
|
||||||
|
var otherTasks = { todo: [], in_progress: [], done: [], review: [] };
|
||||||
|
|
||||||
|
for (var j = 0; j < relevant.length; j++) {
|
||||||
|
var task = relevant[j];
|
||||||
|
var col = getEffectiveColumn(task);
|
||||||
|
var bucket = (task.owner === forMember) ? myTasks : otherTasks;
|
||||||
|
if (col === 'todo') bucket.todo.push(task);
|
||||||
|
else if (col === 'in_progress') bucket.in_progress.push(task);
|
||||||
|
else if (col === 'done') bucket.done.push(task);
|
||||||
|
else if (col === 'review') bucket.review.push(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByMtime(arr) {
|
||||||
|
return arr.sort(function (a, b) {
|
||||||
|
var da = a._mtime || '';
|
||||||
|
var db = b._mtime || '';
|
||||||
|
return da < db ? 1 : da > db ? -1 : 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
myTasks.done = sortByMtime(myTasks.done).slice(0, 15);
|
||||||
|
otherTasks.done = sortByMtime(otherTasks.done).slice(0, 15);
|
||||||
|
|
||||||
|
var lines = [];
|
||||||
|
lines.push('=== Task Briefing for ' + forMember + ' ===');
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
function formatTask(t) {
|
||||||
|
var parts = [];
|
||||||
|
parts.push('#' + t.id + ' [' + getEffectiveColumn(t).toUpperCase() + '] ' + t.subject);
|
||||||
|
if (t.owner) parts.push(' Owner: ' + t.owner);
|
||||||
|
if (t.description && t.description !== t.subject) {
|
||||||
|
parts.push(' Description: ' + t.description.slice(0, 500));
|
||||||
|
}
|
||||||
|
if (t.blockedBy && t.blockedBy.length > 0) {
|
||||||
|
parts.push(' Blocked by: ' + t.blockedBy.map(function(id) { return '#' + id; }).join(', '));
|
||||||
|
}
|
||||||
|
if (t.related && t.related.length > 0) {
|
||||||
|
parts.push(' Related: ' + t.related.map(function(id) { return '#' + id; }).join(', '));
|
||||||
|
}
|
||||||
|
if (Array.isArray(t.comments) && t.comments.length > 0) {
|
||||||
|
parts.push(' Comments (' + t.comments.length + '):');
|
||||||
|
for (var c = 0; c < t.comments.length; c++) {
|
||||||
|
var cm = t.comments[c];
|
||||||
|
var ts = cm.createdAt ? ' (' + cm.createdAt + ')' : '';
|
||||||
|
parts.push(' [' + (cm.author || '?') + ts + '] ' + (cm.text || '').slice(0, 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSection(label, tasks) {
|
||||||
|
if (tasks.length === 0) return;
|
||||||
|
lines.push('--- ' + label + ' (' + tasks.length + ') ---');
|
||||||
|
for (var k = 0; k < tasks.length; k++) {
|
||||||
|
lines.push(formatTask(tasks[k]));
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('== YOUR TASKS ==');
|
||||||
|
renderSection('IN PROGRESS', myTasks.in_progress);
|
||||||
|
renderSection('TODO', myTasks.todo);
|
||||||
|
renderSection('REVIEW', myTasks.review);
|
||||||
|
renderSection('DONE (recent)', myTasks.done);
|
||||||
|
|
||||||
|
if (myTasks.in_progress.length + myTasks.todo.length + myTasks.review.length + myTasks.done.length === 0) {
|
||||||
|
lines.push('(no tasks assigned to you)');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('== TEAM BOARD (others) ==');
|
||||||
|
renderSection('IN PROGRESS', otherTasks.in_progress);
|
||||||
|
renderSection('TODO', otherTasks.todo);
|
||||||
|
renderSection('REVIEW', otherTasks.review);
|
||||||
|
renderSection('DONE (recent)', otherTasks.done);
|
||||||
|
|
||||||
|
if (otherTasks.in_progress.length + otherTasks.todo.length + otherTasks.review.length + otherTasks.done.length === 0) {
|
||||||
|
lines.push('(no other tasks on the board)');
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(lines.join('\n') + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
function printHelp() {
|
function printHelp() {
|
||||||
const inferred = inferTeamNameFromScriptPath();
|
const inferred = inferTeamNameFromScriptPath();
|
||||||
const teamHint = inferred ? ' (inferred team: ' + String(inferred) + ')' : '';
|
const teamHint = inferred ? ' (inferred team: ' + String(inferred) + ')' : '';
|
||||||
|
|
@ -518,6 +636,7 @@ function printHelp() {
|
||||||
' node teamctl.js task start <id> [--team <team>]',
|
' node teamctl.js task start <id> [--team <team>]',
|
||||||
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team <team>]',
|
' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team <team>]',
|
||||||
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
|
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
|
||||||
|
' node teamctl.js task briefing --for <member-name> [--team <team>]',
|
||||||
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
|
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
|
||||||
' node teamctl.js kanban clear <id> [--team <team>]',
|
' node teamctl.js kanban clear <id> [--team <team>]',
|
||||||
' node teamctl.js review approve <id> [--notify-owner --from "member" --note "..."] [--team <team>]',
|
' node teamctl.js review approve <id> [--notify-owner --from "member" --note "..."] [--team <team>]',
|
||||||
|
|
@ -643,6 +762,10 @@ async function main() {
|
||||||
process.stdout.write('OK comment added to task #' + String(id) + '\n');
|
process.stdout.write('OK comment added to task #' + String(id) + '\n');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === 'briefing') {
|
||||||
|
taskBriefing(paths, teamName, args.flags);
|
||||||
|
return;
|
||||||
|
}
|
||||||
die('Unknown task action: ' + String(action));
|
die('Unknown task action: ' + String(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -664,6 +664,14 @@ export class TeamDataService {
|
||||||
await this.taskWriter.updateStatus(teamName, taskId, status);
|
await this.taskWriter.updateStatus(teamName, taskId, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async softDeleteTask(teamName: string, taskId: string): Promise<void> {
|
||||||
|
await this.taskWriter.softDelete(teamName, taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
|
||||||
|
return this.taskReader.getDeletedTasks(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
|
async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise<void> {
|
||||||
await this.taskWriter.updateOwner(teamName, taskId, owner);
|
await this.taskWriter.updateOwner(teamName, taskId, owner);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -506,17 +506,19 @@ function buildLaunchPrompt(
|
||||||
const memberSpawnInstructions = members
|
const memberSpawnInstructions = members
|
||||||
.map((m) => {
|
.map((m) => {
|
||||||
const taskBlock = memberTaskBlocks.get(m.name) || '';
|
const taskBlock = memberTaskBlocks.get(m.name) || '';
|
||||||
const resumeInstruction = taskBlock
|
const hasTasks = Boolean(taskBlock);
|
||||||
? `Include these tasks in the prompt for ${m.name} so they know what to resume:${taskBlock}`
|
|
||||||
: `${m.name} has no pending tasks — tell them to wait for new assignments.`;
|
|
||||||
|
|
||||||
return ` For "${m.name}":
|
return ` For "${m.name}":
|
||||||
- prompt:
|
- prompt:
|
||||||
You are ${m.name}, a ${m.role || 'team member'} on team "${request.teamName}".
|
You are ${m.name}, a ${m.role || 'team member'} on team "${request.teamName}".
|
||||||
${languageInstruction}
|
${languageInstruction}
|
||||||
The team has been reconnected after a restart.${taskBlock}
|
The team has been reconnected after a restart.
|
||||||
${taskBlock ? 'Resume your pending tasks immediately — start with in_progress tasks first, then pending.' : 'Wait for new task assignments.'}
|
${hasTasks ? `You have pending tasks from the previous session.` : 'You have no pending tasks currently.'}
|
||||||
Note: ${resumeInstruction}`;
|
|
||||||
|
Your FIRST action: run this command to get your full task briefing with descriptions and comments:
|
||||||
|
node "$HOME/.claude/tools/teamctl.js" --team "${request.teamName}" task briefing --for "${m.name}"
|
||||||
|
Then resume in_progress tasks first, then pending tasks.
|
||||||
|
If you have no tasks, wait for new assignments.`;
|
||||||
})
|
})
|
||||||
.join('\n\n');
|
.join('\n\n');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,70 @@ export class TeamTaskReader {
|
||||||
return tasks;
|
return tasks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDeletedTasks(teamName: string): Promise<TeamTask[]> {
|
||||||
|
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||||
|
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = await fs.promises.readdir(tasksDir);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks: TeamTask[] = [];
|
||||||
|
for (const file of entries) {
|
||||||
|
if (
|
||||||
|
!file.endsWith('.json') ||
|
||||||
|
file.startsWith('.') ||
|
||||||
|
file === '.lock' ||
|
||||||
|
file === '.highwatermark'
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskPath = path.join(tasksDir, file);
|
||||||
|
try {
|
||||||
|
const raw = await fs.promises.readFile(taskPath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
// Skip internal CLI tracking entries
|
||||||
|
const metadata = parsed.metadata as Record<string, unknown> | undefined;
|
||||||
|
if (metadata?._internal === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parsed.status !== 'deleted') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subject =
|
||||||
|
typeof parsed.subject === 'string'
|
||||||
|
? parsed.subject
|
||||||
|
: typeof parsed.title === 'string'
|
||||||
|
? parsed.title
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const task: TeamTask = {
|
||||||
|
id:
|
||||||
|
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||||
|
subject,
|
||||||
|
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
||||||
|
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||||
|
status: 'deleted',
|
||||||
|
deletedAt: typeof parsed.deletedAt === 'string' ? parsed.deletedAt : undefined,
|
||||||
|
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
tasks.push(task);
|
||||||
|
} catch {
|
||||||
|
logger.debug(`Skipping invalid task file: ${taskPath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
}
|
||||||
|
|
||||||
async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> {
|
async getAllTasks(): Promise<(TeamTask & { teamName: string })[]> {
|
||||||
const tasksBase = getTasksBasePath();
|
const tasksBase = getTasksBasePath();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,33 @@ export class TeamTaskWriter {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async softDelete(teamName: string, taskId: string): Promise<void> {
|
||||||
|
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||||
|
|
||||||
|
await withTaskLock(taskPath, async () => {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = await fs.promises.readFile(taskPath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
throw new Error(`Task not found: ${taskId}`);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = JSON.parse(raw) as TeamTask;
|
||||||
|
task.status = 'deleted';
|
||||||
|
task.deletedAt = new Date().toISOString();
|
||||||
|
await atomicWriteAsync(taskPath, JSON.stringify(task, null, 2));
|
||||||
|
|
||||||
|
const verifyRaw = await fs.promises.readFile(taskPath, 'utf8');
|
||||||
|
const verifyTask = JSON.parse(verifyRaw) as TeamTask;
|
||||||
|
if (verifyTask.status !== 'deleted') {
|
||||||
|
throw new Error(`Task soft-delete verification failed: ${taskId}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async addComment(
|
async addComment(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,12 @@ export const TEAM_KILL_PROCESS = 'team:killProcess';
|
||||||
/** Get lead process activity state (active/idle/offline) */
|
/** Get lead process activity state (active/idle/offline) */
|
||||||
export const TEAM_LEAD_ACTIVITY = 'team:leadActivity';
|
export const TEAM_LEAD_ACTIVITY = 'team:leadActivity';
|
||||||
|
|
||||||
|
/** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */
|
||||||
|
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
||||||
|
|
||||||
|
/** Get all soft-deleted tasks for a team */
|
||||||
|
export const TEAM_GET_DELETED_TASKS = 'team:getDeletedTasks';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Review API Channels
|
// Review API Channels
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ import {
|
||||||
TEAM_GET_ALL_TASKS,
|
TEAM_GET_ALL_TASKS,
|
||||||
TEAM_GET_ATTACHMENTS,
|
TEAM_GET_ATTACHMENTS,
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
|
TEAM_GET_DELETED_TASKS,
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
TEAM_GET_LOGS_FOR_TASK,
|
||||||
TEAM_GET_MEMBER_LOGS,
|
TEAM_GET_MEMBER_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
|
|
@ -58,6 +59,7 @@ import {
|
||||||
TEAM_REMOVE_MEMBER,
|
TEAM_REMOVE_MEMBER,
|
||||||
TEAM_REQUEST_REVIEW,
|
TEAM_REQUEST_REVIEW,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
|
TEAM_SOFT_DELETE_TASK,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_STOP,
|
TEAM_STOP,
|
||||||
TEAM_UPDATE_CONFIG,
|
TEAM_UPDATE_CONFIG,
|
||||||
|
|
@ -672,6 +674,12 @@ const electronAPI: ElectronAPI = {
|
||||||
const result = await invokeIpcWithResult<string>(TEAM_LEAD_ACTIVITY, teamName);
|
const result = await invokeIpcWithResult<string>(TEAM_LEAD_ACTIVITY, teamName);
|
||||||
return result as 'active' | 'idle' | 'offline';
|
return result as 'active' | 'idle' | 'offline';
|
||||||
},
|
},
|
||||||
|
softDeleteTask: async (teamName: string, taskId: string) => {
|
||||||
|
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
||||||
|
},
|
||||||
|
getDeletedTasks: async (teamName: string) => {
|
||||||
|
return invokeIpcWithResult<TeamTask[]>(TEAM_GET_DELETED_TASKS, teamName);
|
||||||
|
},
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||||
ipcRenderer.on(
|
ipcRenderer.on(
|
||||||
TEAM_CHANGE,
|
TEAM_CHANGE,
|
||||||
|
|
|
||||||
|
|
@ -767,6 +767,12 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
|
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
|
||||||
return 'offline';
|
return 'offline';
|
||||||
},
|
},
|
||||||
|
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
||||||
|
// Not available via HTTP client — no-op
|
||||||
|
},
|
||||||
|
getDeletedTasks: async (_teamName: string): Promise<TeamTask[]> => {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
|
||||||
return this.addEventListener('team-change', (data: unknown) =>
|
return this.addEventListener('team-change', (data: unknown) =>
|
||||||
callback(null, data as TeamChangeEvent)
|
callback(null, data as TeamChangeEvent)
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,7 @@ import { SendMessageDialog } from './dialogs/SendMessageDialog';
|
||||||
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
|
import { TaskDetailDialog } from './dialogs/TaskDetailDialog';
|
||||||
import { KanbanBoard } from './kanban/KanbanBoard';
|
import { KanbanBoard } from './kanban/KanbanBoard';
|
||||||
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover';
|
||||||
|
import { TrashDialog } from './kanban/TrashDialog';
|
||||||
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
import { MemberDetailDialog } from './members/MemberDetailDialog';
|
||||||
import { MemberList } from './members/MemberList';
|
import { MemberList } from './members/MemberList';
|
||||||
import { MessageComposer } from './messages/MessageComposer';
|
import { MessageComposer } from './messages/MessageComposer';
|
||||||
|
|
@ -117,6 +118,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
const [stoppingTeam, setStoppingTeam] = useState(false);
|
const [stoppingTeam, setStoppingTeam] = useState(false);
|
||||||
|
const [trashOpen, setTrashOpen] = useState(false);
|
||||||
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
|
const [sendDialogRecipient, setSendDialogRecipient] = useState<string | undefined>(undefined);
|
||||||
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
|
const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>(
|
||||||
undefined
|
undefined
|
||||||
|
|
@ -126,6 +128,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
mode: 'agent' | 'task';
|
mode: 'agent' | 'task';
|
||||||
memberName?: string;
|
memberName?: string;
|
||||||
taskId?: string;
|
taskId?: string;
|
||||||
|
initialFilePath?: string;
|
||||||
}>({ open: false, mode: 'task' });
|
}>({ open: false, mode: 'task' });
|
||||||
|
|
||||||
// Active teams for conflict warning in LaunchTeamDialog
|
// Active teams for conflict warning in LaunchTeamDialog
|
||||||
|
|
@ -173,6 +176,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
refreshTeamData,
|
refreshTeamData,
|
||||||
kanbanFilterQuery,
|
kanbanFilterQuery,
|
||||||
clearKanbanFilter,
|
clearKanbanFilter,
|
||||||
|
softDeleteTask,
|
||||||
|
fetchDeletedTasks,
|
||||||
|
deletedTasks,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
data: s.selectedTeamData,
|
data: s.selectedTeamData,
|
||||||
|
|
@ -207,6 +213,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
refreshTeamData: s.refreshTeamData,
|
refreshTeamData: s.refreshTeamData,
|
||||||
kanbanFilterQuery: s.kanbanFilterQuery,
|
kanbanFilterQuery: s.kanbanFilterQuery,
|
||||||
clearKanbanFilter: s.clearKanbanFilter,
|
clearKanbanFilter: s.clearKanbanFilter,
|
||||||
|
softDeleteTask: s.softDeleteTask,
|
||||||
|
fetchDeletedTasks: s.fetchDeletedTasks,
|
||||||
|
deletedTasks: s.deletedTasks,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -223,7 +232,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void selectTeam(teamName);
|
void selectTeam(teamName);
|
||||||
}, [teamName, selectTeam]);
|
void fetchDeletedTasks(teamName);
|
||||||
|
}, [teamName, selectTeam, fetchDeletedTasks]);
|
||||||
|
|
||||||
// Fetch active teams when launch dialog opens (for conflict warning)
|
// Fetch active teams when launch dialog opens (for conflict warning)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -498,6 +508,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
|
|
||||||
const selectReviewFile = useStore((s) => s.selectReviewFile);
|
const selectReviewFile = useStore((s) => s.selectReviewFile);
|
||||||
|
|
||||||
|
const handleDeleteTask = useCallback(
|
||||||
|
(taskId: string) => {
|
||||||
|
void softDeleteTask(teamName, taskId);
|
||||||
|
},
|
||||||
|
[teamName, softDeleteTask]
|
||||||
|
);
|
||||||
|
|
||||||
const handleViewChanges = useCallback((taskId: string) => {
|
const handleViewChanges = useCallback((taskId: string) => {
|
||||||
setReviewDialogState({ open: true, mode: 'task', taskId });
|
setReviewDialogState({ open: true, mode: 'task', taskId });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -991,6 +1008,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
onTaskClick={(task) => setSelectedTask(task)}
|
onTaskClick={(task) => setSelectedTask(task)}
|
||||||
onViewChanges={handleViewChanges}
|
onViewChanges={handleViewChanges}
|
||||||
onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)}
|
onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)}
|
||||||
|
onDeleteTask={handleDeleteTask}
|
||||||
|
deletedTaskCount={deletedTasks.length}
|
||||||
|
onOpenTrash={() => setTrashOpen(true)}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
|
@ -1194,6 +1214,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
setRemoveMemberConfirm(name);
|
setRemoveMemberConfirm(name);
|
||||||
}}
|
}}
|
||||||
|
onViewMemberChanges={(memberName, filePath) => {
|
||||||
|
setSelectedMember(null);
|
||||||
|
setReviewDialogState({
|
||||||
|
open: true,
|
||||||
|
mode: 'agent',
|
||||||
|
memberName,
|
||||||
|
initialFilePath: filePath,
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CreateTaskDialog
|
<CreateTaskDialog
|
||||||
|
|
@ -1360,15 +1389,25 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
void updateTaskOwner(teamName, taskId, owner);
|
void updateTaskOwner(teamName, taskId, owner);
|
||||||
}}
|
}}
|
||||||
onViewChanges={handleViewChangesForFile}
|
onViewChanges={handleViewChangesForFile}
|
||||||
|
onDeleteTask={handleDeleteTask}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TrashDialog open={trashOpen} tasks={deletedTasks} onClose={() => setTrashOpen(false)} />
|
||||||
|
|
||||||
<ChangeReviewDialog
|
<ChangeReviewDialog
|
||||||
open={reviewDialogState.open}
|
open={reviewDialogState.open}
|
||||||
onOpenChange={(open) => setReviewDialogState((prev) => ({ ...prev, open }))}
|
onOpenChange={(open) =>
|
||||||
|
setReviewDialogState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
open,
|
||||||
|
...(open ? {} : { initialFilePath: undefined }),
|
||||||
|
}))
|
||||||
|
}
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
mode={reviewDialogState.mode}
|
mode={reviewDialogState.mode}
|
||||||
memberName={reviewDialogState.memberName}
|
memberName={reviewDialogState.memberName}
|
||||||
taskId={reviewDialogState.taskId}
|
taskId={reviewDialogState.taskId}
|
||||||
|
initialFilePath={reviewDialogState.initialFilePath}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import {
|
||||||
Link2,
|
Link2,
|
||||||
Loader2,
|
Loader2,
|
||||||
PenLine,
|
PenLine,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { TaskCommentsSection } from './TaskCommentsSection';
|
import { TaskCommentsSection } from './TaskCommentsSection';
|
||||||
|
|
@ -57,6 +58,7 @@ interface TaskDetailDialogProps {
|
||||||
onScrollToTask?: (taskId: string) => void;
|
onScrollToTask?: (taskId: string) => void;
|
||||||
onOwnerChange?: (taskId: string, owner: string | null) => void;
|
onOwnerChange?: (taskId: string, owner: string | null) => void;
|
||||||
onViewChanges?: (taskId: string, filePath?: string) => void;
|
onViewChanges?: (taskId: string, filePath?: string) => void;
|
||||||
|
onDeleteTask?: (taskId: string) => void;
|
||||||
/** Extra content rendered in the dialog header (e.g. "Open team" button). */
|
/** Extra content rendered in the dialog header (e.g. "Open team" button). */
|
||||||
headerExtra?: React.ReactNode;
|
headerExtra?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
@ -72,6 +74,7 @@ export const TaskDetailDialog = ({
|
||||||
onScrollToTask,
|
onScrollToTask,
|
||||||
onOwnerChange,
|
onOwnerChange,
|
||||||
onViewChanges,
|
onViewChanges,
|
||||||
|
onDeleteTask,
|
||||||
headerExtra,
|
headerExtra,
|
||||||
}: TaskDetailDialogProps): React.JSX.Element => {
|
}: TaskDetailDialogProps): React.JSX.Element => {
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
|
|
@ -261,6 +264,18 @@ export const TaskDetailDialog = ({
|
||||||
)}
|
)}
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
{/* Execution Logs — sessions that reference this task */}
|
||||||
|
<CollapsibleTeamSection title="Execution Logs" defaultOpen>
|
||||||
|
<div className="min-w-0 overflow-hidden">
|
||||||
|
<MemberLogsTab
|
||||||
|
teamName={teamName}
|
||||||
|
taskId={currentTask.id}
|
||||||
|
taskOwner={currentTask.owner}
|
||||||
|
taskStatus={currentTask.status}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
{/* Changes */}
|
{/* Changes */}
|
||||||
{isTaskCompleted && onViewChanges ? (
|
{isTaskCompleted && onViewChanges ? (
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
|
|
@ -448,19 +463,22 @@ export const TaskDetailDialog = ({
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
{/* Execution Logs — sessions that reference this task */}
|
<DialogFooter className="flex items-center justify-between sm:justify-between">
|
||||||
<CollapsibleTeamSection title="Execution Logs" defaultOpen>
|
{onDeleteTask && currentTask ? (
|
||||||
<div className="min-w-0 overflow-hidden">
|
<Button
|
||||||
<MemberLogsTab
|
variant="destructive"
|
||||||
teamName={teamName}
|
size="sm"
|
||||||
taskId={currentTask.id}
|
onClick={() => {
|
||||||
taskOwner={currentTask.owner}
|
onDeleteTask(currentTask.id);
|
||||||
taskStatus={currentTask.status}
|
onClose();
|
||||||
/>
|
}}
|
||||||
</div>
|
>
|
||||||
</CollapsibleTeamSection>
|
<Trash2 size={14} className="mr-1" />
|
||||||
|
Delete
|
||||||
<DialogFooter>
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div />
|
||||||
|
)}
|
||||||
<Button variant="outline" onClick={onClose}>
|
<Button variant="outline" onClick={onClose}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
Plus,
|
Plus,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { KanbanColumn } from './KanbanColumn';
|
import { KanbanColumn } from './KanbanColumn';
|
||||||
|
|
@ -84,6 +85,12 @@ interface KanbanBoardProps {
|
||||||
toolbarLeft?: React.ReactNode;
|
toolbarLeft?: React.ReactNode;
|
||||||
/** Opens the create-task dialog with pre-set startImmediately value. */
|
/** Opens the create-task dialog with pre-set startImmediately value. */
|
||||||
onAddTask?: (startImmediately: boolean) => void;
|
onAddTask?: (startImmediately: boolean) => void;
|
||||||
|
/** Soft-delete a task. */
|
||||||
|
onDeleteTask?: (taskId: string) => void;
|
||||||
|
/** Number of soft-deleted tasks (for trash button badge). */
|
||||||
|
deletedTaskCount?: number;
|
||||||
|
/** Opens the trash dialog. */
|
||||||
|
onOpenTrash?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type KanbanViewMode = 'grid' | 'columns';
|
type KanbanViewMode = 'grid' | 'columns';
|
||||||
|
|
@ -154,6 +161,7 @@ interface SortableKanbanTaskCardProps {
|
||||||
onScrollToTask?: (taskId: string) => void;
|
onScrollToTask?: (taskId: string) => void;
|
||||||
onTaskClick?: (task: TeamTask) => void;
|
onTaskClick?: (task: TeamTask) => void;
|
||||||
onViewChanges?: (taskId: string) => void;
|
onViewChanges?: (taskId: string) => void;
|
||||||
|
onDeleteTask?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SortableKanbanTaskCard = ({
|
const SortableKanbanTaskCard = ({
|
||||||
|
|
@ -173,6 +181,7 @@ const SortableKanbanTaskCard = ({
|
||||||
onScrollToTask,
|
onScrollToTask,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
onViewChanges,
|
onViewChanges,
|
||||||
|
onDeleteTask,
|
||||||
}: SortableKanbanTaskCardProps): React.JSX.Element => {
|
}: SortableKanbanTaskCardProps): React.JSX.Element => {
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
|
|
@ -206,6 +215,7 @@ const SortableKanbanTaskCard = ({
|
||||||
onScrollToTask={onScrollToTask}
|
onScrollToTask={onScrollToTask}
|
||||||
onTaskClick={onTaskClick}
|
onTaskClick={onTaskClick}
|
||||||
onViewChanges={onViewChanges}
|
onViewChanges={onViewChanges}
|
||||||
|
onDeleteTask={onDeleteTask}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -233,6 +243,9 @@ export const KanbanBoard = ({
|
||||||
onColumnOrderChange,
|
onColumnOrderChange,
|
||||||
toolbarLeft,
|
toolbarLeft,
|
||||||
onAddTask,
|
onAddTask,
|
||||||
|
onDeleteTask,
|
||||||
|
deletedTaskCount,
|
||||||
|
onOpenTrash,
|
||||||
}: KanbanBoardProps): React.JSX.Element => {
|
}: KanbanBoardProps): React.JSX.Element => {
|
||||||
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
|
||||||
|
|
||||||
|
|
@ -342,6 +355,7 @@ export const KanbanBoard = ({
|
||||||
onScrollToTask={onScrollToTask}
|
onScrollToTask={onScrollToTask}
|
||||||
onTaskClick={onTaskClick}
|
onTaskClick={onTaskClick}
|
||||||
onViewChanges={onViewChanges}
|
onViewChanges={onViewChanges}
|
||||||
|
onDeleteTask={onDeleteTask}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
|
|
@ -371,6 +385,7 @@ export const KanbanBoard = ({
|
||||||
onScrollToTask={onScrollToTask}
|
onScrollToTask={onScrollToTask}
|
||||||
onTaskClick={onTaskClick}
|
onTaskClick={onTaskClick}
|
||||||
onViewChanges={onViewChanges}
|
onViewChanges={onViewChanges}
|
||||||
|
onDeleteTask={onDeleteTask}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{addButton}
|
{addButton}
|
||||||
|
|
@ -390,6 +405,22 @@ export const KanbanBoard = ({
|
||||||
members={members}
|
members={members}
|
||||||
onFilterChange={onFilterChange}
|
onFilterChange={onFilterChange}
|
||||||
/>
|
/>
|
||||||
|
{deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-[var(--color-text-muted)]"
|
||||||
|
onClick={onOpenTrash}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
<span className="ml-1 text-xs">{deletedTaskCount}</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">Trash</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
<div className="inline-flex rounded-md border border-[var(--color-border)]">
|
<div className="inline-flex rounded-md border border-[var(--color-border)]">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
FileCode,
|
FileCode,
|
||||||
Play,
|
Play,
|
||||||
|
Trash2,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -37,6 +38,7 @@ interface KanbanTaskCardProps {
|
||||||
onScrollToTask?: (taskId: string) => void;
|
onScrollToTask?: (taskId: string) => void;
|
||||||
onTaskClick?: (task: TeamTask) => void;
|
onTaskClick?: (task: TeamTask) => void;
|
||||||
onViewChanges?: (taskId: string) => void;
|
onViewChanges?: (taskId: string) => void;
|
||||||
|
onDeleteTask?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DependencyBadgeProps {
|
interface DependencyBadgeProps {
|
||||||
|
|
@ -143,6 +145,7 @@ export const KanbanTaskCard = ({
|
||||||
onScrollToTask,
|
onScrollToTask,
|
||||||
onTaskClick,
|
onTaskClick,
|
||||||
onViewChanges,
|
onViewChanges,
|
||||||
|
onDeleteTask,
|
||||||
}: KanbanTaskCardProps): React.JSX.Element => {
|
}: KanbanTaskCardProps): React.JSX.Element => {
|
||||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||||
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
|
const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments);
|
||||||
|
|
@ -369,6 +372,19 @@ export const KanbanTaskCard = ({
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
<UnreadCommentsBadge unreadCount={unreadCount} totalCount={task.comments?.length ?? 0} />
|
||||||
|
{onDeleteTask ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteTask(task.id);
|
||||||
|
}}
|
||||||
|
className="text-[var(--color-text-muted)] transition-colors hover:text-red-400"
|
||||||
|
title="Delete task"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
77
src/renderer/components/team/kanban/TrashDialog.tsx
Normal file
77
src/renderer/components/team/kanban/TrashDialog.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@renderer/components/ui/dialog';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { TeamTask } from '@shared/types';
|
||||||
|
|
||||||
|
interface TrashDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
tasks: TeamTask[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrashDialog = ({ open, tasks, onClose }: TrashDialogProps): React.JSX.Element => {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-sm">
|
||||||
|
<Trash2 size={14} className="text-[var(--color-text-muted)]" />
|
||||||
|
Trash
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-xs text-[var(--color-text-muted)]">
|
||||||
|
No deleted tasks
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[400px] overflow-y-auto">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-[var(--color-border)] text-left text-[var(--color-text-muted)]">
|
||||||
|
<th className="pb-2 pr-3 font-medium">#</th>
|
||||||
|
<th className="pb-2 pr-3 font-medium">Subject</th>
|
||||||
|
<th className="pb-2 pr-3 font-medium">Owner</th>
|
||||||
|
<th className="pb-2 font-medium">Deleted</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{tasks.map((task) => (
|
||||||
|
<tr
|
||||||
|
key={task.id}
|
||||||
|
className="border-b border-[var(--color-border-subtle)] last:border-0"
|
||||||
|
>
|
||||||
|
<td className="py-2 pr-3 text-[var(--color-text-muted)]">{task.id}</td>
|
||||||
|
<td className="py-2 pr-3 text-[var(--color-text)]">{task.subject}</td>
|
||||||
|
<td className="py-2 pr-3 text-[var(--color-text-secondary)]">
|
||||||
|
{task.owner ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-[var(--color-text-muted)]">
|
||||||
|
{task.deletedAt
|
||||||
|
? formatDistanceToNow(new Date(task.deletedAt), { addSuffix: true })
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -35,6 +35,7 @@ interface MemberDetailDialogProps {
|
||||||
onRemoveMember?: () => void;
|
onRemoveMember?: () => void;
|
||||||
onUpdateRole?: (memberName: string, role: string | undefined) => Promise<void> | void;
|
onUpdateRole?: (memberName: string, role: string | undefined) => Promise<void> | void;
|
||||||
updatingRole?: boolean;
|
updatingRole?: boolean;
|
||||||
|
onViewMemberChanges?: (memberName: string, filePath?: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberDetailDialog = ({
|
export const MemberDetailDialog = ({
|
||||||
|
|
@ -53,6 +54,7 @@ export const MemberDetailDialog = ({
|
||||||
onRemoveMember,
|
onRemoveMember,
|
||||||
onUpdateRole,
|
onUpdateRole,
|
||||||
updatingRole,
|
updatingRole,
|
||||||
|
onViewMemberChanges,
|
||||||
}: MemberDetailDialogProps): React.JSX.Element | null => {
|
}: MemberDetailDialogProps): React.JSX.Element | null => {
|
||||||
const memberTasks = useMemo(
|
const memberTasks = useMemo(
|
||||||
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
() => (member ? tasks.filter((t) => t.owner === member.name) : []),
|
||||||
|
|
@ -143,7 +145,12 @@ export const MemberDetailDialog = ({
|
||||||
<MemberMessagesTab messages={memberMessages} teamName={teamName} />
|
<MemberMessagesTab messages={memberMessages} teamName={teamName} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="stats">
|
<TabsContent value="stats">
|
||||||
<MemberStatsTab teamName={teamName} memberName={member.name} />
|
<MemberStatsTab
|
||||||
|
teamName={teamName}
|
||||||
|
memberName={member.name}
|
||||||
|
onFileClick={(filePath) => onViewMemberChanges?.(member.name, filePath)}
|
||||||
|
onShowAllFiles={() => onViewMemberChanges?.(member.name)}
|
||||||
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="logs" className="min-w-0 overflow-hidden">
|
<TabsContent value="logs" className="min-w-0 overflow-hidden">
|
||||||
<MemberLogsTab teamName={teamName} memberName={member.name} />
|
<MemberLogsTab teamName={teamName} memberName={member.name} />
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
|
import { formatTokensCompact } from '@shared/utils/tokenFormatting';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
|
@ -17,11 +18,15 @@ import type { MemberFullStats } from '@shared/types';
|
||||||
interface MemberStatsTabProps {
|
interface MemberStatsTabProps {
|
||||||
teamName: string;
|
teamName: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
|
onFileClick?: (filePath: string) => void;
|
||||||
|
onShowAllFiles?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MemberStatsTab = ({
|
export const MemberStatsTab = ({
|
||||||
teamName,
|
teamName,
|
||||||
memberName,
|
memberName,
|
||||||
|
onFileClick,
|
||||||
|
onShowAllFiles,
|
||||||
}: MemberStatsTabProps): React.JSX.Element => {
|
}: MemberStatsTabProps): React.JSX.Element => {
|
||||||
const [stats, setStats] = useState<MemberFullStats | null>(null);
|
const [stats, setStats] = useState<MemberFullStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -88,7 +93,11 @@ export const MemberStatsTab = ({
|
||||||
<div className="max-h-[400px] space-y-3 overflow-y-auto pr-1">
|
<div className="max-h-[400px] space-y-3 overflow-y-auto pr-1">
|
||||||
<SummaryCards stats={stats} totalTokens={totalTokens} totalToolCalls={totalToolCalls} />
|
<SummaryCards stats={stats} totalTokens={totalTokens} totalToolCalls={totalToolCalls} />
|
||||||
<ToolUsageBars toolUsage={stats.toolUsage} />
|
<ToolUsageBars toolUsage={stats.toolUsage} />
|
||||||
<FilesTouchedSection files={stats.filesTouched} />
|
<FilesTouchedSection
|
||||||
|
files={stats.filesTouched}
|
||||||
|
onFileClick={onFileClick}
|
||||||
|
onShowAll={onShowAllFiles}
|
||||||
|
/>
|
||||||
<StatsFooter stats={stats} />
|
<StatsFooter stats={stats} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -182,30 +191,53 @@ const ToolUsageBars = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FilesTouchedSection = ({ files }: { files: string[] }): React.JSX.Element | null => {
|
const FilesTouchedSection = ({
|
||||||
|
files,
|
||||||
|
onFileClick,
|
||||||
|
onShowAll,
|
||||||
|
}: {
|
||||||
|
files: string[];
|
||||||
|
onFileClick?: (filePath: string) => void;
|
||||||
|
onShowAll?: () => void;
|
||||||
|
}): React.JSX.Element | null => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
if (files.length === 0) return null;
|
if (files.length === 0) return null;
|
||||||
|
|
||||||
const visibleFiles = expanded ? files : files.slice(0, 5);
|
const visibleFiles = expanded ? files : files.slice(0, 5);
|
||||||
const hiddenCount = files.length - 5;
|
const hiddenCount = files.length - 5;
|
||||||
|
const isClickable = !!onFileClick;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||||||
<p className="mb-2 text-[11px] font-medium text-[var(--color-text-secondary)]">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
Files Touched ({files.length})
|
<p className="text-[11px] font-medium text-[var(--color-text-secondary)]">
|
||||||
</p>
|
Files Touched ({files.length})
|
||||||
|
</p>
|
||||||
|
{onShowAll && (
|
||||||
|
<button className="text-[10px] text-blue-400 hover:text-blue-300" onClick={onShowAll}>
|
||||||
|
View All Changes
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{visibleFiles.map((filePath) => {
|
{visibleFiles.map((filePath) => {
|
||||||
const basename = filePath.split('/').pop() ?? filePath;
|
const basename = filePath.split('/').pop() ?? filePath;
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={filePath}
|
key={filePath}
|
||||||
className="flex items-center gap-1.5 text-[11px] text-[var(--color-text-muted)]"
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-1.5 rounded px-1 py-0.5 text-left text-[11px] text-[var(--color-text-muted)]',
|
||||||
|
isClickable &&
|
||||||
|
'cursor-pointer hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]'
|
||||||
|
)}
|
||||||
title={filePath}
|
title={filePath}
|
||||||
|
onClick={() => onFileClick?.(filePath)}
|
||||||
|
disabled={!isClickable}
|
||||||
>
|
>
|
||||||
<FileCode size={10} className="shrink-0 opacity-50" />
|
<FileCode size={10} className="shrink-0 opacity-50" />
|
||||||
<span className="truncate">{basename}</span>
|
<span className="truncate">{basename}</span>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent';
|
import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent';
|
||||||
import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection';
|
import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection';
|
||||||
|
|
@ -67,6 +67,20 @@ export const ContinuousScrollView = ({
|
||||||
memberName,
|
memberName,
|
||||||
fetchFileContent,
|
fetchFileContent,
|
||||||
}: ContinuousScrollViewProps): React.ReactElement => {
|
}: ContinuousScrollViewProps): React.ReactElement => {
|
||||||
|
const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const handleToggleCollapse = useCallback((filePath: string) => {
|
||||||
|
setCollapsedFiles((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(filePath)) {
|
||||||
|
next.delete(filePath);
|
||||||
|
} else {
|
||||||
|
next.add(filePath);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const filePaths = useMemo(() => files.map((f) => f.filePath), [files]);
|
const filePaths = useMemo(() => files.map((f) => f.filePath), [files]);
|
||||||
|
|
||||||
const { registerFileSectionRef } = useVisibleFileSection({
|
const { registerFileSectionRef } = useVisibleFileSection({
|
||||||
|
|
@ -149,6 +163,8 @@ export const ContinuousScrollView = ({
|
||||||
const isViewed = viewedSet.has(filePath);
|
const isViewed = viewedSet.has(filePath);
|
||||||
const decision = fileDecisions[filePath];
|
const decision = fileDecisions[filePath];
|
||||||
|
|
||||||
|
const isCollapsed = collapsedFiles.has(filePath);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={filePath} ref={combinedRef(filePath)} className="border-b border-border">
|
<div key={filePath} ref={combinedRef(filePath)} className="border-b border-border">
|
||||||
<FileSectionHeader
|
<FileSectionHeader
|
||||||
|
|
@ -157,28 +173,31 @@ export const ContinuousScrollView = ({
|
||||||
fileDecision={decision}
|
fileDecision={decision}
|
||||||
hasEdits={hasEdits}
|
hasEdits={hasEdits}
|
||||||
applying={applying}
|
applying={applying}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onToggleCollapse={handleToggleCollapse}
|
||||||
onDiscard={onDiscard}
|
onDiscard={onDiscard}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hasContent ? (
|
{!isCollapsed &&
|
||||||
<FileSectionDiff
|
(hasContent ? (
|
||||||
file={file}
|
<FileSectionDiff
|
||||||
fileContent={content}
|
file={file}
|
||||||
isLoading={false}
|
fileContent={content}
|
||||||
collapseUnchanged={collapseUnchanged}
|
isLoading={false}
|
||||||
onHunkAccepted={onHunkAccepted}
|
collapseUnchanged={collapseUnchanged}
|
||||||
onHunkRejected={onHunkRejected}
|
onHunkAccepted={onHunkAccepted}
|
||||||
onFullyViewed={onFullyViewed}
|
onHunkRejected={onHunkRejected}
|
||||||
onContentChanged={onContentChanged}
|
onFullyViewed={onFullyViewed}
|
||||||
onEditorViewReady={handleEditorViewReady}
|
onContentChanged={onContentChanged}
|
||||||
discardCounter={discardCounter}
|
onEditorViewReady={handleEditorViewReady}
|
||||||
autoViewed={autoViewed}
|
discardCounter={discardCounter}
|
||||||
isViewed={isViewed}
|
autoViewed={autoViewed}
|
||||||
/>
|
isViewed={isViewed}
|
||||||
) : (
|
/>
|
||||||
<FileSectionPlaceholder fileName={file.relativePath} />
|
) : (
|
||||||
)}
|
<FileSectionPlaceholder fileName={file.relativePath} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -79,11 +79,21 @@ export const FileSectionDiff = ({
|
||||||
return <FileSectionPlaceholder fileName={file.relativePath} />;
|
return <FileSectionPlaceholder fileName={file.relativePath} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unavailable / no content fallback
|
// Resolve modified content: prefer full content, fall back to write-type snippet
|
||||||
|
// Only write-new/write-update snippets contain the full file — edit snippets are partial
|
||||||
|
const resolvedModified =
|
||||||
|
fileContent?.modifiedFullContent ??
|
||||||
|
(() => {
|
||||||
|
const writeSnippets = file.snippets.filter(
|
||||||
|
(s) => !s.isError && (s.type === 'write-new' || s.type === 'write-update')
|
||||||
|
);
|
||||||
|
if (writeSnippets.length === 0) return null;
|
||||||
|
// Take the last write (most recent full-file content)
|
||||||
|
return writeSnippets[writeSnippets.length - 1].newString;
|
||||||
|
})();
|
||||||
|
|
||||||
const hasCodeMirrorContent =
|
const hasCodeMirrorContent =
|
||||||
fileContent &&
|
fileContent && fileContent.contentSource !== 'unavailable' && resolvedModified !== null;
|
||||||
fileContent.contentSource !== 'unavailable' &&
|
|
||||||
fileContent.modifiedFullContent !== null;
|
|
||||||
|
|
||||||
if (!hasCodeMirrorContent) {
|
if (!hasCodeMirrorContent) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -99,12 +109,12 @@ export const FileSectionDiff = ({
|
||||||
<DiffErrorBoundary
|
<DiffErrorBoundary
|
||||||
filePath={file.filePath}
|
filePath={file.filePath}
|
||||||
oldString={fileContent.originalFullContent ?? ''}
|
oldString={fileContent.originalFullContent ?? ''}
|
||||||
newString={fileContent.modifiedFullContent!}
|
newString={resolvedModified}
|
||||||
>
|
>
|
||||||
<CodeMirrorDiffView
|
<CodeMirrorDiffView
|
||||||
key={`${file.filePath}:${discardCounter}`}
|
key={`${file.filePath}:${discardCounter}`}
|
||||||
original={fileContent.originalFullContent ?? ''}
|
original={fileContent.originalFullContent ?? ''}
|
||||||
modified={fileContent.modifiedFullContent!}
|
modified={resolvedModified}
|
||||||
fileName={file.relativePath}
|
fileName={file.relativePath}
|
||||||
readOnly={false}
|
readOnly={false}
|
||||||
showMergeControls={true}
|
showMergeControls={true}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { Loader2, Save, Undo2 } from 'lucide-react';
|
import { ChevronDown, ChevronRight, Loader2, Save, Undo2 } from 'lucide-react';
|
||||||
|
|
||||||
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
|
import type { FileChangeWithContent, HunkDecision } from '@shared/types';
|
||||||
import type { FileChangeSummary } from '@shared/types/review';
|
import type { FileChangeSummary } from '@shared/types/review';
|
||||||
|
|
@ -20,6 +20,8 @@ interface FileSectionHeaderProps {
|
||||||
fileDecision: HunkDecision | undefined;
|
fileDecision: HunkDecision | undefined;
|
||||||
hasEdits: boolean;
|
hasEdits: boolean;
|
||||||
applying: boolean;
|
applying: boolean;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
onToggleCollapse: (filePath: string) => void;
|
||||||
onDiscard: (filePath: string) => void;
|
onDiscard: (filePath: string) => void;
|
||||||
onSave: (filePath: string) => void;
|
onSave: (filePath: string) => void;
|
||||||
}
|
}
|
||||||
|
|
@ -30,11 +32,35 @@ export const FileSectionHeader = ({
|
||||||
fileDecision,
|
fileDecision,
|
||||||
hasEdits,
|
hasEdits,
|
||||||
applying,
|
applying,
|
||||||
|
isCollapsed,
|
||||||
|
onToggleCollapse,
|
||||||
onDiscard,
|
onDiscard,
|
||||||
onSave,
|
onSave,
|
||||||
}: FileSectionHeaderProps): React.ReactElement => {
|
}: FileSectionHeaderProps): React.ReactElement => {
|
||||||
|
const handleHeaderClick = (e: React.MouseEvent): void => {
|
||||||
|
// Don't collapse when clicking action buttons
|
||||||
|
if ((e.target as HTMLElement).closest('[data-no-collapse]')) return;
|
||||||
|
onToggleCollapse(file.filePath);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleHeaderKeyDown = (e: React.KeyboardEvent): void => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onToggleCollapse(file.filePath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-10 flex items-center gap-2 border-b border-border bg-surface-sidebar px-4 py-2">
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleHeaderClick}
|
||||||
|
onKeyDown={handleHeaderKeyDown}
|
||||||
|
className="hover:bg-surface-raised/50 sticky top-0 z-10 flex cursor-pointer select-none items-center gap-2 border-b border-border bg-surface-sidebar px-4 py-2"
|
||||||
|
>
|
||||||
|
<span className="flex shrink-0 items-center text-text-muted">
|
||||||
|
{isCollapsed ? <ChevronRight className="size-3.5" /> : <ChevronDown className="size-3.5" />}
|
||||||
|
</span>
|
||||||
<span className="text-xs font-medium text-text">{file.relativePath}</span>
|
<span className="text-xs font-medium text-text">{file.relativePath}</span>
|
||||||
|
|
||||||
{file.isNewFile && (
|
{file.isNewFile && (
|
||||||
|
|
@ -63,7 +89,7 @@ export const FileSectionHeader = ({
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-1.5">
|
<div className="ml-auto flex items-center gap-1.5" data-no-collapse>
|
||||||
{hasEdits && (
|
{hasEdits && (
|
||||||
<>
|
<>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,7 @@ import React from 'react';
|
||||||
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import {
|
import { Check, Eye, EyeOff, GitMerge, Loader2, Pencil, X } from 'lucide-react';
|
||||||
Check,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
FoldVertical,
|
|
||||||
GitMerge,
|
|
||||||
Loader2,
|
|
||||||
Pencil,
|
|
||||||
UnfoldVertical,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import type { ChangeStats } from '@shared/types';
|
import type { ChangeStats } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -34,14 +24,14 @@ interface ReviewToolbarProps {
|
||||||
export const ReviewToolbar = ({
|
export const ReviewToolbar = ({
|
||||||
stats,
|
stats,
|
||||||
changeStats,
|
changeStats,
|
||||||
collapseUnchanged,
|
collapseUnchanged: _collapseUnchanged,
|
||||||
applying,
|
applying,
|
||||||
autoViewed,
|
autoViewed,
|
||||||
onAutoViewedChange,
|
onAutoViewedChange,
|
||||||
onAcceptAll,
|
onAcceptAll,
|
||||||
onRejectAll,
|
onRejectAll,
|
||||||
onApply,
|
onApply,
|
||||||
onCollapseUnchangedChange,
|
onCollapseUnchangedChange: _onCollapseUnchangedChange,
|
||||||
instantApply = false,
|
instantApply = false,
|
||||||
editedCount = 0,
|
editedCount = 0,
|
||||||
}: ReviewToolbarProps): React.ReactElement => {
|
}: ReviewToolbarProps): React.ReactElement => {
|
||||||
|
|
@ -97,7 +87,7 @@ export const ReviewToolbar = ({
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<Tooltip>
|
{/* <Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
onClick={() => onCollapseUnchangedChange(!collapseUnchanged)}
|
onClick={() => onCollapseUnchangedChange(!collapseUnchanged)}
|
||||||
|
|
@ -116,7 +106,7 @@ export const ReviewToolbar = ({
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
{collapseUnchanged ? 'Show all lines' : 'Collapse unchanged regions'}
|
{collapseUnchanged ? 'Show all lines' : 'Collapse unchanged regions'}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip> */}
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,10 @@ export interface TeamSlice {
|
||||||
memberName: string,
|
memberName: string,
|
||||||
role: string | undefined
|
role: string | undefined
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
deletedTasks: TeamTask[];
|
||||||
|
deletedTasksLoading: boolean;
|
||||||
|
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
||||||
|
fetchDeletedTasks: (teamName: string) => Promise<void>;
|
||||||
deleteTeam: (teamName: string) => Promise<void>;
|
deleteTeam: (teamName: string) => Promise<void>;
|
||||||
createTeam: (request: TeamCreateRequest) => Promise<string>;
|
createTeam: (request: TeamCreateRequest) => Promise<string>;
|
||||||
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
|
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
|
||||||
|
|
@ -147,6 +151,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
addingComment: false,
|
addingComment: false,
|
||||||
addCommentError: null,
|
addCommentError: null,
|
||||||
provisioningProgressUnsubscribe: null,
|
provisioningProgressUnsubscribe: null,
|
||||||
|
deletedTasks: [],
|
||||||
|
deletedTasksLoading: false,
|
||||||
|
|
||||||
fetchTeams: async () => {
|
fetchTeams: async () => {
|
||||||
set({ teamsLoading: true, teamsError: null });
|
set({ teamsLoading: true, teamsError: null });
|
||||||
|
|
@ -504,6 +510,25 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
await get().refreshTeamData(teamName);
|
await get().refreshTeamData(teamName);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
softDeleteTask: async (teamName: string, taskId: string) => {
|
||||||
|
await unwrapIpc('team:softDeleteTask', () => api.teams.softDeleteTask(teamName, taskId));
|
||||||
|
await get().refreshTeamData(teamName);
|
||||||
|
await get().fetchDeletedTasks(teamName);
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchDeletedTasks: async (teamName: string) => {
|
||||||
|
set({ deletedTasksLoading: true });
|
||||||
|
try {
|
||||||
|
const tasks = await unwrapIpc('team:getDeletedTasks', () =>
|
||||||
|
api.teams.getDeletedTasks(teamName)
|
||||||
|
);
|
||||||
|
set({ deletedTasks: tasks, deletedTasksLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to fetch deleted tasks:', error);
|
||||||
|
set({ deletedTasks: [], deletedTasksLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
deleteTeam: async (teamName: string) => {
|
deleteTeam: async (teamName: string) => {
|
||||||
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
|
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
|
||||||
const state = get();
|
const state = get();
|
||||||
|
|
|
||||||
|
|
@ -428,6 +428,8 @@ export interface TeamsAPI {
|
||||||
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
||||||
killProcess: (teamName: string, pid: number) => Promise<void>;
|
killProcess: (teamName: string, pid: number) => Promise<void>;
|
||||||
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
|
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
|
||||||
|
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
||||||
|
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
|
||||||
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void) => () => void;
|
||||||
onProvisioningProgress: (
|
onProvisioningProgress: (
|
||||||
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
callback: (event: unknown, data: TeamProvisioningProgress) => void
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,8 @@ export interface TeamTask {
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
projectPath?: string;
|
projectPath?: string;
|
||||||
comments?: TaskComment[];
|
comments?: TaskComment[];
|
||||||
|
/** ISO timestamp — when the task was soft-deleted. Only set for status === 'deleted'. */
|
||||||
|
deletedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Task enriched for UI/DTO use (overlay from kanban-state.json). */
|
/** Task enriched for UI/DTO use (overlay from kanban-state.json). */
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,10 @@ vi.mock('@preload/constants/ipcChannels', () => ({
|
||||||
TEAM_UPDATE_MEMBER_ROLE: 'team:updateMemberRole',
|
TEAM_UPDATE_MEMBER_ROLE: 'team:updateMemberRole',
|
||||||
TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch',
|
TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch',
|
||||||
TEAM_GET_ATTACHMENTS: 'team:getAttachments',
|
TEAM_GET_ATTACHMENTS: 'team:getAttachments',
|
||||||
|
TEAM_KILL_PROCESS: 'team:killProcess',
|
||||||
TEAM_LEAD_ACTIVITY: 'team:leadActivity',
|
TEAM_LEAD_ACTIVITY: 'team:leadActivity',
|
||||||
|
TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask',
|
||||||
|
TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -72,9 +75,12 @@ import {
|
||||||
TEAM_ADD_MEMBER,
|
TEAM_ADD_MEMBER,
|
||||||
TEAM_ADD_TASK_COMMENT,
|
TEAM_ADD_TASK_COMMENT,
|
||||||
TEAM_GET_ATTACHMENTS,
|
TEAM_GET_ATTACHMENTS,
|
||||||
|
TEAM_GET_DELETED_TASKS,
|
||||||
TEAM_GET_PROJECT_BRANCH,
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
|
TEAM_KILL_PROCESS,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
TEAM_REMOVE_MEMBER,
|
TEAM_REMOVE_MEMBER,
|
||||||
|
TEAM_SOFT_DELETE_TASK,
|
||||||
TEAM_UPDATE_MEMBER_ROLE,
|
TEAM_UPDATE_MEMBER_ROLE,
|
||||||
} from '../../../src/preload/constants/ipcChannels';
|
} from '../../../src/preload/constants/ipcChannels';
|
||||||
import {
|
import {
|
||||||
|
|
@ -114,6 +120,8 @@ describe('ipc teams handlers', () => {
|
||||||
addMember: vi.fn(async () => undefined),
|
addMember: vi.fn(async () => undefined),
|
||||||
removeMember: vi.fn(async () => undefined),
|
removeMember: vi.fn(async () => undefined),
|
||||||
updateMemberRole: vi.fn(async () => ({ oldRole: undefined, changed: true })),
|
updateMemberRole: vi.fn(async () => ({ oldRole: undefined, changed: true })),
|
||||||
|
softDeleteTask: vi.fn(async () => undefined),
|
||||||
|
getDeletedTasks: vi.fn(async () => []),
|
||||||
};
|
};
|
||||||
const provisioningService = {
|
const provisioningService = {
|
||||||
prepareForProvisioning: vi.fn(async () => ({
|
prepareForProvisioning: vi.fn(async () => ({
|
||||||
|
|
@ -177,7 +185,10 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(true);
|
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(true);
|
||||||
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(true);
|
expect(handlers.has(TEAM_REMOVE_MEMBER)).toBe(true);
|
||||||
expect(handlers.has(TEAM_UPDATE_MEMBER_ROLE)).toBe(true);
|
expect(handlers.has(TEAM_UPDATE_MEMBER_ROLE)).toBe(true);
|
||||||
|
expect(handlers.has(TEAM_KILL_PROCESS)).toBe(true);
|
||||||
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(true);
|
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(true);
|
||||||
|
expect(handlers.has(TEAM_SOFT_DELETE_TASK)).toBe(true);
|
||||||
|
expect(handlers.has(TEAM_GET_DELETED_TASKS)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns success false on invalid sendMessage args', async () => {
|
it('returns success false on invalid sendMessage args', async () => {
|
||||||
|
|
@ -486,6 +497,9 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_UPDATE_MEMBER_ROLE)).toBe(false);
|
expect(handlers.has(TEAM_UPDATE_MEMBER_ROLE)).toBe(false);
|
||||||
expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(false);
|
expect(handlers.has(TEAM_GET_PROJECT_BRANCH)).toBe(false);
|
||||||
expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(false);
|
expect(handlers.has(TEAM_GET_ATTACHMENTS)).toBe(false);
|
||||||
|
expect(handlers.has(TEAM_KILL_PROCESS)).toBe(false);
|
||||||
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(false);
|
expect(handlers.has(TEAM_LEAD_ACTIVITY)).toBe(false);
|
||||||
|
expect(handlers.has(TEAM_SOFT_DELETE_TASK)).toBe(false);
|
||||||
|
expect(handlers.has(TEAM_GET_DELETED_TASKS)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue