feat: enhance attachment handling and metadata management
- Added filePath to attachment metadata in various components to support file access. - Updated saveTaskAttachmentFile and related functions to include file paths for stored attachments. - Enhanced documentation and comments to clarify the importance of using MCP tools for task review operations. - Improved UI components to display file paths where applicable, ensuring better user experience with attachments.
This commit is contained in:
parent
eacd7d82e1
commit
572cfab1a5
11 changed files with 261 additions and 44 deletions
|
|
@ -440,6 +440,7 @@ function saveTaskAttachmentFile(paths, taskId, flags) {
|
||||||
mimeType,
|
mimeType,
|
||||||
size: stats.size,
|
size: stats.size,
|
||||||
addedAt: nowIso(),
|
addedAt: nowIso(),
|
||||||
|
filePath: destPath,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -393,6 +393,7 @@ function buildMemberTaskProtocol(teamName) {
|
||||||
This is MANDATORY before review_approve or review_request_changes. Without this step, the kanban board may not show the task in REVIEW during your review.
|
This is MANDATORY before review_approve or review_request_changes. Without this step, the kanban board may not show the task in REVIEW during your review.
|
||||||
4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve:
|
4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve:
|
||||||
{ teamName: "${teamName}", taskId: "<taskId>", note?: "<optional note>", notifyOwner: true }
|
{ teamName: "${teamName}", taskId: "<taskId>", note?: "<optional note>", notifyOwner: true }
|
||||||
|
CRITICAL: Text comments like "approved" or "LGTM" do NOT change the kanban board. You MUST call review_approve to move a task from REVIEW to APPROVED. Without the tool call the task stays stuck in the REVIEW column.
|
||||||
5. If review fails and changes are needed, use MCP tool review_request_changes:
|
5. If review fails and changes are needed, use MCP tool review_request_changes:
|
||||||
{ teamName: "${teamName}", taskId: "<taskId>", comment: "<what to fix>" }
|
{ teamName: "${teamName}", taskId: "<taskId>", comment: "<what to fix>" }
|
||||||
6. NEVER skip status updates. A task is NOT done until completed status is written.
|
6. NEVER skip status updates. A task is NOT done until completed status is written.
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(source ? { source } : {}),
|
...(source ? { source } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Preserve attachment metadata by reference only — no blob copying
|
// Preserve attachment metadata — filePath included when available
|
||||||
if (Array.isArray(message.attachments) && message.attachments.length > 0) {
|
if (Array.isArray(message.attachments) && message.attachments.length > 0) {
|
||||||
sourceMessage.attachments = (message.attachments as Record<string, unknown>[])
|
sourceMessage.attachments = (message.attachments as Record<string, unknown>[])
|
||||||
.filter(
|
.filter(
|
||||||
|
|
@ -185,6 +185,7 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
filename: String(a.filename),
|
filename: String(a.filename),
|
||||||
mimeType: typeof a.mimeType === 'string' ? a.mimeType : '',
|
mimeType: typeof a.mimeType === 'string' ? a.mimeType : '',
|
||||||
size: typeof a.size === 'number' ? a.size : 0,
|
size: typeof a.size === 'number' ? a.size : 0,
|
||||||
|
...(typeof a.filePath === 'string' ? { filePath: a.filePath } : {}),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -212,7 +213,11 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'task_get',
|
name: 'task_get',
|
||||||
description: 'Get a task by id',
|
description:
|
||||||
|
'Get a task by id. Response includes:\n' +
|
||||||
|
'- sourceMessage.attachments: from original user message (filePath = absolute path to file on disk, use Read tool to view)\n' +
|
||||||
|
'- attachments: files attached to the task (filePath = absolute path, use Read tool to view)\n' +
|
||||||
|
'- comments[].attachments: files on comments (filePath = absolute path, use Read tool to view)',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
|
|
@ -314,7 +319,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'task_attach_file',
|
name: 'task_attach_file',
|
||||||
description: 'Attach a file to a task',
|
description:
|
||||||
|
'Attach a file to a task. Returns attachment metadata with filePath for future access via Read tool.',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
|
|
@ -351,7 +357,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'task_attach_comment_file',
|
name: 'task_attach_comment_file',
|
||||||
description: 'Attach a file to a task comment',
|
description:
|
||||||
|
'Attach a file to a task comment. Returns attachment metadata with filePath for future access via Read tool.',
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
|
|
|
||||||
|
|
@ -1247,12 +1247,30 @@ async function handleSendMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stdinSent) {
|
if (stdinSent) {
|
||||||
const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({
|
// Save attachment files to disk FIRST to get file paths for metadata
|
||||||
id: a.id,
|
let attachmentFilePaths: Map<string, string> | undefined;
|
||||||
filename: a.filename,
|
if (validatedAttachments?.length) {
|
||||||
mimeType: a.mimeType,
|
try {
|
||||||
size: a.size,
|
attachmentFilePaths = await attachmentStore.saveAttachments(
|
||||||
}));
|
tn,
|
||||||
|
preGeneratedMessageId,
|
||||||
|
validatedAttachments
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Failed to save attachments: ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => {
|
||||||
|
const fp = attachmentFilePaths?.get(a.id);
|
||||||
|
return {
|
||||||
|
id: a.id,
|
||||||
|
filename: a.filename,
|
||||||
|
mimeType: a.mimeType,
|
||||||
|
size: a.size,
|
||||||
|
...(fp ? { filePath: fp } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Persistence is best-effort — stdin already delivered the message
|
// Persistence is best-effort — stdin already delivered the message
|
||||||
let result: SendMessageResult;
|
let result: SendMessageResult;
|
||||||
|
|
@ -1271,12 +1289,7 @@ async function handleSendMessage(
|
||||||
result = { deliveredToInbox: false, messageId: preGeneratedMessageId };
|
result = { deliveredToInbox: false, messageId: preGeneratedMessageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save attachment binary data to disk (best-effort)
|
// Attachment files already saved above (before metadata construction)
|
||||||
if (validatedAttachments?.length && result.messageId) {
|
|
||||||
void attachmentStore
|
|
||||||
.saveAttachments(tn, result.messageId, validatedAttachments)
|
|
||||||
.catch((e) => logger.warn(`Failed to save attachments: ${e}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
provisioning.pushLiveLeadProcessMessage(tn, {
|
provisioning.pushLiveLeadProcessMessage(tn, {
|
||||||
from: 'user',
|
from: 'user',
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,27 @@ import { createLogger } from '@shared/utils/logger';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
|
||||||
|
|
||||||
import type { AttachmentFileData, AttachmentPayload } from '@shared/types';
|
import type { AttachmentFileData, AttachmentPayload } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:TeamAttachmentStore');
|
const logger = createLogger('Service:TeamAttachmentStore');
|
||||||
|
|
||||||
const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap
|
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB per file
|
||||||
|
const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB legacy JSON cap
|
||||||
|
|
||||||
|
/** Per-attachment metadata stored in the index file. */
|
||||||
|
interface StoredAttachmentIndex {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamAttachmentStore {
|
export class TeamAttachmentStore {
|
||||||
private assertSafePathSegment(label: string, value: string): void {
|
private assertSafePathSegment(label: string, value: string): void {
|
||||||
if (
|
if (
|
||||||
value.length === 0 ||
|
value.length === 0 ||
|
||||||
|
value.trim().length === 0 ||
|
||||||
|
value === '.' ||
|
||||||
|
value === '..' ||
|
||||||
value.includes('/') ||
|
value.includes('/') ||
|
||||||
value.includes('\\') ||
|
value.includes('\\') ||
|
||||||
value.includes('..') ||
|
value.includes('..') ||
|
||||||
|
|
@ -24,37 +33,204 @@ export class TeamAttachmentStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDir(teamName: string): string {
|
private sanitizeStoredFilename(original: string): string {
|
||||||
|
const raw = String(original ?? '').trim();
|
||||||
|
const base = raw ? (raw.split(/[\\/]/).pop() ?? raw) : '';
|
||||||
|
const cleaned = base
|
||||||
|
.replace(/\0/g, '')
|
||||||
|
.replace(/[\r\n\t]/g, ' ')
|
||||||
|
.replace(/[\\/]/g, '_')
|
||||||
|
.trim();
|
||||||
|
if (!cleaned) return 'attachment';
|
||||||
|
return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Base directory for all message attachments of a team. */
|
||||||
|
private getTeamDir(teamName: string): string {
|
||||||
this.assertSafePathSegment('teamName', teamName);
|
this.assertSafePathSegment('teamName', teamName);
|
||||||
return path.join(getAppDataPath(), 'attachments', teamName);
|
return path.join(getAppDataPath(), 'attachments', teamName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFilePath(teamName: string, messageId: string): string {
|
/** Directory for a specific message's attachments (new file-based format). */
|
||||||
|
private getMessageDir(teamName: string, messageId: string): string {
|
||||||
this.assertSafePathSegment('messageId', messageId);
|
this.assertSafePathSegment('messageId', messageId);
|
||||||
return path.join(this.getDir(teamName), `${messageId}.json`);
|
return path.join(this.getTeamDir(teamName), messageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Path to the metadata index file inside a message attachment directory. */
|
||||||
|
private getIndexPath(teamName: string, messageId: string): string {
|
||||||
|
return path.join(this.getMessageDir(teamName, messageId), '_index.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy JSON bundle path (old format). */
|
||||||
|
private getLegacyFilePath(teamName: string, messageId: string): string {
|
||||||
|
this.assertSafePathSegment('messageId', messageId);
|
||||||
|
return path.join(this.getTeamDir(teamName), `${messageId}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stored file path for an individual attachment. */
|
||||||
|
private getStoredFilePath(
|
||||||
|
teamName: string,
|
||||||
|
messageId: string,
|
||||||
|
attachmentId: string,
|
||||||
|
filename: string
|
||||||
|
): string {
|
||||||
|
this.assertSafePathSegment('attachmentId', attachmentId);
|
||||||
|
const safeName = this.sanitizeStoredFilename(filename);
|
||||||
|
return path.join(this.getMessageDir(teamName, messageId), `${attachmentId}--${safeName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save message attachments as individual files on disk.
|
||||||
|
* Returns a Map of attachmentId → absolute file path for each saved file.
|
||||||
|
*/
|
||||||
async saveAttachments(
|
async saveAttachments(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
attachments: AttachmentPayload[]
|
attachments: AttachmentPayload[]
|
||||||
): Promise<void> {
|
): Promise<Map<string, string>> {
|
||||||
if (attachments.length === 0) return;
|
const filePaths = new Map<string, string>();
|
||||||
|
if (attachments.length === 0) return filePaths;
|
||||||
|
|
||||||
const fileData: AttachmentFileData[] = attachments.map((a) => ({
|
const dir = this.getMessageDir(teamName, messageId);
|
||||||
id: a.id,
|
await fs.promises.mkdir(dir, { recursive: true });
|
||||||
data: a.data,
|
|
||||||
mimeType: a.mimeType,
|
const indexEntries: StoredAttachmentIndex[] = [];
|
||||||
}));
|
|
||||||
|
for (const att of attachments) {
|
||||||
|
const buffer = Buffer.from(att.data, 'base64');
|
||||||
|
if (buffer.length > MAX_ATTACHMENT_SIZE) {
|
||||||
|
logger.warn(
|
||||||
|
`[${teamName}] Skipping oversized attachment ${att.id} (${(buffer.length / (1024 * 1024)).toFixed(1)} MB)`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storedPath = this.getStoredFilePath(teamName, messageId, att.id, att.filename);
|
||||||
|
try {
|
||||||
|
await fs.promises.writeFile(storedPath, buffer);
|
||||||
|
} catch (writeError) {
|
||||||
|
logger.warn(`[${teamName}] Failed to write attachment ${att.id}: ${writeError}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
filePaths.set(att.id, storedPath);
|
||||||
|
|
||||||
|
indexEntries.push({
|
||||||
|
id: att.id,
|
||||||
|
filename: att.filename,
|
||||||
|
mimeType: att.mimeType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write metadata index for successful files (mimeType, original filename)
|
||||||
|
if (indexEntries.length > 0) {
|
||||||
|
const indexPath = this.getIndexPath(teamName, messageId);
|
||||||
|
await fs.promises.writeFile(indexPath, JSON.stringify(indexEntries, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
await atomicWriteAsync(this.getFilePath(teamName, messageId), JSON.stringify(fileData));
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`[${teamName}] Saved ${attachments.length} attachment(s) for message ${messageId}`
|
`[${teamName}] Saved ${filePaths.size} attachment file(s) for message ${messageId}`
|
||||||
);
|
);
|
||||||
|
return filePaths;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Map of attachmentId → absolute file path.
|
||||||
|
* Only available for new file-based format. Legacy JSON bundles have no individual file paths.
|
||||||
|
*/
|
||||||
|
async getAttachmentFilePaths(teamName: string, messageId: string): Promise<Map<string, string>> {
|
||||||
|
const result = new Map<string, string>();
|
||||||
|
|
||||||
|
// Try new file-based format first
|
||||||
|
const dir = this.getMessageDir(teamName, messageId);
|
||||||
|
try {
|
||||||
|
const entries = await fs.promises.readdir(dir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry === '_index.json') continue;
|
||||||
|
const dashIdx = entry.indexOf('--');
|
||||||
|
if (dashIdx > 0) {
|
||||||
|
const attachmentId = entry.slice(0, dashIdx);
|
||||||
|
result.set(attachmentId, path.join(dir, entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.size > 0) return result;
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No new-format files found — not available for legacy format
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read attachment data (base64) for rendering in UI.
|
||||||
|
* Supports both new file-based format and legacy JSON bundle.
|
||||||
|
*/
|
||||||
async getAttachments(teamName: string, messageId: string): Promise<AttachmentFileData[]> {
|
async getAttachments(teamName: string, messageId: string): Promise<AttachmentFileData[]> {
|
||||||
const filePath = this.getFilePath(teamName, messageId);
|
// Try new file-based format first
|
||||||
|
const newResult = await this.readNewFormatAttachments(teamName, messageId);
|
||||||
|
if (newResult !== null) return newResult;
|
||||||
|
|
||||||
|
// Fallback to legacy JSON format
|
||||||
|
return this.readLegacyAttachments(teamName, messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read attachments from new file-based directory format. */
|
||||||
|
private async readNewFormatAttachments(
|
||||||
|
teamName: string,
|
||||||
|
messageId: string
|
||||||
|
): Promise<AttachmentFileData[] | null> {
|
||||||
|
const indexPath = this.getIndexPath(teamName, messageId);
|
||||||
|
|
||||||
|
let indexRaw: string;
|
||||||
|
try {
|
||||||
|
indexRaw = await fs.promises.readFile(indexPath, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index: StoredAttachmentIndex[];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(indexRaw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return null;
|
||||||
|
index = parsed as StoredAttachmentIndex[];
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: AttachmentFileData[] = [];
|
||||||
|
for (const entry of index) {
|
||||||
|
if (!entry || typeof entry.id !== 'string') continue;
|
||||||
|
const filename =
|
||||||
|
typeof entry.filename === 'string' && entry.filename ? entry.filename : 'attachment';
|
||||||
|
const mimeType =
|
||||||
|
typeof entry.mimeType === 'string' && entry.mimeType
|
||||||
|
? entry.mimeType
|
||||||
|
: 'application/octet-stream';
|
||||||
|
const filePath = this.getStoredFilePath(teamName, messageId, entry.id, filename);
|
||||||
|
try {
|
||||||
|
const buffer = await fs.promises.readFile(filePath);
|
||||||
|
result.push({
|
||||||
|
id: entry.id,
|
||||||
|
data: buffer.toString('base64'),
|
||||||
|
mimeType,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read attachments from legacy JSON bundle format. */
|
||||||
|
private async readLegacyAttachments(
|
||||||
|
teamName: string,
|
||||||
|
messageId: string
|
||||||
|
): Promise<AttachmentFileData[]> {
|
||||||
|
const filePath = this.getLegacyFilePath(teamName, messageId);
|
||||||
|
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
|
|
@ -103,5 +279,5 @@ export class TeamAttachmentStore {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: add deleteAttachments(teamName, messageId) for cleanup on failed/cancelled sends.
|
// TODO: add deleteAttachments(teamName, messageId) for cleanup on failed/cancelled sends.
|
||||||
// Best-effort removal of the attachment JSON file — useful for retry/cancel flows.
|
// Best-effort removal of attachment files — useful for retry/cancel flows.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -558,6 +558,13 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
|
||||||
`- Link related: task_link { teamName: "${teamName}", taskId: "<id>", targetId: "<targetId>", relationship: "related" }`,
|
`- Link related: task_link { teamName: "${teamName}", taskId: "<id>", targetId: "<targetId>", relationship: "related" }`,
|
||||||
`- Unlink: task_unlink { teamName: "${teamName}", taskId: "<id>", targetId: "<targetId>", relationship: "blocked-by" }`,
|
`- Unlink: task_unlink { teamName: "${teamName}", taskId: "<id>", targetId: "<targetId>", relationship: "blocked-by" }`,
|
||||||
``,
|
``,
|
||||||
|
`Review operations — use MCP tools directly (text comments do NOT change kanban state):`,
|
||||||
|
`- Request review (after task_complete): review_request { teamName: "${teamName}", taskId: "<id>", from: "${leadName}", reviewer: "<reviewer-name>" }`,
|
||||||
|
`- Start review (reviewer signals they are beginning): review_start { teamName: "${teamName}", taskId: "<id>", from: "<reviewer-name>" }`,
|
||||||
|
`- Approve review: review_approve { teamName: "${teamName}", taskId: "<id>", note?: "<note>", notifyOwner: true }`,
|
||||||
|
`- Request changes: review_request_changes { teamName: "${teamName}", taskId: "<id>", comment: "<what to fix>" }`,
|
||||||
|
`CRITICAL: Writing "approved" or "LGTM" as a task comment does NOT move the task on the kanban board. You MUST call the review_approve MCP tool. Without the tool call the task stays stuck in the REVIEW column.`,
|
||||||
|
``,
|
||||||
`Attachment storage modes (IMPORTANT):`,
|
`Attachment storage modes (IMPORTANT):`,
|
||||||
`- Default is copy (safe, robust).`,
|
`- Default is copy (safe, robust).`,
|
||||||
`- Use mode: "link" to try a hardlink (no duplication). It may fall back to copy unless you disable fallback.`,
|
`- Use mode: "link" to try a hardlink (no duplication). It may fall back to copy unless you disable fallback.`,
|
||||||
|
|
@ -831,6 +838,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string {
|
||||||
- If a task is blocked (uses blockedBy), it MUST be created as pending (for example with task_create + startImmediately: false). Do NOT mark blocked tasks in_progress.
|
- If a task is blocked (uses blockedBy), it MUST be created as pending (for example with task_create + startImmediately: false). Do NOT mark blocked tasks in_progress.
|
||||||
- Review guidance:
|
- Review guidance:
|
||||||
- Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: call review_start when beginning review, then review_approve/review_request_changes on the implementation task #X.
|
- Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: call review_start when beginning review, then review_approve/review_request_changes on the implementation task #X.
|
||||||
|
- CRITICAL: Text comments ("approved", "LGTM") do NOT move the task on the kanban board. You MUST call the MCP tool review_approve to move from REVIEW to APPROVED. Without the tool call, the task stays stuck in REVIEW.
|
||||||
- If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task:
|
- If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task:
|
||||||
- Use related to connect it to #X (non-blocking link).
|
- Use related to connect it to #X (non-blocking link).
|
||||||
- If the review truly cannot start until #X is done, ALSO add blockedBy #X.
|
- If the review truly cannot start until #X is done, ALSO add blockedBy #X.
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,7 @@ export class TeamTaskAttachmentStore {
|
||||||
mimeType,
|
mimeType,
|
||||||
size: buffer.length,
|
size: buffer.length,
|
||||||
addedAt: new Date().toISOString(),
|
addedAt: new Date().toISOString(),
|
||||||
|
filePath,
|
||||||
};
|
};
|
||||||
|
|
||||||
logger.debug(`[${teamName}] Saved task attachment ${attachmentId} for task #${taskId}`);
|
logger.debug(`[${teamName}] Saved task attachment ${attachmentId} for task #${taskId}`);
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export const SourceMessageAttachments = ({
|
||||||
filename: a.filename,
|
filename: a.filename,
|
||||||
mimeType: a.mimeType,
|
mimeType: a.mimeType,
|
||||||
size: a.size,
|
size: a.size,
|
||||||
|
...(a.filePath ? { filePath: a.filePath } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const truncatedText =
|
const truncatedText =
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
CheckCheck,
|
CheckCheck,
|
||||||
|
|
@ -321,7 +320,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
);
|
);
|
||||||
|
|
||||||
const messagesContent = (
|
const messagesContent = (
|
||||||
<>
|
<div className="pb-14">
|
||||||
<MessageComposer
|
<MessageComposer
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
members={members}
|
members={members}
|
||||||
|
|
@ -381,7 +380,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
teamColorByName={teamColorByName}
|
teamColorByName={teamColorByName}
|
||||||
onTeamClick={openTeamTab}
|
onTeamClick={openTeamTab}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Sidebar mode ----
|
// ---- Sidebar mode ----
|
||||||
|
|
@ -497,7 +496,7 @@ export const MessagesPanel = memo(function MessagesPanel({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Scrollable content */}
|
{/* Scrollable content */}
|
||||||
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden py-2 pr-3">
|
<div className="min-h-0 min-w-0 flex-1 overflow-y-auto overflow-x-hidden pb-14 pr-3 pt-2">
|
||||||
<div className="pl-3">
|
<div className="pl-3">
|
||||||
<MessageComposer
|
<MessageComposer
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
|
|
|
||||||
|
|
@ -88,12 +88,12 @@ const TEAMMATE_COLORS: Record<string, TeamColorSet> = {
|
||||||
},
|
},
|
||||||
/** Reserved for the human user — never assigned to team members. */
|
/** Reserved for the human user — never assigned to team members. */
|
||||||
user: {
|
user: {
|
||||||
border: '#f5f5f4',
|
border: '#a8a29e',
|
||||||
borderLight: '#78716c',
|
borderLight: '#57534e',
|
||||||
badge: 'rgba(245, 245, 244, 0.12)',
|
badge: 'rgba(168, 162, 158, 0.18)',
|
||||||
badgeLight: 'rgba(87, 83, 78, 0.18)',
|
badgeLight: 'rgba(68, 64, 60, 0.14)',
|
||||||
text: '#d6d3d1',
|
text: '#d6d3d1',
|
||||||
textLight: '#44403c',
|
textLight: '#292524',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,8 +154,14 @@ export interface SourceMessageSnapshot {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
/** Message source type (e.g. "user_sent", "inbox"). */
|
/** Message source type (e.g. "user_sent", "inbox"). */
|
||||||
source?: string;
|
source?: string;
|
||||||
/** Attachment metadata references (IDs only, no blobs). */
|
/** Attachment metadata references (IDs only, no blobs). filePath present when file is stored on disk. */
|
||||||
attachments?: { id: string; filename: string; mimeType: string; size: number }[];
|
attachments?: {
|
||||||
|
id: string;
|
||||||
|
filename: string;
|
||||||
|
mimeType: string;
|
||||||
|
size: number;
|
||||||
|
filePath?: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record<keyof TeamTask, unknown>`.
|
// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record<keyof TeamTask, unknown>`.
|
||||||
|
|
@ -229,6 +235,8 @@ export interface TaskAttachmentMeta {
|
||||||
size: number;
|
size: number;
|
||||||
/** ISO timestamp when the attachment was added. */
|
/** ISO timestamp when the attachment was added. */
|
||||||
addedAt: string;
|
addedAt: string;
|
||||||
|
/** Absolute path to the file on disk. Null/absent for metadata-only references. */
|
||||||
|
filePath?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload for uploading an attachment with base64 data (renderer → main). */
|
/** Payload for uploading an attachment with base64 data (renderer → main). */
|
||||||
|
|
@ -256,6 +264,8 @@ export interface AttachmentMeta {
|
||||||
filename: string;
|
filename: string;
|
||||||
mimeType: AttachmentMediaType;
|
mimeType: AttachmentMediaType;
|
||||||
size: number;
|
size: number;
|
||||||
|
/** Absolute path to the file on disk. Absent for metadata-only references. */
|
||||||
|
filePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttachmentPayload extends AttachmentMeta {
|
export interface AttachmentPayload extends AttachmentMeta {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue