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:
iliya 2026-02-25 19:30:27 +02:00
parent 0ab2b8d769
commit 1c6b7c4ef1
25 changed files with 672 additions and 77 deletions

View file

@ -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<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(
_event: IpcMainInvokeEvent,
teamName: unknown,

View file

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

View file

@ -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 <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() {
const inferred = inferTeamNameFromScriptPath();
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 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 briefing --for <member-name> [--team <team>]',
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
' node teamctl.js kanban clear <id> [--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');
return;
}
if (action === 'briefing') {
taskBriefing(paths, teamName, args.flags);
return;
}
die('Unknown task action: ' + String(action));
}

View file

@ -664,6 +664,14 @@ export class TeamDataService {
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> {
await this.taskWriter.updateOwner(teamName, taskId, owner);
}

View file

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

View file

@ -139,6 +139,70 @@ export class TeamTaskReader {
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 })[]> {
const tasksBase = getTasksBasePath();

View file

@ -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(
teamName: string,
taskId: string,

View file

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

View file

@ -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<string>(TEAM_LEAD_ACTIVITY, teamName);
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) => {
ipcRenderer.on(
TEAM_CHANGE,

View file

@ -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<void> => {
// Not available via HTTP client — no-op
},
getDeletedTasks: async (_teamName: string): Promise<TeamTask[]> => {
return [];
},
onTeamChange: (callback: (event: unknown, data: TeamChangeEvent) => void): (() => void) => {
return this.addEventListener('team-change', (data: unknown) =>
callback(null, data as TeamChangeEvent)

View file

@ -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<string | undefined>(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)}
/>
</CollapsibleTeamSection>
@ -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,
});
}}
/>
<CreateTaskDialog
@ -1360,15 +1389,25 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
void updateTaskOwner(teamName, taskId, owner);
}}
onViewChanges={handleViewChangesForFile}
onDeleteTask={handleDeleteTask}
/>
<TrashDialog open={trashOpen} tasks={deletedTasks} onClose={() => setTrashOpen(false)} />
<ChangeReviewDialog
open={reviewDialogState.open}
onOpenChange={(open) => 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}
/>
</div>
);

View file

@ -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 = ({
)}
</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 */}
{isTaskCompleted && onViewChanges ? (
<CollapsibleTeamSection
@ -448,19 +463,22 @@ export const TaskDetailDialog = ({
/>
</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>
<DialogFooter>
<DialogFooter className="flex items-center justify-between sm:justify-between">
{onDeleteTask && currentTask ? (
<Button
variant="destructive"
size="sm"
onClick={() => {
onDeleteTask(currentTask.id);
onClose();
}}
>
<Trash2 size={14} className="mr-1" />
Delete
</Button>
) : (
<div />
)}
<Button variant="outline" onClick={onClose}>
Close
</Button>

View file

@ -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}
/>
</div>
);
@ -233,6 +243,9 @@ export const KanbanBoard = ({
onColumnOrderChange,
toolbarLeft,
onAddTask,
onDeleteTask,
deletedTaskCount,
onOpenTrash,
}: KanbanBoardProps): React.JSX.Element => {
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
@ -342,6 +355,7 @@ export const KanbanBoard = ({
onScrollToTask={onScrollToTask}
onTaskClick={onTaskClick}
onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask}
/>
))}
</SortableContext>
@ -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 ? (
<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)]">
<Tooltip>
<TooltipTrigger asChild>

View file

@ -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 = ({
</button>
) : null}
<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>

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

View file

@ -35,6 +35,7 @@ interface MemberDetailDialogProps {
onRemoveMember?: () => void;
onUpdateRole?: (memberName: string, role: string | undefined) => Promise<void> | 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 = ({
<MemberMessagesTab messages={memberMessages} teamName={teamName} />
</TabsContent>
<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 value="logs" className="min-w-0 overflow-hidden">
<MemberLogsTab teamName={teamName} memberName={member.name} />

View file

@ -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<MemberFullStats | null>(null);
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">
<SummaryCards stats={stats} totalTokens={totalTokens} totalToolCalls={totalToolCalls} />
<ToolUsageBars toolUsage={stats.toolUsage} />
<FilesTouchedSection files={stats.filesTouched} />
<FilesTouchedSection
files={stats.filesTouched}
onFileClick={onFileClick}
onShowAll={onShowAllFiles}
/>
<StatsFooter stats={stats} />
</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);
if (files.length === 0) return null;
const visibleFiles = expanded ? files : files.slice(0, 5);
const hiddenCount = files.length - 5;
const isClickable = !!onFileClick;
return (
<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)]">
Files Touched ({files.length})
</p>
<div className="mb-2 flex items-center justify-between">
<p className="text-[11px] font-medium text-[var(--color-text-secondary)]">
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">
{visibleFiles.map((filePath) => {
const basename = filePath.split('/').pop() ?? filePath;
return (
<div
<button
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}
onClick={() => onFileClick?.(filePath)}
disabled={!isClickable}
>
<FileCode size={10} className="shrink-0 opacity-50" />
<span className="truncate">{basename}</span>
</div>
</button>
);
})}
</div>

View file

@ -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<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 { registerFileSectionRef } = useVisibleFileSection({
@ -149,6 +163,8 @@ export const ContinuousScrollView = ({
const isViewed = viewedSet.has(filePath);
const decision = fileDecisions[filePath];
const isCollapsed = collapsedFiles.has(filePath);
return (
<div key={filePath} ref={combinedRef(filePath)} className="border-b border-border">
<FileSectionHeader
@ -157,28 +173,31 @@ export const ContinuousScrollView = ({
fileDecision={decision}
hasEdits={hasEdits}
applying={applying}
isCollapsed={isCollapsed}
onToggleCollapse={handleToggleCollapse}
onDiscard={onDiscard}
onSave={onSave}
/>
{hasContent ? (
<FileSectionDiff
file={file}
fileContent={content}
isLoading={false}
collapseUnchanged={collapseUnchanged}
onHunkAccepted={onHunkAccepted}
onHunkRejected={onHunkRejected}
onFullyViewed={onFullyViewed}
onContentChanged={onContentChanged}
onEditorViewReady={handleEditorViewReady}
discardCounter={discardCounter}
autoViewed={autoViewed}
isViewed={isViewed}
/>
) : (
<FileSectionPlaceholder fileName={file.relativePath} />
)}
{!isCollapsed &&
(hasContent ? (
<FileSectionDiff
file={file}
fileContent={content}
isLoading={false}
collapseUnchanged={collapseUnchanged}
onHunkAccepted={onHunkAccepted}
onHunkRejected={onHunkRejected}
onFullyViewed={onFullyViewed}
onContentChanged={onContentChanged}
onEditorViewReady={handleEditorViewReady}
discardCounter={discardCounter}
autoViewed={autoViewed}
isViewed={isViewed}
/>
) : (
<FileSectionPlaceholder fileName={file.relativePath} />
))}
</div>
);
})}

View file

@ -79,11 +79,21 @@ export const FileSectionDiff = ({
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 =
fileContent &&
fileContent.contentSource !== 'unavailable' &&
fileContent.modifiedFullContent !== null;
fileContent && fileContent.contentSource !== 'unavailable' && resolvedModified !== null;
if (!hasCodeMirrorContent) {
return (
@ -99,12 +109,12 @@ export const FileSectionDiff = ({
<DiffErrorBoundary
filePath={file.filePath}
oldString={fileContent.originalFullContent ?? ''}
newString={fileContent.modifiedFullContent!}
newString={resolvedModified}
>
<CodeMirrorDiffView
key={`${file.filePath}:${discardCounter}`}
original={fileContent.originalFullContent ?? ''}
modified={fileContent.modifiedFullContent!}
modified={resolvedModified}
fileName={file.relativePath}
readOnly={false}
showMergeControls={true}

View file

@ -1,7 +1,7 @@
import React from 'react';
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 { FileChangeSummary } from '@shared/types/review';
@ -20,6 +20,8 @@ interface FileSectionHeaderProps {
fileDecision: HunkDecision | undefined;
hasEdits: boolean;
applying: boolean;
isCollapsed: boolean;
onToggleCollapse: (filePath: string) => 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 (
<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>
{file.isNewFile && (
@ -63,7 +89,7 @@ export const FileSectionHeader = ({
</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 && (
<>
<Tooltip>

View file

@ -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 = ({
<div className="flex-1" />
<Tooltip>
{/* <Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onCollapseUnchangedChange(!collapseUnchanged)}
@ -116,7 +106,7 @@ export const ReviewToolbar = ({
<TooltipContent side="bottom">
{collapseUnchanged ? 'Show all lines' : 'Collapse unchanged regions'}
</TooltipContent>
</Tooltip>
</Tooltip> */}
<Tooltip>
<TooltipTrigger asChild>

View file

@ -104,6 +104,10 @@ export interface TeamSlice {
memberName: string,
role: string | undefined
) => Promise<void>;
deletedTasks: TeamTask[];
deletedTasksLoading: boolean;
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
fetchDeletedTasks: (teamName: string) => Promise<void>;
deleteTeam: (teamName: string) => Promise<void>;
createTeam: (request: TeamCreateRequest) => Promise<string>;
launchTeam: (request: TeamLaunchRequest) => Promise<string>;
@ -147,6 +151,8 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
addingComment: false,
addCommentError: null,
provisioningProgressUnsubscribe: null,
deletedTasks: [],
deletedTasksLoading: false,
fetchTeams: async () => {
set({ teamsLoading: true, teamsError: null });
@ -504,6 +510,25 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
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) => {
await unwrapIpc('team:deleteTeam', () => api.teams.deleteTeam(teamName));
const state = get();

View file

@ -428,6 +428,8 @@ export interface TeamsAPI {
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
killProcess: (teamName: string, pid: number) => Promise<void>;
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;
onProvisioningProgress: (
callback: (event: unknown, data: TeamProvisioningProgress) => void

View file

@ -78,6 +78,8 @@ export interface TeamTask {
updatedAt?: string;
projectPath?: string;
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). */

View file

@ -40,7 +40,10 @@ vi.mock('@preload/constants/ipcChannels', () => ({
TEAM_UPDATE_MEMBER_ROLE: 'team:updateMemberRole',
TEAM_GET_PROJECT_BRANCH: 'team:getProjectBranch',
TEAM_GET_ATTACHMENTS: 'team:getAttachments',
TEAM_KILL_PROCESS: 'team:killProcess',
TEAM_LEAD_ACTIVITY: 'team:leadActivity',
TEAM_SOFT_DELETE_TASK: 'team:softDeleteTask',
TEAM_GET_DELETED_TASKS: 'team:getDeletedTasks',
}));
import {
@ -72,9 +75,12 @@ import {
TEAM_ADD_MEMBER,
TEAM_ADD_TASK_COMMENT,
TEAM_GET_ATTACHMENTS,
TEAM_GET_DELETED_TASKS,
TEAM_GET_PROJECT_BRANCH,
TEAM_KILL_PROCESS,
TEAM_LEAD_ACTIVITY,
TEAM_REMOVE_MEMBER,
TEAM_SOFT_DELETE_TASK,
TEAM_UPDATE_MEMBER_ROLE,
} from '../../../src/preload/constants/ipcChannels';
import {
@ -114,6 +120,8 @@ describe('ipc teams handlers', () => {
addMember: vi.fn(async () => undefined),
removeMember: vi.fn(async () => undefined),
updateMemberRole: vi.fn(async () => ({ oldRole: undefined, changed: true })),
softDeleteTask: vi.fn(async () => undefined),
getDeletedTasks: vi.fn(async () => []),
};
const provisioningService = {
prepareForProvisioning: vi.fn(async () => ({
@ -177,7 +185,10 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_ADD_MEMBER)).toBe(true);
expect(handlers.has(TEAM_REMOVE_MEMBER)).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_SOFT_DELETE_TASK)).toBe(true);
expect(handlers.has(TEAM_GET_DELETED_TASKS)).toBe(true);
});
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_GET_PROJECT_BRANCH)).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_SOFT_DELETE_TASK)).toBe(false);
expect(handlers.has(TEAM_GET_DELETED_TASKS)).toBe(false);
});
});