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.
This commit is contained in:
iliya 2026-03-05 16:21:44 +02:00
parent fdb52922fe
commit f1d08e6d33
22 changed files with 1067 additions and 135 deletions

View file

@ -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 <path>');
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 <id> --related <targetId> [--team <team>]',
' node teamctl.js task set-owner <id> <member|clear> [--notify --from "member"] [--team <team>]',
' node teamctl.js task comment <id> --text "..." [--from "member"] [--team <team>]',
' node teamctl.js task attach <id> --file <path> [--mode copy|link] [--filename <name>] [--mime-type <type>] [--no-fallback] [--team <team>]',
' node teamctl.js task comment-attach <id> <commentId> --file <path> [--mode copy|link] [--filename <name>] [--mime-type <type>] [--no-fallback] [--team <team>]',
' node teamctl.js task set-clarification <id> <lead|user|clear> [--from "member"] [--team <team>]',
' node teamctl.js task briefing --for <member-name> [--team <team>]',
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
@ -951,6 +1177,8 @@ function printHelp() {
'Options:',
' --team <name> Team name (if not under ~/.claude/teams/<team>/tools)',
' --claude-dir <path> Override ~/.claude location',
' --mode <copy|link> 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 <id> --file <path>');
// 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 <id> <commentId> --file <path>');
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;

View file

@ -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 <id>`,
`- Complete task (preferred over set-status): node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task complete <id>`,
`- Update status: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task set-status <id> <pending|in_progress|completed|deleted>`,
`- Add comment: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment <id> --text "..." --from "${leadName}"`,
`- Attach file to task: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task attach <id> --file "<path>" [--mode copy|link] [--filename "<name>"] [--mime-type "<type>"]`,
`- Attach file to a specific comment:`,
` 1) Find commentId: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task get <id>`,
` 2) Attach: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task comment-attach <id> <commentId> --file "<path>" [--mode copy|link] [--filename "<name>"] [--mime-type "<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 "<member>" --notify --from "${leadName}"`,
`- Link dependency: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --blocked-by <targetId>`,
`- Link related: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task link <id> --related <targetId>`,
`- Unlink: node "$HOME/.claude/tools/teamctl.js" --team "${teamName}" task unlink <id> --blocked-by <targetId>`,
``,
`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,

View file

@ -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<string> = new Set<AttachmentMediaType>([
const KNOWN_IMAGE_MIME_TYPES: ReadonlySet<string> = new Set<string>([
'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<string | null> {
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: "<id>--<filename>"
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 "<id>." (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<TaskAttachmentMeta> {
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<string | null> {
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<void> {
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);

View file

@ -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<string> = new Set<AttachmentMediaType>([
'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<string, unknown>).id === 'string' &&
typeof (a as Record<string, unknown>).filename === 'string' &&
typeof (a as Record<string, unknown>).mimeType === 'string' &&
VALID_ATTACHMENT_MIME_TYPES.has(
(a as Record<string, unknown>).mimeType as string
) &&
typeof (a as Record<string, unknown>).size === 'number' &&
typeof (a as Record<string, unknown>).addedAt === 'string'
)
.filter((a): a is TaskAttachmentMeta => {
if (!a || typeof a !== 'object') return false;
const row = a as Record<string, unknown>;
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<string, unknown>).id === 'string' &&
typeof (a as Record<string, unknown>).filename === 'string' &&
typeof (a as Record<string, unknown>).mimeType === 'string' &&
VALID_ATTACHMENT_MIME_TYPES.has(
(a as Record<string, unknown>).mimeType as string
) &&
typeof (a as Record<string, unknown>).size === 'number' &&
typeof (a as Record<string, unknown>).addedAt === 'string'
)
.filter((a): a is TaskAttachmentMeta => {
if (!a || typeof a !== 'object') return false;
const row = a as Record<string, unknown>;
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,
}))

View file

@ -46,7 +46,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
{tab.type === 'notifications' && <NotificationsView />}
{tab.type === 'settings' && <SettingsView />}
{tab.type === 'teams' && <TeamListView />}
{tab.type === 'team' && <TeamDetailView teamName={tab.teamName ?? ''} />}
{tab.type === 'team' && (
<TabUIProvider tabId={tab.id}>
<TeamDetailView teamName={tab.teamName ?? ''} />
</TabUIProvider>
)}
{tab.type === 'session' && (
<TabUIProvider tabId={tab.id}>
<SessionTabContent tab={tab} isActive={isActive} />

View file

@ -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<void> => {
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)}
>
<SidebarTaskItem
task={task}
@ -425,6 +443,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)}
>
<SidebarTaskItem
task={task}
@ -472,6 +491,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)}
>
<SidebarTaskItem
task={task}
@ -523,6 +543,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)}
>
<SidebarTaskItem
task={task}

View file

@ -5,7 +5,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from '@renderer/components/ui/context-menu';
import { Archive, ArchiveRestore, Pencil, Pin, PinOff } from 'lucide-react';
import { Archive, ArchiveRestore, Pencil, Pin, PinOff, Trash2 } from 'lucide-react';
import type { GlobalTask } from '@shared/types';
@ -16,6 +16,7 @@ export interface TaskContextMenuProps {
onTogglePin: () => 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 = ({
</>
)}
</ContextMenuItem>
{onDelete && (
<>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={onDelete}
className="text-red-400 focus:text-red-400"
>
<Trash2 className="size-3.5 shrink-0" />
<span>Delete task</span>
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);

View file

@ -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<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
@ -112,21 +153,24 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
<div
className={cn(
'max-h-[320px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2',
'rounded',
loading && 'opacity-80'
)}
>
{error ? (
<p className="text-xs text-red-300">{error}</p>
) : data.lines.length > 0 ? (
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-4 text-[var(--color-text-secondary)]">
{data.lines.join('\n')}
</pre>
) : (
<p className="text-xs text-[var(--color-text-muted)]">
{error ? <p className="p-2 text-xs text-red-300">{error}</p> : null}
{!error && data.lines.length > 0 ? (
<CliLogsRichView
// Parser expects chronological order; UI shows newest-first.
cliLogsTail={normalizeToStreamJsonText(data.lines)}
order="newest-first"
className="max-h-[320px] p-2"
/>
) : null}
{!error && data.lines.length === 0 ? (
<p className="p-2 text-xs text-[var(--color-text-muted)]">
{loading ? 'Loading…' : 'No logs captured.'}
</p>
)}
) : null}
</div>
</CollapsibleTeamSection>
);

View file

@ -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<HTMLDivElement>(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 (
<div ref={scrollRef} className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}>
{groups.map((group) =>
{visibleGroups.map((group) =>
group.items.length === 1 ? (
// Single item — render flat without collapsible group wrapper
<FlatGroupItem

View file

@ -110,7 +110,9 @@ export const TaskTooltip = ({
name={task.owner}
color={colorMap.get(task.owner)}
/>
) : null}
) : (
<span className="text-[10px] text-[var(--color-text-muted)]">Unassigned</span>
)}
</div>
{/* Description — full markdown with scroll */}

View file

@ -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 (
<>
<div ref={contentRef} className="size-full overflow-auto p-4" data-team-name={teamName}>
<div className="flex size-full overflow-hidden">
<div
ref={contentRef}
className="size-full flex-1 overflow-auto p-4"
data-team-name={teamName}
>
{/* Sticky Context button (same interaction as Session view) */}
{leadSessionId && (
<div className="pointer-events-none sticky top-0 z-10 flex justify-end pb-0 pt-3">
<button
onClick={() => {
const next = !isContextPanelVisible;
setContextPanelVisible(next);
if (next && tabId && projectId && leadSessionId && !leadSessionLoaded) {
void fetchSessionDetail(projectId, leadSessionId, tabId);
}
}}
onMouseEnter={() => setIsContextButtonHovered(true)}
onMouseLeave={() => setIsContextButtonHovered(false)}
className="pointer-events-auto flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs shadow-lg backdrop-blur-md transition-colors"
style={{
backgroundColor: isContextPanelVisible
? 'var(--context-btn-active-bg)'
: isContextButtonHovered
? 'var(--context-btn-bg-hover)'
: 'var(--context-btn-bg)',
color: isContextPanelVisible
? 'var(--context-btn-active-text)'
: 'var(--color-text-secondary)',
}}
title={
leadSessionLoaded
? `Session: ${leadSessionId}`
: leadSessionLoading
? 'Loading context…'
: leadSessionId
}
>
{leadSessionLoaded ? `Context (${allContextInjections.length})` : 'Context'}
</button>
</div>
)}
<div
className="relative mb-3 overflow-hidden rounded-lg border border-[var(--color-border)] px-4 py-3"
style={
@ -1601,6 +1738,53 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
projectPath={data.config.projectPath}
onEditorAction={handleEditorAction}
/>
</div>
{/* Context panel sidebar */}
{isContextPanelVisible && leadSessionId && (
<div className="w-80 shrink-0">
{leadSessionLoaded ? (
<SessionContextPanel
injections={allContextInjections}
onClose={() => setContextPanelVisible(false)}
projectRoot={leadSessionDetail?.session?.projectPath ?? data.config.projectPath}
totalSessionTokens={lastAiGroupTotalTokens}
sessionMetrics={leadSessionDetail?.metrics}
subagentCostUsd={leadSubagentCostUsd}
phaseInfo={leadSessionPhaseInfo ?? undefined}
selectedPhase={selectedContextPhase}
onPhaseChange={setSelectedContextPhase}
/>
) : (
<div
className="flex h-full flex-col border-l border-[var(--color-border)] bg-[var(--color-surface)]"
style={{ backgroundColor: 'var(--color-surface)' }}
>
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
<div className="min-w-0">
<p className="text-sm font-medium text-[var(--color-text)]">Visible Context</p>
<p className="text-[10px] text-[var(--color-text-muted)]">
{leadSessionLoading ? 'Loading…' : 'No session loaded'}
</p>
</div>
<button
type="button"
className="rounded p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
onClick={() => setContextPanelVisible(false)}
aria-label="Close panel"
>
×
</button>
</div>
<div className="flex flex-1 items-center justify-center p-4">
<p className="text-xs text-[var(--color-text-muted)]">
{leadSessionLoading ? 'Loading context…' : 'Open the team lead session to view context.'}
</p>
</div>
</div>
)}
</div>
)}
</div>
{editorOpen && data.config.projectPath && (

View file

@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="sm:max-w-[560px]"
className="sm:max-w-[720px]"
onDragEnter={canAttach ? handleDragEnter : undefined}
onDragLeave={canAttach ? handleDragLeave : undefined}
onDragOver={canAttach ? handleDragOver : undefined}
@ -415,7 +422,7 @@ export const SendMessageDialog = ({
<MentionableTextarea
id="smd-message"
className={quote ? 'rounded-t-none' : undefined}
placeholder="Write your message..."
placeholder={`Write your message... (${getModifierKeyName()}+Enter to send)`}
value={textDraft.value}
onValueChange={textDraft.setValue}
suggestions={mentionSuggestions}
@ -426,10 +433,38 @@ export const SendMessageDialog = ({
onModEnter={handleSubmit}
minRows={4}
maxRows={12}
maxLength={MAX_MESSAGE_LENGTH}
disabled={sending}
cornerAction={
<button
type="button"
className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-600 px-3 py-1.5 text-[11px] font-medium text-white shadow-sm transition-colors hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!canSend}
onClick={handleSubmit}
>
<Send size={12} />
{sending ? 'Sending...' : 'Send'}
</button>
}
footerRight={
textDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null
<div className="flex items-center gap-2">
{sendError ? (
<span className="inline-flex items-center gap-1 rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] text-red-400">
<AlertCircle size={10} className="shrink-0" />
{sendError}
</span>
) : null}
{remaining < 200 ? (
<span
className={`text-[10px] ${remaining < 100 ? 'text-yellow-400' : 'text-[var(--color-text-muted)]'}`}
>
{remaining} chars left
</span>
) : null}
{textDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
) : null}
</div>
}
/>
</div>
@ -449,16 +484,12 @@ export const SendMessageDialog = ({
</p>
</div>
{sendError ? <p className="text-xs text-red-400">{sendError}</p> : null}
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose} disabled={sending}>
Cancel
</Button>
<Button size="sm" onClick={handleSubmit} disabled={!canSend}>
{sending ? 'Sending...' : 'Send'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View file

@ -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<string>(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
@ -30,7 +32,7 @@ export const TaskAttachments = ({
const [error, setError] = useState<string | null>(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 ? (
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
{isImageMimeType(attachment.mimeType) ? (
thumbUrl ? (
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
) : (
<Loader2 size={16} className="animate-spin text-[var(--color-text-muted)]" />
)
) : (
<Loader2 size={16} className="animate-spin text-[var(--color-text-muted)]" />
<div className="flex flex-col items-center gap-1 px-1 text-center">
<File size={18} className="text-[var(--color-text-muted)]" />
<div className="max-w-full truncate text-[9px] text-[var(--color-text-muted)]">
{attachment.filename}
</div>
</div>
)}
{/* Delete button overlay */}
<button

View file

@ -11,11 +11,12 @@ import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
import { useStore } from '@renderer/store';
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { formatDistanceToNow } from 'date-fns';
import { CheckCircle2, Eye, Loader2, MessageSquare, Reply, Send, X } from 'lucide-react';
import { CheckCircle2, Eye, File, Loader2, MessageSquare, Reply, Send, X } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TaskAttachmentMeta, TaskComment } from '@shared/types';
@ -414,11 +415,13 @@ const CommentAttachmentThumbnail = ({
}: CommentAttachmentThumbnailProps): React.JSX.Element => {
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
const [downloading, setDownloading] = useState(false);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
if (!isImageMimeType(attachment.mimeType)) return;
const base64 = await getTaskAttachmentData(
teamName,
taskId,
@ -440,12 +443,51 @@ const CommentAttachmentThumbnail = ({
return (
<div
className="group relative flex size-14 cursor-pointer items-center justify-center overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors hover:border-[var(--color-border-emphasis)]"
onClick={() => thumbUrl && onPreview(thumbUrl)}
onClick={() => {
if (isImageMimeType(attachment.mimeType)) {
if (thumbUrl) onPreview(thumbUrl);
return;
}
void (async () => {
setDownloading(true);
try {
const base64 = await getTaskAttachmentData(
teamName,
taskId,
attachment.id,
attachment.mimeType
);
if (!base64) return;
const mime =
attachment.mimeType && typeof attachment.mimeType === 'string'
? attachment.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 = attachment.filename || 'attachment';
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} finally {
setDownloading(false);
}
})();
}}
>
{thumbUrl ? (
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
) : (
{isImageMimeType(attachment.mimeType) ? (
thumbUrl ? (
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
) : (
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
)
) : downloading ? (
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
) : (
<File size={14} className="text-[var(--color-text-muted)]" />
)}
<div className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-0.5 py-px text-center text-[7px] text-white opacity-0 transition-opacity group-hover:opacity-100">
{attachment.filename}

View file

@ -268,7 +268,11 @@ export const KanbanTaskCard = ({
</span>
<div className="mb-2 pt-2">
<div className="flex items-center gap-1">
{task.owner ? <MemberBadge name={task.owner} color={colorMap.get(task.owner)} /> : null}
{task.owner ? (
<MemberBadge name={task.owner} color={colorMap.get(task.owner)} />
) : (
<span className="text-xs text-[var(--color-text-muted)]">Unassigned</span>
)}
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
</div>
{task.needsClarification ? (

View file

@ -66,7 +66,7 @@ export const TrashDialog = ({
<td className="py-2 pr-3 text-[var(--color-text-muted)]">{task.id}</td>
<td className="py-2 pr-3 text-[var(--color-text)]">{task.subject}</td>
<td className="py-2 pr-3 text-[var(--color-text-secondary)]">
{task.owner ?? ''}
{task.owner ?? 'Unassigned'}
</td>
<td className="py-2 pr-3 text-[var(--color-text-muted)]">
{task.deletedAt

View file

@ -146,19 +146,26 @@ export const MemberCard = ({
</span>
) : null;
})()}
<Badge
variant="secondary"
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${isRemoved ? 'bg-zinc-600 text-zinc-300' : 'text-[var(--color-text-muted)]'}`}
title={
isRemoved
? 'This member has been removed'
: member.currentTaskId
? `Current task: ${member.currentTaskId}`
: undefined
}
>
{isRemoved ? 'removed' : presenceLabel}
</Badge>
{presenceLabel === 'connecting' && !isRemoved ? (
<Loader2
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
aria-label="connecting"
/>
) : (
<Badge
variant="secondary"
className={`shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none ${isRemoved ? 'bg-zinc-600 text-zinc-300' : 'text-[var(--color-text-muted)]'}`}
title={
isRemoved
? 'This member has been removed'
: member.currentTaskId
? `Current task: ${member.currentTaskId}`
: undefined
}
>
{isRemoved ? 'removed' : presenceLabel}
</Badge>
)}
<div
className="shrink-0"
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}

View file

@ -447,6 +447,9 @@ export const MessageComposer = ({
}
footerRight={
<div className="flex items-center gap-2">
<span className="text-[10px] text-[var(--color-text-muted)] opacity-70">
Mention &quot;create a task&quot; to add it to the board
</span>
{sendError ? (
<span className="inline-flex items-center gap-1 rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] text-red-400">
<AlertCircle size={10} className="shrink-0" />

View file

@ -14,7 +14,7 @@ export const TaskRow = ({ task }: TaskRowProps): React.JSX.Element => {
<tr className="border-t border-[var(--color-border)]">
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.id}</td>
<td className="px-3 py-2 text-sm text-[var(--color-text)]">{task.subject}</td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.owner ?? '\u2014'}</td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">{task.owner ?? 'Unassigned'}</td>
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
{task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY
? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label

View file

@ -1,6 +1,6 @@
import type { AttachmentMediaType, AttachmentPayload } from '@shared/types';
import type { AttachmentPayload, ImageMimeType } from '@shared/types';
export const ALLOWED_MIME_TYPES = new Set<AttachmentMediaType>([
export const ALLOWED_MIME_TYPES = new Set<ImageMimeType>([
'image/png',
'image/jpeg',
'image/gif',
@ -11,8 +11,8 @@ export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
export const MAX_FILES = 5;
export const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB
export function isImageMimeType(type: string): type is AttachmentMediaType {
return ALLOWED_MIME_TYPES.has(type as AttachmentMediaType);
export function isImageMimeType(type: string): type is ImageMimeType {
return ALLOWED_MIME_TYPES.has(type as ImageMimeType);
}
export function validateAttachment(file: File): { valid: true } | { valid: false; error: string } {
@ -35,7 +35,7 @@ export async function fileToAttachmentPayload(file: File): Promise<AttachmentPay
resolve({
id: crypto.randomUUID(),
filename: file.name,
mimeType: file.type as AttachmentMediaType,
mimeType: file.type,
size: file.size,
data: base64,
});

View file

@ -84,7 +84,7 @@ export interface TaskComment {
text: string;
createdAt: string;
type: TaskCommentType;
/** Image attachments on this comment. Metadata only — files stored on disk. */
/** Attachments on this comment. Metadata only — files stored on disk. */
attachments?: TaskAttachmentMeta[];
}
@ -125,7 +125,7 @@ export interface TeamTask {
needsClarification?: 'lead' | 'user';
/** ISO timestamp — when the task was soft-deleted. Only set for status === 'deleted'. */
deletedAt?: string;
/** Image attachments associated with this task. Metadata only — actual files stored on disk. */
/** Attachments associated with this task. Metadata only — actual files stored on disk. */
attachments?: TaskAttachmentMeta[];
}
@ -135,7 +135,7 @@ export interface TeamTaskWithKanban extends TeamTask {
kanbanColumn?: 'review' | 'approved';
}
/** Metadata for an image attached to a task description. */
/** Metadata for an attachment associated with a task or comment. */
export interface TaskAttachmentMeta {
/** Unique attachment ID (uuid). */
id: string;
@ -157,7 +157,16 @@ export interface CommentAttachmentPayload {
base64Data: string;
}
export type AttachmentMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
/**
* Broad MIME type string (e.g. "image/png", "application/pdf").
*
* Note: the UI may still choose to preview only certain types (e.g. images),
* but tasks/comments can store arbitrary attachments for agent workflows.
*/
export type AttachmentMediaType = string;
/** Supported image MIME types (used for preview/validation in UI). */
export type ImageMimeType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
export interface AttachmentMeta {
id: string;

View file

@ -782,6 +782,161 @@ describe('teamctl.js', () => {
});
});
// =========================================================================
// Attachments (task + comment)
// =========================================================================
describe('attachments', () => {
it('task attach copies file into storage and records metadata', () => {
// Create task
expect(run(claudeDir, ['task', 'create', '--subject', 'With attachment']).exitCode).toBe(0);
const samplePath = path.join(claudeDir, 'sample.txt');
fs.writeFileSync(samplePath, 'hello');
const { stdout, exitCode } = run(claudeDir, ['task', 'attach', '1', '--file', samplePath]);
expect(exitCode).toBe(0);
const meta = JSON.parse(stdout) as {
id: string;
filename: string;
mimeType: string;
size: number;
addedAt: string;
};
expect(meta.id).toBeDefined();
expect(meta.filename).toBe('sample.txt');
expect(meta.mimeType).toBe('text/plain');
expect(meta.size).toBe(5);
expect(meta.addedAt).toMatch(ISO_RE);
const storedPath = path.join(
claudeDir,
'teams',
TEAM,
'task-attachments',
'1',
`${meta.id}--${meta.filename}`
);
expect(fs.existsSync(storedPath)).toBe(true);
expect(fs.readFileSync(storedPath, 'utf8')).toBe('hello');
const task = readTask(claudeDir, '1');
const attachments = task.attachments as Record<string, unknown>[];
expect(attachments).toHaveLength(1);
expect(attachments[0].id).toBe(meta.id);
expect(attachments[0].filename).toBe(meta.filename);
expect(attachments[0].mimeType).toBe(meta.mimeType);
});
it('task attach supports --filename and --mime-type overrides', () => {
expect(run(claudeDir, ['task', 'create', '--subject', 'With override']).exitCode).toBe(0);
const samplePath = path.join(claudeDir, 'sample.bin');
fs.writeFileSync(samplePath, Buffer.from([1, 2, 3, 4]));
const { stdout, exitCode } = run(claudeDir, [
'task',
'attach',
'1',
'--file',
samplePath,
'--filename',
'renamed.dat',
'--mime-type',
'application/octet-stream',
]);
expect(exitCode).toBe(0);
const meta = JSON.parse(stdout) as { id: string; filename: string; mimeType: string; size: number };
expect(meta.filename).toBe('renamed.dat');
expect(meta.mimeType).toBe('application/octet-stream');
const storedPath = path.join(
claudeDir,
'teams',
TEAM,
'task-attachments',
'1',
`${meta.id}--${meta.filename}`
);
expect(fs.existsSync(storedPath)).toBe(true);
expect(fs.readFileSync(storedPath)).toEqual(Buffer.from([1, 2, 3, 4]));
});
it('task comment-attach adds attachment to a specific comment', () => {
expect(run(claudeDir, ['task', 'create', '--subject', 'Comment attach']).exitCode).toBe(0);
expect(run(claudeDir, ['task', 'comment', '1', '--text', 'First comment', '--from', 'alice']).exitCode).toBe(
0
);
const taskAfterComment = readTask(claudeDir, '1');
const commentId = String((taskAfterComment.comments as Record<string, unknown>[])[0].id);
const samplePath = path.join(claudeDir, 'comment.txt');
fs.writeFileSync(samplePath, 'comment-file');
const { stdout, exitCode } = run(claudeDir, [
'task',
'comment-attach',
'1',
commentId,
'--file',
samplePath,
]);
expect(exitCode).toBe(0);
const meta = JSON.parse(stdout) as { id: string; filename: string; mimeType: string };
expect(meta.filename).toBe('comment.txt');
const storedPath = path.join(
claudeDir,
'teams',
TEAM,
'task-attachments',
'1',
`${meta.id}--${meta.filename}`
);
expect(fs.existsSync(storedPath)).toBe(true);
const taskAfterAttach = readTask(claudeDir, '1');
const comment = (taskAfterAttach.comments as Record<string, unknown>[]).find(
(c) => String(c.id) === commentId
) as Record<string, unknown>;
expect(comment).toBeDefined();
const attachments = comment.attachments as Record<string, unknown>[];
expect(attachments).toHaveLength(1);
expect(attachments[0].id).toBe(meta.id);
expect(attachments[0].filename).toBe(meta.filename);
expect(attachments[0].mimeType).toBe(meta.mimeType);
});
it('task attach with --mode link succeeds (may fall back to copy)', () => {
expect(run(claudeDir, ['task', 'create', '--subject', 'Link mode']).exitCode).toBe(0);
const samplePath = path.join(claudeDir, 'link.txt');
fs.writeFileSync(samplePath, 'link');
const { stdout, exitCode } = run(claudeDir, [
'task',
'attach',
'1',
'--file',
samplePath,
'--mode',
'link',
]);
expect(exitCode).toBe(0);
const meta = JSON.parse(stdout) as { id: string; filename: string };
const storedPath = path.join(
claudeDir,
'teams',
TEAM,
'task-attachments',
'1',
`${meta.id}--${meta.filename}`
);
expect(fs.existsSync(storedPath)).toBe(true);
});
});
// =========================================================================
// Comment Auto-Clear needsClarification
// =========================================================================