From 1c6b7c4ef17965deb75926bf0bb5ff4d46c10e1e Mon Sep 17 00:00:00 2001 From: iliya Date: Wed, 25 Feb 2026 19:30:27 +0200 Subject: [PATCH] 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. --- src/main/ipc/teams.ts | 40 ++++++ src/main/services/team/MemberStatsComputer.ts | 9 +- .../services/team/TeamAgentToolsInstaller.ts | 125 +++++++++++++++++- src/main/services/team/TeamDataService.ts | 8 ++ .../services/team/TeamProvisioningService.ts | 14 +- src/main/services/team/TeamTaskReader.ts | 64 +++++++++ src/main/services/team/TeamTaskWriter.ts | 27 ++++ src/preload/constants/ipcChannels.ts | 6 + src/preload/index.ts | 8 ++ src/renderer/api/httpClient.ts | 6 + .../components/team/TeamDetailView.tsx | 43 +++++- .../team/dialogs/TaskDetailDialog.tsx | 44 ++++-- .../components/team/kanban/KanbanBoard.tsx | 31 +++++ .../components/team/kanban/KanbanTaskCard.tsx | 16 +++ .../components/team/kanban/TrashDialog.tsx | 77 +++++++++++ .../team/members/MemberDetailDialog.tsx | 9 +- .../team/members/MemberStatsTab.tsx | 48 +++++-- .../team/review/ContinuousScrollView.tsx | 57 +++++--- .../team/review/FileSectionDiff.tsx | 22 ++- .../team/review/FileSectionHeader.tsx | 32 ++++- .../components/team/review/ReviewToolbar.tsx | 20 +-- src/renderer/store/slices/teamSlice.ts | 25 ++++ src/shared/types/api.ts | 2 + src/shared/types/team.ts | 2 + test/main/ipc/teams.test.ts | 14 ++ 25 files changed, 672 insertions(+), 77 deletions(-) create mode 100644 src/renderer/components/team/kanban/TrashDialog.tsx diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index ea0693f0..5d8ab4e7 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -12,6 +12,7 @@ import { TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, TEAM_GET_DATA, + TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, @@ -28,6 +29,7 @@ import { TEAM_REMOVE_MEMBER, TEAM_REQUEST_REVIEW, TEAM_SEND_MESSAGE, + TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, TEAM_STOP, TEAM_UPDATE_CONFIG, @@ -197,6 +199,8 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments); ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess); 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'); } @@ -235,6 +239,8 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_GET_ATTACHMENTS); ipcMain.removeHandler(TEAM_KILL_PROCESS); ipcMain.removeHandler(TEAM_LEAD_ACTIVITY); + ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK); + ipcMain.removeHandler(TEAM_GET_DELETED_TASKS); } function getTeamDataService(): TeamDataService { @@ -1069,6 +1075,40 @@ async function handleUpdateTaskStatus( ); } +async function handleSoftDeleteTask( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown +): Promise> { + 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> { + 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( _event: IpcMainInvokeEvent, teamName: unknown, diff --git a/src/main/services/team/MemberStatsComputer.ts b/src/main/services/team/MemberStatsComputer.ts index b1215a22..34cd60cf 100644 --- a/src/main/services/team/MemberStatsComputer.ts +++ b/src/main/services/team/MemberStatsComputer.ts @@ -57,7 +57,9 @@ export class MemberStatsComputer { const stats: MemberFullStats = { linesAdded, 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, inputTokens, outputTokens, @@ -308,8 +310,9 @@ export function estimateBashLinesChanged(command: string): BashLinesResult { } // 2. Echo / printf with redirect: echo "..." > /path OR printf "..." > /path + 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; while ((echoMatch = echoPattern.exec(command)) !== null) { const content = echoMatch[1] ?? echoMatch[2] ?? ''; @@ -349,7 +352,7 @@ export function estimateBashLinesChanged(command: string): BashLinesResult { } // 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; while ((teeMatch = teePattern.exec(command)) !== null) { const filePath = teeMatch[1]; diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts index e6def397..608f0942 100644 --- a/src/main/services/team/TeamAgentToolsInstaller.ts +++ b/src/main/services/team/TeamAgentToolsInstaller.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import { atomicWriteAsync } from './atomicWrite'; const TOOL_FILE_NAME = 'teamctl.js'; -const TOOL_VERSION = 6; +const TOOL_VERSION = 7; function buildTeamCtlScript(): string { const script = String.raw`#!/usr/bin/env node @@ -505,6 +505,124 @@ function processList(paths) { 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 '); + + 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() { const inferred = inferTeamNameFromScriptPath(); const teamHint = inferred ? ' (inferred team: ' + String(inferred) + ')' : ''; @@ -518,6 +636,7 @@ function printHelp() { ' node teamctl.js task start [--team ]', ' node teamctl.js task create --subject "..." [--description "..."] [--prompt "..."] [--owner "member"] [--status pending|in_progress|completed|deleted] [--notify --from "member"] [--team ]', ' node teamctl.js task comment --text "..." [--from "member"] [--team ]', + ' node teamctl.js task briefing --for [--team ]', ' node teamctl.js kanban set-column [--team ]', ' node teamctl.js kanban clear [--team ]', ' node teamctl.js review approve [--notify-owner --from "member" --note "..."] [--team ]', @@ -643,6 +762,10 @@ async function main() { process.stdout.write('OK comment added to task #' + String(id) + '\n'); return; } + if (action === 'briefing') { + taskBriefing(paths, teamName, args.flags); + return; + } die('Unknown task action: ' + String(action)); } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index a9859140..bc93b97f 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -664,6 +664,14 @@ export class TeamDataService { await this.taskWriter.updateStatus(teamName, taskId, status); } + async softDeleteTask(teamName: string, taskId: string): Promise { + await this.taskWriter.softDelete(teamName, taskId); + } + + async getDeletedTasks(teamName: string): Promise { + return this.taskReader.getDeletedTasks(teamName); + } + async updateTaskOwner(teamName: string, taskId: string, owner: string | null): Promise { await this.taskWriter.updateOwner(teamName, taskId, owner); } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 6e77e2e6..cbd0678d 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -506,17 +506,19 @@ function buildLaunchPrompt( const memberSpawnInstructions = members .map((m) => { const taskBlock = memberTaskBlocks.get(m.name) || ''; - const resumeInstruction = 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.`; + const hasTasks = Boolean(taskBlock); return ` For "${m.name}": - prompt: You are ${m.name}, a ${m.role || 'team member'} on team "${request.teamName}". ${languageInstruction} - The team has been reconnected after a restart.${taskBlock} - ${taskBlock ? 'Resume your pending tasks immediately — start with in_progress tasks first, then pending.' : 'Wait for new task assignments.'} - Note: ${resumeInstruction}`; + The team has been reconnected after a restart. + ${hasTasks ? `You have pending tasks from the previous session.` : 'You have no pending tasks currently.'} + + 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'); diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 72fc7505..2a283690 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -139,6 +139,70 @@ export class TeamTaskReader { return tasks; } + async getDeletedTasks(teamName: string): Promise { + 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; + // Skip internal CLI tracking entries + const metadata = parsed.metadata as Record | 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 })[]> { const tasksBase = getTasksBasePath(); diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index 29ab89bd..f2d4c11c 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -142,6 +142,33 @@ export class TeamTaskWriter { }); } + async softDelete(teamName: string, taskId: string): Promise { + 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( teamName: string, taskId: string, diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index fc1a23fe..2345b266 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -291,6 +291,12 @@ export const TEAM_KILL_PROCESS = 'team:killProcess'; /** Get lead process activity state (active/idle/offline) */ 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 // ============================================================================= diff --git a/src/preload/index.ts b/src/preload/index.ts index d822de50..c7762519 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -42,6 +42,7 @@ import { TEAM_GET_ALL_TASKS, TEAM_GET_ATTACHMENTS, TEAM_GET_DATA, + TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, @@ -58,6 +59,7 @@ import { TEAM_REMOVE_MEMBER, TEAM_REQUEST_REVIEW, TEAM_SEND_MESSAGE, + TEAM_SOFT_DELETE_TASK, TEAM_START_TASK, TEAM_STOP, TEAM_UPDATE_CONFIG, @@ -672,6 +674,12 @@ const electronAPI: ElectronAPI = { const result = await invokeIpcWithResult(TEAM_LEAD_ACTIVITY, teamName); return result as 'active' | 'idle' | 'offline'; }, + softDeleteTask: async (teamName: string, taskId: string) => { + return invokeIpcWithResult(TEAM_SOFT_DELETE_TASK, teamName, taskId); + }, + getDeletedTasks: async (teamName: string) => { + return invokeIpcWithResult(TEAM_GET_DELETED_TASKS, teamName); + }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { ipcRenderer.on( TEAM_CHANGE, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 8051bb1c..ed5fd1be 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -767,6 +767,12 @@ export class HttpAPIClient implements ElectronAPI { getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => { return 'offline'; }, + softDeleteTask: async (_teamName: string, _taskId: string): Promise => { + // Not available via HTTP client — no-op + }, + getDeletedTasks: async (_teamName: string): Promise => { + return []; + }, onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => { return this.addEventListener('team-change', (data: unknown) => callback(null, data as TeamChangeEvent) diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index d855a787..f3cbf9ba 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -48,6 +48,7 @@ import { SendMessageDialog } from './dialogs/SendMessageDialog'; import { TaskDetailDialog } from './dialogs/TaskDetailDialog'; import { KanbanBoard } from './kanban/KanbanBoard'; import { UNASSIGNED_OWNER } from './kanban/KanbanFilterPopover'; +import { TrashDialog } from './kanban/TrashDialog'; import { MemberDetailDialog } from './members/MemberDetailDialog'; import { MemberList } from './members/MemberList'; import { MessageComposer } from './messages/MessageComposer'; @@ -117,6 +118,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const [sendDialogOpen, setSendDialogOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [stoppingTeam, setStoppingTeam] = useState(false); + const [trashOpen, setTrashOpen] = useState(false); const [sendDialogRecipient, setSendDialogRecipient] = useState(undefined); const [replyQuote, setReplyQuote] = useState<{ from: string; text: string } | undefined>( undefined @@ -126,6 +128,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele mode: 'agent' | 'task'; memberName?: string; taskId?: string; + initialFilePath?: string; }>({ open: false, mode: 'task' }); // Active teams for conflict warning in LaunchTeamDialog @@ -173,6 +176,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele refreshTeamData, kanbanFilterQuery, clearKanbanFilter, + softDeleteTask, + fetchDeletedTasks, + deletedTasks, } = useStore( useShallow((s) => ({ data: s.selectedTeamData, @@ -207,6 +213,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele refreshTeamData: s.refreshTeamData, kanbanFilterQuery: s.kanbanFilterQuery, 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; } void selectTeam(teamName); - }, [teamName, selectTeam]); + void fetchDeletedTasks(teamName); + }, [teamName, selectTeam, fetchDeletedTasks]); // Fetch active teams when launch dialog opens (for conflict warning) useEffect(() => { @@ -498,6 +508,13 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele const selectReviewFile = useStore((s) => s.selectReviewFile); + const handleDeleteTask = useCallback( + (taskId: string) => { + void softDeleteTask(teamName, taskId); + }, + [teamName, softDeleteTask] + ); + const handleViewChanges = useCallback((taskId: string) => { setReviewDialogState({ open: true, mode: 'task', taskId }); }, []); @@ -991,6 +1008,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele onTaskClick={(task) => setSelectedTask(task)} onViewChanges={handleViewChanges} onAddTask={(startImmediately) => openCreateTaskDialog('', '', '', startImmediately)} + onDeleteTask={handleDeleteTask} + deletedTaskCount={deletedTasks.length} + onOpenTrash={() => setTrashOpen(true)} /> @@ -1194,6 +1214,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele if (!name) return; setRemoveMemberConfirm(name); }} + onViewMemberChanges={(memberName, filePath) => { + setSelectedMember(null); + setReviewDialogState({ + open: true, + mode: 'agent', + memberName, + initialFilePath: filePath, + }); + }} /> + setTrashOpen(false)} /> + setReviewDialogState((prev) => ({ ...prev, open }))} + onOpenChange={(open) => + setReviewDialogState((prev) => ({ + ...prev, + open, + ...(open ? {} : { initialFilePath: undefined }), + })) + } teamName={teamName} mode={reviewDialogState.mode} memberName={reviewDialogState.memberName} taskId={reviewDialogState.taskId} + initialFilePath={reviewDialogState.initialFilePath} /> ); diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 1f6fbb8f..7ec13ff3 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -40,6 +40,7 @@ import { Link2, Loader2, PenLine, + Trash2, } from 'lucide-react'; import { TaskCommentsSection } from './TaskCommentsSection'; @@ -57,6 +58,7 @@ interface TaskDetailDialogProps { onScrollToTask?: (taskId: string) => void; onOwnerChange?: (taskId: string, owner: string | null) => void; onViewChanges?: (taskId: string, filePath?: string) => void; + onDeleteTask?: (taskId: string) => void; /** Extra content rendered in the dialog header (e.g. "Open team" button). */ headerExtra?: React.ReactNode; } @@ -72,6 +74,7 @@ export const TaskDetailDialog = ({ onScrollToTask, onOwnerChange, onViewChanges, + onDeleteTask, headerExtra, }: TaskDetailDialogProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); @@ -261,6 +264,18 @@ export const TaskDetailDialog = ({ )} + {/* Execution Logs — sessions that reference this task */} + +
+ +
+
+ {/* Changes */} {isTaskCompleted && onViewChanges ? ( - {/* Execution Logs — sessions that reference this task */} - -
- -
-
- - + + {onDeleteTask && currentTask ? ( + + ) : ( +
+ )} diff --git a/src/renderer/components/team/kanban/KanbanBoard.tsx b/src/renderer/components/team/kanban/KanbanBoard.tsx index 95ff2e6a..4c12010e 100644 --- a/src/renderer/components/team/kanban/KanbanBoard.tsx +++ b/src/renderer/components/team/kanban/KanbanBoard.tsx @@ -16,6 +16,7 @@ import { PlayCircle, Plus, ShieldCheck, + Trash2, } from 'lucide-react'; import { KanbanColumn } from './KanbanColumn'; @@ -84,6 +85,12 @@ interface KanbanBoardProps { toolbarLeft?: React.ReactNode; /** Opens the create-task dialog with pre-set startImmediately value. */ 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'; @@ -154,6 +161,7 @@ interface SortableKanbanTaskCardProps { onScrollToTask?: (taskId: string) => void; onTaskClick?: (task: TeamTask) => void; onViewChanges?: (taskId: string) => void; + onDeleteTask?: (taskId: string) => void; } const SortableKanbanTaskCard = ({ @@ -173,6 +181,7 @@ const SortableKanbanTaskCard = ({ onScrollToTask, onTaskClick, onViewChanges, + onDeleteTask, }: SortableKanbanTaskCardProps): React.JSX.Element => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id, @@ -206,6 +215,7 @@ const SortableKanbanTaskCard = ({ onScrollToTask={onScrollToTask} onTaskClick={onTaskClick} onViewChanges={onViewChanges} + onDeleteTask={onDeleteTask} />
); @@ -233,6 +243,9 @@ export const KanbanBoard = ({ onColumnOrderChange, toolbarLeft, onAddTask, + onDeleteTask, + deletedTaskCount, + onOpenTrash, }: KanbanBoardProps): React.JSX.Element => { const [viewMode, setViewMode] = useState('grid'); @@ -342,6 +355,7 @@ export const KanbanBoard = ({ onScrollToTask={onScrollToTask} onTaskClick={onTaskClick} onViewChanges={onViewChanges} + onDeleteTask={onDeleteTask} /> ))} @@ -371,6 +385,7 @@ export const KanbanBoard = ({ onScrollToTask={onScrollToTask} onTaskClick={onTaskClick} onViewChanges={onViewChanges} + onDeleteTask={onDeleteTask} /> ))} {addButton} @@ -390,6 +405,22 @@ export const KanbanBoard = ({ members={members} onFilterChange={onFilterChange} /> + {deletedTaskCount != null && deletedTaskCount > 0 && onOpenTrash ? ( + + + + + Trash + + ) : null}
diff --git a/src/renderer/components/team/kanban/KanbanTaskCard.tsx b/src/renderer/components/team/kanban/KanbanTaskCard.tsx index 8b2c1200..1eb58c8d 100644 --- a/src/renderer/components/team/kanban/KanbanTaskCard.tsx +++ b/src/renderer/components/team/kanban/KanbanTaskCard.tsx @@ -14,6 +14,7 @@ import { CheckCircle2, FileCode, Play, + Trash2, XCircle, } from 'lucide-react'; @@ -37,6 +38,7 @@ interface KanbanTaskCardProps { onScrollToTask?: (taskId: string) => void; onTaskClick?: (task: TeamTask) => void; onViewChanges?: (taskId: string) => void; + onDeleteTask?: (taskId: string) => void; } interface DependencyBadgeProps { @@ -143,6 +145,7 @@ export const KanbanTaskCard = ({ onScrollToTask, onTaskClick, onViewChanges, + onDeleteTask, }: KanbanTaskCardProps): React.JSX.Element => { const colorMap = useMemo(() => buildMemberColorMap(members), [members]); const unreadCount = useUnreadCommentCount(teamName, task.id, task.comments); @@ -369,6 +372,19 @@ export const KanbanTaskCard = ({ ) : null} + {onDeleteTask ? ( + + ) : null}
diff --git a/src/renderer/components/team/kanban/TrashDialog.tsx b/src/renderer/components/team/kanban/TrashDialog.tsx new file mode 100644 index 00000000..0b312fe0 --- /dev/null +++ b/src/renderer/components/team/kanban/TrashDialog.tsx @@ -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 ( + !v && onClose()}> + + + + + Trash + + + + {tasks.length === 0 ? ( +
+ No deleted tasks +
+ ) : ( +
+ + + + + + + + + + + {tasks.map((task) => ( + + + + + + + ))} + +
#SubjectOwnerDeleted
{task.id}{task.subject} + {task.owner ?? '—'} + + {task.deletedAt + ? formatDistanceToNow(new Date(task.deletedAt), { addSuffix: true }) + : '—'} +
+
+ )} + + + + +
+
+ ); +}; diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index d7906a8e..5fcddb78 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -35,6 +35,7 @@ interface MemberDetailDialogProps { onRemoveMember?: () => void; onUpdateRole?: (memberName: string, role: string | undefined) => Promise | void; updatingRole?: boolean; + onViewMemberChanges?: (memberName: string, filePath?: string) => void; } export const MemberDetailDialog = ({ @@ -53,6 +54,7 @@ export const MemberDetailDialog = ({ onRemoveMember, onUpdateRole, updatingRole, + onViewMemberChanges, }: MemberDetailDialogProps): React.JSX.Element | null => { const memberTasks = useMemo( () => (member ? tasks.filter((t) => t.owner === member.name) : []), @@ -143,7 +145,12 @@ export const MemberDetailDialog = ({ - + onViewMemberChanges?.(member.name, filePath)} + onShowAllFiles={() => onViewMemberChanges?.(member.name)} + /> diff --git a/src/renderer/components/team/members/MemberStatsTab.tsx b/src/renderer/components/team/members/MemberStatsTab.tsx index ff9023dc..e21d93c6 100644 --- a/src/renderer/components/team/members/MemberStatsTab.tsx +++ b/src/renderer/components/team/members/MemberStatsTab.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react'; import { api } from '@renderer/api'; +import { cn } from '@renderer/lib/utils'; import { formatTokensCompact } from '@shared/utils/tokenFormatting'; import { AlertCircle, @@ -17,11 +18,15 @@ import type { MemberFullStats } from '@shared/types'; interface MemberStatsTabProps { teamName: string; memberName: string; + onFileClick?: (filePath: string) => void; + onShowAllFiles?: () => void; } export const MemberStatsTab = ({ teamName, memberName, + onFileClick, + onShowAllFiles, }: MemberStatsTabProps): React.JSX.Element => { const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); @@ -88,7 +93,11 @@ export const MemberStatsTab = ({
- +
); @@ -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); if (files.length === 0) return null; const visibleFiles = expanded ? files : files.slice(0, 5); const hiddenCount = files.length - 5; + const isClickable = !!onFileClick; return (
-

- Files Touched ({files.length}) -

+
+

+ Files Touched ({files.length}) +

+ {onShowAll && ( + + )} +
{visibleFiles.map((filePath) => { const basename = filePath.split('/').pop() ?? filePath; return ( -
onFileClick?.(filePath)} + disabled={!isClickable} > {basename} -
+ ); })}
diff --git a/src/renderer/components/team/review/ContinuousScrollView.tsx b/src/renderer/components/team/review/ContinuousScrollView.tsx index 405943a8..f1bab01e 100644 --- a/src/renderer/components/team/review/ContinuousScrollView.tsx +++ b/src/renderer/components/team/review/ContinuousScrollView.tsx @@ -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 { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection'; @@ -67,6 +67,20 @@ export const ContinuousScrollView = ({ memberName, fetchFileContent, }: ContinuousScrollViewProps): React.ReactElement => { + const [collapsedFiles, setCollapsedFiles] = useState>(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 { registerFileSectionRef } = useVisibleFileSection({ @@ -149,6 +163,8 @@ export const ContinuousScrollView = ({ const isViewed = viewedSet.has(filePath); const decision = fileDecisions[filePath]; + const isCollapsed = collapsedFiles.has(filePath); + return (
- {hasContent ? ( - - ) : ( - - )} + {!isCollapsed && + (hasContent ? ( + + ) : ( + + ))}
); })} diff --git a/src/renderer/components/team/review/FileSectionDiff.tsx b/src/renderer/components/team/review/FileSectionDiff.tsx index 3ff04bc0..63a53e9d 100644 --- a/src/renderer/components/team/review/FileSectionDiff.tsx +++ b/src/renderer/components/team/review/FileSectionDiff.tsx @@ -79,11 +79,21 @@ export const FileSectionDiff = ({ return ; } - // 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 = - fileContent && - fileContent.contentSource !== 'unavailable' && - fileContent.modifiedFullContent !== null; + fileContent && fileContent.contentSource !== 'unavailable' && resolvedModified !== null; if (!hasCodeMirrorContent) { return ( @@ -99,12 +109,12 @@ export const FileSectionDiff = ({ void; onDiscard: (filePath: string) => void; onSave: (filePath: string) => void; } @@ -30,11 +32,35 @@ export const FileSectionHeader = ({ fileDecision, hasEdits, applying, + isCollapsed, + onToggleCollapse, onDiscard, onSave, }: 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 ( -
+
+ + {isCollapsed ? : } + {file.relativePath} {file.isNewFile && ( @@ -63,7 +89,7 @@ export const FileSectionHeader = ({ )} -
+
{hasEdits && ( <> diff --git a/src/renderer/components/team/review/ReviewToolbar.tsx b/src/renderer/components/team/review/ReviewToolbar.tsx index b53e2aa0..a8782418 100644 --- a/src/renderer/components/team/review/ReviewToolbar.tsx +++ b/src/renderer/components/team/review/ReviewToolbar.tsx @@ -2,17 +2,7 @@ import React from 'react'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { cn } from '@renderer/lib/utils'; -import { - Check, - Eye, - EyeOff, - FoldVertical, - GitMerge, - Loader2, - Pencil, - UnfoldVertical, - X, -} from 'lucide-react'; +import { Check, Eye, EyeOff, GitMerge, Loader2, Pencil, X } from 'lucide-react'; import type { ChangeStats } from '@shared/types'; @@ -34,14 +24,14 @@ interface ReviewToolbarProps { export const ReviewToolbar = ({ stats, changeStats, - collapseUnchanged, + collapseUnchanged: _collapseUnchanged, applying, autoViewed, onAutoViewedChange, onAcceptAll, onRejectAll, onApply, - onCollapseUnchangedChange, + onCollapseUnchangedChange: _onCollapseUnchangedChange, instantApply = false, editedCount = 0, }: ReviewToolbarProps): React.ReactElement => { @@ -97,7 +87,7 @@ export const ReviewToolbar = ({
- + {/*