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_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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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;
|
||||
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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue