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:
iliya 2026-03-05 00:57:30 +02:00
parent 1f35e86f0a
commit 43d2953874
27 changed files with 872 additions and 79 deletions

View file

@ -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
---

View file

@ -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;

View file

@ -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;

View file

@ -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`);
}

View file

@ -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([

View file

@ -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`);
}
}
}
/**

View file

@ -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)`

View file

@ -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(

View file

@ -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 () => {

View file

@ -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';

View file

@ -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);
},

View file

@ -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
},

View file

@ -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 }) => (

View file

@ -1498,6 +1498,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
<SendMessageDialog
open={sendDialogOpen}
teamName={teamName}
members={activeMembers}
defaultRecipient={sendDialogRecipient}
defaultText={sendDialogDefaultText}

View file

@ -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(

View file

@ -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}

View file

@ -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>
);

View file

@ -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';

View file

@ -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">

View file

@ -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}`;
}

View file

@ -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">

View file

@ -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>

View file

@ -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(() => {

View file

@ -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);

View file

@ -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';

View file

@ -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[]>;

View file

@ -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;
}