From f1d08e6d3326961641fbb2c194e55bffeaf52c0b Mon Sep 17 00:00:00 2001
From: iliya
Date: Thu, 5 Mar 2026 16:21:44 +0200
Subject: [PATCH] feat: enhance task attachment handling and UI components
- Introduced functionality for managing task attachments, including file storage and retrieval with size validation.
- Added methods for sanitizing filenames and detecting MIME types based on file headers.
- Enhanced TeamTaskAttachmentStore to support new attachment naming conventions and improved file path management.
- Updated TeamProvisioningService to include new commands for attaching files to tasks and comments.
- Improved UI components to facilitate attachment management, including confirmation dialogs for task deletions and enhanced log displays.
---
.../services/team/TeamAgentToolsInstaller.ts | 290 +++++++++++++++++-
.../services/team/TeamProvisioningService.ts | 36 ++-
.../services/team/TeamTaskAttachmentStore.ts | 93 +++++-
src/main/services/team/TeamTaskReader.ts | 79 ++---
.../components/layout/PaneContent.tsx | 6 +-
.../components/sidebar/GlobalTaskList.tsx | 21 ++
.../components/sidebar/TaskContextMenu.tsx | 17 +-
.../components/team/ClaudeLogsSection.tsx | 64 +++-
.../components/team/CliLogsRichView.tsx | 14 +-
src/renderer/components/team/TaskTooltip.tsx | 4 +-
.../components/team/TeamDetailView.tsx | 186 ++++++++++-
.../team/dialogs/SendMessageDialog.tsx | 53 +++-
.../team/dialogs/TaskAttachments.tsx | 59 +++-
.../team/dialogs/TaskCommentsSection.tsx | 52 +++-
.../components/team/kanban/KanbanTaskCard.tsx | 6 +-
.../components/team/kanban/TrashDialog.tsx | 2 +-
.../components/team/members/MemberCard.tsx | 33 +-
.../team/messages/MessageComposer.tsx | 3 +
.../components/team/tasks/TaskRow.tsx | 2 +-
src/renderer/utils/attachmentUtils.ts | 10 +-
src/shared/types/team.ts | 17 +-
test/main/services/team/teamctl.test.ts | 155 ++++++++++
22 files changed, 1067 insertions(+), 135 deletions(-)
diff --git a/src/main/services/team/TeamAgentToolsInstaller.ts b/src/main/services/team/TeamAgentToolsInstaller.ts
index 908a2aef..02e5b91b 100644
--- a/src/main/services/team/TeamAgentToolsInstaller.ts
+++ b/src/main/services/team/TeamAgentToolsInstaller.ts
@@ -26,6 +26,10 @@ function nowIso() {
return new Date().toISOString();
}
+function makeId() {
+ return crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random());
+}
+
function formatError(err) {
if (!err) return 'Unknown error';
if (typeof err === 'string') return err;
@@ -175,6 +179,234 @@ function atomicWrite(filePath, data) {
}
}
+// ---------------------------------------------------------------------------
+// Attachments (task + comment)
+// ---------------------------------------------------------------------------
+
+const TASK_ATTACHMENTS_DIR = 'task-attachments';
+const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024; // 20 MB
+
+function sanitizeFilename(original) {
+ const raw = String(original == null ? '' : original).trim();
+ const parts = raw.split(/[\\/]/);
+ const base = (parts.length ? parts[parts.length - 1] : raw).trim();
+ const cleaned = base
+ .replace(/\0/g, '')
+ .replace(/[\r\n\t]/g, ' ')
+ .replace(/[\\/]/g, '_')
+ .trim();
+ if (!cleaned) return 'attachment';
+ return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned;
+}
+
+function readFileHeader(filePath, maxBytes) {
+ const fd = fs.openSync(filePath, 'r');
+ try {
+ const buf = Buffer.alloc(maxBytes);
+ const bytes = fs.readSync(fd, buf, 0, maxBytes, 0);
+ return buf.slice(0, bytes);
+ } finally {
+ try { fs.closeSync(fd); } catch { /* ignore */ }
+ }
+}
+
+function startsWithBytes(buf, bytes) {
+ if (!buf || buf.length < bytes.length) return false;
+ for (let i = 0; i < bytes.length; i++) {
+ if (buf[i] !== bytes[i]) return false;
+ }
+ return true;
+}
+
+function detectMimeTypeFromPathAndHeader(filePath, filename) {
+ const name = String(filename || '').toLowerCase();
+ const ext = path.extname(name);
+
+ // Fast path by extension for common types.
+ if (ext === '.png') return 'image/png';
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
+ if (ext === '.gif') return 'image/gif';
+ if (ext === '.webp') return 'image/webp';
+ if (ext === '.pdf') return 'application/pdf';
+ if (ext === '.txt') return 'text/plain';
+ if (ext === '.md') return 'text/markdown';
+ if (ext === '.json') return 'application/json';
+ if (ext === '.zip') return 'application/zip';
+
+ // Sniff magic bytes for a few important formats.
+ let header;
+ try {
+ header = readFileHeader(filePath, 16);
+ } catch {
+ return 'application/octet-stream';
+ }
+ if (startsWithBytes(header, [0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a])) return 'image/png'; // PNG
+ if (startsWithBytes(header, [0xff,0xd8,0xff])) return 'image/jpeg'; // JPEG
+ if (header.length >= 6) {
+ const sig6 = header.slice(0, 6).toString('ascii');
+ if (sig6 === 'GIF87a' || sig6 === 'GIF89a') return 'image/gif';
+ }
+ if (header.length >= 12) {
+ const riff = header.slice(0, 4).toString('ascii');
+ const webp = header.slice(8, 12).toString('ascii');
+ if (riff === 'RIFF' && webp === 'WEBP') return 'image/webp';
+ }
+ if (header.length >= 5 && header.slice(0, 5).toString('ascii') === '%PDF-') return 'application/pdf';
+ if (startsWithBytes(header, [0x50,0x4b,0x03,0x04])) return 'application/zip';
+
+ return 'application/octet-stream';
+}
+
+function getTaskAttachmentsDir(paths, taskId) {
+ return path.join(paths.teamDir, TASK_ATTACHMENTS_DIR, String(taskId));
+}
+
+function getStoredAttachmentPath(paths, taskId, attachmentId, filename) {
+ const safeName = sanitizeFilename(filename);
+ return path.join(getTaskAttachmentsDir(paths, taskId), String(attachmentId) + '--' + safeName);
+}
+
+function ensureSourceFileReadable(srcPath) {
+ const st = fs.statSync(srcPath);
+ if (!st.isFile()) die('Not a file: ' + String(srcPath));
+ if (st.size > MAX_TASK_ATTACHMENT_BYTES) {
+ die(
+ 'Attachment too large: ' +
+ (st.size / (1024 * 1024)).toFixed(1) +
+ ' MB (max ' +
+ String(MAX_TASK_ATTACHMENT_BYTES / (1024 * 1024)) +
+ ' MB)'
+ );
+ }
+ return st;
+}
+
+function copyOrLinkFile(srcPath, destPath, mode, allowFallback) {
+ const m = String(mode || 'copy').toLowerCase();
+ if (m === 'link') {
+ try {
+ fs.linkSync(srcPath, destPath);
+ return { mode: 'link', fallbackUsed: false };
+ } catch (e) {
+ if (!allowFallback) throw e;
+ // Fall back to copy (cross-device link, permissions, etc.)
+ try {
+ fs.copyFileSync(srcPath, destPath);
+ return { mode: 'copy', fallbackUsed: true };
+ } catch (e2) {
+ // Bubble up most useful error
+ throw e2 || e;
+ }
+ }
+ }
+ fs.copyFileSync(srcPath, destPath);
+ return { mode: 'copy', fallbackUsed: false };
+}
+
+function saveTaskAttachmentFile(paths, taskId, flags) {
+ const rawFile = (typeof flags.file === 'string' && flags.file.trim())
+ ? flags.file.trim()
+ : (typeof flags.path === 'string' && flags.path.trim())
+ ? flags.path.trim()
+ : '';
+ if (!rawFile) die('Missing --file ');
+
+ const srcPath = path.resolve(rawFile);
+ ensureSourceFileReadable(srcPath);
+
+ const filename = (typeof flags.filename === 'string' && flags.filename.trim())
+ ? flags.filename.trim()
+ : path.basename(srcPath);
+ const mimeType = (typeof flags['mime-type'] === 'string' && flags['mime-type'].trim())
+ ? flags['mime-type'].trim()
+ : (typeof flags.mimeType === 'string' && flags.mimeType.trim())
+ ? flags.mimeType.trim()
+ : detectMimeTypeFromPathAndHeader(srcPath, filename);
+
+ const attachmentId = makeId();
+ const dir = getTaskAttachmentsDir(paths, taskId);
+ ensureDir(dir);
+ const destPath = getStoredAttachmentPath(paths, taskId, attachmentId, filename);
+ const allowFallback = !(flags['no-fallback'] === true);
+
+ if (fs.existsSync(destPath)) die('Attachment destination already exists');
+ const result = copyOrLinkFile(srcPath, destPath, flags.mode, allowFallback);
+
+ // Verify write/link
+ const st = fs.statSync(destPath);
+ if (!st.isFile() || st.size < 0) die('Attachment write verification failed');
+
+ const meta = {
+ id: attachmentId,
+ filename: filename,
+ mimeType: mimeType,
+ size: st.size,
+ addedAt: nowIso(),
+ };
+ return { meta: meta, storedPath: destPath, storageMode: result.mode, fallbackUsed: result.fallbackUsed };
+}
+
+function addAttachmentToTask(paths, taskId, meta) {
+ var lastErr;
+ for (var attempt = 0; attempt < 8; attempt++) {
+ try {
+ const ref = readTask(paths, taskId);
+ const task = ref.task;
+ const taskPath = ref.taskPath;
+ const existing = Array.isArray(task.attachments) ? task.attachments : [];
+ if (existing.some(function(a) { return a && a.id === meta.id; })) return;
+ task.attachments = existing.concat([meta]);
+ writeTask(taskPath, task);
+ // Verify meta persisted (best-effort)
+ const verify = readJson(taskPath, null);
+ if (verify && Array.isArray(verify.attachments) && verify.attachments.some(function(a) { return a && a.id === meta.id; })) {
+ return;
+ }
+ // Verification failed (concurrent overwrite) — retry
+ } catch (e) {
+ lastErr = e;
+ if (attempt === 7) throw e;
+ }
+ }
+ throw lastErr;
+}
+
+function addAttachmentToComment(paths, taskId, commentId, meta) {
+ var lastErr;
+ for (var attempt = 0; attempt < 8; attempt++) {
+ try {
+ const ref = readTask(paths, taskId);
+ const task = ref.task;
+ const taskPath = ref.taskPath;
+ const comments = Array.isArray(task.comments) ? task.comments : [];
+ const idx = comments.findIndex(function(c) { return c && String(c.id) === String(commentId); });
+ if (idx < 0) die('Comment not found: ' + String(commentId));
+ const comment = comments[idx];
+ const existing = Array.isArray(comment.attachments) ? comment.attachments : [];
+ if (!existing.some(function(a) { return a && a.id === meta.id; })) {
+ comment.attachments = existing.concat([meta]);
+ }
+ // Persist update (single atomic write)
+ task.comments = comments;
+ writeTask(taskPath, task);
+
+ // Verify
+ const verify = readJson(taskPath, null);
+ if (verify && Array.isArray(verify.comments)) {
+ const vc = verify.comments.find(function(c) { return c && String(c.id) === String(commentId); });
+ if (vc && Array.isArray(vc.attachments) && vc.attachments.some(function(a) { return a && a.id === meta.id; })) {
+ return;
+ }
+ }
+ // Retry on verification failure
+ } catch (e) {
+ lastErr = e;
+ if (attempt === 7) throw e;
+ }
+ }
+ throw lastErr;
+}
+
function normalizeStatus(value) {
const v = String(value || '').trim();
if (v === 'pending' || v === 'in_progress' || v === 'completed' || v === 'deleted') return v;
@@ -297,9 +529,7 @@ function addTaskComment(paths, taskId, flags) {
}
existing = Array.isArray(task.comments) ? task.comments : [];
- commentId = crypto.randomUUID
- ? crypto.randomUUID()
- : String(Date.now()) + '-' + String(Math.random());
+ commentId = makeId();
comment = {
id: commentId,
author: from,
@@ -598,9 +828,7 @@ function sendInboxMessage(paths, teamName, flags) {
const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json');
ensureDir(path.dirname(inboxPath));
- const messageId = crypto.randomUUID
- ? crypto.randomUUID()
- : String(Date.now()) + '-' + String(Math.random());
+ const messageId = makeId();
const payload = {
from,
to,
@@ -640,9 +868,7 @@ function reviewApprove(paths, teamName, taskId, flags) {
// Record review comment in task.comments
var existing = Array.isArray(task.comments) ? task.comments : [];
- var reviewCommentId = crypto.randomUUID
- ? crypto.randomUUID()
- : String(Date.now()) + '-' + String(Math.random());
+ var reviewCommentId = makeId();
task.comments = existing.concat([{
id: reviewCommentId,
author: from,
@@ -681,9 +907,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
// Record review comment in task.comments
var existing = Array.isArray(task.comments) ? task.comments : [];
- var reviewCommentId = crypto.randomUUID
- ? crypto.randomUUID()
- : String(Date.now()) + '-' + String(Math.random());
+ var reviewCommentId = makeId();
task.comments = existing.concat([{
id: reviewCommentId,
author: from,
@@ -747,7 +971,7 @@ function processRegister(paths, flags) {
const existingIdx = list.findIndex(function (p) { return p.pid === pid; });
const entry = {
- id: existingIdx >= 0 ? list[existingIdx].id : (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random())),
+ id: existingIdx >= 0 ? list[existingIdx].id : makeId(),
port: port,
url: url,
label: label,
@@ -936,6 +1160,8 @@ function printHelp() {
' node teamctl.js task unlink --related [--team ]',
' node teamctl.js task set-owner [--notify --from "member"] [--team ]',
' node teamctl.js task comment --text "..." [--from "member"] [--team ]',
+ ' node teamctl.js task attach --file [--mode copy|link] [--filename ] [--mime-type ] [--no-fallback] [--team ]',
+ ' node teamctl.js task comment-attach --file [--mode copy|link] [--filename ] [--mime-type ] [--no-fallback] [--team ]',
' node teamctl.js task set-clarification [--from "member"] [--team ]',
' node teamctl.js task briefing --for [--team ]',
' node teamctl.js kanban set-column [--team ]',
@@ -951,6 +1177,8 @@ function printHelp() {
'Options:',
' --team Team name (if not under ~/.claude/teams//tools)',
' --claude-dir Override ~/.claude location',
+ ' --mode For attachments: copy into storage (default) or try hardlink to avoid duplication',
+ ' --no-fallback For --mode link: fail instead of falling back to copy',
'',
].join('\n')
);
@@ -1069,6 +1297,42 @@ async function main() {
process.stdout.write('OK comment added to task #' + String(id) + '\n');
return;
}
+ if (action === 'attach') {
+ const id = rest[0] || args.flags.id;
+ if (!id) die('Usage: task attach --file ');
+ // Save file to storage first, then update task metadata
+ const saved = saveTaskAttachmentFile(paths, String(id), args.flags);
+ try {
+ addAttachmentToTask(paths, String(id), saved.meta);
+ } catch (e) {
+ // Best-effort cleanup of orphaned file on failure
+ try { fs.unlinkSync(saved.storedPath); } catch { /* ignore */ }
+ throw e;
+ }
+ if (saved.fallbackUsed) {
+ process.stderr.write('WARN: link failed; fell back to copy\n');
+ }
+ process.stdout.write(JSON.stringify(saved.meta, null, 2) + '\n');
+ return;
+ }
+ if (action === 'comment-attach') {
+ const id = rest[0] || args.flags.id;
+ const commentId = rest[1] || args.flags['comment-id'] || args.flags.commentId;
+ if (!id || !commentId) die('Usage: task comment-attach --file ');
+ const saved = saveTaskAttachmentFile(paths, String(id), args.flags);
+ try {
+ addAttachmentToComment(paths, String(id), String(commentId), saved.meta);
+ } catch (e) {
+ // Best-effort cleanup of orphaned file on failure
+ try { fs.unlinkSync(saved.storedPath); } catch { /* ignore */ }
+ throw e;
+ }
+ if (saved.fallbackUsed) {
+ process.stderr.write('WARN: link failed; fell back to copy\n');
+ }
+ process.stdout.write(JSON.stringify(saved.meta, null, 2) + '\n');
+ return;
+ }
if (action === 'set-clarification') {
const id = rest[0] || args.flags.id;
const val = rest[1] || args.flags.value;
diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts
index 7d30a616..71450578 100644
--- a/src/main/services/team/TeamProvisioningService.ts
+++ b/src/main/services/team/TeamProvisioningService.ts
@@ -116,6 +116,8 @@ interface ProvisioningRun {
stderrBuffer: string;
/** Rolling buffer of CLI log lines (oldest -> newest). */
claudeLogLines: string[];
+ /** Last stream used for claudeLogLines markers. */
+ lastClaudeLogStream: 'stdout' | 'stderr' | null;
/** Carry buffer for stdout line splitting (CLI output). */
stdoutLogLineBuf: string;
/** Carry buffer for stderr line splitting (CLI output). */
@@ -486,11 +488,20 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
`- Start task (preferred over set-status): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task start `,
`- Complete task (preferred over set-status): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete `,
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status `,
+ `- Add comment: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment --text "..." --from "${leadName}"`,
+ `- Attach file to task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task attach --file "" [--mode copy|link] [--filename ""] [--mime-type ""]`,
+ `- Attach file to a specific comment:`,
+ ` 1) Find commentId: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task get `,
+ ` 2) Attach: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment-attach --file "" [--mode copy|link] [--filename ""] [--mime-type ""]`,
`- Create with deps (blocked work MUST be pending): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task create --subject "..." --blocked-by 1,2 --related 3 --status pending --owner "" --notify --from "${leadName}"`,
`- Link dependency: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link --blocked-by `,
`- Link related: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link --related `,
`- Unlink: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task unlink --blocked-by `,
``,
+ `Attachment storage modes (IMPORTANT):`,
+ `- Default is copy (safe, robust).`,
+ `- Use --mode link to try a hardlink (no duplication). It may fall back to copy unless you add --no-fallback.`,
+ ``,
`Dependency guidelines:`,
`- Use --blocked-by when a task cannot start until another is done.`,
`- If you set --blocked-by, create the task in pending (use --status pending). Do NOT put blocked tasks into in_progress.`,
@@ -1021,7 +1032,16 @@ export class TeamProvisioningService {
const newestExclusive = Math.max(0, total - offset);
const oldestInclusive = Math.max(0, newestExclusive - limit);
- const windowOldestToNewest = run.claudeLogLines.slice(oldestInclusive, newestExclusive);
+ const normalizeLine = (line: string): string => {
+ // Back-compat: older builds prefixed every line with "[stdout] " / "[stderr] "
+ if (line.startsWith('[stdout] ') && line !== '[stdout]') return line.slice('[stdout] '.length);
+ if (line.startsWith('[stderr] ') && line !== '[stderr]') return line.slice('[stderr] '.length);
+ return line;
+ };
+
+ const windowOldestToNewest = run.claudeLogLines
+ .slice(oldestInclusive, newestExclusive)
+ .map(normalizeLine);
const lines = windowOldestToNewest.reverse();
return {
lines,
@@ -1035,14 +1055,19 @@ export class TeamProvisioningService {
const nowMs = Date.now();
run.claudeLogsUpdatedAt = new Date(nowMs).toISOString();
- const prefix = stream === 'stdout' ? '[stdout] ' : '[stderr] ';
+ const marker = stream === 'stdout' ? '[stdout]' : '[stderr]';
+ if (run.lastClaudeLogStream !== stream) {
+ run.lastClaudeLogStream = stream;
+ run.claudeLogLines.push(marker);
+ }
+
if (stream === 'stdout') {
run.stdoutLogLineBuf += text;
const parts = run.stdoutLogLineBuf.split('\n');
run.stdoutLogLineBuf = parts.pop() ?? '';
for (const part of parts) {
const normalized = part.endsWith('\r') ? part.slice(0, -1) : part;
- run.claudeLogLines.push(prefix + normalized);
+ run.claudeLogLines.push(normalized);
}
} else {
run.stderrLogLineBuf += text;
@@ -1050,7 +1075,7 @@ export class TeamProvisioningService {
run.stderrLogLineBuf = parts.pop() ?? '';
for (const part of parts) {
const normalized = part.endsWith('\r') ? part.slice(0, -1) : part;
- run.claudeLogLines.push(prefix + normalized);
+ run.claudeLogLines.push(normalized);
}
}
if (run.claudeLogLines.length > TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT) {
@@ -1390,6 +1415,7 @@ export class TeamProvisioningService {
run.stdoutBuffer = '';
run.stderrBuffer = '';
run.claudeLogLines = [];
+ run.lastClaudeLogStream = null;
run.stdoutLogLineBuf = '';
run.stderrLogLineBuf = '';
run.claudeLogsUpdatedAt = undefined;
@@ -1612,6 +1638,7 @@ export class TeamProvisioningService {
stdoutBuffer: '',
stderrBuffer: '',
claudeLogLines: [],
+ lastClaudeLogStream: null,
stdoutLogLineBuf: '',
stderrLogLineBuf: '',
claudeLogsUpdatedAt: undefined,
@@ -1900,6 +1927,7 @@ export class TeamProvisioningService {
stdoutBuffer: '',
stderrBuffer: '',
claudeLogLines: [],
+ lastClaudeLogStream: null,
stdoutLogLineBuf: '',
stderrLogLineBuf: '',
claudeLogsUpdatedAt: undefined,
diff --git a/src/main/services/team/TeamTaskAttachmentStore.ts b/src/main/services/team/TeamTaskAttachmentStore.ts
index 0d71dcd4..953cc5ba 100644
--- a/src/main/services/team/TeamTaskAttachmentStore.ts
+++ b/src/main/services/team/TeamTaskAttachmentStore.ts
@@ -10,7 +10,7 @@ const logger = createLogger('Service:TeamTaskAttachmentStore');
const TASK_ATTACHMENTS_DIR = 'task-attachments';
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
-const ALLOWED_MIME_TYPES: ReadonlySet = new Set([
+const KNOWN_IMAGE_MIME_TYPES: ReadonlySet = new Set([
'image/png',
'image/jpeg',
'image/gif',
@@ -40,14 +40,33 @@ export class TeamTaskAttachmentStore {
return path.join(getTeamsBasePath(), teamName, TASK_ATTACHMENTS_DIR, taskId);
}
- /** Returns the file path for a specific attachment. */
- private getFilePath(teamName: string, taskId: string, attachmentId: string, ext: string): string {
- this.assertSafePathSegment('attachmentId', attachmentId);
- return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}${ext}`);
+ private sanitizeStoredFilename(original: string): string {
+ const raw = String(original ?? '').trim();
+ const base = raw ? raw.split(/[\\/]/).pop() ?? raw : '';
+ const cleaned = base
+ .replace(/\0/g, '')
+ .replace(/[\r\n\t]/g, ' ')
+ .replace(/[\\/]/g, '_')
+ .trim();
+ if (!cleaned) return 'attachment';
+ // Keep filenames bounded to avoid OS/path length issues.
+ return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned;
}
- /** Map MIME type to file extension. */
- private mimeToExt(mimeType: AttachmentMediaType): string {
+ /** Returns the file path for a stored attachment (new format). */
+ private getStoredFilePath(
+ teamName: string,
+ taskId: string,
+ attachmentId: string,
+ filename: string
+ ): string {
+ this.assertSafePathSegment('attachmentId', attachmentId);
+ const safeName = this.sanitizeStoredFilename(filename);
+ return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}--${safeName}`);
+ }
+
+ /** Map known MIME types to file extension (legacy storage format). */
+ private mimeToExt(mimeType: string): string {
switch (mimeType) {
case 'image/png':
return '.png';
@@ -57,9 +76,54 @@ export class TeamTaskAttachmentStore {
return '.gif';
case 'image/webp':
return '.webp';
+ default:
+ return '.bin';
}
}
+ private async findAttachmentFilePath(
+ teamName: string,
+ taskId: string,
+ attachmentId: string,
+ mimeType?: string
+ ): Promise {
+ const dir = this.getTaskDir(teamName, taskId);
+
+ // 1) Prefer legacy path for known image types (older storage format).
+ if (mimeType && KNOWN_IMAGE_MIME_TYPES.has(mimeType)) {
+ const legacy = path.join(dir, `${attachmentId}${this.mimeToExt(mimeType)}`);
+ try {
+ const stat = await fs.promises.stat(legacy);
+ if (stat.isFile()) return legacy;
+ } catch {
+ // ignore
+ }
+ }
+
+ // 2) New format: "--"
+ try {
+ const entries = await fs.promises.readdir(dir);
+ const prefix = `${attachmentId}--`;
+ const matches = entries.filter((e) => e.startsWith(prefix));
+ if (matches.length > 0) {
+ return path.join(dir, matches[0]);
+ }
+
+ // 3) Fallback: any file starting with "." (covers legacy when mimeType missing/wrong).
+ const dotPrefix = `${attachmentId}.`;
+ const dotMatches = entries.filter((e) => e.startsWith(dotPrefix));
+ if (dotMatches.length > 0) {
+ return path.join(dir, dotMatches[0]);
+ }
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
+ // Non-directory or other IO errors should surface.
+ throw error;
+ }
+
+ return null;
+ }
+
/**
* Save an attachment to disk. Data is expected as a base64-encoded string.
* Returns metadata for the saved attachment.
@@ -72,10 +136,6 @@ export class TeamTaskAttachmentStore {
mimeType: AttachmentMediaType,
base64Data: string
): Promise {
- if (!ALLOWED_MIME_TYPES.has(mimeType)) {
- throw new Error(`Unsupported MIME type: ${mimeType}`);
- }
-
const trimmed = base64Data.trim();
// Avoid allocating huge Buffers for obviously too-large payloads.
// Base64 decoded size is roughly 3/4 of the string length minus padding.
@@ -97,8 +157,7 @@ export class TeamTaskAttachmentStore {
const dir = this.getTaskDir(teamName, taskId);
await fs.promises.mkdir(dir, { recursive: true });
- const ext = this.mimeToExt(mimeType);
- const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
+ const filePath = this.getStoredFilePath(teamName, taskId, attachmentId, filename);
await fs.promises.writeFile(filePath, buffer);
const meta: TaskAttachmentMeta = {
@@ -122,8 +181,8 @@ export class TeamTaskAttachmentStore {
attachmentId: string,
mimeType: AttachmentMediaType
): Promise {
- const ext = this.mimeToExt(mimeType);
- const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
+ const filePath = await this.findAttachmentFilePath(teamName, taskId, attachmentId, mimeType);
+ if (!filePath) return null;
try {
const buffer = await fs.promises.readFile(filePath);
@@ -145,8 +204,8 @@ export class TeamTaskAttachmentStore {
attachmentId: string,
mimeType: AttachmentMediaType
): Promise {
- const ext = this.mimeToExt(mimeType);
- const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
+ const filePath = await this.findAttachmentFilePath(teamName, taskId, attachmentId, mimeType);
+ if (!filePath) return;
try {
await fs.promises.unlink(filePath);
diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts
index ef445afa..89a1dde6 100644
--- a/src/main/services/team/TeamTaskReader.ts
+++ b/src/main/services/team/TeamTaskReader.ts
@@ -8,7 +8,6 @@ import * as path from 'path';
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
import type {
- AttachmentMediaType,
StatusTransition,
TaskAttachmentMeta,
TaskComment,
@@ -20,12 +19,18 @@ import type {
const logger = createLogger('Service:TeamTaskReader');
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
-const VALID_ATTACHMENT_MIME_TYPES: ReadonlySet = new Set([
- 'image/png',
- 'image/jpeg',
- 'image/gif',
- 'image/webp',
-]);
+function isValidMimeTypeString(value: unknown): value is string {
+ if (typeof value !== 'string') return false;
+ const v = value.trim();
+ if (!v) return false;
+ // Keep it reasonably bounded and avoid control characters.
+ if (v.length > 200) return false;
+ if (v.includes('\0') || /[\r\n]/.test(v)) return false;
+ // Minimal MIME shape: type/subtype
+ const slash = v.indexOf('/');
+ if (slash <= 0 || slash === v.length - 1) return false;
+ return true;
+}
export class TeamTaskReader {
/**
@@ -203,23 +208,25 @@ export class TeamTaskReader {
attachments: Array.isArray(c.attachments)
? (() => {
const filtered = (c.attachments as unknown[])
- .filter(
- (a): a is TaskAttachmentMeta =>
- Boolean(a) &&
- typeof a === 'object' &&
- typeof (a as Record).id === 'string' &&
- typeof (a as Record).filename === 'string' &&
- typeof (a as Record).mimeType === 'string' &&
- VALID_ATTACHMENT_MIME_TYPES.has(
- (a as Record).mimeType as string
- ) &&
- typeof (a as Record).size === 'number' &&
- typeof (a as Record).addedAt === 'string'
- )
+ .filter((a): a is TaskAttachmentMeta => {
+ if (!a || typeof a !== 'object') return false;
+ const row = a as Record;
+ const size = row.size;
+ return (
+ typeof row.id === 'string' &&
+ typeof row.filename === 'string' &&
+ typeof row.mimeType === 'string' &&
+ isValidMimeTypeString(row.mimeType) &&
+ typeof size === 'number' &&
+ Number.isFinite(size) &&
+ size >= 0 &&
+ typeof row.addedAt === 'string'
+ );
+ })
.map((a) => ({
id: a.id,
filename: a.filename,
- mimeType: a.mimeType,
+ mimeType: String(a.mimeType).trim(),
size: a.size,
addedAt: a.addedAt,
}));
@@ -236,23 +243,25 @@ export class TeamTaskReader {
deletedAt: undefined, // deleted tasks are filtered out below
attachments: Array.isArray(parsed.attachments)
? (parsed.attachments as unknown[])
- .filter(
- (a): a is TaskAttachmentMeta =>
- Boolean(a) &&
- typeof a === 'object' &&
- typeof (a as Record).id === 'string' &&
- typeof (a as Record).filename === 'string' &&
- typeof (a as Record).mimeType === 'string' &&
- VALID_ATTACHMENT_MIME_TYPES.has(
- (a as Record).mimeType as string
- ) &&
- typeof (a as Record).size === 'number' &&
- typeof (a as Record).addedAt === 'string'
- )
+ .filter((a): a is TaskAttachmentMeta => {
+ if (!a || typeof a !== 'object') return false;
+ const row = a as Record;
+ const size = row.size;
+ return (
+ typeof row.id === 'string' &&
+ typeof row.filename === 'string' &&
+ typeof row.mimeType === 'string' &&
+ isValidMimeTypeString(row.mimeType) &&
+ typeof size === 'number' &&
+ Number.isFinite(size) &&
+ size >= 0 &&
+ typeof row.addedAt === 'string'
+ );
+ })
.map((a) => ({
id: a.id,
filename: a.filename,
- mimeType: a.mimeType,
+ mimeType: String(a.mimeType).trim(),
size: a.size,
addedAt: a.addedAt,
}))
diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx
index 7d12e284..ed036990 100644
--- a/src/renderer/components/layout/PaneContent.tsx
+++ b/src/renderer/components/layout/PaneContent.tsx
@@ -46,7 +46,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
{tab.type === 'notifications' && }
{tab.type === 'settings' && }
{tab.type === 'teams' && }
- {tab.type === 'team' && }
+ {tab.type === 'team' && (
+
+
+
+ )}
{tab.type === 'session' && (
diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx
index eed2c5d6..45d585fb 100644
--- a/src/renderer/components/sidebar/GlobalTaskList.tsx
+++ b/src/renderer/components/sidebar/GlobalTaskList.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
+import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
import { cn } from '@renderer/lib/utils';
@@ -96,6 +97,7 @@ export const GlobalTaskList = ({
globalTasksLoading,
globalTasksInitialized,
fetchAllTasks,
+ softDeleteTask,
projects,
viewMode,
repositoryGroups,
@@ -106,6 +108,7 @@ export const GlobalTaskList = ({
globalTasksLoading: s.globalTasksLoading,
globalTasksInitialized: s.globalTasksInitialized,
fetchAllTasks: s.fetchAllTasks,
+ softDeleteTask: s.softDeleteTask,
projects: s.projects,
viewMode: s.viewMode,
repositoryGroups: s.repositoryGroups,
@@ -145,6 +148,20 @@ export const GlobalTaskList = ({
setRenamingTaskKey(null);
};
+ const handleDeleteTask = async (teamName: string, taskId: string): Promise => {
+ const confirmed = await confirm({
+ title: 'Delete task',
+ message: `Move task #${taskId} to trash?`,
+ confirmLabel: 'Delete',
+ cancelLabel: 'Cancel',
+ variant: 'danger',
+ });
+ if (confirmed) {
+ await softDeleteTask(teamName, taskId);
+ await fetchAllTasks();
+ }
+ };
+
// Fetch tasks on mount — loading guard in the store action prevents
// duplicate IPC calls when the centralized init chain is already fetching.
useEffect(() => {
@@ -329,6 +346,7 @@ export const GlobalTaskList = ({
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
+ onDelete={() => handleDeleteTask(task.teamName, task.id)}
>
taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
+ onDelete={() => handleDeleteTask(task.teamName, task.id)}
>
taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
+ onDelete={() => handleDeleteTask(task.teamName, task.id)}
>
taskLocalState.togglePin(task.teamName, task.id)}
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
+ onDelete={() => handleDeleteTask(task.teamName, task.id)}
>
void;
onToggleArchive: () => void;
onRename: () => void;
+ onDelete?: () => void;
children: React.ReactNode;
}
@@ -26,6 +27,7 @@ export const TaskContextMenu = ({
onTogglePin,
onToggleArchive,
onRename,
+ onDelete,
children,
}: TaskContextMenuProps): React.JSX.Element => {
return (
@@ -68,6 +70,19 @@ export const TaskContextMenu = ({
>
)}
+
+ {onDelete && (
+ <>
+
+
+
+ Delete task
+
+ >
+ )}
);
diff --git a/src/renderer/components/team/ClaudeLogsSection.tsx b/src/renderer/components/team/ClaudeLogsSection.tsx
index 7bb46cd0..9c110816 100644
--- a/src/renderer/components/team/ClaudeLogsSection.tsx
+++ b/src/renderer/components/team/ClaudeLogsSection.tsx
@@ -6,6 +6,7 @@ import { cn } from '@renderer/lib/utils';
import { Terminal } from 'lucide-react';
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
+import { CliLogsRichView } from './CliLogsRichView';
import type { TeamClaudeLogsResponse } from '@shared/types';
@@ -24,6 +25,46 @@ function isRecent(updatedAt: string | undefined): boolean {
return Date.now() - t <= ONLINE_WINDOW_MS;
}
+function normalizeToStreamJsonText(linesNewestFirst: string[]): string {
+ // We want to feed CliLogsRichView the exact format it expects:
+ // - marker lines: "[stdout]" / "[stderr]"
+ // - raw JSON lines without any "[stdout] " prefix
+ const chronological = [...linesNewestFirst].reverse();
+
+ const out: string[] = [];
+ let lastStream: 'stdout' | 'stderr' | null = null;
+
+ const pushMarker = (stream: 'stdout' | 'stderr'): void => {
+ if (lastStream === stream) return;
+ lastStream = stream;
+ out.push(stream === 'stdout' ? '[stdout]' : '[stderr]');
+ };
+
+ for (const rawLine of chronological) {
+ const line = rawLine ?? '';
+ if (line === '[stdout]' || line === '[stderr]') {
+ lastStream = line === '[stdout]' ? 'stdout' : 'stderr';
+ out.push(line);
+ continue;
+ }
+
+ if (line.startsWith('[stdout] ')) {
+ pushMarker('stdout');
+ out.push(line.slice('[stdout] '.length));
+ continue;
+ }
+ if (line.startsWith('[stderr] ')) {
+ pushMarker('stderr');
+ out.push(line.slice('[stderr] '.length));
+ continue;
+ }
+
+ out.push(line);
+ }
+
+ return out.join('\n');
+}
+
export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => {
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [data, setData] = useState({ lines: [], total: 0, hasMore: false });
@@ -112,21 +153,24 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
- {error ? (
-
{error}
- ) : data.lines.length > 0 ? (
-
- {data.lines.join('\n')}
-
- ) : (
-
+ {error ?
{error}
: null}
+ {!error && data.lines.length > 0 ? (
+
+ ) : null}
+ {!error && data.lines.length === 0 ? (
+
{loading ? 'Loading…' : 'No logs captured.'}
- )}
+ ) : null}
);
diff --git a/src/renderer/components/team/CliLogsRichView.tsx b/src/renderer/components/team/CliLogsRichView.tsx
index 8fbd0d2c..dfc82fc0 100644
--- a/src/renderer/components/team/CliLogsRichView.tsx
+++ b/src/renderer/components/team/CliLogsRichView.tsx
@@ -18,6 +18,7 @@ import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser';
interface CliLogsRichViewProps {
cliLogsTail: string;
+ order?: 'oldest-first' | 'newest-first';
className?: string;
}
@@ -128,6 +129,7 @@ const StreamGroup = ({
export const CliLogsRichView = ({
cliLogsTail,
+ order = 'oldest-first',
className,
}: CliLogsRichViewProps): React.JSX.Element => {
const scrollRef = useRef(null);
@@ -151,9 +153,13 @@ export const CliLogsRichView = ({
// Auto-scroll to bottom on new content
useEffect(() => {
if (scrollRef.current) {
- scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ if (order === 'newest-first') {
+ scrollRef.current.scrollTop = 0;
+ } else {
+ scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
+ }
}
- }, [cliLogsTail]);
+ }, [cliLogsTail, order]);
const handleGroupToggle = useCallback((groupId: string) => {
setCollapsedGroupIds((prev) => {
@@ -203,9 +209,11 @@ export const CliLogsRichView = ({
);
}
+ const visibleGroups = order === 'newest-first' ? [...groups].reverse() : groups;
+
return (
- {groups.map((group) =>
+ {visibleGroups.map((group) =>
group.items.length === 1 ? (
// Single item — render flat without collapsible group wrapper
- ) : null}
+ ) : (
+ Unassigned
+ )}
{/* Description — full markdown with scroll */}
diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx
index dc2b1cb9..2cb64c8e 100644
--- a/src/renderer/components/team/TeamDetailView.tsx
+++ b/src/renderer/components/team/TeamDetailView.tsx
@@ -3,6 +3,7 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } fro
import { api } from '@renderer/api';
import { confirm } from '@renderer/components/common/ConfirmDialog';
import { Button } from '@renderer/components/ui/button';
+import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
import {
Dialog,
DialogContent,
@@ -17,6 +18,7 @@ import { useBranchSync } from '@renderer/hooks/useBranchSync';
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
+import { useTabUI } from '@renderer/hooks/useTabUI';
import { createChipFromSelection } from '@renderer/utils/chipUtils';
import { formatProjectPath } from '@renderer/utils/pathDisplay';
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
@@ -77,6 +79,7 @@ import { TeamSessionsSection } from './TeamSessionsSection';
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
+import type { ContextInjection } from '@renderer/types/contextInjection';
import type { Session } from '@renderer/types/data';
import type { InlineChip } from '@renderer/types/inlineChip';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
@@ -192,6 +195,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
projects,
repositoryGroups,
teams,
+ fetchSessionDetail,
+ initTabUIState,
selectTeam,
updateKanban,
updateKanbanColumnOrder,
@@ -230,6 +235,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
projects: s.projects,
repositoryGroups: s.repositoryGroups,
teams: s.teams,
+ fetchSessionDetail: s.fetchSessionDetail,
+ initTabUIState: s.initTabUIState,
selectTeam: s.selectTeam,
updateKanban: s.updateKanban,
updateKanbanColumnOrder: s.updateKanbanColumnOrder,
@@ -265,6 +272,22 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
}))
);
+ // Per-tab UI state (context panel visibility + selected phase)
+ const {
+ tabId,
+ isContextPanelVisible,
+ setContextPanelVisible,
+ selectedContextPhase,
+ setSelectedContextPhase,
+ } = useTabUI();
+ const [isContextButtonHovered, setIsContextButtonHovered] = useState(false);
+
+ useEffect(() => {
+ if (tabId) {
+ initTabUIState(tabId);
+ }
+ }, [tabId, initTabUIState]);
+
useEffect(() => {
const wasProvisioning = wasProvisioningRef.current;
wasProvisioningRef.current = isTeamProvisioning;
@@ -336,6 +359,78 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
[projects, repositoryGroups, data?.config.projectPath]
);
+ // Lead session context panel (reuses the same session context pipeline for exact stats)
+ const leadSessionId = data?.config.leadSessionId ?? null;
+ const leadTabData = useStore(
+ useShallow((s) => (tabId ? s.tabSessionData[tabId] : null))
+ );
+ const leadSessionDetail = leadTabData?.sessionDetail ?? null;
+ const leadConversation = leadTabData?.conversation ?? null;
+ const leadSessionContextStats = leadTabData?.sessionContextStats ?? null;
+ const leadSessionPhaseInfo = leadTabData?.sessionPhaseInfo ?? null;
+ const leadSessionLoading = leadTabData?.sessionDetailLoading ?? false;
+ const leadSessionLoaded = Boolean(leadSessionId && leadSessionDetail?.session?.id === leadSessionId);
+
+ const leadSubagentCostUsd = useMemo(() => {
+ const processes = leadSessionDetail?.processes;
+ if (!processes || processes.length === 0) return undefined;
+ const total = processes.reduce((sum, p) => sum + (p.metrics.costUsd ?? 0), 0);
+ return total > 0 ? total : undefined;
+ }, [leadSessionDetail?.processes]);
+
+ const { allContextInjections, lastAiGroupTotalTokens } = useMemo(() => {
+ if (!leadSessionLoaded || !leadSessionContextStats || !leadConversation?.items.length) {
+ return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined };
+ }
+
+ // Determine which phase to show
+ const effectivePhase = selectedContextPhase;
+
+ // If a specific phase is selected, find the last AI group in that phase
+ let targetAiGroupId: string | undefined;
+ if (effectivePhase !== null && leadSessionPhaseInfo) {
+ const phase = leadSessionPhaseInfo.phases.find((p) => p.phaseNumber === effectivePhase);
+ if (phase) {
+ targetAiGroupId = phase.lastAIGroupId;
+ }
+ }
+
+ // Default: use the last AI group overall
+ if (!targetAiGroupId) {
+ const lastAiItem = [...leadConversation.items].reverse().find((item) => item.type === 'ai');
+ if (lastAiItem?.type !== 'ai') {
+ return { allContextInjections: [] as ContextInjection[], lastAiGroupTotalTokens: undefined };
+ }
+ targetAiGroupId = lastAiItem.group.id;
+ }
+
+ const stats = leadSessionContextStats.get(targetAiGroupId);
+ const injections = stats?.accumulatedInjections ?? [];
+
+ // Get total tokens from the target AI group
+ let totalTokens: number | undefined;
+ const targetItem = leadConversation.items.find(
+ (item) => item.type === 'ai' && item.group.id === targetAiGroupId
+ );
+ if (targetItem?.type === 'ai') {
+ const responses = targetItem.group.responses || [];
+ for (let i = responses.length - 1; i >= 0; i--) {
+ const msg = responses[i];
+ if (msg.type === 'assistant' && msg.usage) {
+ const usage = msg.usage;
+ totalTokens =
+ (usage.input_tokens ?? 0) +
+ (usage.output_tokens ?? 0) +
+ (usage.cache_read_input_tokens ?? 0) +
+ (usage.cache_creation_input_tokens ?? 0);
+ break;
+ }
+ }
+ }
+
+ return { allContextInjections: injections, lastAiGroupTotalTokens: totalTokens };
+ }, [leadSessionLoaded, leadSessionContextStats, leadConversation, selectedContextPhase, leadSessionPhaseInfo]);
+
useEffect(() => {
if (!projectId) return;
@@ -746,7 +841,49 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
return (
<>
-
+
+
+ {/* Sticky Context button (same interaction as Session view) */}
+ {leadSessionId && (
+
+
+
+ )}
+
+
+
+ {/* Context panel sidebar */}
+ {isContextPanelVisible && leadSessionId && (
+
+ {leadSessionLoaded ? (
+
setContextPanelVisible(false)}
+ projectRoot={leadSessionDetail?.session?.projectPath ?? data.config.projectPath}
+ totalSessionTokens={lastAiGroupTotalTokens}
+ sessionMetrics={leadSessionDetail?.metrics}
+ subagentCostUsd={leadSubagentCostUsd}
+ phaseInfo={leadSessionPhaseInfo ?? undefined}
+ selectedPhase={selectedContextPhase}
+ onPhaseChange={setSelectedContextPhase}
+ />
+ ) : (
+
+
+
+
Visible Context
+
+ {leadSessionLoading ? 'Loading…' : 'No session loaded'}
+
+
+
+
+
+
+ {leadSessionLoading ? 'Loading context…' : 'Open the team lead session to view context.'}
+
+
+
+ )}
+
+ )}
{editorOpen && data.config.projectPath && (
diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx
index 0815db50..339c244c 100644
--- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx
+++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx
@@ -27,7 +27,8 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
-import { Check, ImagePlus, X } from 'lucide-react';
+import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
+import { AlertCircle, Check, ImagePlus, Send, X } from 'lucide-react';
import { MemberBadge } from '../MemberBadge';
@@ -40,6 +41,8 @@ interface QuotedMessage {
text: string;
}
+const MAX_MESSAGE_LENGTH = 4000;
+
interface SendMessageDialogProps {
open: boolean;
teamName: string;
@@ -177,9 +180,13 @@ export const SendMessageDialog = ({
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
+ const trimmedText = textDraft.value.trim();
+ const remaining = MAX_MESSAGE_LENGTH - trimmedText.length;
+
const canSend =
member.trim().length > 0 &&
- textDraft.value.trim().length > 0 &&
+ trimmedText.length > 0 &&
+ trimmedText.length <= MAX_MESSAGE_LENGTH &&
summary.trim().length > 0 &&
!sending &&
!attachmentsBlocked;
@@ -262,7 +269,7 @@ export const SendMessageDialog = ({
return (
@@ -449,16 +484,12 @@ export const SendMessageDialog = ({
- {sendError ? {sendError}
: null}
-
diff --git a/src/renderer/components/team/dialogs/TaskAttachments.tsx b/src/renderer/components/team/dialogs/TaskAttachments.tsx
index 1d68bcf2..2bbe3858 100644
--- a/src/renderer/components/team/dialogs/TaskAttachments.tsx
+++ b/src/renderer/components/team/dialogs/TaskAttachments.tsx
@@ -2,9 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { useStore } from '@renderer/store';
-import { ImagePlus, Loader2, Trash2, X } from 'lucide-react';
+import { File, ImagePlus, Loader2, Trash2, X } from 'lucide-react';
-import type { AttachmentMediaType, TaskAttachmentMeta } from '@shared/types';
+import { isImageMimeType } from '@renderer/utils/attachmentUtils';
+
+import type { TaskAttachmentMeta } from '@shared/types';
const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
@@ -30,7 +32,7 @@ export const TaskAttachments = ({
const [error, setError] = useState(null);
const [previewAttachment, setPreviewAttachment] = useState<{
id: string;
- mimeType: AttachmentMediaType;
+ mimeType: string;
dataUrl: string | null;
loading: boolean;
} | null>(null);
@@ -73,7 +75,7 @@ export const TaskAttachments = ({
);
const handleDelete = useCallback(
- async (attachmentId: string, mimeType: AttachmentMediaType) => {
+ async (attachmentId: string, mimeType: string) => {
setDeletingId(attachmentId);
try {
await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType);
@@ -89,8 +91,39 @@ export const TaskAttachments = ({
[teamName, taskId, deleteTaskAttachment, previewAttachment]
);
+ const handleDownload = useCallback(
+ async (att: TaskAttachmentMeta) => {
+ setError(null);
+ try {
+ const base64 = await getTaskAttachmentData(teamName, taskId, att.id, att.mimeType);
+ if (!base64) {
+ setError('Attachment file not found');
+ return;
+ }
+ const mime = att.mimeType && typeof att.mimeType === 'string' ? att.mimeType : 'application/octet-stream';
+ const dataUrl = `data:${mime};base64,${base64}`;
+ const blob = await fetch(dataUrl).then((r) => r.blob());
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = att.filename || 'attachment';
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : 'Failed to download');
+ }
+ },
+ [getTaskAttachmentData, teamName, taskId]
+ );
+
const handlePreview = useCallback(
async (att: TaskAttachmentMeta) => {
+ if (!isImageMimeType(att.mimeType)) {
+ void handleDownload(att);
+ return;
+ }
if (previewAttachment?.id === att.id && previewAttachment.dataUrl) {
setPreviewAttachment(null);
return;
@@ -114,7 +147,7 @@ export const TaskAttachments = ({
setError('Failed to load attachment');
}
},
- [teamName, taskId, getTaskAttachmentData, previewAttachment]
+ [teamName, taskId, getTaskAttachmentData, previewAttachment, handleDownload]
);
// Handle paste events for quick image attachment
@@ -277,6 +310,7 @@ const AttachmentThumbnail = ({
let cancelled = false;
void (async () => {
try {
+ if (!isImageMimeType(attachment.mimeType)) return;
const base64 = await getTaskAttachmentData(
teamName,
taskId,
@@ -311,10 +345,19 @@ const AttachmentThumbnail = ({
} bg-[var(--color-surface)]`}
onClick={onPreview}
>
- {thumbUrl ? (
-
+ {isImageMimeType(attachment.mimeType) ? (
+ thumbUrl ? (
+
+ ) : (
+
+ )
) : (
-
+
+
+
+ {attachment.filename}
+
+
)}
{/* Delete button overlay */}