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 ( + + {sending ? 'Sending...' : 'Send'} + + } footerRight={ - textDraft.isSaved ? ( - Draft saved - ) : null +
+ {sendError ? ( + + + {sendError} + + ) : null} + {remaining < 200 ? ( + + {remaining} chars left + + ) : null} + {textDraft.isSaved ? ( + Draft saved + ) : null} +
} />
@@ -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 ? ( - {attachment.filename} + {isImageMimeType(attachment.mimeType) ? ( + thumbUrl ? ( + {attachment.filename} + ) : ( + + ) ) : ( - +
+ +
+ {attachment.filename} +
+
)} {/* Delete button overlay */}