diff --git a/README.md b/README.md index 8eb74574..788f3ac4 100644 --- a/README.md +++ b/README.md @@ -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 --- diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 9f1a05c8..29206169 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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> { + 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> { 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> { 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; + 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; diff --git a/src/main/services/discovery/ProjectScanner.ts b/src/main/services/discovery/ProjectScanner.ts index a7c86dad..c9056f04 100644 --- a/src/main/services/discovery/ProjectScanner.ts +++ b/src/main/services/discovery/ProjectScanner.ts @@ -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; diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts index c402c895..cd4d36d7 100644 --- a/src/main/services/team/TeamAttachmentStore.ts +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -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`); } diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index 749af3fd..2f9cc319 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -983,8 +983,15 @@ export class TeamDataService { await this.taskWriter.removeRelationship(teamName, taskId, targetId, type); } - async addTaskComment(teamName: string, taskId: string, text: string): Promise { - const comment = await this.taskWriter.addComment(teamName, taskId, text); + async addTaskComment( + teamName: string, + taskId: string, + text: string, + attachments?: import('@shared/types').TaskAttachmentMeta[] + ): Promise { + const comment = await this.taskWriter.addComment(teamName, taskId, text, { + attachments, + }); try { const [tasks, toolPath, config] = await Promise.all([ diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f75c87f7..a6a44fe8 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -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 { 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; + if (messageObj && typeof messageObj === 'object') { + const msgId = typeof messageObj.id === 'string' ? messageObj.id : null; + const usage = messageObj.usage as Record | 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 | undefined)?.modelUsage) as + | Record> + | 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 | undefined)?.usage) as + | Record + | 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`); + } + } } /** diff --git a/src/main/services/team/TeamTaskAttachmentStore.ts b/src/main/services/team/TeamTaskAttachmentStore.ts index c5bff7c4..9661df70 100644 --- a/src/main/services/team/TeamTaskAttachmentStore.ts +++ b/src/main/services/team/TeamTaskAttachmentStore.ts @@ -18,13 +18,28 @@ const ALLOWED_MIME_TYPES: ReadonlySet = new Set([ ]); 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)` diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 339ad3d9..7b7246f6 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -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).id === 'string' && + typeof (a as Record).filename === 'string' && + typeof (a as Record).mimeType === 'string' && + VALID_ATTACHMENT_MIME_TYPES.has( + (a as Record).mimeType as string + ) && + typeof (a as Record).size === 'number' && + typeof (a as Record).addedAt === 'string' + ) + : undefined, })) : undefined, needsClarification: (['lead', 'user'] as const).includes( diff --git a/src/main/services/team/TeamTaskWriter.ts b/src/main/services/team/TeamTaskWriter.ts index e5e21971..d1d6603e 100644 --- a/src/main/services/team/TeamTaskWriter.ts +++ b/src/main/services/team/TeamTaskWriter.ts @@ -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 { 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 () => { diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 1e1c3416..a3ca64c3 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -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'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 0a1f0fd9..8361d241 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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(TEAM_UPDATE_CONFIG, teamName, updates); }, - addTaskComment: async (teamName: string, taskId: string, text: string) => { - return invokeIpcWithResult(TEAM_ADD_TASK_COMMENT, teamName, taskId, text); + addTaskComment: async ( + teamName: string, + taskId: string, + text: string, + attachments?: CommentAttachmentPayload[] + ) => { + return invokeIpcWithResult( + TEAM_ADD_TASK_COMMENT, + teamName, + taskId, + text, + attachments + ); }, addMember: async (teamName: string, request: AddMemberRequest) => { return invokeIpcWithResult(TEAM_ADD_MEMBER, teamName, request); @@ -829,6 +843,9 @@ const electronAPI: ElectronAPI = { const result = await invokeIpcWithResult(TEAM_LEAD_ACTIVITY, teamName); return result as 'active' | 'idle' | 'offline'; }, + getLeadContext: async (teamName: string) => { + return invokeIpcWithResult(TEAM_LEAD_CONTEXT, teamName); + }, softDeleteTask: async (teamName: string, taskId: string) => { return invokeIpcWithResult(TEAM_SOFT_DELETE_TASK, teamName, taskId); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 66a8fd80..b2c69fa8 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -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 => { // Not available via HTTP client — no-op }, diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 17386879..f7670baf 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -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 }) => ( - { - e.preventDefault(); - if (href && !href.startsWith('task://')) { - void api.openExternal(href); - } - }} - > - {children} - - ), + // 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 ( + + {children} + + ); + } + return ( + { + e.preventDefault(); + if (href && !href.startsWith('task://')) { + void api.openExternal(href); + } + }} + > + {children} + + ); + }, // Strong/Bold — inline element, no hl() strong: ({ children }) => ( diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 89eda366..1c2f7a40 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -1498,6 +1498,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele ; 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 { + 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 `#` 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( diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index 2f588b60..a4033cad 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -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; 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} diff --git a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx index e8206e4a..29efede0 100644 --- a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx +++ b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx @@ -20,8 +20,10 @@ export const ReplyQuoteBlock = ({ @{reply.agentName} -

{reply.originalText}

+
+ +
- + ); diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index ded38444..7a662e00 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -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'; diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index 602253a4..4d44f30f 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -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([]); + const [attachError, setAttachError] = useState(null); + const fileInputRef = useRef(null); const mentionSuggestions = useMemo( () => @@ -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 (
@@ -103,7 +214,41 @@ export const TaskCommentInput = ({
) : null} -
+ {/* Pending attachment previews */} + {pendingAttachments.length > 0 ? ( +
+ {pendingAttachments.map((att) => ( +
+ {att.filename} + +
+ ))} +
+ ) : null} + + {attachError ?

{attachError}

: null} + +
+ { + if (e.target.files) addFiles(e.target.files); + e.target.value = ''; + }} + /> void handleSubmit()} - > - - Comment - +
+ + + + + Attach image (or paste) + + +
} footerRight={
diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 709e0998..4e12c84b 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -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 { + 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>(new Set()); const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COMMENTS); + const [previewImageUrl, setPreviewImageUrl] = useState(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 = ({ } > { + 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 = ({
); })()} + {comment.attachments && comment.attachments.length > 0 ? ( + + ) : null}
))} @@ -347,6 +378,24 @@ export const TaskCommentsSection = ({
) : null} + {/* Full-size image preview overlay */} + {previewImageUrl ? ( +
+ + Attachment preview +
+ ) : 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(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 ( +
thumbUrl && onPreview(thumbUrl)} + > + {thumbUrl ? ( + {attachment.filename} + ) : ( + + )} +
+ {attachment.filename} +
+
+ ); +}; + +// --------------------------------------------------------------------------- +// 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 => ( +
+ {attachments.map((att) => ( + + ))} +
+); + function teamIdKey(teamName: string, taskId: string): string { return `${teamName}::${taskId}`; } diff --git a/src/renderer/components/team/members/MemberCard.tsx b/src/renderer/components/team/members/MemberCard.tsx index 79704020..fdff66fb 100644 --- a/src/renderer/components/team/members/MemberCard.tsx +++ b/src/renderer/components/team/members/MemberCard.tsx @@ -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 = ({ /> )} + {leadContext && leadContext.percent > 0 && ( + + +
+
90 + ? 'bg-red-500' + : leadContext.percent > 70 + ? 'bg-amber-500' + : 'bg-blue-500' + }`} + style={{ width: `${Math.min(leadContext.percent, 100)}%` }} + /> +
+ + + Context: {Math.round(leadContext.percent)}% ( + {(leadContext.currentTokens / 1000).toFixed(1)}k /{' '} + {(leadContext.contextWindow / 1000).toFixed(0)}k tokens) + + + )}
{!isRemoved && (
diff --git a/src/renderer/components/team/members/MemberDetailHeader.tsx b/src/renderer/components/team/members/MemberDetailHeader.tsx index 73e13c20..5dc422ae 100644 --- a/src/renderer/components/team/members/MemberDetailHeader.tsx +++ b/src/renderer/components/team/members/MemberDetailHeader.tsx @@ -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 && ( - - {presenceLabel} - + <> + + {presenceLabel} + + {leadContext && leadContext.percent > 0 && ( + + {(leadContext.currentTokens / 1000).toFixed(1)}k /{' '} + {(leadContext.contextWindow / 1000).toFixed(0)}k + + )} + )}
diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 7fb0450e..0829db2e 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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)[ + 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(() => { diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index ec69987b..1e203b26 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -69,6 +69,7 @@ import type { GlobalTask, KanbanColumnId, LeadActivityState, + LeadContextUsage, SendMessageRequest, SendMessageResult, TaskComment, @@ -256,6 +257,7 @@ export interface TeamSlice { */ provisioningStartedAtFloorByTeam: Record; leadActivityByTeam: Record; + leadContextByTeam: Record; activeProvisioningRunId: string | null; provisioningError: string | null; clearProvisioningError: () => void; @@ -288,7 +290,12 @@ export interface TeamSlice { ) => Promise; addingComment: boolean; addCommentError: string | null; - addTaskComment: (teamName: string, taskId: string, text: string) => Promise; + addTaskComment: ( + teamName: string, + taskId: string, + text: string, + attachments?: import('@shared/types').CommentAttachmentPayload[] + ) => Promise; addMember: (teamName: string, request: AddMemberRequest) => Promise; removeMember: (teamName: string, memberName: string) => Promise; updateMemberRole: ( @@ -369,6 +376,7 @@ export const createTeamSlice: StateCreator = (set, provisioningRuns: {}, provisioningStartedAtFloorByTeam: {}, leadActivityByTeam: {}, + leadContextByTeam: {}, activeProvisioningRunId: null, provisioningError: null, clearProvisioningError: () => set({ provisioningError: null }), @@ -848,11 +856,11 @@ export const createTeamSlice: StateCreator = (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); diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index de937fa9..9509bc6e 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -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'; diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 35e36d49..46fb4e87 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -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; - addTaskComment: (teamName: string, taskId: string, text: string) => Promise; + addTaskComment: ( + teamName: string, + taskId: string, + text: string, + attachments?: CommentAttachmentPayload[] + ) => Promise; setTaskClarification: ( teamName: string, taskId: string, @@ -460,6 +467,7 @@ export interface TeamsAPI { getAttachments: (teamName: string, messageId: string) => Promise; killProcess: (teamName: string, pid: number) => Promise; getLeadActivity: (teamName: string) => Promise; + getLeadContext: (teamName: string) => Promise; softDeleteTask: (teamName: string, taskId: string) => Promise; restoreTask: (teamName: string, taskId: string) => Promise; getDeletedTasks: (teamName: string) => Promise; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 40ff3817..f67c6c9c 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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`. @@ -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; }