feat: enhance team context management and comment functionality
- Added support for lead context usage tracking, allowing retrieval of context window usage for team leads. - Implemented attachment handling in task comments, enabling users to add and manage attachments with size and type validation. - Updated README to include new features related to visual workflow editor and multi-model support. - Improved error handling and validation for task comments and attachments, ensuring a smoother user experience.
This commit is contained in:
parent
1f35e86f0a
commit
43d2953874
27 changed files with 872 additions and 79 deletions
|
|
@ -203,6 +203,9 @@ pnpm dist # macOS + Windows + Linux
|
|||
## TODO
|
||||
|
||||
- [ ] CLI runtime: Run not only on a local PC but in any headless/console environment (web UI), e.g. VPS, remote server, etc.
|
||||
- [ ] Visual workflow editor ([@xyflow/react](https://github.com/xyflow/xyflow)) for building and orchestrating agent pipelines with drag & drop
|
||||
- [ ] Context management: control and curate what context each agent sees (files, docs, MCP servers, skills)
|
||||
- [ ] Multi-model support: proxy layer to use other popular LLMs (GPT, Gemini, DeepSeek, Llama, etc.), including offline/local models
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LEAD_CONTEXT,
|
||||
TEAM_LIST,
|
||||
TEAM_PERMANENTLY_DELETE,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
|
|
@ -95,6 +96,7 @@ import type {
|
|||
GlobalTask,
|
||||
IpcResult,
|
||||
KanbanColumnId,
|
||||
LeadContextUsage,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
SendMessageRequest,
|
||||
|
|
@ -229,6 +231,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_GET_ATTACHMENTS, handleGetAttachments);
|
||||
ipcMain.handle(TEAM_KILL_PROCESS, handleKillProcess);
|
||||
ipcMain.handle(TEAM_LEAD_ACTIVITY, handleLeadActivity);
|
||||
ipcMain.handle(TEAM_LEAD_CONTEXT, handleLeadContext);
|
||||
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
||||
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
||||
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
||||
|
|
@ -281,6 +284,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_GET_ATTACHMENTS);
|
||||
ipcMain.removeHandler(TEAM_KILL_PROCESS);
|
||||
ipcMain.removeHandler(TEAM_LEAD_ACTIVITY);
|
||||
ipcMain.removeHandler(TEAM_LEAD_CONTEXT);
|
||||
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
||||
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
||||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
||||
|
|
@ -1293,12 +1297,21 @@ async function handleUpdateTaskOwner(
|
|||
return { success: false, error: validatedTaskId.error ?? 'Invalid taskId' };
|
||||
}
|
||||
|
||||
if (owner !== null && (typeof owner !== 'string' || owner.length === 0)) {
|
||||
return { success: false, error: 'owner must be a non-empty string or null' };
|
||||
let nextOwner: string | null = null;
|
||||
if (owner !== null) {
|
||||
const validatedOwner = validateMemberName(owner);
|
||||
if (!validatedOwner.valid) {
|
||||
return { success: false, error: validatedOwner.error ?? 'Invalid owner' };
|
||||
}
|
||||
nextOwner = validatedOwner.value!;
|
||||
}
|
||||
|
||||
return wrapTeamHandler('updateTaskOwner', () =>
|
||||
getTeamDataService().updateTaskOwner(validatedTeamName.value!, validatedTaskId.value!, owner)
|
||||
getTeamDataService().updateTaskOwner(
|
||||
validatedTeamName.value!,
|
||||
validatedTaskId.value!,
|
||||
nextOwner
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1523,6 +1536,19 @@ async function handleLeadActivity(
|
|||
);
|
||||
}
|
||||
|
||||
async function handleLeadContext(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
): Promise<IpcResult<LeadContextUsage | null>> {
|
||||
const validated = validateTeamName(teamName);
|
||||
if (!validated.valid) {
|
||||
return { success: false, error: validated.error ?? 'Invalid teamName' };
|
||||
}
|
||||
return wrapTeamHandler('leadContext', async () =>
|
||||
getTeamProvisioningService().getLeadContextUsage(validated.value!)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleStopTeam(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
|
|
@ -1693,9 +1719,9 @@ async function handleUpdateTaskFields(
|
|||
): Promise<IpcResult<void>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
if (typeof taskId !== 'string' || !taskId.trim()) {
|
||||
return { success: false, error: 'taskId must be a non-empty string' };
|
||||
}
|
||||
const vTask = validateTaskId(taskId);
|
||||
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||||
const tid = vTask.value!;
|
||||
if (!fields || typeof fields !== 'object') {
|
||||
return { success: false, error: 'fields must be an object' };
|
||||
}
|
||||
|
|
@ -1711,7 +1737,7 @@ async function handleUpdateTaskFields(
|
|||
}
|
||||
|
||||
const validFields: { subject?: string; description?: string } = {};
|
||||
if (typeof subject === 'string') validFields.subject = subject;
|
||||
if (typeof subject === 'string') validFields.subject = subject.trim();
|
||||
if (typeof description === 'string') validFields.description = description;
|
||||
|
||||
if (Object.keys(validFields).length === 0) {
|
||||
|
|
@ -1720,7 +1746,7 @@ async function handleUpdateTaskFields(
|
|||
|
||||
return wrapTeamHandler('updateTaskFields', async () => {
|
||||
const tn = vTeam.value!;
|
||||
await getTeamDataService().updateTaskFields(tn, taskId, validFields);
|
||||
await getTeamDataService().updateTaskFields(tn, tid, validFields);
|
||||
|
||||
// Notify the lead about updated task fields
|
||||
const provisioning = getTeamProvisioningService();
|
||||
|
|
@ -1729,12 +1755,12 @@ async function handleUpdateTaskFields(
|
|||
if (validFields.subject) changedParts.push('title');
|
||||
if (validFields.description !== undefined) changedParts.push('description');
|
||||
const message =
|
||||
`Task #${taskId} has been updated by the user (changed: ${changedParts.join(', ')}). ` +
|
||||
`Task #${tid} has been updated by the user (changed: ${changedParts.join(', ')}). ` +
|
||||
`New title: "${validFields.subject ?? '(unchanged)'}".`;
|
||||
try {
|
||||
await provisioning.sendMessageToTeam(tn, message);
|
||||
} catch {
|
||||
logger.warn(`Failed to notify lead about task fields update for #${taskId} in ${tn}`);
|
||||
logger.warn(`Failed to notify lead about task fields update for #${tid} in ${tn}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1910,7 +1936,8 @@ async function handleAddTaskComment(
|
|||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
taskId: unknown,
|
||||
text: unknown
|
||||
text: unknown,
|
||||
attachments?: unknown
|
||||
): Promise<IpcResult<TaskComment>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
|
|
@ -1921,9 +1948,54 @@ async function handleAddTaskComment(
|
|||
if (text.trim().length > 2000)
|
||||
return { success: false, error: 'Comment exceeds 2000 characters' };
|
||||
|
||||
return wrapTeamHandler('addTaskComment', () =>
|
||||
getTeamDataService().addTaskComment(vTeam.value!, vTask.value!, text.trim())
|
||||
);
|
||||
const rawAttachments = Array.isArray(attachments) ? attachments : [];
|
||||
if (rawAttachments.length > MAX_ATTACHMENTS) {
|
||||
return { success: false, error: `Maximum ${MAX_ATTACHMENTS} attachments per comment` };
|
||||
}
|
||||
|
||||
return wrapTeamHandler('addTaskComment', async () => {
|
||||
// Save comment attachments (images). Done inside wrapTeamHandler so failures return IpcResult.
|
||||
let savedAttachments: TaskAttachmentMeta[] | undefined;
|
||||
if (rawAttachments.length > 0) {
|
||||
savedAttachments = [];
|
||||
for (const att of rawAttachments) {
|
||||
if (!att || typeof att !== 'object') {
|
||||
throw new Error('Invalid attachment data');
|
||||
}
|
||||
const a = att as Record<string, unknown>;
|
||||
if (
|
||||
typeof a.id !== 'string' ||
|
||||
typeof a.filename !== 'string' ||
|
||||
typeof a.mimeType !== 'string' ||
|
||||
typeof a.base64Data !== 'string' ||
|
||||
a.base64Data.length === 0 ||
|
||||
!ALLOWED_ATTACHMENT_TYPES.has(a.mimeType)
|
||||
) {
|
||||
throw new Error('Invalid attachment data');
|
||||
}
|
||||
const safeId = a.id.trim();
|
||||
if (safeId.includes('/') || safeId.includes('\\') || safeId.includes('..')) {
|
||||
throw new Error('Invalid attachment ID');
|
||||
}
|
||||
const meta = await taskAttachmentStore.saveAttachment(
|
||||
vTeam.value!,
|
||||
vTask.value!,
|
||||
safeId,
|
||||
a.filename,
|
||||
a.mimeType as AttachmentMediaType,
|
||||
a.base64Data
|
||||
);
|
||||
savedAttachments.push(meta);
|
||||
}
|
||||
}
|
||||
|
||||
return getTeamDataService().addTaskComment(
|
||||
vTeam.value!,
|
||||
vTask.value!,
|
||||
text.trim(),
|
||||
savedAttachments
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const VALID_RELATIONSHIP_TYPES = ['blockedBy', 'blocks', 'related'] as const;
|
||||
|
|
|
|||
|
|
@ -703,6 +703,11 @@ export class ProjectScanner {
|
|||
let startIndex = 0;
|
||||
if (cursor) {
|
||||
try {
|
||||
// Defensive limit: cursor originates from a query param / IPC input and should be tiny.
|
||||
// Prevent pathological memory allocation on Buffer.from(cursor, 'base64').
|
||||
if (cursor.length > 4096) {
|
||||
throw new Error('cursor too large');
|
||||
}
|
||||
const decoded = JSON.parse(
|
||||
Buffer.from(cursor, 'base64').toString('utf8')
|
||||
) as SessionCursor;
|
||||
|
|
|
|||
|
|
@ -12,11 +12,25 @@ const logger = createLogger('Service:TeamAttachmentStore');
|
|||
const ATTACHMENTS_DIR = 'attachments';
|
||||
|
||||
export class TeamAttachmentStore {
|
||||
private assertSafePathSegment(label: string, value: string): void {
|
||||
if (
|
||||
value.length === 0 ||
|
||||
value.includes('/') ||
|
||||
value.includes('\\') ||
|
||||
value.includes('..') ||
|
||||
value.includes('\0')
|
||||
) {
|
||||
throw new Error(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
private getDir(teamName: string): string {
|
||||
this.assertSafePathSegment('teamName', teamName);
|
||||
return path.join(getTeamsBasePath(), teamName, ATTACHMENTS_DIR);
|
||||
}
|
||||
|
||||
private getFilePath(teamName: string, messageId: string): string {
|
||||
this.assertSafePathSegment('messageId', messageId);
|
||||
return path.join(this.getDir(teamName), `${messageId}.json`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -983,8 +983,15 @@ export class TeamDataService {
|
|||
await this.taskWriter.removeRelationship(teamName, taskId, targetId, type);
|
||||
}
|
||||
|
||||
async addTaskComment(teamName: string, taskId: string, text: string): Promise<TaskComment> {
|
||||
const comment = await this.taskWriter.addComment(teamName, taskId, text);
|
||||
async addTaskComment(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
attachments?: import('@shared/types').TaskAttachmentMeta[]
|
||||
): Promise<TaskComment> {
|
||||
const comment = await this.taskWriter.addComment(teamName, taskId, text, {
|
||||
attachments,
|
||||
});
|
||||
|
||||
try {
|
||||
const [tasks, toolPath, config] = await Promise.all([
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { TeamTaskReader } from './TeamTaskReader';
|
|||
|
||||
import type {
|
||||
InboxMessage,
|
||||
LeadContextUsage,
|
||||
TeamChangeEvent,
|
||||
TeamCreateRequest,
|
||||
TeamCreateResponse,
|
||||
|
|
@ -154,6 +155,13 @@ interface ProvisioningRun {
|
|||
authFailureRetried: boolean;
|
||||
/** Set to true while auth-failure respawn is in progress to prevent duplicate handling. */
|
||||
authRetryInProgress: boolean;
|
||||
/** Tracks lead process context window usage from stream-json usage data. */
|
||||
leadContextUsage: {
|
||||
currentTokens: number;
|
||||
contextWindow: number;
|
||||
lastUsageMessageId: string | null;
|
||||
lastEmittedAt: number;
|
||||
} | null;
|
||||
/** Saved spawn context for auth-failure respawn. */
|
||||
spawnContext: {
|
||||
claudePath: string;
|
||||
|
|
@ -1014,6 +1022,16 @@ export class TeamProvisioningService {
|
|||
return run.leadActivityState;
|
||||
}
|
||||
|
||||
getLeadContextUsage(teamName: string): LeadContextUsage | null {
|
||||
const runId = this.activeByTeam.get(teamName);
|
||||
if (!runId) return null;
|
||||
const run = this.runs.get(runId);
|
||||
if (!run?.leadContextUsage || run.processKilled || run.cancelRequested) return null;
|
||||
const { currentTokens, contextWindow } = run.leadContextUsage;
|
||||
const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0;
|
||||
return { currentTokens, contextWindow, percent, updatedAt: new Date().toISOString() };
|
||||
}
|
||||
|
||||
private setLeadActivity(run: ProvisioningRun, state: 'active' | 'idle' | 'offline'): void {
|
||||
if (run.leadActivityState === state) return;
|
||||
run.leadActivityState = state;
|
||||
|
|
@ -1024,6 +1042,33 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
|
||||
private static readonly CONTEXT_EMIT_THROTTLE_MS = 2000;
|
||||
|
||||
private emitLeadContextUsage(run: ProvisioningRun): void {
|
||||
if (!run.leadContextUsage || !run.provisioningComplete) return;
|
||||
const now = Date.now();
|
||||
if (
|
||||
now - run.leadContextUsage.lastEmittedAt <
|
||||
TeamProvisioningService.CONTEXT_EMIT_THROTTLE_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
run.leadContextUsage.lastEmittedAt = now;
|
||||
const { currentTokens, contextWindow } = run.leadContextUsage;
|
||||
const percent = contextWindow > 0 ? Math.round((currentTokens / contextWindow) * 100) : 0;
|
||||
const payload: LeadContextUsage = {
|
||||
currentTokens,
|
||||
contextWindow,
|
||||
percent,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.teamChangeEmitter?.({
|
||||
type: 'lead-context',
|
||||
teamName: run.teamName,
|
||||
detail: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async warmup(): Promise<void> {
|
||||
try {
|
||||
if (cachedProbeResult && Date.now() - cachedProbeResult.cachedAtMs < PROBE_CACHE_TTL_MS) {
|
||||
|
|
@ -1433,6 +1478,7 @@ export class TeamProvisioningService {
|
|||
provisioningOutputParts: [],
|
||||
detectedSessionId: null,
|
||||
leadActivityState: 'active',
|
||||
leadContextUsage: null,
|
||||
authFailureRetried: false,
|
||||
authRetryInProgress: false,
|
||||
spawnContext: null,
|
||||
|
|
@ -1716,6 +1762,7 @@ export class TeamProvisioningService {
|
|||
provisioningOutputParts: [],
|
||||
detectedSessionId: null,
|
||||
leadActivityState: 'active',
|
||||
leadContextUsage: null,
|
||||
authFailureRetried: false,
|
||||
authRetryInProgress: false,
|
||||
spawnContext: null,
|
||||
|
|
@ -2464,6 +2511,40 @@ export class TeamProvisioningService {
|
|||
if (run.provisioningComplete) {
|
||||
this.captureSendMessageToUser(run, content ?? []);
|
||||
}
|
||||
|
||||
// Extract context window usage from message.usage for real-time tracking.
|
||||
// SDKAssistantMessage wraps BetaMessage which contains usage stats.
|
||||
const messageObj = (msg.message ?? msg) as Record<string, unknown>;
|
||||
if (messageObj && typeof messageObj === 'object') {
|
||||
const msgId = typeof messageObj.id === 'string' ? messageObj.id : null;
|
||||
const usage = messageObj.usage as Record<string, unknown> | undefined;
|
||||
if (usage && typeof usage === 'object') {
|
||||
// Dedup: skip if same message.id (SDK bug: multi-block = same usage repeated)
|
||||
if (!msgId || run.leadContextUsage?.lastUsageMessageId !== msgId) {
|
||||
const inputTokens = typeof usage.input_tokens === 'number' ? usage.input_tokens : 0;
|
||||
const cacheCreation =
|
||||
typeof usage.cache_creation_input_tokens === 'number'
|
||||
? usage.cache_creation_input_tokens
|
||||
: 0;
|
||||
const cacheRead =
|
||||
typeof usage.cache_read_input_tokens === 'number' ? usage.cache_read_input_tokens : 0;
|
||||
const currentTokens = inputTokens + cacheCreation + cacheRead;
|
||||
|
||||
if (!run.leadContextUsage) {
|
||||
run.leadContextUsage = {
|
||||
currentTokens,
|
||||
contextWindow: 200_000,
|
||||
lastUsageMessageId: msgId,
|
||||
lastEmittedAt: 0,
|
||||
};
|
||||
} else {
|
||||
run.leadContextUsage.currentTokens = currentTokens;
|
||||
run.leadContextUsage.lastUsageMessageId = msgId;
|
||||
}
|
||||
this.emitLeadContextUsage(run);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture session_id from any message type (first occurrence wins)
|
||||
|
|
@ -2489,6 +2570,53 @@ export class TeamProvisioningService {
|
|||
})();
|
||||
if (subtype === 'success') {
|
||||
logger.info(`[${run.teamName}] stream-json result: success — turn complete, process alive`);
|
||||
|
||||
// Extract contextWindow from modelUsage if available (SDKResultSuccess.modelUsage)
|
||||
const modelUsageObj = (msg.modelUsage ??
|
||||
(msg.result as Record<string, unknown> | undefined)?.modelUsage) as
|
||||
| Record<string, Record<string, unknown>>
|
||||
| undefined;
|
||||
if (modelUsageObj && typeof modelUsageObj === 'object') {
|
||||
for (const modelData of Object.values(modelUsageObj)) {
|
||||
if (
|
||||
modelData &&
|
||||
typeof modelData === 'object' &&
|
||||
typeof modelData.contextWindow === 'number' &&
|
||||
modelData.contextWindow > 0
|
||||
) {
|
||||
if (run.leadContextUsage) {
|
||||
run.leadContextUsage.contextWindow = modelData.contextWindow;
|
||||
run.leadContextUsage.lastEmittedAt = 0; // force re-emit
|
||||
this.emitLeadContextUsage(run);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract usage from result message itself (final turn usage)
|
||||
const resultUsage = (msg.usage ??
|
||||
(msg.result as Record<string, unknown> | undefined)?.usage) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (resultUsage && typeof resultUsage === 'object') {
|
||||
const inp = typeof resultUsage.input_tokens === 'number' ? resultUsage.input_tokens : 0;
|
||||
const cc =
|
||||
typeof resultUsage.cache_creation_input_tokens === 'number'
|
||||
? resultUsage.cache_creation_input_tokens
|
||||
: 0;
|
||||
const cr =
|
||||
typeof resultUsage.cache_read_input_tokens === 'number'
|
||||
? resultUsage.cache_read_input_tokens
|
||||
: 0;
|
||||
const total = inp + cc + cr;
|
||||
if (total > 0 && run.leadContextUsage) {
|
||||
run.leadContextUsage.currentTokens = total;
|
||||
run.leadContextUsage.lastEmittedAt = 0;
|
||||
this.emitLeadContextUsage(run);
|
||||
}
|
||||
}
|
||||
|
||||
if (run.provisioningComplete) {
|
||||
this.setLeadActivity(run, 'idle');
|
||||
}
|
||||
|
|
@ -2585,6 +2713,15 @@ export class TeamProvisioningService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle compact_boundary — context was compacted, next assistant message will carry fresh usage
|
||||
if (msg.type === 'system') {
|
||||
const sub = typeof msg.subtype === 'string' ? msg.subtype : undefined;
|
||||
if (sub === 'compact_boundary' && run.leadContextUsage) {
|
||||
run.leadContextUsage.lastUsageMessageId = null;
|
||||
logger.info(`[${run.teamName}] compact_boundary — context will refresh on next turn`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -18,13 +18,28 @@ const ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set<AttachmentMediaType>([
|
|||
]);
|
||||
|
||||
export class TeamTaskAttachmentStore {
|
||||
private assertSafePathSegment(label: string, value: string): void {
|
||||
if (
|
||||
value.length === 0 ||
|
||||
value.includes('/') ||
|
||||
value.includes('\\') ||
|
||||
value.includes('..') ||
|
||||
value.includes('\0')
|
||||
) {
|
||||
throw new Error(`Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the directory for a specific task's attachments. */
|
||||
private getTaskDir(teamName: string, taskId: string): string {
|
||||
this.assertSafePathSegment('teamName', teamName);
|
||||
this.assertSafePathSegment('taskId', taskId);
|
||||
return path.join(getTeamsBasePath(), teamName, TASK_ATTACHMENTS_DIR, taskId);
|
||||
}
|
||||
|
||||
/** Returns the file path for a specific attachment. */
|
||||
private getFilePath(teamName: string, taskId: string, attachmentId: string, ext: string): string {
|
||||
this.assertSafePathSegment('attachmentId', attachmentId);
|
||||
return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}${ext}`);
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +73,18 @@ export class TeamTaskAttachmentStore {
|
|||
throw new Error(`Unsupported MIME type: ${mimeType}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
const trimmed = base64Data.trim();
|
||||
// Avoid allocating huge Buffers for obviously too-large payloads.
|
||||
// Base64 decoded size is roughly 3/4 of the string length minus padding.
|
||||
const padding = trimmed.endsWith('==') ? 2 : trimmed.endsWith('=') ? 1 : 0;
|
||||
const estimatedBytes = Math.max(0, Math.floor((trimmed.length * 3) / 4) - padding);
|
||||
if (estimatedBytes > MAX_ATTACHMENT_SIZE) {
|
||||
throw new Error(
|
||||
`Attachment too large: ${(estimatedBytes / (1024 * 1024)).toFixed(1)} MB (max ${MAX_ATTACHMENT_SIZE / (1024 * 1024)} MB)`
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(trimmed, 'base64');
|
||||
if (buffer.length > MAX_ATTACHMENT_SIZE) {
|
||||
throw new Error(
|
||||
`Attachment too large: ${(buffer.length / (1024 * 1024)).toFixed(1)} MB (max ${MAX_ATTACHMENT_SIZE / (1024 * 1024)} MB)`
|
||||
|
|
|
|||
|
|
@ -196,6 +196,21 @@ export class TeamTaskReader {
|
|||
type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type)
|
||||
? c.type
|
||||
: ('regular' as const),
|
||||
attachments: Array.isArray(c.attachments)
|
||||
? (c.attachments as unknown[]).filter(
|
||||
(a): a is TaskAttachmentMeta =>
|
||||
Boolean(a) &&
|
||||
typeof a === 'object' &&
|
||||
typeof (a as Record<string, unknown>).id === 'string' &&
|
||||
typeof (a as Record<string, unknown>).filename === 'string' &&
|
||||
typeof (a as Record<string, unknown>).mimeType === 'string' &&
|
||||
VALID_ATTACHMENT_MIME_TYPES.has(
|
||||
(a as Record<string, unknown>).mimeType as string
|
||||
) &&
|
||||
typeof (a as Record<string, unknown>).size === 'number' &&
|
||||
typeof (a as Record<string, unknown>).addedAt === 'string'
|
||||
)
|
||||
: undefined,
|
||||
}))
|
||||
: undefined,
|
||||
needsClarification: (['lead', 'user'] as const).includes(
|
||||
|
|
|
|||
|
|
@ -521,7 +521,13 @@ export class TeamTaskWriter {
|
|||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
options?: { id?: string; author?: string; createdAt?: string; type?: TaskCommentType }
|
||||
options?: {
|
||||
id?: string;
|
||||
author?: string;
|
||||
createdAt?: string;
|
||||
type?: TaskCommentType;
|
||||
attachments?: TaskAttachmentMeta[];
|
||||
}
|
||||
): Promise<TaskComment> {
|
||||
const taskPath = path.join(getTasksBasePath(), teamName, `${taskId}.json`);
|
||||
const comment: TaskComment = {
|
||||
|
|
@ -530,6 +536,9 @@ export class TeamTaskWriter {
|
|||
text,
|
||||
createdAt: options?.createdAt ?? new Date().toISOString(),
|
||||
type: options?.type ?? 'regular',
|
||||
...(options?.attachments && options.attachments.length > 0
|
||||
? { attachments: options.attachments }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await withTaskLock(taskPath, async () => {
|
||||
|
|
|
|||
|
|
@ -325,6 +325,9 @@ export const TEAM_KILL_PROCESS = 'team:killProcess';
|
|||
/** Get lead process activity state (active/idle/offline) */
|
||||
export const TEAM_LEAD_ACTIVITY = 'team:leadActivity';
|
||||
|
||||
/** Get lead process context window usage */
|
||||
export const TEAM_LEAD_CONTEXT = 'team:leadContext';
|
||||
|
||||
/** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */
|
||||
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
||||
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ import {
|
|||
TEAM_KILL_PROCESS,
|
||||
TEAM_LAUNCH,
|
||||
TEAM_LEAD_ACTIVITY,
|
||||
TEAM_LEAD_CONTEXT,
|
||||
TEAM_LIST,
|
||||
TEAM_PERMANENTLY_DELETE,
|
||||
TEAM_PREPARE_PROVISIONING,
|
||||
|
|
@ -176,6 +177,7 @@ import type {
|
|||
HunkDecision,
|
||||
IpcResult,
|
||||
KanbanColumnId,
|
||||
LeadContextUsage,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
NotificationTrigger,
|
||||
|
|
@ -190,6 +192,7 @@ import type {
|
|||
SshConnectionConfig,
|
||||
SshConnectionStatus,
|
||||
SshLastConnection,
|
||||
CommentAttachmentPayload,
|
||||
TaskAttachmentMeta,
|
||||
TaskChangeSetV2,
|
||||
TaskComment,
|
||||
|
|
@ -801,8 +804,19 @@ const electronAPI: ElectronAPI = {
|
|||
updateConfig: async (teamName: string, updates: TeamUpdateConfigRequest) => {
|
||||
return invokeIpcWithResult<TeamConfig>(TEAM_UPDATE_CONFIG, teamName, updates);
|
||||
},
|
||||
addTaskComment: async (teamName: string, taskId: string, text: string) => {
|
||||
return invokeIpcWithResult<TaskComment>(TEAM_ADD_TASK_COMMENT, teamName, taskId, text);
|
||||
addTaskComment: async (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
attachments?: CommentAttachmentPayload[]
|
||||
) => {
|
||||
return invokeIpcWithResult<TaskComment>(
|
||||
TEAM_ADD_TASK_COMMENT,
|
||||
teamName,
|
||||
taskId,
|
||||
text,
|
||||
attachments
|
||||
);
|
||||
},
|
||||
addMember: async (teamName: string, request: AddMemberRequest) => {
|
||||
return invokeIpcWithResult<void>(TEAM_ADD_MEMBER, teamName, request);
|
||||
|
|
@ -829,6 +843,9 @@ const electronAPI: ElectronAPI = {
|
|||
const result = await invokeIpcWithResult<string>(TEAM_LEAD_ACTIVITY, teamName);
|
||||
return result as 'active' | 'idle' | 'offline';
|
||||
},
|
||||
getLeadContext: async (teamName: string) => {
|
||||
return invokeIpcWithResult<LeadContextUsage | null>(TEAM_LEAD_CONTEXT, teamName);
|
||||
},
|
||||
softDeleteTask: async (teamName: string, taskId: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -796,6 +796,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
getLeadActivity: async (_teamName: string): Promise<'active' | 'idle' | 'offline'> => {
|
||||
return 'offline';
|
||||
},
|
||||
getLeadContext: async () => {
|
||||
return null;
|
||||
},
|
||||
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
||||
// Not available via HTTP client — no-op
|
||||
},
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import ReactMarkdown, { type Components } from 'react-markdown';
|
|||
|
||||
import { api } from '@renderer/api';
|
||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import {
|
||||
CODE_BG,
|
||||
CODE_BORDER,
|
||||
|
|
@ -200,21 +201,44 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon
|
|||
|
||||
// Links — inline element, no hl(); parent block element's hl() descends here
|
||||
// task:// links are handled by ancestor onClickCapture handlers (e.g. ActivityItem)
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="cursor-pointer no-underline hover:underline"
|
||||
style={{ color: PROSE_LINK }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href && !href.startsWith('task://')) {
|
||||
void api.openExternal(href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
// mention:// links render as colored inline badges
|
||||
a: ({ href, children }) => {
|
||||
if (href?.startsWith('mention://')) {
|
||||
const path = href.slice('mention://'.length);
|
||||
const slashIdx = path.indexOf('/');
|
||||
const color = slashIdx >= 0 ? decodeURIComponent(path.slice(0, slashIdx)) : '';
|
||||
const colorSet = getTeamColorSet(color);
|
||||
const bg = colorSet.badge;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: bg,
|
||||
color: colorSet.text,
|
||||
borderRadius: '3px',
|
||||
boxShadow: `0 0 0 1.5px ${bg}`,
|
||||
fontSize: 'inherit',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
className="cursor-pointer no-underline hover:underline"
|
||||
style={{ color: PROSE_LINK }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (href && !href.startsWith('task://')) {
|
||||
void api.openExternal(href);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
|
||||
// Strong/Bold — inline element, no hl()
|
||||
strong: ({ children }) => (
|
||||
|
|
|
|||
|
|
@ -1498,6 +1498,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
|
||||
<SendMessageDialog
|
||||
open={sendDialogOpen}
|
||||
teamName={teamName}
|
||||
members={activeMembers}
|
||||
defaultRecipient={sendDialogRecipient}
|
||||
defaultText={sendDialogDefaultText}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ interface ActivityItemProps {
|
|||
recipientColor?: string;
|
||||
/** When true, show a blue unread dot. */
|
||||
isUnread?: boolean;
|
||||
/** Map of member name → color name for @mention badge rendering. */
|
||||
memberColorMap?: Map<string, string>;
|
||||
onMemberNameClick?: (memberName: string) => void;
|
||||
onCreateTask?: (subject: string, description: string) => void;
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
|
|
@ -153,6 +155,26 @@ function linkifyTaskIdsInMarkdown(text: string): string {
|
|||
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert `@memberName` in plain text to markdown links with mention:// protocol.
|
||||
* Encodes color in the URL so MarkdownViewer can render colored badges without extra context.
|
||||
* Greedy match: longer names are tried first to avoid partial matches.
|
||||
*/
|
||||
function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, string>): string {
|
||||
if (memberColorMap.size === 0) return text;
|
||||
// Sort by name length descending for greedy matching
|
||||
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
|
||||
// Build regex that matches @name at start or after whitespace, followed by boundary
|
||||
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}-]|$)`, 'gi');
|
||||
return text.replace(pattern, (match, prefix: string, name: string) => {
|
||||
// Find the canonical name (case-insensitive lookup)
|
||||
const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
|
||||
const color = memberColorMap.get(canonical) ?? '';
|
||||
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Render `#<digits>` in plain text as clickable inline elements. */
|
||||
function linkifyTaskIds(text: string, onClick: (taskId: string) => void): React.ReactNode[] {
|
||||
return text.split(/(#\d+)/g).map((part, i) => {
|
||||
|
|
@ -182,6 +204,7 @@ export const ActivityItem = ({
|
|||
memberColor,
|
||||
recipientColor,
|
||||
isUnread,
|
||||
memberColorMap,
|
||||
onMemberNameClick,
|
||||
onCreateTask,
|
||||
onReply,
|
||||
|
|
@ -210,15 +233,19 @@ export const ActivityItem = ({
|
|||
const systemLabel = !structured && !rateLimited ? getSystemMessageLabel(message.text) : null;
|
||||
const [isExpanded, setIsExpanded] = useState(!systemLabel);
|
||||
|
||||
// Strip agent-only blocks from displayed text + linkify task IDs
|
||||
// Strip agent-only blocks from displayed text + linkify task IDs + @mentions
|
||||
const displayText = useMemo(() => {
|
||||
if (structured) return null;
|
||||
const stripped = stripAgentBlocks(message.text).trim();
|
||||
if (!stripped) return null; // All content was agent-only blocks → show summary instead
|
||||
// Normalize literal \n from CLI tools (teamctl.js) to real newlines
|
||||
const normalized = stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
|
||||
return onTaskIdClick ? linkifyTaskIdsInMarkdown(normalized) : normalized;
|
||||
}, [structured, message.text, onTaskIdClick]);
|
||||
let result = normalized;
|
||||
if (onTaskIdClick) result = linkifyTaskIdsInMarkdown(result);
|
||||
if (memberColorMap && memberColorMap.size > 0)
|
||||
result = linkifyMentionsInMarkdown(result, memberColorMap);
|
||||
return result;
|
||||
}, [structured, message.text, onTaskIdClick, memberColorMap]);
|
||||
|
||||
// Check if this is a reply message
|
||||
const parsedReply = useMemo(
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ const MessageRowWithObserver = ({
|
|||
isUnread,
|
||||
isNew,
|
||||
zebraShade,
|
||||
memberColorMap,
|
||||
onMemberNameClick,
|
||||
onCreateTask,
|
||||
onReply,
|
||||
|
|
@ -54,6 +55,7 @@ const MessageRowWithObserver = ({
|
|||
isUnread?: boolean;
|
||||
isNew?: boolean;
|
||||
zebraShade?: boolean;
|
||||
memberColorMap?: Map<string, string>;
|
||||
onMemberNameClick?: (name: string) => void;
|
||||
onCreateTask?: (subject: string, description: string) => void;
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
|
|
@ -101,6 +103,7 @@ const MessageRowWithObserver = ({
|
|||
recipientColor={recipientColor}
|
||||
isUnread={isUnread}
|
||||
zebraShade={zebraShade}
|
||||
memberColorMap={memberColorMap}
|
||||
onMemberNameClick={onMemberNameClick}
|
||||
onCreateTask={onCreateTask}
|
||||
onReply={onReply}
|
||||
|
|
@ -274,6 +277,7 @@ export const ActivityTimeline = ({
|
|||
isUnread={isUnread}
|
||||
isNew={newMessageKeys.has(messageKey)}
|
||||
zebraShade={zebraShadeSet.has(index)}
|
||||
memberColorMap={colorMap}
|
||||
onMemberNameClick={onMemberClick ? handleMemberNameClick : undefined}
|
||||
onCreateTask={onCreateTaskFromMessage}
|
||||
onReply={onReplyToMessage}
|
||||
|
|
|
|||
|
|
@ -20,8 +20,10 @@ export const ReplyQuoteBlock = ({
|
|||
<span className="mb-0.5 block text-[10px] font-medium text-[var(--color-text-muted)]">
|
||||
@{reply.agentName}
|
||||
</span>
|
||||
<p className="line-clamp-3 text-xs text-[var(--color-text-muted)]">{reply.originalText}</p>
|
||||
<div className="line-clamp-3 text-xs text-[var(--color-text-muted)]">
|
||||
<MarkdownViewer content={reply.originalText} maxHeight="max-h-[60px]" bare />
|
||||
</div>
|
||||
</div>
|
||||
<MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable />
|
||||
<MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable bare />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ interface QuotedMessage {
|
|||
|
||||
interface SendMessageDialogProps {
|
||||
open: boolean;
|
||||
teamName: string;
|
||||
members: ResolvedTeamMember[];
|
||||
defaultRecipient?: string;
|
||||
/** Pre-filled message text (e.g. from editor selection action) */
|
||||
|
|
@ -72,6 +73,7 @@ const NO_MEMBER = '__none__';
|
|||
|
||||
export const SendMessageDialog = ({
|
||||
open,
|
||||
teamName,
|
||||
members,
|
||||
defaultRecipient,
|
||||
defaultText,
|
||||
|
|
@ -108,7 +110,7 @@ export const SendMessageDialog = ({
|
|||
clearAttachments,
|
||||
handlePaste,
|
||||
handleDrop,
|
||||
} = useAttachments({ persistenceKey: 'sendMessage:attachments' });
|
||||
} = useAttachments({ persistenceKey: `sendMessage:${teamName}:attachments` });
|
||||
|
||||
const selectedMember = members.find((m) => m.name === member);
|
||||
const isLeadRecipient = selectedMember?.role === 'lead' || selectedMember?.name === 'team-lead';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { MentionableTextarea } from '@renderer/components/ui/MentionableTextarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
|
@ -9,12 +9,15 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
|||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { getModifierKeyName } from '@renderer/utils/keyboardUtils';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { Send, X } from 'lucide-react';
|
||||
import { ImagePlus, Send, Trash2, X } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
import type { CommentAttachmentPayload, ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
const MAX_COMMENT_LENGTH = 2000;
|
||||
const MAX_ATTACHMENTS = 5;
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024;
|
||||
const ACCEPTED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
||||
|
||||
interface TaskCommentInputProps {
|
||||
teamName: string;
|
||||
|
|
@ -24,6 +27,15 @@ interface TaskCommentInputProps {
|
|||
onClearReply: () => void;
|
||||
}
|
||||
|
||||
interface PendingAttachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
base64Data: string;
|
||||
previewUrl: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const TaskCommentInput = ({
|
||||
teamName,
|
||||
taskId,
|
||||
|
|
@ -37,6 +49,9 @@ export const TaskCommentInput = ({
|
|||
|
||||
const draft = useDraftPersistence({ key: `taskComment:${teamName}:${taskId}` });
|
||||
const colorMap = useMemo(() => buildMemberColorMap(members), [members]);
|
||||
const [pendingAttachments, setPendingAttachments] = useState<PendingAttachment[]>([]);
|
||||
const [attachError, setAttachError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const mentionSuggestions = useMemo<MentionSuggestion[]>(
|
||||
() =>
|
||||
|
|
@ -51,19 +66,115 @@ export const TaskCommentInput = ({
|
|||
|
||||
const trimmed = draft.value.trim();
|
||||
const remaining = MAX_COMMENT_LENGTH - trimmed.length;
|
||||
const canSubmit = trimmed.length > 0 && trimmed.length <= MAX_COMMENT_LENGTH && !addingComment;
|
||||
const canSubmit =
|
||||
(trimmed.length > 0 || pendingAttachments.length > 0) &&
|
||||
trimmed.length <= MAX_COMMENT_LENGTH &&
|
||||
!addingComment;
|
||||
|
||||
const addFiles = useCallback(
|
||||
(files: FileList | File[]) => {
|
||||
setAttachError(null);
|
||||
const fileArray = Array.from(files);
|
||||
for (const file of fileArray) {
|
||||
if (!ACCEPTED_TYPES.has(file.type)) {
|
||||
setAttachError(`Unsupported type: ${file.type}`);
|
||||
continue;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setAttachError(
|
||||
`File too large: ${(file.size / (1024 * 1024)).toFixed(1)} MB (max 20 MB)`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (pendingAttachments.length >= MAX_ATTACHMENTS) {
|
||||
setAttachError(`Maximum ${MAX_ATTACHMENTS} attachments per comment`);
|
||||
break;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(',')[1];
|
||||
if (!base64) return;
|
||||
const id = crypto.randomUUID();
|
||||
setPendingAttachments((prev) => {
|
||||
if (prev.length >= MAX_ATTACHMENTS) return prev;
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
base64Data: base64,
|
||||
previewUrl: result,
|
||||
size: file.size,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
},
|
||||
[pendingAttachments.length]
|
||||
);
|
||||
|
||||
const removeAttachment = useCallback((id: string) => {
|
||||
setPendingAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!canSubmit) return;
|
||||
try {
|
||||
const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, trimmed) : trimmed;
|
||||
await addTaskComment(teamName, taskId, text);
|
||||
const text = replyTo
|
||||
? buildReplyBlock(replyTo.author, replyTo.text, trimmed || '(image)')
|
||||
: trimmed || '(image)';
|
||||
const attachments: CommentAttachmentPayload[] | undefined =
|
||||
pendingAttachments.length > 0
|
||||
? pendingAttachments.map((a) => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
mimeType: a.mimeType as CommentAttachmentPayload['mimeType'],
|
||||
base64Data: a.base64Data,
|
||||
}))
|
||||
: undefined;
|
||||
await addTaskComment(teamName, taskId, text, attachments);
|
||||
draft.clearDraft();
|
||||
setPendingAttachments([]);
|
||||
setAttachError(null);
|
||||
onClearReply();
|
||||
} catch {
|
||||
// Error is stored in addCommentError via store
|
||||
}
|
||||
}, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, replyTo, onClearReply]);
|
||||
}, [
|
||||
canSubmit,
|
||||
addTaskComment,
|
||||
teamName,
|
||||
taskId,
|
||||
trimmed,
|
||||
draft,
|
||||
replyTo,
|
||||
onClearReply,
|
||||
pendingAttachments,
|
||||
]);
|
||||
|
||||
// Handle paste from MentionableTextarea area
|
||||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const imageFiles: File[] = [];
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file' && ACCEPTED_TYPES.has(item.type)) {
|
||||
const file = item.getAsFile();
|
||||
if (file) imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
addFiles(imageFiles);
|
||||
}
|
||||
},
|
||||
[addFiles]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -103,7 +214,41 @@ export const TaskCommentInput = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative">
|
||||
{/* Pending attachment previews */}
|
||||
{pendingAttachments.length > 0 ? (
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
{pendingAttachments.map((att) => (
|
||||
<div
|
||||
key={att.id}
|
||||
className="group relative size-14 overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||
>
|
||||
<img src={att.previewUrl} alt={att.filename} className="size-full object-cover" />
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-0.5 top-0.5 rounded bg-black/60 p-0.5 text-white opacity-0 transition-opacity hover:bg-red-600 group-hover:opacity-100"
|
||||
onClick={() => removeAttachment(att.id)}
|
||||
>
|
||||
<Trash2 size={8} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{attachError ? <p className="mb-1 text-[10px] text-red-400">{attachError}</p> : null}
|
||||
|
||||
<div className="relative" onPaste={handlePaste}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files) addFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
<MentionableTextarea
|
||||
id={`task-comment-${taskId}`}
|
||||
placeholder={`Add a comment... (${getModifierKeyName()}+Enter to send)`}
|
||||
|
|
@ -116,15 +261,30 @@ export const TaskCommentInput = ({
|
|||
maxLength={MAX_COMMENT_LENGTH}
|
||||
disabled={addingComment}
|
||||
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={!canSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
<Send size={12} />
|
||||
Comment
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex shrink-0 items-center rounded-full p-1.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-secondary)]"
|
||||
disabled={addingComment || pendingAttachments.length >= MAX_ATTACHMENTS}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<ImagePlus size={14} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Attach image (or paste)</TooltipContent>
|
||||
</Tooltip>
|
||||
<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={!canSubmit}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
<Send size={12} />
|
||||
Comment
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
footerRight={
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { ReplyQuoteBlock } from '@renderer/components/team/activity/ReplyQuoteBlock';
|
||||
|
|
@ -19,6 +19,7 @@ import {
|
|||
ChevronDown,
|
||||
ChevronUp,
|
||||
Eye,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Reply,
|
||||
Send,
|
||||
|
|
@ -26,7 +27,12 @@ import {
|
|||
} from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember, TaskComment } from '@shared/types';
|
||||
import type {
|
||||
AttachmentMediaType,
|
||||
ResolvedTeamMember,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
} from '@shared/types';
|
||||
|
||||
/**
|
||||
* Convert literal backslash-n sequences to real newlines.
|
||||
|
|
@ -62,6 +68,19 @@ function linkifyTaskIdsInMarkdown(text: string): string {
|
|||
return text.replace(/#(\d+)/g, '[#$1](task://$1)');
|
||||
}
|
||||
|
||||
/** Convert `@memberName` to markdown links with mention:// protocol for colored badge rendering. */
|
||||
function linkifyMentionsInMarkdown(text: string, memberColorMap: Map<string, string>): string {
|
||||
if (memberColorMap.size === 0) return text;
|
||||
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
|
||||
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
const pattern = new RegExp(`(^|\\s)@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}-]|$)`, 'gi');
|
||||
return text.replace(pattern, (match, prefix: string, name: string) => {
|
||||
const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
|
||||
const color = memberColorMap.get(canonical) ?? '';
|
||||
return `${prefix}[@${canonical}](mention://${encodeURIComponent(color)}/${encodeURIComponent(canonical)})`;
|
||||
});
|
||||
}
|
||||
|
||||
export const TaskCommentsSection = ({
|
||||
teamName,
|
||||
taskId,
|
||||
|
|
@ -79,6 +98,7 @@ export const TaskCommentsSection = ({
|
|||
const [replyTo, setReplyTo] = useState<{ author: string; text: string } | null>(null);
|
||||
const [expandedCommentIds, setExpandedCommentIds] = useState<Set<string>>(new Set());
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS);
|
||||
const [previewImageUrl, setPreviewImageUrl] = useState<string | null>(null);
|
||||
|
||||
// Reset local state when team/task changes (React-recommended pattern for
|
||||
// adjusting state based on props without using effects or refs during render)
|
||||
|
|
@ -278,9 +298,12 @@ export const TaskCommentsSection = ({
|
|||
}
|
||||
>
|
||||
<MarkdownViewer
|
||||
content={
|
||||
onTaskIdClick ? linkifyTaskIdsInMarkdown(displayText) : displayText
|
||||
}
|
||||
content={(() => {
|
||||
let t = displayText;
|
||||
if (onTaskIdClick) t = linkifyTaskIdsInMarkdown(t);
|
||||
if (colorMap.size > 0) t = linkifyMentionsInMarkdown(t, colorMap);
|
||||
return t;
|
||||
})()}
|
||||
maxHeight={
|
||||
needsExpandCollapse && !expanded ? collapsedHeight : 'max-h-none'
|
||||
}
|
||||
|
|
@ -328,6 +351,14 @@ export const TaskCommentsSection = ({
|
|||
</div>
|
||||
);
|
||||
})()}
|
||||
{comment.attachments && comment.attachments.length > 0 ? (
|
||||
<CommentAttachments
|
||||
attachments={comment.attachments}
|
||||
teamName={teamName}
|
||||
taskId={taskId}
|
||||
onPreview={setPreviewImageUrl}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
|
@ -347,6 +378,24 @@ export const TaskCommentsSection = ({
|
|||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Full-size image preview overlay */}
|
||||
{previewImageUrl ? (
|
||||
<div className="relative mb-3 rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2 rounded p-0.5 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={() => setPreviewImageUrl(null)}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<img
|
||||
src={previewImageUrl}
|
||||
alt="Attachment preview"
|
||||
className="max-h-[400px] max-w-full rounded object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!hideInput && (
|
||||
<>
|
||||
{replyTo ? (
|
||||
|
|
@ -419,6 +468,95 @@ export const TaskCommentsSection = ({
|
|||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comment attachment thumbnail (read-only, no delete)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CommentAttachmentThumbnailProps {
|
||||
attachment: TaskAttachmentMeta;
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
onPreview: (dataUrl: string) => void;
|
||||
}
|
||||
|
||||
const CommentAttachmentThumbnail = ({
|
||||
attachment,
|
||||
teamName,
|
||||
taskId,
|
||||
onPreview,
|
||||
}: CommentAttachmentThumbnailProps): React.JSX.Element => {
|
||||
const getTaskAttachmentData = useStore((s) => s.getTaskAttachmentData);
|
||||
const [thumbUrl, setThumbUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const base64 = await getTaskAttachmentData(
|
||||
teamName,
|
||||
taskId,
|
||||
attachment.id,
|
||||
attachment.mimeType
|
||||
);
|
||||
if (!cancelled && base64) {
|
||||
setThumbUrl(`data:${attachment.mimeType};base64,${base64}`);
|
||||
}
|
||||
} catch {
|
||||
// ignore — thumbnail simply won't render
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [teamName, taskId, attachment.id, attachment.mimeType, getTaskAttachmentData]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex size-14 cursor-pointer items-center justify-center overflow-hidden rounded border border-[var(--color-border)] bg-[var(--color-surface)] transition-colors hover:border-[var(--color-border-emphasis)]"
|
||||
onClick={() => thumbUrl && onPreview(thumbUrl)}
|
||||
>
|
||||
{thumbUrl ? (
|
||||
<img src={thumbUrl} alt={attachment.filename} className="size-full object-cover" />
|
||||
) : (
|
||||
<Loader2 size={12} className="animate-spin text-[var(--color-text-muted)]" />
|
||||
)}
|
||||
<div className="absolute inset-x-0 bottom-0 truncate bg-black/60 px-0.5 py-px text-center text-[7px] text-white opacity-0 transition-opacity group-hover:opacity-100">
|
||||
{attachment.filename}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comment attachments grid
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface CommentAttachmentsProps {
|
||||
attachments: TaskAttachmentMeta[];
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
onPreview: (dataUrl: string) => void;
|
||||
}
|
||||
|
||||
const CommentAttachments = ({
|
||||
attachments,
|
||||
teamName,
|
||||
taskId,
|
||||
onPreview,
|
||||
}: CommentAttachmentsProps): React.JSX.Element => (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{attachments.map((att) => (
|
||||
<CommentAttachmentThumbnail
|
||||
key={att.id}
|
||||
attachment={att}
|
||||
teamName={teamName}
|
||||
taskId={taskId}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
function teamIdKey(teamName: string, taskId: string): string {
|
||||
return `${teamName}::${taskId}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||
import { GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||
|
|
@ -39,8 +40,18 @@ export const MemberCard = ({
|
|||
onSendMessage,
|
||||
onAssignTask,
|
||||
}: MemberCardProps): React.JSX.Element => {
|
||||
const teamName = useStore((s) => s.selectedTeamName);
|
||||
const leadContext = useStore((s) =>
|
||||
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
);
|
||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const presenceLabel = getPresenceLabel(
|
||||
member,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
leadContext?.percent
|
||||
);
|
||||
const colors = getTeamColorSet(memberColor);
|
||||
const pending = taskCounts?.pending ?? 0;
|
||||
const inProgress = taskCounts?.inProgress ?? 0;
|
||||
|
|
@ -171,6 +182,29 @@ export const MemberCard = ({
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
{leadContext && leadContext.percent > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="mx-0.5 mt-0.5 h-[2px] rounded-full bg-[var(--color-border)]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
leadContext.percent > 90
|
||||
? 'bg-red-500'
|
||||
: leadContext.percent > 70
|
||||
? 'bg-amber-500'
|
||||
: 'bg-blue-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(leadContext.percent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
Context: {Math.round(leadContext.percent)}% (
|
||||
{(leadContext.currentTokens / 1000).toFixed(1)}k /{' '}
|
||||
{(leadContext.contextWindow / 1000).toFixed(0)}k tokens)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{!isRemoved && (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useState } from 'react';
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { DialogDescription, DialogTitle } from '@renderer/components/ui/dialog';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { agentAvatarUrl, getMemberDotClass, getPresenceLabel } from '@renderer/utils/memberHelpers';
|
||||
import { Pencil } from 'lucide-react';
|
||||
|
|
@ -30,9 +31,20 @@ export const MemberDetailHeader = ({
|
|||
}: MemberDetailHeaderProps): React.JSX.Element => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const teamName = useStore((s) => s.selectedTeamName);
|
||||
const leadContext = useStore((s) =>
|
||||
member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
);
|
||||
|
||||
const colors = getTeamColorSet(member.color ?? '');
|
||||
const role = member.role || formatAgentRole(member.agentType);
|
||||
const presenceLabel = getPresenceLabel(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
const presenceLabel = getPresenceLabel(
|
||||
member,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
leadContext?.percent
|
||||
);
|
||||
const dotClass = getMemberDotClass(member, isTeamAlive, isTeamProvisioning, leadActivity);
|
||||
|
||||
const canEditRole =
|
||||
|
|
@ -88,12 +100,20 @@ export const MemberDetailHeader = ({
|
|||
</>
|
||||
)}
|
||||
{!editing && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||
>
|
||||
{presenceLabel}
|
||||
</Badge>
|
||||
<>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||
>
|
||||
{presenceLabel}
|
||||
</Badge>
|
||||
{leadContext && leadContext.percent > 0 && (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">
|
||||
{(leadContext.currentTokens / 1000).toFixed(1)}k /{' '}
|
||||
{(leadContext.contextWindow / 1000).toFixed(0)}k
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogDescription>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,12 @@ import { createUpdateSlice } from './slices/updateSlice';
|
|||
|
||||
import type { DetectedError } from '../types/data';
|
||||
import type { AppState } from './types';
|
||||
import type { CliInstallerProgress, TeamChangeEvent, UpdaterStatus } from '@shared/types';
|
||||
import type {
|
||||
CliInstallerProgress,
|
||||
LeadContextUsage,
|
||||
TeamChangeEvent,
|
||||
UpdaterStatus,
|
||||
} from '@shared/types';
|
||||
|
||||
// =============================================================================
|
||||
// Store Creation
|
||||
|
|
@ -362,11 +367,33 @@ export function initializeNotificationListeners(): () => void {
|
|||
};
|
||||
}
|
||||
|
||||
// Clear context data when lead goes offline
|
||||
if (nextActivity === 'offline') {
|
||||
nextState.leadContextByTeam = { ...prev.leadContextByTeam };
|
||||
delete (nextState.leadContextByTeam as Record<string, LeadContextUsage>)[
|
||||
event.teamName
|
||||
];
|
||||
}
|
||||
|
||||
return nextState as typeof prev;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Immediate in-memory update for lead context usage — no filesystem refresh needed
|
||||
if (event.type === 'lead-context' && event.detail) {
|
||||
try {
|
||||
const ctx = JSON.parse(event.detail) as LeadContextUsage;
|
||||
useStore.setState((prev) => ({
|
||||
...prev,
|
||||
leadContextByTeam: { ...prev.leadContextByTeam, [event.teamName]: ctx },
|
||||
}));
|
||||
} catch {
|
||||
/* ignore malformed detail */
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Throttled refresh of summary list (keeps TeamListView current without flooding).
|
||||
if (!teamListRefreshTimer) {
|
||||
teamListRefreshTimer = setTimeout(() => {
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ import type {
|
|||
GlobalTask,
|
||||
KanbanColumnId,
|
||||
LeadActivityState,
|
||||
LeadContextUsage,
|
||||
SendMessageRequest,
|
||||
SendMessageResult,
|
||||
TaskComment,
|
||||
|
|
@ -256,6 +257,7 @@ export interface TeamSlice {
|
|||
*/
|
||||
provisioningStartedAtFloorByTeam: Record<string, string>;
|
||||
leadActivityByTeam: Record<string, LeadActivityState>;
|
||||
leadContextByTeam: Record<string, LeadContextUsage>;
|
||||
activeProvisioningRunId: string | null;
|
||||
provisioningError: string | null;
|
||||
clearProvisioningError: () => void;
|
||||
|
|
@ -288,7 +290,12 @@ export interface TeamSlice {
|
|||
) => Promise<void>;
|
||||
addingComment: boolean;
|
||||
addCommentError: string | null;
|
||||
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||
addTaskComment: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
attachments?: import('@shared/types').CommentAttachmentPayload[]
|
||||
) => Promise<TaskComment>;
|
||||
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||
updateMemberRole: (
|
||||
|
|
@ -369,6 +376,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
provisioningRuns: {},
|
||||
provisioningStartedAtFloorByTeam: {},
|
||||
leadActivityByTeam: {},
|
||||
leadContextByTeam: {},
|
||||
activeProvisioningRunId: null,
|
||||
provisioningError: null,
|
||||
clearProvisioningError: () => set({ provisioningError: null }),
|
||||
|
|
@ -848,11 +856,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
);
|
||||
},
|
||||
|
||||
addTaskComment: async (teamName, taskId, text) => {
|
||||
addTaskComment: async (teamName, taskId, text, attachments) => {
|
||||
set({ addingComment: true, addCommentError: null });
|
||||
try {
|
||||
const comment = await unwrapIpc('team:addTaskComment', () =>
|
||||
api.teams.addTaskComment(teamName, taskId, text)
|
||||
api.teams.addTaskComment(teamName, taskId, text, attachments)
|
||||
);
|
||||
set({ addingComment: false });
|
||||
await get().refreshTeamData(teamName);
|
||||
|
|
|
|||
|
|
@ -41,13 +41,19 @@ export function getPresenceLabel(
|
|||
member: ResolvedTeamMember,
|
||||
isTeamAlive?: boolean,
|
||||
isTeamProvisioning?: boolean,
|
||||
leadActivity?: LeadActivityState
|
||||
leadActivity?: LeadActivityState,
|
||||
leadContextPercent?: number
|
||||
): string {
|
||||
if (member.status === 'terminated') return 'terminated';
|
||||
if (isTeamProvisioning) return 'connecting';
|
||||
if (isTeamAlive === false) return 'offline';
|
||||
if (leadActivity && member.agentType === 'team-lead') {
|
||||
return leadActivity === 'active' ? 'processing' : 'ready';
|
||||
if (leadActivity === 'active') {
|
||||
return leadContextPercent != null && leadContextPercent > 0
|
||||
? `processing (${Math.round(leadContextPercent)}%)`
|
||||
: 'processing';
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
if (member.status === 'unknown') return 'idle';
|
||||
return member.currentTaskId ? 'working' : 'idle';
|
||||
|
|
|
|||
|
|
@ -31,10 +31,12 @@ import type {
|
|||
AddMemberRequest,
|
||||
AttachmentFileData,
|
||||
AttachmentMediaType,
|
||||
CommentAttachmentPayload,
|
||||
CreateTaskRequest,
|
||||
GlobalTask,
|
||||
KanbanColumnId,
|
||||
LeadActivityState,
|
||||
LeadContextUsage,
|
||||
MemberFullStats,
|
||||
MemberLogSummary,
|
||||
ReplaceMembersRequest,
|
||||
|
|
@ -450,7 +452,12 @@ export interface TeamsAPI {
|
|||
memberName: string,
|
||||
role: string | undefined
|
||||
) => Promise<void>;
|
||||
addTaskComment: (teamName: string, taskId: string, text: string) => Promise<TaskComment>;
|
||||
addTaskComment: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
attachments?: CommentAttachmentPayload[]
|
||||
) => Promise<TaskComment>;
|
||||
setTaskClarification: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
|
|
@ -460,6 +467,7 @@ export interface TeamsAPI {
|
|||
getAttachments: (teamName: string, messageId: string) => Promise<AttachmentFileData[]>;
|
||||
killProcess: (teamName: string, pid: number) => Promise<void>;
|
||||
getLeadActivity: (teamName: string) => Promise<LeadActivityState>;
|
||||
getLeadContext: (teamName: string) => Promise<LeadContextUsage | null>;
|
||||
softDeleteTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
restoreTask: (teamName: string, taskId: string) => Promise<void>;
|
||||
getDeletedTasks: (teamName: string) => Promise<TeamTask[]>;
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export interface TaskComment {
|
|||
text: string;
|
||||
createdAt: string;
|
||||
type: TaskCommentType;
|
||||
/** Image attachments on this comment. Metadata only — files stored on disk. */
|
||||
attachments?: TaskAttachmentMeta[];
|
||||
}
|
||||
|
||||
// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record<keyof TeamTask, unknown>`.
|
||||
|
|
@ -147,6 +149,14 @@ export interface TaskAttachmentMeta {
|
|||
addedAt: string;
|
||||
}
|
||||
|
||||
/** Payload for uploading an attachment with base64 data (renderer → main). */
|
||||
export interface CommentAttachmentPayload {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: AttachmentMediaType;
|
||||
base64Data: string;
|
||||
}
|
||||
|
||||
export type AttachmentMediaType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/webp';
|
||||
|
||||
export interface AttachmentMeta {
|
||||
|
|
@ -284,8 +294,19 @@ export interface CreateTaskRequest {
|
|||
|
||||
export type LeadActivityState = 'active' | 'idle' | 'offline';
|
||||
|
||||
export interface LeadContextUsage {
|
||||
/** Total tokens currently in context (input + cache_creation + cache_read) */
|
||||
currentTokens: number;
|
||||
/** Model's context window size */
|
||||
contextWindow: number;
|
||||
/** Usage percentage (0-100) */
|
||||
percent: number;
|
||||
/** ISO timestamp of last update */
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TeamChangeEvent {
|
||||
type: 'config' | 'inbox' | 'task' | 'lead-activity' | 'process';
|
||||
type: 'config' | 'inbox' | 'task' | 'lead-activity' | 'lead-context' | 'process';
|
||||
teamName: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue