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:
parent
fdb52922fe
commit
f1d08e6d33
22 changed files with 1067 additions and 135 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 "create a task" 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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// =========================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue