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();
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeId() {
|
||||||
|
return crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + '-' + String(Math.random());
|
||||||
|
}
|
||||||
|
|
||||||
function formatError(err) {
|
function formatError(err) {
|
||||||
if (!err) return 'Unknown error';
|
if (!err) return 'Unknown error';
|
||||||
if (typeof err === 'string') return err;
|
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) {
|
function normalizeStatus(value) {
|
||||||
const v = String(value || '').trim();
|
const v = String(value || '').trim();
|
||||||
if (v === 'pending' || v === 'in_progress' || v === 'completed' || v === 'deleted') return v;
|
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 : [];
|
existing = Array.isArray(task.comments) ? task.comments : [];
|
||||||
commentId = crypto.randomUUID
|
commentId = makeId();
|
||||||
? crypto.randomUUID()
|
|
||||||
: String(Date.now()) + '-' + String(Math.random());
|
|
||||||
comment = {
|
comment = {
|
||||||
id: commentId,
|
id: commentId,
|
||||||
author: from,
|
author: from,
|
||||||
|
|
@ -598,9 +828,7 @@ function sendInboxMessage(paths, teamName, flags) {
|
||||||
const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json');
|
const inboxPath = path.join(paths.teamDir, 'inboxes', String(to) + '.json');
|
||||||
ensureDir(path.dirname(inboxPath));
|
ensureDir(path.dirname(inboxPath));
|
||||||
|
|
||||||
const messageId = crypto.randomUUID
|
const messageId = makeId();
|
||||||
? crypto.randomUUID()
|
|
||||||
: String(Date.now()) + '-' + String(Math.random());
|
|
||||||
const payload = {
|
const payload = {
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
|
|
@ -640,9 +868,7 @@ function reviewApprove(paths, teamName, taskId, flags) {
|
||||||
|
|
||||||
// Record review comment in task.comments
|
// Record review comment in task.comments
|
||||||
var existing = Array.isArray(task.comments) ? task.comments : [];
|
var existing = Array.isArray(task.comments) ? task.comments : [];
|
||||||
var reviewCommentId = crypto.randomUUID
|
var reviewCommentId = makeId();
|
||||||
? crypto.randomUUID()
|
|
||||||
: String(Date.now()) + '-' + String(Math.random());
|
|
||||||
task.comments = existing.concat([{
|
task.comments = existing.concat([{
|
||||||
id: reviewCommentId,
|
id: reviewCommentId,
|
||||||
author: from,
|
author: from,
|
||||||
|
|
@ -681,9 +907,7 @@ function reviewRequestChanges(paths, teamName, taskId, flags) {
|
||||||
|
|
||||||
// Record review comment in task.comments
|
// Record review comment in task.comments
|
||||||
var existing = Array.isArray(task.comments) ? task.comments : [];
|
var existing = Array.isArray(task.comments) ? task.comments : [];
|
||||||
var reviewCommentId = crypto.randomUUID
|
var reviewCommentId = makeId();
|
||||||
? crypto.randomUUID()
|
|
||||||
: String(Date.now()) + '-' + String(Math.random());
|
|
||||||
task.comments = existing.concat([{
|
task.comments = existing.concat([{
|
||||||
id: reviewCommentId,
|
id: reviewCommentId,
|
||||||
author: from,
|
author: from,
|
||||||
|
|
@ -747,7 +971,7 @@ function processRegister(paths, flags) {
|
||||||
const existingIdx = list.findIndex(function (p) { return p.pid === pid; });
|
const existingIdx = list.findIndex(function (p) { return p.pid === pid; });
|
||||||
|
|
||||||
const entry = {
|
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,
|
port: port,
|
||||||
url: url,
|
url: url,
|
||||||
label: label,
|
label: label,
|
||||||
|
|
@ -936,6 +1160,8 @@ function printHelp() {
|
||||||
' node teamctl.js task unlink <id> --related <targetId> [--team <team>]',
|
' 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 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 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 set-clarification <id> <lead|user|clear> [--from "member"] [--team <team>]',
|
||||||
' node teamctl.js task briefing --for <member-name> [--team <team>]',
|
' node teamctl.js task briefing --for <member-name> [--team <team>]',
|
||||||
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
|
' node teamctl.js kanban set-column <id> <review|approved> [--team <team>]',
|
||||||
|
|
@ -951,6 +1177,8 @@ function printHelp() {
|
||||||
'Options:',
|
'Options:',
|
||||||
' --team <name> Team name (if not under ~/.claude/teams/<team>/tools)',
|
' --team <name> Team name (if not under ~/.claude/teams/<team>/tools)',
|
||||||
' --claude-dir <path> Override ~/.claude location',
|
' --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')
|
].join('\n')
|
||||||
);
|
);
|
||||||
|
|
@ -1069,6 +1297,42 @@ async function main() {
|
||||||
process.stdout.write('OK comment added to task #' + String(id) + '\n');
|
process.stdout.write('OK comment added to task #' + String(id) + '\n');
|
||||||
return;
|
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') {
|
if (action === 'set-clarification') {
|
||||||
const id = rest[0] || args.flags.id;
|
const id = rest[0] || args.flags.id;
|
||||||
const val = rest[1] || args.flags.value;
|
const val = rest[1] || args.flags.value;
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,8 @@ interface ProvisioningRun {
|
||||||
stderrBuffer: string;
|
stderrBuffer: string;
|
||||||
/** Rolling buffer of CLI log lines (oldest -> newest). */
|
/** Rolling buffer of CLI log lines (oldest -> newest). */
|
||||||
claudeLogLines: string[];
|
claudeLogLines: string[];
|
||||||
|
/** Last stream used for claudeLogLines markers. */
|
||||||
|
lastClaudeLogStream: 'stdout' | 'stderr' | null;
|
||||||
/** Carry buffer for stdout line splitting (CLI output). */
|
/** Carry buffer for stdout line splitting (CLI output). */
|
||||||
stdoutLogLineBuf: string;
|
stdoutLogLineBuf: string;
|
||||||
/** Carry buffer for stderr line splitting (CLI output). */
|
/** 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>`,
|
`- 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>`,
|
`- 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>`,
|
`- 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}"`,
|
`- 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 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>`,
|
`- 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>`,
|
`- 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:`,
|
`Dependency guidelines:`,
|
||||||
`- Use --blocked-by when a task cannot start until another is done.`,
|
`- 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.`,
|
`- 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 newestExclusive = Math.max(0, total - offset);
|
||||||
const oldestInclusive = Math.max(0, newestExclusive - limit);
|
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();
|
const lines = windowOldestToNewest.reverse();
|
||||||
return {
|
return {
|
||||||
lines,
|
lines,
|
||||||
|
|
@ -1035,14 +1055,19 @@ export class TeamProvisioningService {
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
run.claudeLogsUpdatedAt = new Date(nowMs).toISOString();
|
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') {
|
if (stream === 'stdout') {
|
||||||
run.stdoutLogLineBuf += text;
|
run.stdoutLogLineBuf += text;
|
||||||
const parts = run.stdoutLogLineBuf.split('\n');
|
const parts = run.stdoutLogLineBuf.split('\n');
|
||||||
run.stdoutLogLineBuf = parts.pop() ?? '';
|
run.stdoutLogLineBuf = parts.pop() ?? '';
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
const normalized = part.endsWith('\r') ? part.slice(0, -1) : part;
|
const normalized = part.endsWith('\r') ? part.slice(0, -1) : part;
|
||||||
run.claudeLogLines.push(prefix + normalized);
|
run.claudeLogLines.push(normalized);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
run.stderrLogLineBuf += text;
|
run.stderrLogLineBuf += text;
|
||||||
|
|
@ -1050,7 +1075,7 @@ export class TeamProvisioningService {
|
||||||
run.stderrLogLineBuf = parts.pop() ?? '';
|
run.stderrLogLineBuf = parts.pop() ?? '';
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
const normalized = part.endsWith('\r') ? part.slice(0, -1) : part;
|
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) {
|
if (run.claudeLogLines.length > TeamProvisioningService.CLAUDE_LOG_LINES_LIMIT) {
|
||||||
|
|
@ -1390,6 +1415,7 @@ export class TeamProvisioningService {
|
||||||
run.stdoutBuffer = '';
|
run.stdoutBuffer = '';
|
||||||
run.stderrBuffer = '';
|
run.stderrBuffer = '';
|
||||||
run.claudeLogLines = [];
|
run.claudeLogLines = [];
|
||||||
|
run.lastClaudeLogStream = null;
|
||||||
run.stdoutLogLineBuf = '';
|
run.stdoutLogLineBuf = '';
|
||||||
run.stderrLogLineBuf = '';
|
run.stderrLogLineBuf = '';
|
||||||
run.claudeLogsUpdatedAt = undefined;
|
run.claudeLogsUpdatedAt = undefined;
|
||||||
|
|
@ -1612,6 +1638,7 @@ export class TeamProvisioningService {
|
||||||
stdoutBuffer: '',
|
stdoutBuffer: '',
|
||||||
stderrBuffer: '',
|
stderrBuffer: '',
|
||||||
claudeLogLines: [],
|
claudeLogLines: [],
|
||||||
|
lastClaudeLogStream: null,
|
||||||
stdoutLogLineBuf: '',
|
stdoutLogLineBuf: '',
|
||||||
stderrLogLineBuf: '',
|
stderrLogLineBuf: '',
|
||||||
claudeLogsUpdatedAt: undefined,
|
claudeLogsUpdatedAt: undefined,
|
||||||
|
|
@ -1900,6 +1927,7 @@ export class TeamProvisioningService {
|
||||||
stdoutBuffer: '',
|
stdoutBuffer: '',
|
||||||
stderrBuffer: '',
|
stderrBuffer: '',
|
||||||
claudeLogLines: [],
|
claudeLogLines: [],
|
||||||
|
lastClaudeLogStream: null,
|
||||||
stdoutLogLineBuf: '',
|
stdoutLogLineBuf: '',
|
||||||
stderrLogLineBuf: '',
|
stderrLogLineBuf: '',
|
||||||
claudeLogsUpdatedAt: undefined,
|
claudeLogsUpdatedAt: undefined,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const logger = createLogger('Service:TeamTaskAttachmentStore');
|
||||||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
||||||
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
|
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/png',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/gif',
|
'image/gif',
|
||||||
|
|
@ -40,14 +40,33 @@ export class TeamTaskAttachmentStore {
|
||||||
return path.join(getTeamsBasePath(), teamName, TASK_ATTACHMENTS_DIR, taskId);
|
return path.join(getTeamsBasePath(), teamName, TASK_ATTACHMENTS_DIR, taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns the file path for a specific attachment. */
|
private sanitizeStoredFilename(original: string): string {
|
||||||
private getFilePath(teamName: string, taskId: string, attachmentId: string, ext: string): string {
|
const raw = String(original ?? '').trim();
|
||||||
this.assertSafePathSegment('attachmentId', attachmentId);
|
const base = raw ? raw.split(/[\\/]/).pop() ?? raw : '';
|
||||||
return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}${ext}`);
|
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. */
|
/** Returns the file path for a stored attachment (new format). */
|
||||||
private mimeToExt(mimeType: AttachmentMediaType): string {
|
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) {
|
switch (mimeType) {
|
||||||
case 'image/png':
|
case 'image/png':
|
||||||
return '.png';
|
return '.png';
|
||||||
|
|
@ -57,9 +76,54 @@ export class TeamTaskAttachmentStore {
|
||||||
return '.gif';
|
return '.gif';
|
||||||
case 'image/webp':
|
case 'image/webp':
|
||||||
return '.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.
|
* Save an attachment to disk. Data is expected as a base64-encoded string.
|
||||||
* Returns metadata for the saved attachment.
|
* Returns metadata for the saved attachment.
|
||||||
|
|
@ -72,10 +136,6 @@ export class TeamTaskAttachmentStore {
|
||||||
mimeType: AttachmentMediaType,
|
mimeType: AttachmentMediaType,
|
||||||
base64Data: string
|
base64Data: string
|
||||||
): Promise<TaskAttachmentMeta> {
|
): Promise<TaskAttachmentMeta> {
|
||||||
if (!ALLOWED_MIME_TYPES.has(mimeType)) {
|
|
||||||
throw new Error(`Unsupported MIME type: ${mimeType}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmed = base64Data.trim();
|
const trimmed = base64Data.trim();
|
||||||
// Avoid allocating huge Buffers for obviously too-large payloads.
|
// Avoid allocating huge Buffers for obviously too-large payloads.
|
||||||
// Base64 decoded size is roughly 3/4 of the string length minus padding.
|
// 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);
|
const dir = this.getTaskDir(teamName, taskId);
|
||||||
await fs.promises.mkdir(dir, { recursive: true });
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
|
|
||||||
const ext = this.mimeToExt(mimeType);
|
const filePath = this.getStoredFilePath(teamName, taskId, attachmentId, filename);
|
||||||
const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
|
|
||||||
await fs.promises.writeFile(filePath, buffer);
|
await fs.promises.writeFile(filePath, buffer);
|
||||||
|
|
||||||
const meta: TaskAttachmentMeta = {
|
const meta: TaskAttachmentMeta = {
|
||||||
|
|
@ -122,8 +181,8 @@ export class TeamTaskAttachmentStore {
|
||||||
attachmentId: string,
|
attachmentId: string,
|
||||||
mimeType: AttachmentMediaType
|
mimeType: AttachmentMediaType
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const ext = this.mimeToExt(mimeType);
|
const filePath = await this.findAttachmentFilePath(teamName, taskId, attachmentId, mimeType);
|
||||||
const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
|
if (!filePath) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await fs.promises.readFile(filePath);
|
const buffer = await fs.promises.readFile(filePath);
|
||||||
|
|
@ -145,8 +204,8 @@ export class TeamTaskAttachmentStore {
|
||||||
attachmentId: string,
|
attachmentId: string,
|
||||||
mimeType: AttachmentMediaType
|
mimeType: AttachmentMediaType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const ext = this.mimeToExt(mimeType);
|
const filePath = await this.findAttachmentFilePath(teamName, taskId, attachmentId, mimeType);
|
||||||
const filePath = this.getFilePath(teamName, taskId, attachmentId, ext);
|
if (!filePath) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import * as path from 'path';
|
||||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AttachmentMediaType,
|
|
||||||
StatusTransition,
|
StatusTransition,
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
|
|
@ -20,12 +19,18 @@ import type {
|
||||||
const logger = createLogger('Service:TeamTaskReader');
|
const logger = createLogger('Service:TeamTaskReader');
|
||||||
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
const MAX_TASK_FILE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
const VALID_ATTACHMENT_MIME_TYPES: ReadonlySet<string> = new Set<AttachmentMediaType>([
|
function isValidMimeTypeString(value: unknown): value is string {
|
||||||
'image/png',
|
if (typeof value !== 'string') return false;
|
||||||
'image/jpeg',
|
const v = value.trim();
|
||||||
'image/gif',
|
if (!v) return false;
|
||||||
'image/webp',
|
// 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 {
|
export class TeamTaskReader {
|
||||||
/**
|
/**
|
||||||
|
|
@ -203,23 +208,25 @@ export class TeamTaskReader {
|
||||||
attachments: Array.isArray(c.attachments)
|
attachments: Array.isArray(c.attachments)
|
||||||
? (() => {
|
? (() => {
|
||||||
const filtered = (c.attachments as unknown[])
|
const filtered = (c.attachments as unknown[])
|
||||||
.filter(
|
.filter((a): a is TaskAttachmentMeta => {
|
||||||
(a): a is TaskAttachmentMeta =>
|
if (!a || typeof a !== 'object') return false;
|
||||||
Boolean(a) &&
|
const row = a as Record<string, unknown>;
|
||||||
typeof a === 'object' &&
|
const size = row.size;
|
||||||
typeof (a as Record<string, unknown>).id === 'string' &&
|
return (
|
||||||
typeof (a as Record<string, unknown>).filename === 'string' &&
|
typeof row.id === 'string' &&
|
||||||
typeof (a as Record<string, unknown>).mimeType === 'string' &&
|
typeof row.filename === 'string' &&
|
||||||
VALID_ATTACHMENT_MIME_TYPES.has(
|
typeof row.mimeType === 'string' &&
|
||||||
(a as Record<string, unknown>).mimeType as string
|
isValidMimeTypeString(row.mimeType) &&
|
||||||
) &&
|
typeof size === 'number' &&
|
||||||
typeof (a as Record<string, unknown>).size === 'number' &&
|
Number.isFinite(size) &&
|
||||||
typeof (a as Record<string, unknown>).addedAt === 'string'
|
size >= 0 &&
|
||||||
)
|
typeof row.addedAt === 'string'
|
||||||
|
);
|
||||||
|
})
|
||||||
.map((a) => ({
|
.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
filename: a.filename,
|
filename: a.filename,
|
||||||
mimeType: a.mimeType,
|
mimeType: String(a.mimeType).trim(),
|
||||||
size: a.size,
|
size: a.size,
|
||||||
addedAt: a.addedAt,
|
addedAt: a.addedAt,
|
||||||
}));
|
}));
|
||||||
|
|
@ -236,23 +243,25 @@ export class TeamTaskReader {
|
||||||
deletedAt: undefined, // deleted tasks are filtered out below
|
deletedAt: undefined, // deleted tasks are filtered out below
|
||||||
attachments: Array.isArray(parsed.attachments)
|
attachments: Array.isArray(parsed.attachments)
|
||||||
? (parsed.attachments as unknown[])
|
? (parsed.attachments as unknown[])
|
||||||
.filter(
|
.filter((a): a is TaskAttachmentMeta => {
|
||||||
(a): a is TaskAttachmentMeta =>
|
if (!a || typeof a !== 'object') return false;
|
||||||
Boolean(a) &&
|
const row = a as Record<string, unknown>;
|
||||||
typeof a === 'object' &&
|
const size = row.size;
|
||||||
typeof (a as Record<string, unknown>).id === 'string' &&
|
return (
|
||||||
typeof (a as Record<string, unknown>).filename === 'string' &&
|
typeof row.id === 'string' &&
|
||||||
typeof (a as Record<string, unknown>).mimeType === 'string' &&
|
typeof row.filename === 'string' &&
|
||||||
VALID_ATTACHMENT_MIME_TYPES.has(
|
typeof row.mimeType === 'string' &&
|
||||||
(a as Record<string, unknown>).mimeType as string
|
isValidMimeTypeString(row.mimeType) &&
|
||||||
) &&
|
typeof size === 'number' &&
|
||||||
typeof (a as Record<string, unknown>).size === 'number' &&
|
Number.isFinite(size) &&
|
||||||
typeof (a as Record<string, unknown>).addedAt === 'string'
|
size >= 0 &&
|
||||||
)
|
typeof row.addedAt === 'string'
|
||||||
|
);
|
||||||
|
})
|
||||||
.map((a) => ({
|
.map((a) => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
filename: a.filename,
|
filename: a.filename,
|
||||||
mimeType: a.mimeType,
|
mimeType: String(a.mimeType).trim(),
|
||||||
size: a.size,
|
size: a.size,
|
||||||
addedAt: a.addedAt,
|
addedAt: a.addedAt,
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,11 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => {
|
||||||
{tab.type === 'notifications' && <NotificationsView />}
|
{tab.type === 'notifications' && <NotificationsView />}
|
||||||
{tab.type === 'settings' && <SettingsView />}
|
{tab.type === 'settings' && <SettingsView />}
|
||||||
{tab.type === 'teams' && <TeamListView />}
|
{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' && (
|
{tab.type === 'session' && (
|
||||||
<TabUIProvider tabId={tab.id}>
|
<TabUIProvider tabId={tab.id}>
|
||||||
<SessionTabContent tab={tab} isActive={isActive} />
|
<SessionTabContent tab={tab} isActive={isActive} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
|
import { useTaskLocalState } from '@renderer/hooks/useTaskLocalState';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
|
|
@ -96,6 +97,7 @@ export const GlobalTaskList = ({
|
||||||
globalTasksLoading,
|
globalTasksLoading,
|
||||||
globalTasksInitialized,
|
globalTasksInitialized,
|
||||||
fetchAllTasks,
|
fetchAllTasks,
|
||||||
|
softDeleteTask,
|
||||||
projects,
|
projects,
|
||||||
viewMode,
|
viewMode,
|
||||||
repositoryGroups,
|
repositoryGroups,
|
||||||
|
|
@ -106,6 +108,7 @@ export const GlobalTaskList = ({
|
||||||
globalTasksLoading: s.globalTasksLoading,
|
globalTasksLoading: s.globalTasksLoading,
|
||||||
globalTasksInitialized: s.globalTasksInitialized,
|
globalTasksInitialized: s.globalTasksInitialized,
|
||||||
fetchAllTasks: s.fetchAllTasks,
|
fetchAllTasks: s.fetchAllTasks,
|
||||||
|
softDeleteTask: s.softDeleteTask,
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
viewMode: s.viewMode,
|
viewMode: s.viewMode,
|
||||||
repositoryGroups: s.repositoryGroups,
|
repositoryGroups: s.repositoryGroups,
|
||||||
|
|
@ -145,6 +148,20 @@ export const GlobalTaskList = ({
|
||||||
setRenamingTaskKey(null);
|
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
|
// Fetch tasks on mount — loading guard in the store action prevents
|
||||||
// duplicate IPC calls when the centralized init chain is already fetching.
|
// duplicate IPC calls when the centralized init chain is already fetching.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -329,6 +346,7 @@ export const GlobalTaskList = ({
|
||||||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||||
|
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||||
>
|
>
|
||||||
<SidebarTaskItem
|
<SidebarTaskItem
|
||||||
task={task}
|
task={task}
|
||||||
|
|
@ -425,6 +443,7 @@ export const GlobalTaskList = ({
|
||||||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||||
|
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||||
>
|
>
|
||||||
<SidebarTaskItem
|
<SidebarTaskItem
|
||||||
task={task}
|
task={task}
|
||||||
|
|
@ -472,6 +491,7 @@ export const GlobalTaskList = ({
|
||||||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||||
|
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||||
>
|
>
|
||||||
<SidebarTaskItem
|
<SidebarTaskItem
|
||||||
task={task}
|
task={task}
|
||||||
|
|
@ -523,6 +543,7 @@ export const GlobalTaskList = ({
|
||||||
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
onTogglePin={() => taskLocalState.togglePin(task.teamName, task.id)}
|
||||||
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
onToggleArchive={() => taskLocalState.toggleArchive(task.teamName, task.id)}
|
||||||
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
onRename={() => setRenamingTaskKey(`${task.teamName}:${task.id}`)}
|
||||||
|
onDelete={() => handleDeleteTask(task.teamName, task.id)}
|
||||||
>
|
>
|
||||||
<SidebarTaskItem
|
<SidebarTaskItem
|
||||||
task={task}
|
task={task}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from '@renderer/components/ui/context-menu';
|
} 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';
|
import type { GlobalTask } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -16,6 +16,7 @@ export interface TaskContextMenuProps {
|
||||||
onTogglePin: () => void;
|
onTogglePin: () => void;
|
||||||
onToggleArchive: () => void;
|
onToggleArchive: () => void;
|
||||||
onRename: () => void;
|
onRename: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,6 +27,7 @@ export const TaskContextMenu = ({
|
||||||
onTogglePin,
|
onTogglePin,
|
||||||
onToggleArchive,
|
onToggleArchive,
|
||||||
onRename,
|
onRename,
|
||||||
|
onDelete,
|
||||||
children,
|
children,
|
||||||
}: TaskContextMenuProps): React.JSX.Element => {
|
}: TaskContextMenuProps): React.JSX.Element => {
|
||||||
return (
|
return (
|
||||||
|
|
@ -68,6 +70,19 @@ export const TaskContextMenu = ({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</ContextMenuItem>
|
</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>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { cn } from '@renderer/lib/utils';
|
||||||
import { Terminal } from 'lucide-react';
|
import { Terminal } from 'lucide-react';
|
||||||
|
|
||||||
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from './CollapsibleTeamSection';
|
||||||
|
import { CliLogsRichView } from './CliLogsRichView';
|
||||||
|
|
||||||
import type { TeamClaudeLogsResponse } from '@shared/types';
|
import type { TeamClaudeLogsResponse } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -24,6 +25,46 @@ function isRecent(updatedAt: string | undefined): boolean {
|
||||||
return Date.now() - t <= ONLINE_WINDOW_MS;
|
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 => {
|
export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => {
|
||||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||||
const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
const [data, setData] = useState<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
||||||
|
|
@ -112,21 +153,24 @@ export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.J
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-h-[320px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2',
|
'rounded',
|
||||||
loading && 'opacity-80'
|
loading && 'opacity-80'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{error ? (
|
{error ? <p className="p-2 text-xs text-red-300">{error}</p> : null}
|
||||||
<p className="text-xs text-red-300">{error}</p>
|
{!error && data.lines.length > 0 ? (
|
||||||
) : data.lines.length > 0 ? (
|
<CliLogsRichView
|
||||||
<pre className="whitespace-pre-wrap break-words font-mono text-[11px] leading-4 text-[var(--color-text-secondary)]">
|
// Parser expects chronological order; UI shows newest-first.
|
||||||
{data.lines.join('\n')}
|
cliLogsTail={normalizeToStreamJsonText(data.lines)}
|
||||||
</pre>
|
order="newest-first"
|
||||||
) : (
|
className="max-h-[320px] p-2"
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
/>
|
||||||
|
) : null}
|
||||||
|
{!error && data.lines.length === 0 ? (
|
||||||
|
<p className="p-2 text-xs text-[var(--color-text-muted)]">
|
||||||
{loading ? 'Loading…' : 'No logs captured.'}
|
{loading ? 'Loading…' : 'No logs captured.'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import type { StreamJsonGroup } from '@renderer/utils/streamJsonParser';
|
||||||
|
|
||||||
interface CliLogsRichViewProps {
|
interface CliLogsRichViewProps {
|
||||||
cliLogsTail: string;
|
cliLogsTail: string;
|
||||||
|
order?: 'oldest-first' | 'newest-first';
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,6 +129,7 @@ const StreamGroup = ({
|
||||||
|
|
||||||
export const CliLogsRichView = ({
|
export const CliLogsRichView = ({
|
||||||
cliLogsTail,
|
cliLogsTail,
|
||||||
|
order = 'oldest-first',
|
||||||
className,
|
className,
|
||||||
}: CliLogsRichViewProps): React.JSX.Element => {
|
}: CliLogsRichViewProps): React.JSX.Element => {
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -151,9 +153,13 @@ export const CliLogsRichView = ({
|
||||||
// Auto-scroll to bottom on new content
|
// Auto-scroll to bottom on new content
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
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) => {
|
const handleGroupToggle = useCallback((groupId: string) => {
|
||||||
setCollapsedGroupIds((prev) => {
|
setCollapsedGroupIds((prev) => {
|
||||||
|
|
@ -203,9 +209,11 @@ export const CliLogsRichView = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibleGroups = order === 'newest-first' ? [...groups].reverse() : groups;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={scrollRef} className={cn('max-h-[400px] space-y-1.5 overflow-y-auto', className)}>
|
<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 ? (
|
group.items.length === 1 ? (
|
||||||
// Single item — render flat without collapsible group wrapper
|
// Single item — render flat without collapsible group wrapper
|
||||||
<FlatGroupItem
|
<FlatGroupItem
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,9 @@ export const TaskTooltip = ({
|
||||||
name={task.owner}
|
name={task.owner}
|
||||||
color={colorMap.get(task.owner)}
|
color={colorMap.get(task.owner)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : (
|
||||||
|
<span className="text-[10px] text-[var(--color-text-muted)]">Unassigned</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Description — full markdown with scroll */}
|
{/* Description — full markdown with scroll */}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } fro
|
||||||
import { api } from '@renderer/api';
|
import { api } from '@renderer/api';
|
||||||
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
import { confirm } from '@renderer/components/common/ConfirmDialog';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import { SessionContextPanel } from '@renderer/components/chat/SessionContextPanel/index';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
|
@ -17,6 +18,7 @@ import { useBranchSync } from '@renderer/hooks/useBranchSync';
|
||||||
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
import { cn } from '@renderer/lib/utils';
|
import { cn } from '@renderer/lib/utils';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
import { useTabUI } from '@renderer/hooks/useTabUI';
|
||||||
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
import { createChipFromSelection } from '@renderer/utils/chipUtils';
|
||||||
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
import { formatProjectPath } from '@renderer/utils/pathDisplay';
|
||||||
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
import { buildTaskCountsByOwner, normalizePath } from '@renderer/utils/pathNormalize';
|
||||||
|
|
@ -77,6 +79,7 @@ import { TeamSessionsSection } from './TeamSessionsSection';
|
||||||
|
|
||||||
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
import type { KanbanFilterState } from './kanban/KanbanFilterPopover';
|
||||||
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
|
import type { MessagesFilterState } from './messages/MessagesFilterPopover';
|
||||||
|
import type { ContextInjection } from '@renderer/types/contextInjection';
|
||||||
import type { Session } from '@renderer/types/data';
|
import type { Session } from '@renderer/types/data';
|
||||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||||
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
@ -192,6 +195,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
projects,
|
projects,
|
||||||
repositoryGroups,
|
repositoryGroups,
|
||||||
teams,
|
teams,
|
||||||
|
fetchSessionDetail,
|
||||||
|
initTabUIState,
|
||||||
selectTeam,
|
selectTeam,
|
||||||
updateKanban,
|
updateKanban,
|
||||||
updateKanbanColumnOrder,
|
updateKanbanColumnOrder,
|
||||||
|
|
@ -230,6 +235,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
projects: s.projects,
|
projects: s.projects,
|
||||||
repositoryGroups: s.repositoryGroups,
|
repositoryGroups: s.repositoryGroups,
|
||||||
teams: s.teams,
|
teams: s.teams,
|
||||||
|
fetchSessionDetail: s.fetchSessionDetail,
|
||||||
|
initTabUIState: s.initTabUIState,
|
||||||
selectTeam: s.selectTeam,
|
selectTeam: s.selectTeam,
|
||||||
updateKanban: s.updateKanban,
|
updateKanban: s.updateKanban,
|
||||||
updateKanbanColumnOrder: s.updateKanbanColumnOrder,
|
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(() => {
|
useEffect(() => {
|
||||||
const wasProvisioning = wasProvisioningRef.current;
|
const wasProvisioning = wasProvisioningRef.current;
|
||||||
wasProvisioningRef.current = isTeamProvisioning;
|
wasProvisioningRef.current = isTeamProvisioning;
|
||||||
|
|
@ -336,6 +359,78 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
[projects, repositoryGroups, data?.config.projectPath]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
|
|
@ -746,7 +841,49 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
className="relative mb-3 overflow-hidden rounded-lg border border-[var(--color-border)] px-4 py-3"
|
className="relative mb-3 overflow-hidden rounded-lg border border-[var(--color-border)] px-4 py-3"
|
||||||
style={
|
style={
|
||||||
|
|
@ -1601,6 +1738,53 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
||||||
projectPath={data.config.projectPath}
|
projectPath={data.config.projectPath}
|
||||||
onEditorAction={handleEditorAction}
|
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>
|
</div>
|
||||||
|
|
||||||
{editorOpen && data.config.projectPath && (
|
{editorOpen && data.config.projectPath && (
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,8 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
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';
|
import { MemberBadge } from '../MemberBadge';
|
||||||
|
|
||||||
|
|
@ -40,6 +41,8 @@ interface QuotedMessage {
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_MESSAGE_LENGTH = 4000;
|
||||||
|
|
||||||
interface SendMessageDialogProps {
|
interface SendMessageDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -177,9 +180,13 @@ export const SendMessageDialog = ({
|
||||||
|
|
||||||
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
const attachmentsBlocked = attachments.length > 0 && !supportsAttachments;
|
||||||
|
|
||||||
|
const trimmedText = textDraft.value.trim();
|
||||||
|
const remaining = MAX_MESSAGE_LENGTH - trimmedText.length;
|
||||||
|
|
||||||
const canSend =
|
const canSend =
|
||||||
member.trim().length > 0 &&
|
member.trim().length > 0 &&
|
||||||
textDraft.value.trim().length > 0 &&
|
trimmedText.length > 0 &&
|
||||||
|
trimmedText.length <= MAX_MESSAGE_LENGTH &&
|
||||||
summary.trim().length > 0 &&
|
summary.trim().length > 0 &&
|
||||||
!sending &&
|
!sending &&
|
||||||
!attachmentsBlocked;
|
!attachmentsBlocked;
|
||||||
|
|
@ -262,7 +269,7 @@ export const SendMessageDialog = ({
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="sm:max-w-[560px]"
|
className="sm:max-w-[720px]"
|
||||||
onDragEnter={canAttach ? handleDragEnter : undefined}
|
onDragEnter={canAttach ? handleDragEnter : undefined}
|
||||||
onDragLeave={canAttach ? handleDragLeave : undefined}
|
onDragLeave={canAttach ? handleDragLeave : undefined}
|
||||||
onDragOver={canAttach ? handleDragOver : undefined}
|
onDragOver={canAttach ? handleDragOver : undefined}
|
||||||
|
|
@ -415,7 +422,7 @@ export const SendMessageDialog = ({
|
||||||
<MentionableTextarea
|
<MentionableTextarea
|
||||||
id="smd-message"
|
id="smd-message"
|
||||||
className={quote ? 'rounded-t-none' : undefined}
|
className={quote ? 'rounded-t-none' : undefined}
|
||||||
placeholder="Write your message..."
|
placeholder={`Write your message... (${getModifierKeyName()}+Enter to send)`}
|
||||||
value={textDraft.value}
|
value={textDraft.value}
|
||||||
onValueChange={textDraft.setValue}
|
onValueChange={textDraft.setValue}
|
||||||
suggestions={mentionSuggestions}
|
suggestions={mentionSuggestions}
|
||||||
|
|
@ -426,10 +433,38 @@ export const SendMessageDialog = ({
|
||||||
onModEnter={handleSubmit}
|
onModEnter={handleSubmit}
|
||||||
minRows={4}
|
minRows={4}
|
||||||
maxRows={12}
|
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={
|
footerRight={
|
||||||
textDraft.isSaved ? (
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
{sendError ? (
|
||||||
) : null
|
<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>
|
</div>
|
||||||
|
|
@ -449,16 +484,12 @@ export const SendMessageDialog = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sendError ? <p className="text-xs text-red-400">{sendError}</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" size="sm" onClick={onClose} disabled={sending}>
|
<Button variant="outline" size="sm" onClick={onClose} disabled={sending}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" onClick={handleSubmit} disabled={!canSend}>
|
|
||||||
{sending ? 'Sending...' : 'Send'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import { useStore } from '@renderer/store';
|
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']);
|
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 [error, setError] = useState<string | null>(null);
|
||||||
const [previewAttachment, setPreviewAttachment] = useState<{
|
const [previewAttachment, setPreviewAttachment] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
mimeType: AttachmentMediaType;
|
mimeType: string;
|
||||||
dataUrl: string | null;
|
dataUrl: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
@ -73,7 +75,7 @@ export const TaskAttachments = ({
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (attachmentId: string, mimeType: AttachmentMediaType) => {
|
async (attachmentId: string, mimeType: string) => {
|
||||||
setDeletingId(attachmentId);
|
setDeletingId(attachmentId);
|
||||||
try {
|
try {
|
||||||
await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType);
|
await deleteTaskAttachment(teamName, taskId, attachmentId, mimeType);
|
||||||
|
|
@ -89,8 +91,39 @@ export const TaskAttachments = ({
|
||||||
[teamName, taskId, deleteTaskAttachment, previewAttachment]
|
[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(
|
const handlePreview = useCallback(
|
||||||
async (att: TaskAttachmentMeta) => {
|
async (att: TaskAttachmentMeta) => {
|
||||||
|
if (!isImageMimeType(att.mimeType)) {
|
||||||
|
void handleDownload(att);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (previewAttachment?.id === att.id && previewAttachment.dataUrl) {
|
if (previewAttachment?.id === att.id && previewAttachment.dataUrl) {
|
||||||
setPreviewAttachment(null);
|
setPreviewAttachment(null);
|
||||||
return;
|
return;
|
||||||
|
|
@ -114,7 +147,7 @@ export const TaskAttachments = ({
|
||||||
setError('Failed to load attachment');
|
setError('Failed to load attachment');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[teamName, taskId, getTaskAttachmentData, previewAttachment]
|
[teamName, taskId, getTaskAttachmentData, previewAttachment, handleDownload]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle paste events for quick image attachment
|
// Handle paste events for quick image attachment
|
||||||
|
|
@ -277,6 +310,7 @@ const AttachmentThumbnail = ({
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
if (!isImageMimeType(attachment.mimeType)) return;
|
||||||
const base64 = await getTaskAttachmentData(
|
const base64 = await getTaskAttachmentData(
|
||||||
teamName,
|
teamName,
|
||||||
taskId,
|
taskId,
|
||||||
|
|
@ -311,10 +345,19 @@ const AttachmentThumbnail = ({
|
||||||
} bg-[var(--color-surface)]`}
|
} bg-[var(--color-surface)]`}
|
||||||
onClick={onPreview}
|
onClick={onPreview}
|
||||||
>
|
>
|
||||||
{thumbUrl ? (
|
{isImageMimeType(attachment.mimeType) ? (
|
||||||
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
|
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 */}
|
{/* Delete button overlay */}
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,12 @@ import { useMarkCommentsRead } from '@renderer/hooks/useMarkCommentsRead';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
|
import { buildReplyBlock, parseMessageReply } from '@renderer/utils/agentMessageFormatting';
|
||||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||||
|
import { isImageMimeType } from '@renderer/utils/attachmentUtils';
|
||||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
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 { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { ResolvedTeamMember, TaskAttachmentMeta, TaskComment } from '@shared/types';
|
import type { ResolvedTeamMember, TaskAttachmentMeta, TaskComment } from '@shared/types';
|
||||||
|
|
@ -414,11 +415,13 @@ const CommentAttachmentThumbnail = ({
|
||||||
}: CommentAttachmentThumbnailProps): React.JSX.Element => {
|
}: CommentAttachmentThumbnailProps): React.JSX.Element => {
|
||||||
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
|
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
|
||||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||||
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
if (!isImageMimeType(attachment.mimeType)) return;
|
||||||
const base64 = await getTaskAttachmentData(
|
const base64 = await getTaskAttachmentData(
|
||||||
teamName,
|
teamName,
|
||||||
taskId,
|
taskId,
|
||||||
|
|
@ -440,12 +443,51 @@ const CommentAttachmentThumbnail = ({
|
||||||
return (
|
return (
|
||||||
<div
|
<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)]"
|
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 ? (
|
{isImageMimeType(attachment.mimeType) ? (
|
||||||
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
|
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)]" />
|
<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">
|
<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}
|
{attachment.filename}
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,11 @@ export const KanbanTaskCard = ({
|
||||||
</span>
|
</span>
|
||||||
<div className="mb-2 pt-2">
|
<div className="mb-2 pt-2">
|
||||||
<div className="flex items-center gap-1">
|
<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" />}
|
{!compact && <TruncatedTitle text={task.subject} className="min-w-0" />}
|
||||||
</div>
|
</div>
|
||||||
{task.needsClarification ? (
|
{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-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)]">{task.subject}</td>
|
||||||
<td className="py-2 pr-3 text-[var(--color-text-secondary)]">
|
<td className="py-2 pr-3 text-[var(--color-text-secondary)]">
|
||||||
{task.owner ?? '—'}
|
{task.owner ?? 'Unassigned'}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-3 text-[var(--color-text-muted)]">
|
<td className="py-2 pr-3 text-[var(--color-text-muted)]">
|
||||||
{task.deletedAt
|
{task.deletedAt
|
||||||
|
|
|
||||||
|
|
@ -146,19 +146,26 @@ export const MemberCard = ({
|
||||||
</span>
|
</span>
|
||||||
) : null;
|
) : null;
|
||||||
})()}
|
})()}
|
||||||
<Badge
|
{presenceLabel === 'connecting' && !isRemoved ? (
|
||||||
variant="secondary"
|
<Loader2
|
||||||
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)]'}`}
|
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
|
||||||
title={
|
aria-label="connecting"
|
||||||
isRemoved
|
/>
|
||||||
? 'This member has been removed'
|
) : (
|
||||||
: member.currentTaskId
|
<Badge
|
||||||
? `Current task: ${member.currentTaskId}`
|
variant="secondary"
|
||||||
: undefined
|
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
|
||||||
{isRemoved ? 'removed' : presenceLabel}
|
? 'This member has been removed'
|
||||||
</Badge>
|
: member.currentTaskId
|
||||||
|
? `Current task: ${member.currentTaskId}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isRemoved ? 'removed' : presenceLabel}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="shrink-0"
|
className="shrink-0"
|
||||||
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}
|
title={totalTasks > 0 ? `${completed}/${totalTasks} completed` : undefined}
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,9 @@ export const MessageComposer = ({
|
||||||
}
|
}
|
||||||
footerRight={
|
footerRight={
|
||||||
<div className="flex items-center gap-2">
|
<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 ? (
|
{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">
|
<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" />
|
<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)]">
|
<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-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-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)]">
|
<td className="px-3 py-2 text-xs text-[var(--color-text-muted)]">
|
||||||
{task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY
|
{task.kanbanColumn && task.kanbanColumn in KANBAN_COLUMN_DISPLAY
|
||||||
? KANBAN_COLUMN_DISPLAY[task.kanbanColumn].label
|
? 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/png',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/gif',
|
'image/gif',
|
||||||
|
|
@ -11,8 +11,8 @@ export const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
export const MAX_FILES = 5;
|
export const MAX_FILES = 5;
|
||||||
export const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB
|
export const MAX_TOTAL_SIZE = 20 * 1024 * 1024; // 20MB
|
||||||
|
|
||||||
export function isImageMimeType(type: string): type is AttachmentMediaType {
|
export function isImageMimeType(type: string): type is ImageMimeType {
|
||||||
return ALLOWED_MIME_TYPES.has(type as AttachmentMediaType);
|
return ALLOWED_MIME_TYPES.has(type as ImageMimeType);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateAttachment(file: File): { valid: true } | { valid: false; error: string } {
|
export function validateAttachment(file: File): { valid: true } | { valid: false; error: string } {
|
||||||
|
|
@ -35,7 +35,7 @@ export async function fileToAttachmentPayload(file: File): Promise<AttachmentPay
|
||||||
resolve({
|
resolve({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
mimeType: file.type as AttachmentMediaType,
|
mimeType: file.type,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
data: base64,
|
data: base64,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ export interface TaskComment {
|
||||||
text: string;
|
text: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
type: TaskCommentType;
|
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[];
|
attachments?: TaskAttachmentMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -125,7 +125,7 @@ export interface TeamTask {
|
||||||
needsClarification?: 'lead' | 'user';
|
needsClarification?: 'lead' | 'user';
|
||||||
/** ISO timestamp — when the task was soft-deleted. Only set for status === 'deleted'. */
|
/** ISO timestamp — when the task was soft-deleted. Only set for status === 'deleted'. */
|
||||||
deletedAt?: string;
|
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[];
|
attachments?: TaskAttachmentMeta[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,7 +135,7 @@ export interface TeamTaskWithKanban extends TeamTask {
|
||||||
kanbanColumn?: 'review' | 'approved';
|
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 {
|
export interface TaskAttachmentMeta {
|
||||||
/** Unique attachment ID (uuid). */
|
/** Unique attachment ID (uuid). */
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -157,7 +157,16 @@ export interface CommentAttachmentPayload {
|
||||||
base64Data: string;
|
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 {
|
export interface AttachmentMeta {
|
||||||
id: string;
|
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
|
// Comment Auto-Clear needsClarification
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue