diff --git a/CLAUDE.md b/CLAUDE.md index 61d3d26c..7be051f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,6 +86,14 @@ Claude Code's "Orchestrate Teams" feature: multiple sessions coordinate as a tea - **Display summary** counts distinct teammates (by name) separately from regular subagents - **Team tools**: TeamCreate, TaskCreate, TaskUpdate, TaskList, TaskGet, SendMessage, TeamDelete — have readable summaries in `toolSummaryHelpers.ts` +### Structured Task References +- **TaskRef**: `{ taskId, displayId, teamName }` — shared typed reference used to persist task mentions across UI and storage +- **Persisted optional fields**: `InboxMessage.taskRefs`, `TaskComment.taskRefs`, `TeamTask.descriptionTaskRefs`, `TeamTask.promptTaskRefs` +- **Request surfaces**: `SendMessageRequest.taskRefs`, `AddTaskCommentRequest.taskRefs`, `CreateTaskRequest.descriptionTaskRefs`, `CreateTaskRequest.promptTaskRefs`, `UpdateKanbanPatch` `request_changes.taskRefs` +- **Renderer flow**: task-aware inputs use `useTaskSuggestions()` with `taskReferenceUtils.ts` to extract refs from text; encoded zero-width metadata preserves exact task identity while keeping visible text readable +- **Main/IPC flow**: `src/main/ipc/teams.ts` and `src/main/ipc/crossTeam.ts` validate structured refs before `TeamDataService`, inbox stores, task stores, and readers persist/rehydrate them +- **Rendering/navigation**: `linkifyTaskIdsInMarkdown()` and `parseTaskLinkHref()` turn persisted refs into stable `task://` links across messages, comments, task descriptions, and activity items + ### Visible Context Tracking Tracks what consumes tokens in Claude's context window across 6 categories (discriminated union on `category` field): @@ -139,7 +147,7 @@ Check for changes in message parsing or chunk building logic. | Services/Components | PascalCase | `ProjectScanner.ts` | | Utilities | camelCase | `pathDecoder.ts` | | Constants | UPPER_SNAKE_CASE | `PARALLEL_WINDOW_MS` | -| Type Guards | isXxx | `isRealUserMessage()` | +| Type Guards | isXxx | `isParsedRealUserMessage()` | | Builders | buildXxx | `buildChunks()` | | Getters | getXxx | `getResponses()` | diff --git a/agent-teams-controller/src/internal/messageStore.js b/agent-teams-controller/src/internal/messageStore.js index 5bc59bfb..5e549131 100644 --- a/agent-teams-controller/src/internal/messageStore.js +++ b/agent-teams-controller/src/internal/messageStore.js @@ -51,6 +51,23 @@ function normalizeAttachments(attachments) { return normalized.length > 0 ? normalized : undefined; } +function normalizeTaskRefs(taskRefs) { + if (!Array.isArray(taskRefs) || taskRefs.length === 0) { + return undefined; + } + + const normalized = taskRefs + .filter((item) => item && typeof item === 'object') + .map((item) => ({ + taskId: String(item.taskId || '').trim(), + displayId: String(item.displayId || '').trim(), + teamName: String(item.teamName || '').trim(), + })) + .filter((item) => item.taskId && item.displayId && item.teamName); + + return normalized.length > 0 ? normalized : undefined; +} + function buildMessage(flags, defaults) { const timestamp = typeof flags.timestamp === 'string' && flags.timestamp.trim() ? flags.timestamp.trim() : nowIso(); @@ -59,6 +76,7 @@ function buildMessage(flags, defaults) { ? flags.messageId.trim() : crypto.randomUUID(); const attachments = normalizeAttachments(flags.attachments); + const taskRefs = normalizeTaskRefs(flags.taskRefs); return { from: @@ -69,6 +87,7 @@ function buildMessage(flags, defaults) { text: String(flags.text || ''), timestamp, read: defaults.read, + ...(taskRefs ? { taskRefs } : {}), ...(typeof flags.summary === 'string' && flags.summary.trim() ? { summary: flags.summary.trim() } : {}), diff --git a/agent-teams-controller/src/internal/review.js b/agent-teams-controller/src/internal/review.js index a665f183..b2d86afe 100644 --- a/agent-teams-controller/src/internal/review.js +++ b/agent-teams-controller/src/internal/review.js @@ -185,6 +185,7 @@ function requestChanges(context, taskId, flags = {}) { text: comment, from, type: 'review_request', + ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), notifyOwner: false, }); messages.sendMessage(context, { @@ -193,6 +194,7 @@ function requestChanges(context, taskId, flags = {}) { text: `Task #${task.displayId || task.id} needs fixes.\n\n${comment}\n\n` + 'The task has been moved back to pending. When you are ready to resume, review the task context, start it explicitly, implement the fixes, mark it completed, and request review again.', + ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), summary: `Fix request for #${task.displayId || task.id}`, source: 'system_notification', ...(leadSessionId ? { leadSessionId } : {}), diff --git a/agent-teams-controller/src/internal/taskStore.js b/agent-teams-controller/src/internal/taskStore.js index f02216e9..c8c87abc 100644 --- a/agent-teams-controller/src/internal/taskStore.js +++ b/agent-teams-controller/src/internal/taskStore.js @@ -166,6 +166,23 @@ function parseRelationshipList(paths, value) { return rawValues.map((entry) => resolveTaskRef(paths, entry)); } +function normalizeTaskRefs(taskRefs) { + if (!Array.isArray(taskRefs) || taskRefs.length === 0) { + return undefined; + } + + const normalized = taskRefs + .filter((item) => item && typeof item === 'object') + .map((item) => ({ + taskId: String(item.taskId || '').trim(), + displayId: String(item.displayId || '').trim(), + teamName: String(item.teamName || '').trim(), + })) + .filter((item) => item.taskId && item.displayId && item.teamName); + + return normalized.length > 0 ? normalized : undefined; +} + function computeInitialStatus(paths, input, owner, blockedByIds) { const explicit = normalizeStatus(input.status); if (explicit) return explicit; @@ -270,6 +287,7 @@ function createTask(paths, input = {}) { typeof input.description === 'string' && input.description.length > 0 ? input.description : String(input.subject || '').trim(), + descriptionTaskRefs: normalizeTaskRefs(input.descriptionTaskRefs), activeForm: typeof input.activeForm === 'string' ? input.activeForm @@ -301,6 +319,9 @@ function createTask(paths, input = {}) { ? input.projectPath.trim() : undefined, comments: Array.isArray(input.comments) ? input.comments : undefined, + prompt: + typeof input.prompt === 'string' && input.prompt.trim() ? input.prompt.trim() : undefined, + promptTaskRefs: normalizeTaskRefs(input.promptTaskRefs), needsClarification: input.needsClarification === 'lead' || input.needsClarification === 'user' ? input.needsClarification @@ -434,6 +455,7 @@ function addTaskComment(paths, taskRef, text, options = {}) { ? options.createdAt.trim() : nowIso(), type: options.type || 'regular', + ...(normalizeTaskRefs(options.taskRefs) ? { taskRefs: normalizeTaskRefs(options.taskRefs) } : {}), ...(Array.isArray(options.attachments) && options.attachments.length > 0 ? { attachments: options.attachments } : {}), diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 67f61eab..47445b80 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -91,6 +91,7 @@ function maybeNotifyAssignedOwner(context, task, options = {}) { member: owner, from: sender, text: buildAssignmentMessage(context, task, options), + taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined, summary, source: 'system_notification', ...(leadSessionId ? { leadSessionId } : {}), @@ -123,6 +124,7 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) { member: owner, from: normalizeActorName(comment.author) || leadName, text: buildCommentNotificationMessage(context, task, comment), + taskRefs: Array.isArray(comment.taskRefs) ? comment.taskRefs : undefined, summary: `Comment on #${task.displayId || task.id}`, source: 'system_notification', ...(leadSessionId ? { leadSessionId } : {}), @@ -135,6 +137,10 @@ function createTask(context, input) { maybeNotifyAssignedOwner(context, task, { description: input.description, prompt: input.prompt, + taskRefs: [ + ...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []), + ...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []), + ], from: input.from, }); } @@ -221,6 +227,7 @@ function addTaskComment(context, taskId, flags) { ...(flags.id ? { id: flags.id } : {}), ...(flags.createdAt ? { createdAt: flags.createdAt } : {}), ...(flags.type ? { type: flags.type } : {}), + ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), ...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}), }); diff --git a/src/main/ipc/crossTeam.ts b/src/main/ipc/crossTeam.ts index 03d57a25..11b0fff8 100644 --- a/src/main/ipc/crossTeam.ts +++ b/src/main/ipc/crossTeam.ts @@ -7,9 +7,10 @@ import { import { createLogger } from '@shared/utils/logger'; import { isAgentActionMode } from '../services/team/actionModeInstructions'; +import { validateTaskId, validateTeamName } from './guards'; import type { CrossTeamService } from '../services/team/CrossTeamService'; import type { IpcMain, IpcMainInvokeEvent } from 'electron'; -import type { IpcResult } from '@shared/types'; +import type { IpcResult, TaskRef } from '@shared/types'; const logger = createLogger('IPC:crossTeam'); @@ -19,6 +20,42 @@ export function initializeCrossTeamHandlers(service: CrossTeamService): void { crossTeamService = service; } +function validateTaskRefs( + value: unknown +): { valid: true; value: TaskRef[] | undefined } | { valid: false; error: string } { + if (value === undefined) { + return { valid: true, value: undefined }; + } + if (!Array.isArray(value)) { + return { valid: false, error: 'taskRefs must be an array' }; + } + + const taskRefs: TaskRef[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + return { valid: false, error: 'taskRefs entries must be objects' }; + } + const row = entry as Partial; + const taskId = typeof row.taskId === 'string' ? row.taskId.trim() : ''; + const displayId = typeof row.displayId === 'string' ? row.displayId.trim() : ''; + const teamName = typeof row.teamName === 'string' ? row.teamName.trim() : ''; + if (!taskId || !displayId || !teamName) { + return { valid: false, error: 'Each taskRef must include taskId, displayId, and teamName' }; + } + const vTaskId = validateTaskId(taskId); + if (!vTaskId.valid) { + return { valid: false, error: vTaskId.error ?? 'Invalid taskRef taskId' }; + } + const vTeamName = validateTeamName(teamName); + if (!vTeamName.valid) { + return { valid: false, error: vTeamName.error ?? 'Invalid taskRef teamName' }; + } + taskRefs.push({ taskId: vTaskId.value!, displayId, teamName: vTeamName.value! }); + } + + return { valid: true, value: taskRefs }; +} + function getService(): CrossTeamService { if (!crossTeamService) { throw new Error('CrossTeamService not initialized'); @@ -52,6 +89,10 @@ async function handleSend( if (req.actionMode !== undefined && !isAgentActionMode(req.actionMode)) { throw new Error('actionMode must be one of: do, ask, delegate'); } + const taskRefs = validateTaskRefs(req.taskRefs); + if (!taskRefs.valid) { + throw new Error(taskRefs.error); + } return getService().send({ fromTeam: String(req.fromTeam ?? ''), fromMember: String(req.fromMember ?? ''), @@ -60,6 +101,7 @@ async function handleSend( replyToConversationId: typeof req.replyToConversationId === 'string' ? req.replyToConversationId : undefined, text: String(req.text ?? ''), + taskRefs: taskRefs.value, actionMode: isAgentActionMode(req.actionMode) ? req.actionMode : undefined, summary: typeof req.summary === 'string' ? req.summary : undefined, chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 772fc481..44566ab7 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -98,6 +98,7 @@ import type { TeamProvisioningService, } from '../services'; import type { + AddTaskCommentRequest, AgentActionMode, AttachmentFileData, AttachmentMeta, @@ -115,6 +116,7 @@ import type { SendMessageResult, TaskAttachmentMeta, TaskComment, + TaskRef, TeamClaudeLogsQuery, TeamClaudeLogsResponse, TeamConfig, @@ -927,12 +929,55 @@ function isUpdateKanbanPatch(value: unknown): value is UpdateKanbanPatch { } if (patch.op === 'request_changes') { - return patch.comment === undefined || typeof patch.comment === 'string'; + return ( + (patch.comment === undefined || typeof patch.comment === 'string') && + validateTaskRefs((patch as { taskRefs?: unknown }).taskRefs).valid + ); } return patch.op === 'set_column' && (patch.column === 'review' || patch.column === 'approved'); } +function validateTaskRefs( + value: unknown +): { valid: true; value: TaskRef[] | undefined } | { valid: false; error: string } { + if (value === undefined) { + return { valid: true, value: undefined }; + } + if (!Array.isArray(value)) { + return { valid: false, error: 'taskRefs must be an array' }; + } + + const taskRefs: TaskRef[] = []; + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + return { valid: false, error: 'taskRefs entries must be objects' }; + } + const row = entry as Partial; + const taskId = typeof row.taskId === 'string' ? row.taskId.trim() : ''; + const displayId = typeof row.displayId === 'string' ? row.displayId.trim() : ''; + const teamName = typeof row.teamName === 'string' ? row.teamName.trim() : ''; + if (!taskId || !displayId || !teamName) { + return { valid: false, error: 'Each taskRef must include taskId, displayId, and teamName' }; + } + const validatedTaskId = validateTaskId(taskId); + if (!validatedTaskId.valid) { + return { valid: false, error: validatedTaskId.error ?? 'Invalid taskRef taskId' }; + } + const validatedTeamName = validateTeamName(teamName); + if (!validatedTeamName.valid) { + return { valid: false, error: validatedTeamName.error ?? 'Invalid taskRef teamName' }; + } + taskRefs.push({ + taskId: validatedTaskId.value!, + displayId, + teamName: validatedTeamName.value!, + }); + } + + return { valid: true, value: taskRefs }; +} + async function handleGetAttachments( _event: IpcMainInvokeEvent, teamName: unknown, @@ -1068,6 +1113,10 @@ async function handleSendMessage( if (payload.actionMode !== undefined && !isAgentActionMode(payload.actionMode)) { return { success: false, error: 'actionMode must be one of: do, ask, delegate' }; } + const validatedTaskRefs = validateTaskRefs(payload.taskRefs); + if (!validatedTaskRefs.valid) { + return { success: false, error: validatedTaskRefs.error }; + } let validatedAttachments: AttachmentPayload[] | undefined; if ( @@ -1175,7 +1224,8 @@ async function handleSendMessage( resolvedLeadName, payload.text!, payload.summary, - attachmentMeta + attachmentMeta, + validatedTaskRefs.value ); } catch (persistError) { logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`); @@ -1199,6 +1249,7 @@ async function handleSendMessage( messageId: result.messageId, source: 'user_sent', attachments: attachmentMeta, + taskRefs: validatedTaskRefs.value, }); return result; @@ -1217,6 +1268,7 @@ async function handleSendMessage( summary: payload.summary, from: payload.from, source: 'user_sent', + taskRefs: validatedTaskRefs.value, }); // Best-effort live relay so active processes see the inbox row promptly. @@ -1265,6 +1317,10 @@ async function handleCreateTask( if (payload.description !== undefined && typeof payload.description !== 'string') { return { success: false, error: 'description must be string' }; } + const validatedDescriptionTaskRefs = validateTaskRefs(payload.descriptionTaskRefs); + if (!validatedDescriptionTaskRefs.valid) { + return { success: false, error: validatedDescriptionTaskRefs.error }; + } if (payload.owner !== undefined) { const validatedOwner = validateMemberName(payload.owner); if (!validatedOwner.valid) { @@ -1298,6 +1354,10 @@ async function handleCreateTask( return { success: false, error: 'prompt exceeds max length (5000)' }; } } + const validatedPromptTaskRefs = validateTaskRefs(payload.promptTaskRefs); + if (!validatedPromptTaskRefs.valid) { + return { success: false, error: validatedPromptTaskRefs.error }; + } if (payload.startImmediately !== undefined && typeof payload.startImmediately !== 'boolean') { return { success: false, error: 'startImmediately must be a boolean' }; } @@ -1309,7 +1369,9 @@ async function handleCreateTask( owner: payload.owner?.trim() || undefined, blockedBy: payload.blockedBy, related: payload.related, + descriptionTaskRefs: validatedDescriptionTaskRefs.value, prompt: payload.prompt?.trim() || undefined, + promptTaskRefs: validatedPromptTaskRefs.value, startImmediately: payload.startImmediately, }) ); @@ -2222,19 +2284,27 @@ async function handleAddTaskComment( _event: IpcMainInvokeEvent, teamName: unknown, taskId: unknown, - text: unknown, - attachments?: unknown + request: unknown ): Promise> { const vTeam = validateTeamName(teamName); if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; const vTask = validateTaskId(taskId); if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' }; + if (!request || typeof request !== 'object') { + return { success: false, error: 'Invalid add task comment request' }; + } + const payload = request as Partial; + const text = payload.text; if (typeof text !== 'string' || text.trim().length === 0) return { success: false, error: 'Comment text must be non-empty' }; if (text.trim().length > MAX_TEXT_LENGTH) return { success: false, error: `Comment exceeds ${MAX_TEXT_LENGTH} characters` }; + const validatedTaskRefs = validateTaskRefs(payload.taskRefs); + if (!validatedTaskRefs.valid) { + return { success: false, error: validatedTaskRefs.error }; + } - const rawAttachments = Array.isArray(attachments) ? attachments : []; + const rawAttachments = Array.isArray(payload.attachments) ? payload.attachments : []; if (rawAttachments.length > MAX_ATTACHMENTS) { return { success: false, error: `Maximum ${MAX_ATTACHMENTS} attachments per comment` }; } @@ -2248,7 +2318,7 @@ async function handleAddTaskComment( if (!att || typeof att !== 'object') { throw new Error('Invalid attachment data'); } - const a = att as Record; + const a = att as unknown as Record; if ( typeof a.id !== 'string' || typeof a.filename !== 'string' || @@ -2279,7 +2349,8 @@ async function handleAddTaskComment( vTeam.value!, vTask.value!, text.trim(), - savedAttachments + savedAttachments, + validatedTaskRefs.value ); }); } diff --git a/src/main/services/team/CrossTeamService.ts b/src/main/services/team/CrossTeamService.ts index cdc0e48b..2561ba93 100644 --- a/src/main/services/team/CrossTeamService.ts +++ b/src/main/services/team/CrossTeamService.ts @@ -46,7 +46,7 @@ export class CrossTeamService { ) {} async send(request: CrossTeamSendRequest): Promise { - const { fromTeam, fromMember, toTeam, text, summary, actionMode } = request; + const { fromTeam, fromMember, toTeam, text, taskRefs, summary, actionMode } = request; const chainDepth = request.chainDepth ?? 0; const messageId = request.messageId?.trim() || randomUUID(); const timestamp = request.timestamp ?? new Date().toISOString(); @@ -105,6 +105,7 @@ export class CrossTeamService { conversationId, replyToConversationId, text, + taskRefs, summary, chainDepth, timestamp, @@ -127,6 +128,7 @@ export class CrossTeamService { source: CROSS_TEAM_SOURCE, conversationId, replyToConversationId, + taskRefs, }); }); @@ -144,6 +146,7 @@ export class CrossTeamService { from: fromMember, to: `${toTeam}.${leadName}`, text, + taskRefs, timestamp, messageId, summary: summary ?? `Cross-team message to ${toTeam}`, diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index b25bd97d..7ffd317a 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -50,6 +50,7 @@ import type { SendMessageResult, TaskAttachmentMeta, TaskComment, + TaskRef, TeamConfig, TeamCreateConfigRequest, TeamData, @@ -803,12 +804,16 @@ export class TeamDataService { const task = controller.tasks.createTask({ subject: request.subject, ...(request.description?.trim() ? { description: request.description.trim() } : {}), + ...(request.descriptionTaskRefs?.length + ? { descriptionTaskRefs: request.descriptionTaskRefs } + : {}), ...(request.owner ? { owner: request.owner } : {}), ...(blockedBy.length > 0 ? { blockedBy } : {}), ...(related.length > 0 ? { related } : {}), ...(projectPath ? { projectPath } : {}), createdBy: 'user', ...(request.prompt?.trim() ? { prompt: request.prompt.trim() } : {}), + ...(request.promptTaskRefs?.length ? { promptTaskRefs: request.promptTaskRefs } : {}), ...(shouldStart ? { startImmediately: true } : {}), }) as TeamTask; @@ -847,6 +852,7 @@ export class TeamDataService { member: task.owner, from: leadName, text: parts.join('\n'), + taskRefs: task.descriptionTaskRefs, summary: `Task ${this.getTaskLabel(task)} started`, source: 'system_notification', }); @@ -992,13 +998,15 @@ export class TeamDataService { teamName: string, taskId: string, text: string, - attachments?: TaskAttachmentMeta[] + attachments?: TaskAttachmentMeta[], + taskRefs?: TaskRef[] ): Promise { const controller = this.getController(teamName); const addResult = controller.tasks.addTaskComment(taskId, { from: 'user', text, attachments, + taskRefs, }) as { task?: TeamTask; comment?: TaskComment }; const comment = addResult.comment ?? @@ -1008,6 +1016,7 @@ export class TeamDataService { text, createdAt: new Date().toISOString(), type: 'regular', + ...(taskRefs && taskRefs.length > 0 ? { taskRefs } : {}), ...(attachments && attachments.length > 0 ? { attachments } : {}), } as TaskComment); @@ -1031,6 +1040,15 @@ export class TeamDataService { member: enrichedRequest.member, from: enrichedRequest.from, text: enrichedRequest.text, + timestamp: enrichedRequest.timestamp, + messageId: enrichedRequest.messageId, + to: enrichedRequest.to, + color: enrichedRequest.color, + conversationId: enrichedRequest.conversationId, + replyToConversationId: enrichedRequest.replyToConversationId, + toolSummary: enrichedRequest.toolSummary, + toolCalls: enrichedRequest.toolCalls, + taskRefs: enrichedRequest.taskRefs, summary: enrichedRequest.summary, source: enrichedRequest.source, leadSessionId: enrichedRequest.leadSessionId, @@ -1078,7 +1096,8 @@ export class TeamDataService { leadName: string, text: string, summary?: string, - attachments?: AttachmentMeta[] + attachments?: AttachmentMeta[], + taskRefs?: TaskRef[] ): Promise { let leadSessionId: string | undefined; try { @@ -1092,6 +1111,7 @@ export class TeamDataService { from: 'user', to: leadName, text, + taskRefs, summary, source: 'user_sent', attachments: attachments?.length ? attachments : undefined, @@ -1462,6 +1482,9 @@ export class TeamDataService { controller.review.requestChanges(taskId, { from: 'user', comment: patch.comment?.trim() || 'Reviewer requested changes.', + ...(patch.op === 'request_changes' && patch.taskRefs?.length + ? { taskRefs: patch.taskRefs } + : {}), ...(leadSessionId ? { leadSessionId } : {}), }); } diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index b28216e5..c2c2497f 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -98,6 +98,7 @@ export class TeamInboxReader { text: row.text, timestamp: row.timestamp, read: typeof row.read === 'boolean' ? row.read : false, + taskRefs: Array.isArray(row.taskRefs) ? row.taskRefs : undefined, summary: typeof row.summary === 'string' ? row.summary : undefined, color: typeof row.color === 'string' ? row.color : undefined, messageId: row.messageId, diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index a82f2152..fa6368b1 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -27,6 +27,7 @@ export class TeamInboxWriter { text: request.text, timestamp: request.timestamp ?? new Date().toISOString(), read: false, + taskRefs: request.taskRefs?.length ? request.taskRefs : undefined, summary: request.summary, messageId, attachments: attachmentMeta?.length ? attachmentMeta : undefined, diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 65465ae1..2211ed1c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1648,6 +1648,7 @@ export class TeamProvisioningService { leadSessionId: message.leadSessionId, conversationId: message.conversationId, replyToConversationId: message.replyToConversationId, + taskRefs: message.taskRefs, attachments: message.attachments, color: message.color, toolSummary: message.toolSummary, @@ -1674,6 +1675,7 @@ export class TeamProvisioningService { leadSessionId: message.leadSessionId, conversationId: message.conversationId, replyToConversationId: message.replyToConversationId, + taskRefs: message.taskRefs, attachments: message.attachments, color: message.color, toolSummary: message.toolSummary, diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts index 10dece31..9716914d 100644 --- a/src/main/services/team/TeamSentMessagesStore.ts +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -72,6 +72,7 @@ export class TeamSentMessagesStore { text: row.text, timestamp: row.timestamp, read: typeof row.read === 'boolean' ? row.read : true, + taskRefs: Array.isArray(row.taskRefs) ? row.taskRefs : undefined, summary: typeof row.summary === 'string' ? row.summary : undefined, messageId: row.messageId, color: typeof row.color === 'string' ? row.color : undefined, diff --git a/src/main/services/team/TeamTaskReader.ts b/src/main/services/team/TeamTaskReader.ts index 8ff5eaf2..e3e9e10e 100644 --- a/src/main/services/team/TeamTaskReader.ts +++ b/src/main/services/team/TeamTaskReader.ts @@ -13,6 +13,7 @@ import type { TaskAttachmentMeta, TaskComment, TaskHistoryEvent, + TaskRef, TaskWorkInterval, TeamTask, TeamTaskStatus, @@ -34,6 +35,21 @@ function isValidMimeTypeString(value: unknown): value is string { return true; } +function normalizeTaskRefs(value: unknown): TaskRef[] | undefined { + if (!Array.isArray(value)) return undefined; + const taskRefs = (value as unknown[]) + .filter( + (entry): entry is Record => Boolean(entry) && typeof entry === 'object' + ) + .map((entry) => ({ + taskId: typeof entry.taskId === 'string' ? entry.taskId : '', + displayId: typeof entry.displayId === 'string' ? entry.displayId : '', + teamName: typeof entry.teamName === 'string' ? entry.teamName : '', + })) + .filter((entry) => entry.taskId && entry.displayId && entry.teamName); + return taskRefs.length > 0 ? taskRefs : undefined; +} + export class TeamTaskReader { /** * Returns the next available numeric task ID by scanning ALL task files @@ -155,7 +171,10 @@ export class TeamTaskReader { ), subject, description: typeof parsed.description === 'string' ? parsed.description : undefined, + descriptionTaskRefs: normalizeTaskRefs(parsed.descriptionTaskRefs), activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined, + prompt: typeof parsed.prompt === 'string' ? parsed.prompt : undefined, + promptTaskRefs: normalizeTaskRefs(parsed.promptTaskRefs), owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes( @@ -193,6 +212,7 @@ export class TeamTaskReader { type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type) ? c.type : ('regular' as const), + taskRefs: normalizeTaskRefs((c as unknown as Record).taskRefs), attachments: Array.isArray(c.attachments) ? (() => { const filtered = (c.attachments as unknown[]) diff --git a/src/main/workers/team-fs-worker.ts b/src/main/workers/team-fs-worker.ts index b67bfbd1..61324be3 100644 --- a/src/main/workers/team-fs-worker.ts +++ b/src/main/workers/team-fs-worker.ts @@ -106,7 +106,10 @@ interface ParsedTask { subject?: unknown; title?: unknown; description?: unknown; + descriptionTaskRefs?: unknown; activeForm?: unknown; + prompt?: unknown; + promptTaskRefs?: unknown; owner?: unknown; createdBy?: unknown; status?: unknown; @@ -143,6 +146,7 @@ interface RawComment { text?: unknown; createdAt?: unknown; type?: unknown; + taskRefs?: unknown; } // --------------------------------------------------------------------------- @@ -526,6 +530,7 @@ function normalizeComments(parsed: ParsedTask): unknown[] | undefined { author: c.author as string, text: c.text as string, createdAt: c.createdAt as string, + taskRefs: Array.isArray(c.taskRefs) ? c.taskRefs : undefined, type: c.type === 'regular' || c.type === 'review_request' || c.type === 'review_approved' ? (c.type as string) @@ -626,7 +631,14 @@ async function readTasksDirForTeam( ), subject, description: typeof parsed.description === 'string' ? parsed.description : undefined, + descriptionTaskRefs: Array.isArray(parsed.descriptionTaskRefs) + ? (parsed.descriptionTaskRefs as unknown[]) + : undefined, activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined, + prompt: typeof parsed.prompt === 'string' ? parsed.prompt : undefined, + promptTaskRefs: Array.isArray(parsed.promptTaskRefs) + ? (parsed.promptTaskRefs as unknown[]) + : undefined, owner: typeof parsed.owner === 'string' ? parsed.owner : undefined, createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, status: diff --git a/src/preload/index.ts b/src/preload/index.ts index 6cb6f82a..2b49b8ad 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -195,6 +195,7 @@ import { import type { AddMemberRequest, + AddTaskCommentRequest, AgentChangeSet, AppConfig, ApplyReviewRequest, @@ -205,7 +206,6 @@ import type { ClaudeRootInfo, CliInstallationStatus, CliInstallerProgress, - CommentAttachmentPayload, ConflictCheckResult, ContextInfo, CreateScheduleInput, @@ -878,19 +878,8 @@ const electronAPI: ElectronAPI = { updateConfig: async (teamName: string, updates: TeamUpdateConfigRequest) => { return invokeIpcWithResult(TEAM_UPDATE_CONFIG, teamName, updates); }, - addTaskComment: async ( - teamName: string, - taskId: string, - text: string, - attachments?: CommentAttachmentPayload[] - ) => { - return invokeIpcWithResult( - TEAM_ADD_TASK_COMMENT, - teamName, - taskId, - text, - attachments - ); + addTaskComment: async (teamName: string, taskId: string, request: AddTaskCommentRequest) => { + return invokeIpcWithResult(TEAM_ADD_TASK_COMMENT, teamName, taskId, request); }, addMember: async (teamName: string, request: AddMemberRequest) => { return invokeIpcWithResult(TEAM_ADD_MEMBER, teamName, request); diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 171147f5..07f42f62 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -28,6 +28,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors' import { useTheme } from '@renderer/hooks/useTheme'; import { useStore } from '@renderer/store'; import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins'; +import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { FileText, UsersRound } from 'lucide-react'; import remarkGfm from 'remark-gfm'; import { useShallow } from 'zustand/react/shallow'; @@ -269,9 +270,13 @@ function createViewerMarkdownComponents( ); } if (href?.startsWith('task://')) { - const taskId = href.slice('task://'.length); + const parsedTaskLink = parseTaskLinkHref(href); + const taskId = parsedTaskLink?.taskId; + if (!taskId) { + return <>{children}; + } return ( - + onTabClick(tab.id, e)} onMouseDown={(e) => onMouseDown(tab.id, e)} diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 25650a74..c1b99164 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -236,7 +236,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { return (
- +
) : null} diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 58104332..50e4876f 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -79,7 +79,12 @@ import type { KanbanSortState } from './kanban/KanbanSortPopover'; import type { ContextInjection } from '@renderer/types/contextInjection'; import type { Session } from '@renderer/types/data'; import type { InlineChip } from '@renderer/types/inlineChip'; -import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { + MemberSpawnStatusEntry, + ResolvedTeamMember, + TaskRef, + TeamTaskWithKanban, +} from '@shared/types'; import type { EditorSelectionAction } from '@shared/types/editor'; interface TeamDetailViewProps { @@ -796,7 +801,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele blockedBy?: string[], related?: string[], prompt?: string, - startImmediately?: boolean + startImmediately?: boolean, + descriptionTaskRefs?: TaskRef[], + promptTaskRefs?: TaskRef[] ): void => { setCreatingTask(true); void (async () => { @@ -808,6 +815,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele blockedBy, related, prompt, + descriptionTaskRefs, + promptTaskRefs, startImmediately, }); @@ -1567,7 +1576,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele taskId={requestChangesTaskId} members={data?.members ?? []} onCancel={() => setRequestChangesTaskId(null)} - onSubmit={(comment) => { + onSubmit={(comment, taskRefs) => { if (!requestChangesTaskId) { return; } @@ -1576,6 +1585,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele await updateKanban(teamName, requestChangesTaskId, { op: 'request_changes', comment, + taskRefs, }); setRequestChangesTaskId(null); } catch { @@ -1777,7 +1787,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele sending={sendingMessage} sendError={sendMessageError} lastResult={lastSendMessageResult} - onSend={(member, text, summary, attachments, actionMode) => { + onSend={(member, text, summary, attachments, actionMode, taskRefs) => { void (async () => { const sentAtMs = Date.now(); setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); @@ -1788,6 +1798,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele summary, attachments, actionMode, + taskRefs, }); } catch { setPendingRepliesByMember((prev) => { diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 34984506..85dd7186 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -1,6 +1,7 @@ import { Fragment, useMemo } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; +import { CopyButton } from '@renderer/components/common/CopyButton'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; @@ -24,7 +25,7 @@ import { } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; -import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; +import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { CROSS_TEAM_SENT_SOURCE, @@ -129,6 +130,8 @@ interface ActivityItemProps { zebraShade?: boolean; /** Explicit collapse state for timeline-controlled collapsed mode. */ collapseState?: ActivityCollapseState; + /** Compact header mode for narrow message lists. */ + compactHeader?: boolean; } function getStringField(obj: StructuredMessage, key: string): string | null { @@ -297,6 +300,7 @@ export const ActivityItem = ({ onRestartTeam, zebraShade, collapseState, + compactHeader = false, }: ActivityItemProps): React.JSX.Element => { const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const { isLight } = useTheme(); @@ -399,7 +403,7 @@ export const ActivityItem = ({ const displayText = useMemo(() => { if (!strippedText) return null; let result = highlightSystemLabels(strippedText, !!systemLabel); - result = linkifyTaskIdsInMarkdown(result); + result = linkifyTaskIdsInMarkdown(result, message.taskRefs); if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) result = linkifyAllMentionsInMarkdown(result, memberColorMap ?? new Map(), teamNames); return result; @@ -435,7 +439,7 @@ export const ActivityItem = ({ }; const isHeaderClickable = isManaged ? collapseState.canToggle : false; - const showChevron = isHeaderClickable; + const showChevron = isHeaderClickable && !compactHeader; const isUserSent = message.source === 'user_sent' || isCrossTeamSent; const isSystemMessage = message.from === 'system'; const onManagedToggle = isManaged ? collapseState.onToggle : undefined; @@ -518,13 +522,13 @@ export const ActivityItem = ({ {/* Role */} - {formattedRole ? ( + {!compactHeader && formattedRole ? ( {formattedRole} @@ -580,8 +584,9 @@ export const ActivityItem = ({ name={crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to} color={crossTeamTarget ? undefined : recipientColor} hideAvatar={ + compactHeader || (crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to) === - 'user' + 'user' } onClick={onMemberNameClick} disableHoverCard={crossTeamTarget != null} @@ -595,44 +600,8 @@ export const ActivityItem = ({ {onTaskIdClick ? linkifyTaskIds(summaryText, onTaskIdClick) : summaryText} - {/* Timestamp + reply + create task */} + {/* Timestamp */}
- {onReply && ( - - - - - Reply to message - - )} - {onCreateTask && ( - - - - - Create task from message - - )} {timestamp} @@ -660,29 +629,72 @@ export const ActivityItem = ({ ) : displayText ? ( - - { - const link = (e.target as HTMLElement).closest( - 'a[href^="task://"]' - ); - if (link) { - e.preventDefault(); +
+
+ {onReply ? ( + + + + + Reply to message + + ) : null} + {onCreateTask ? ( + + + + + Create task from message + + ) : null} + +
+ + { + const link = (e.target as HTMLElement).closest( + 'a[href^="task://"]' + ); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const href = link.getAttribute('href'); + const parsedTaskLink = href ? parseTaskLinkHref(href) : null; + if (parsedTaskLink?.taskId) onTaskIdClick(parsedTaskLink.taskId); + } } - } - : undefined - } - > - - - + : undefined + } + > + + + +
) : summaryText ? (

{summaryText} diff --git a/src/renderer/components/team/activity/ActivityTimeline.tsx b/src/renderer/components/team/activity/ActivityTimeline.tsx index a40c1b33..689707d9 100644 --- a/src/renderer/components/team/activity/ActivityTimeline.tsx +++ b/src/renderer/components/team/activity/ActivityTimeline.tsx @@ -56,6 +56,7 @@ interface ActivityTimelineProps { const VIEWPORT_THRESHOLD = 0.15; const MESSAGES_PAGE_SIZE = 30; +const COMPACT_MESSAGES_WIDTH_PX = 400; /** Inline compaction boundary divider — styled like session separators but with amber accent. */ const CompactionDivider = ({ message }: { message: InboxMessage }): React.JSX.Element => ( @@ -98,6 +99,7 @@ const MessageRowWithObserver = ({ onTaskIdClick, onRestartTeam, collapseState, + compactHeader, }: { message: InboxMessage; teamName: string; @@ -116,6 +118,7 @@ const MessageRowWithObserver = ({ onTaskIdClick?: (taskId: string) => void; onRestartTeam?: () => void; collapseState?: ActivityCollapseState; + compactHeader?: boolean; }): React.JSX.Element => { const ref = useRef(null); const reportedRef = useRef(false); @@ -165,6 +168,7 @@ const MessageRowWithObserver = ({ onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} collapseState={collapseState} + compactHeader={compactHeader} /> ); @@ -188,6 +192,31 @@ export const ActivityTimeline = ({ currentLeadSessionId, }: ActivityTimelineProps): React.JSX.Element => { const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); + const rootRef = useRef(null); + const [compactHeader, setCompactHeader] = useState(false); + + useEffect(() => { + const el = rootRef.current; + if (!el) return; + + const updateCompactMode = (width: number): void => { + setCompactHeader((prev) => { + const next = width < COMPACT_MESSAGES_WIDTH_PX; + return prev === next ? prev : next; + }); + }; + + updateCompactMode(el.getBoundingClientRect().width); + + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + updateCompactMode(entry.contentRect.width); + }); + + observer.observe(el); + return () => observer.disconnect(); + }, []); const colorMap = members ? buildMemberColorMap(members) : new Map(); const localMemberNames = new Set((members ?? []).map((member) => member.name.trim())); @@ -357,7 +386,7 @@ export const ActivityTimeline = ({ } return ( -

+
{/* Pinned (newest) thought group — always at top */} {pinnedThoughtGroup && (() => { @@ -380,6 +409,7 @@ export const ActivityTimeline = ({ onTaskIdClick={onTaskIdClick} memberColorMap={colorMap} onReply={onReplyToMessage} + compactHeader={compactHeader} /> ); })()} @@ -440,6 +470,7 @@ export const ActivityTimeline = ({ onTaskIdClick={onTaskIdClick} memberColorMap={colorMap} onReply={onReplyToMessage} + compactHeader={compactHeader} /> ); @@ -489,6 +520,7 @@ export const ActivityTimeline = ({ onTaskIdClick={onTaskIdClick} onRestartTeam={onRestartTeam} collapseState={collapseState} + compactHeader={compactHeader} /> ); diff --git a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx index 10db9c8f..ac8853ca 100644 --- a/src/renderer/components/team/activity/LeadThoughtsGroup.tsx +++ b/src/renderer/components/team/activity/LeadThoughtsGroup.tsx @@ -15,7 +15,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors'; import { useStore } from '@renderer/store'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; -import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; +import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; @@ -126,6 +126,8 @@ interface LeadThoughtsGroupRowProps { memberColorMap?: Map; /** Called when user clicks the reply button on a thought. */ onReply?: (message: InboxMessage) => void; + /** Compact header mode for narrow message lists. */ + compactHeader?: boolean; } function formatTime(timestamp: string): string { @@ -237,7 +239,7 @@ const LeadThoughtItem = ({ const displayContent = useMemo(() => { let text = thought.text.replace(/\n/g, ' \n'); - text = linkifyTaskIdsInMarkdown(text); + text = linkifyTaskIdsInMarkdown(text, thought.taskRefs); if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { text = linkifyAllMentionsInMarkdown(text, memberColorMap ?? new Map(), teamNames); } @@ -393,8 +395,9 @@ const LeadThoughtItem = ({ if (link) { e.preventDefault(); e.stopPropagation(); - const taskId = link.getAttribute('href')?.replace('task://', ''); - if (taskId) onTaskIdClick(taskId); + const href = link.getAttribute('href'); + const parsedTaskLink = href ? parseTaskLinkHref(href) : null; + if (parsedTaskLink?.taskId) onTaskIdClick(parsedTaskLink.taskId); } } : undefined @@ -462,6 +465,7 @@ export const LeadThoughtsGroupRow = ({ onTaskIdClick, memberColorMap, onReply, + compactHeader = false, }: LeadThoughtsGroupRowProps): React.JSX.Element => { const ref = useRef(null); const scrollRef = useRef(null); @@ -725,7 +729,7 @@ export const LeadThoughtsGroupRow = ({ } > {/* Chevron for collapse mode */} - {canToggleBodyVisibility ? ( + {canToggleBodyVisibility && !compactHeader ? ( ) : null} {/* Lead avatar with optional live indicator */} -
- - {isLive ? ( - - - - - ) : null} -
+ {!compactHeader ? ( +
+ + {isLive ? ( + + + + + ) : null} +
+ ) : null} {thoughts.length} thoughts diff --git a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx index 64162b29..208291a2 100644 --- a/src/renderer/components/team/activity/ReplyQuoteBlock.tsx +++ b/src/renderer/components/team/activity/ReplyQuoteBlock.tsx @@ -2,8 +2,10 @@ import { useState } from 'react'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; +import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils'; import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting'; +import type { TaskRef } from '@shared/types'; interface ReplyQuoteBlockProps { reply: ParsedMessageReply; @@ -11,6 +13,8 @@ interface ReplyQuoteBlockProps { memberColor?: string; /** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */ bodyMaxHeight?: string; + /** Structured task refs for the reply body, when available. */ + replyTaskRefs?: TaskRef[]; } /** Threshold (characters) above which the "more/less" toggle is shown. */ @@ -20,6 +24,7 @@ export const ReplyQuoteBlock = ({ reply, memberColor, bodyMaxHeight = 'max-h-56', + replyTaskRefs, }: ReplyQuoteBlockProps): React.JSX.Element => { const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD; const [expanded, setExpanded] = useState(false); @@ -43,7 +48,11 @@ export const ReplyQuoteBlock = ({ {/* Quote text */}
- +
{/* More/less toggle */} @@ -59,7 +68,12 @@ export const ReplyQuoteBlock = ({
{/* Reply text */} - +
); }; diff --git a/src/renderer/components/team/dialogs/AddMemberDialog.tsx b/src/renderer/components/team/dialogs/AddMemberDialog.tsx index a53d9a9f..28bd4753 100644 --- a/src/renderer/components/team/dialogs/AddMemberDialog.tsx +++ b/src/renderer/components/team/dialogs/AddMemberDialog.tsx @@ -180,7 +180,7 @@ export const AddMemberDialog = ({ placeholder="How this agent should behave, what tasks it handles..." footerRight={ workflowDraft.isSaved ? ( - Draft saved + Saved ) : null } /> diff --git a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx index 95958717..4b222685 100644 --- a/src/renderer/components/team/dialogs/CreateTaskDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTaskDialog.tsx @@ -30,14 +30,17 @@ import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; +import { + extractTaskRefsFromText, + stripEncodedTaskReferenceMetadata, +} from '@renderer/utils/taskReferenceUtils'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { AlertTriangle, Search } from 'lucide-react'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types'; interface CreateTaskDialogProps { open: boolean; @@ -58,7 +61,9 @@ interface CreateTaskDialogProps { blockedBy?: string[], related?: string[], prompt?: string, - startImmediately?: boolean + startImmediately?: boolean, + descriptionTaskRefs?: TaskRef[], + promptTaskRefs?: TaskRef[] ) => void; submitting?: boolean; } @@ -175,18 +180,23 @@ export const CreateTaskDialog = ({ const handleSubmit = (): void => { if (!canSubmit) return; - const serializedDesc = serializeChipsWithText( - descriptionDraft.value.trim(), - descChipDraft.chips - ); + const trimmedDescription = stripEncodedTaskReferenceMetadata(descriptionDraft.value.trim()); + const trimmedPrompt = stripEncodedTaskReferenceMetadata(promptDraft.value.trim()); + const serializedDesc = serializeChipsWithText(trimmedDescription, descChipDraft.chips); + const descriptionTaskRefs = extractTaskRefsFromText(descriptionDraft.value, taskSuggestions); + const promptTaskRefs = trimmedPrompt + ? extractTaskRefsFromText(promptDraft.value, taskSuggestions) + : []; onSubmit( subject.trim(), serializedDesc, owner || undefined, blockedBy.length > 0 ? blockedBy : undefined, related.length > 0 ? related : undefined, - stripEncodedTaskReferenceMetadata(promptDraft.value.trim()) || undefined, - startImmediately + trimmedPrompt || undefined, + startImmediately, + descriptionTaskRefs, + promptTaskRefs ); descriptionDraft.clearDraft(); descChipDraft.clearChipDraft(); @@ -303,7 +313,7 @@ export const CreateTaskDialog = ({ maxRows={12} footerRight={ descriptionDraft.isSaved ? ( - Draft saved + Saved ) : null } /> @@ -325,7 +335,7 @@ export const CreateTaskDialog = ({ maxRows={12} footerRight={ promptDraft.isSaved ? ( - Draft saved + Saved ) : null } /> diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index 6bd633bd..ea078c71 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -912,7 +912,7 @@ export const CreateTeamDialog = ({ footerRight={ promptDraft.isSaved ? ( - Draft saved + Saved ) : null } @@ -980,7 +980,7 @@ export const CreateTeamDialog = ({ placeholder="Brief description of the team purpose" /> {descriptionDraft.isSaved ? ( - Draft saved + Saved ) : null}
diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index 7317a386..d632279f 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -926,9 +926,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen placeholder="Instructions for team lead..." footerRight={ promptDraft.isSaved ? ( - - Draft saved - + Saved ) : null } /> @@ -1025,9 +1023,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen placeholder="Instructions for Claude to execute on schedule..." footerRight={ promptDraft.isSaved ? ( - - Draft saved - + Saved ) : null } /> diff --git a/src/renderer/components/team/dialogs/ReviewDialog.tsx b/src/renderer/components/team/dialogs/ReviewDialog.tsx index 9781ad4e..ad9c6d4e 100644 --- a/src/renderer/components/team/dialogs/ReviewDialog.tsx +++ b/src/renderer/components/team/dialogs/ReviewDialog.tsx @@ -13,13 +13,16 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions'; import { useStore } from '@renderer/store'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; +import { + extractTaskRefsFromText, + stripEncodedTaskReferenceMetadata, +} from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; import { Send } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { ResolvedTeamMember } from '@shared/types'; +import type { ResolvedTeamMember, TaskRef } from '@shared/types'; interface ReviewDialogProps { open: boolean; @@ -27,7 +30,7 @@ interface ReviewDialogProps { taskId: string | null; members: ResolvedTeamMember[]; onCancel: () => void; - onSubmit: (comment?: string) => void; + onSubmit: (comment?: string, taskRefs?: TaskRef[]) => void; } export const ReviewDialog = ({ @@ -62,8 +65,9 @@ export const ReviewDialog = ({ const handleSubmit = (): void => { const comment = stripEncodedTaskReferenceMetadata(trimmed) || undefined; + const taskRefs = trimmed ? extractTaskRefsFromText(draft.value, taskSuggestions) : []; draft.clearDraft(); - onSubmit(comment); + onSubmit(comment, taskRefs); }; return ( @@ -114,7 +118,7 @@ export const ReviewDialog = ({ ) : null} {draft.isSaved ? ( - Draft saved + Saved ) : null} } diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 2ee40a73..6730b1ff 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -26,7 +26,10 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; +import { + extractTaskRefsFromText, + stripEncodedTaskReferenceMetadata, +} from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; @@ -35,7 +38,12 @@ import { MemberBadge } from '../MemberBadge'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; -import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types'; +import type { + AttachmentPayload, + ResolvedTeamMember, + SendMessageResult, + TaskRef, +} from '@shared/types'; interface QuotedMessage { from: string; @@ -61,7 +69,8 @@ interface SendMessageDialogProps { text: string, summary?: string, attachments?: AttachmentPayload[], - actionMode?: ActionMode + actionMode?: ActionMode, + taskRefs?: TaskRef[] ) => void; onClose: () => void; } @@ -237,12 +246,14 @@ export const SendMessageDialog = ({ const handleSubmit = (): void => { if (!canSend) return; + const taskRefs = extractTaskRefsFromText(textDraft.value, taskSuggestions); onSend( member.trim(), finalText, trimmedText, attachments.length > 0 ? attachments : undefined, - actionMode + actionMode, + taskRefs ); textDraft.clearDraft(); chipDraft.clearChipDraft(); @@ -512,9 +523,7 @@ export const SendMessageDialog = ({ ) : null} {textDraft.isSaved ? ( - - Draft saved - + Saved ) : null} } diff --git a/src/renderer/components/team/dialogs/TaskCommentInput.tsx b/src/renderer/components/team/dialogs/TaskCommentInput.tsx index 613ebda0..024731c7 100644 --- a/src/renderer/components/team/dialogs/TaskCommentInput.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentInput.tsx @@ -12,7 +12,10 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; -import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; +import { + extractTaskRefsFromText, + stripEncodedTaskReferenceMetadata, +} from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; @@ -132,6 +135,7 @@ export const TaskCommentInput = ({ const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, serialized || '(image)') : serialized || '(image)'; + const taskRefs = extractTaskRefsFromText(draft.value, taskSuggestions); const attachments: CommentAttachmentPayload[] | undefined = pendingAttachments.length > 0 ? pendingAttachments.map((a) => ({ @@ -141,7 +145,11 @@ export const TaskCommentInput = ({ base64Data: a.base64Data, })) : undefined; - await addTaskComment(teamName, taskId, text, attachments); + await addTaskComment(teamName, taskId, { + text, + attachments, + taskRefs, + }); draft.clearDraft(); chipDraft.clearChipDraft(); setPendingAttachments([]); @@ -161,6 +169,7 @@ export const TaskCommentInput = ({ replyTo, onClearReply, pendingAttachments, + taskSuggestions, ]); // Handle paste from MentionableTextarea area @@ -340,7 +349,7 @@ export const TaskCommentInput = ({ ) : null} {draft.isSaved ? ( - Draft saved + Saved ) : null} } diff --git a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx index 91106df6..949f2e12 100644 --- a/src/renderer/components/team/dialogs/TaskCommentsSection.tsx +++ b/src/renderer/components/team/dialogs/TaskCommentsSection.tsx @@ -23,7 +23,9 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { + extractTaskRefsFromText, linkifyTaskIdsInMarkdown, + parseTaskLinkHref, stripEncodedTaskReferenceMetadata, } from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; @@ -160,14 +162,25 @@ export const TaskCommentsSection = ({ try { const serialized = serializeChipsWithText(trimmed, chipDraft.chips); const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, serialized) : serialized; - await addTaskComment(teamName, taskId, text); + const taskRefs = extractTaskRefsFromText(draft.value, taskSuggestions); + await addTaskComment(teamName, taskId, { text, taskRefs }); draft.clearDraft(); chipDraft.clearChipDraft(); setReplyTo(null); } catch { // Error is stored in addCommentError via store } - }, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, chipDraft, replyTo]); + }, [ + canSubmit, + addTaskComment, + teamName, + taskId, + trimmed, + draft, + chipDraft, + replyTo, + taskSuggestions, + ]); return (
@@ -281,6 +294,7 @@ export const TaskCommentsSection = ({ replyText: stripAgentBlocks(reply.replyText), }} memberColor={colorMap.get(reply.agentName)} + replyTaskRefs={comment.taskRefs} bodyMaxHeight="max-h-none" /> ) : ( @@ -294,8 +308,9 @@ export const TaskCommentsSection = ({ if (link) { e.preventDefault(); e.stopPropagation(); - const id = link.getAttribute('href')?.replace('task://', ''); - if (id) onTaskIdClick(id); + const href = link.getAttribute('href'); + const parsed = href ? parseTaskLinkHref(href) : null; + if (parsed?.taskId) onTaskIdClick(parsed.taskId); } } : undefined @@ -303,7 +318,7 @@ export const TaskCommentsSection = ({ > { - let t = linkifyTaskIdsInMarkdown(displayText); + let t = linkifyTaskIdsInMarkdown(displayText, comment.taskRefs); if (colorMap.size > 0 || teamNamesForLinkify.length > 0) t = linkifyAllMentionsInMarkdown( t, @@ -426,7 +441,7 @@ export const TaskCommentsSection = ({ ) : null} {draft.isSaved ? ( - Draft saved + Saved ) : null}
} diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index 7281dbb9..c0203880 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -222,7 +222,7 @@ export const MemberDraftRow = ({ placeholder="How this agent should behave, interact with others..." footerRight={ workflowDraft.isSaved ? ( - Draft saved + Saved ) : null } /> diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index b1975755..662e6c8d 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -17,13 +17,21 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { getTeamColorSet } from '@renderer/constants/teamColors'; import { nameColorSet } from '@renderer/utils/projectColor'; -import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils'; +import { + extractTaskRefsFromText, + stripEncodedTaskReferenceMetadata, +} from '@renderer/utils/taskReferenceUtils'; import { MAX_TEXT_LENGTH } from '@shared/constants'; import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; import type { MentionSuggestion } from '@renderer/types/mention'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; -import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types'; +import type { + AttachmentPayload, + ResolvedTeamMember, + SendMessageResult, + TaskRef, +} from '@shared/types'; interface MessageComposerProps { teamName: string; @@ -37,13 +45,15 @@ interface MessageComposerProps { text: string, summary?: string, attachments?: AttachmentPayload[], - actionMode?: ActionMode + actionMode?: ActionMode, + taskRefs?: TaskRef[] ) => void; onCrossTeamSend?: ( toTeam: string, text: string, summary?: string, - actionMode?: ActionMode + actionMode?: ActionMode, + taskRefs?: TaskRef[] ) => void; } @@ -202,9 +212,10 @@ export const MessageComposer = ({ const handleSend = useCallback(() => { if (!canSend) return; pendingSendRef.current = true; + const taskRefs = extractTaskRefsFromText(draft.text, taskSuggestions); const serialized = serializeChipsWithText(trimmed, draft.chips); if (isCrossTeam && selectedTeam && onCrossTeamSend) { - onCrossTeamSend(selectedTeam, serialized, trimmed, actionMode); + onCrossTeamSend(selectedTeam, serialized, trimmed, actionMode, taskRefs); } else { // Summary should stay compact (no expanded chip markdown) onSend( @@ -212,7 +223,8 @@ export const MessageComposer = ({ serialized, trimmed, draft.attachments.length > 0 ? draft.attachments : undefined, - actionMode + actionMode, + taskRefs ); } }, [ @@ -226,6 +238,7 @@ export const MessageComposer = ({ selectedTeam, draft.attachments, draft.chips, + taskSuggestions, ]); // Clear draft only after send completes successfully (sending: true → false, no error) @@ -323,10 +336,12 @@ export const MessageComposer = ({ ); const remaining = MAX_TEXT_LENGTH - trimmed.length; + const hasAttachmentPreviewContent = + draft.attachments.length > 0 || Boolean(draft.attachmentError ?? imageRestrictionError); return (
-
- {isLeadRecipient ? ( - <> - - - - - - - {!isTeamAlive - ? 'Team must be online to attach images' - : !draft.canAddMore - ? 'Maximum attachments reached' - : 'Attach images (paste or drag & drop)'} - - -
- +
+ {isLeadRecipient ? ( + <> + -
- - ) : ( - - )} - -
- {!isTeamAlive && !isProvisioning && ( - - Team offline - - )} - - {/* Combined team + member selector */} - {crossTeamTargets.length > 0 ? ( -
- - + + + + + {!isTeamAlive + ? 'Team must be online to attach images' + : !draft.canAddMore + ? 'Maximum attachments reached' + : 'Attach images (paste or drag & drop)'} + + + + ) : null} + +
+ {!isTeamAlive && !isProvisioning && ( + + Team offline + + )} + + {/* Combined team + member selector */} + {crossTeamTargets.length > 0 ? ( +
+ + + + + +
+ {/* Current team option */} + - - -
- {/* Current team option */} + This team + + current + + {!isCrossTeam ? ( + + ) : null} + + + {/* Separator */} +
+ + {/* Other teams */} + {crossTeamTargets.map((target) => { + const isSelected = selectedTeam === target.teamName; + return ( + + ); + })} +
+ + + + + - - {/* Separator */} -
- - {/* Other teams */} - {crossTeamTargets.map((target) => { - const isSelected = selectedTeam === target.teamName; - return ( - - ); - })} -
- -
- - + ); + } + const sorted = [...filtered].sort((a, b) => { + const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0; + const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0; + return bIsLead - aIsLead; + }); + return sorted.map((m) => { + const resolvedColor = colorMap.get(m.name); + const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); + const isSelected = m.name === recipient; + return ( + + ); + }); + })()} +
+
+ +
+ ) : ( +
- ) : ( - - - - - { - e.preventDefault(); - setRecipientSearch(''); - setTimeout(() => recipientSearchRef.current?.focus(), 0); - }} - > - {members.length > 5 && ( -
- - setRecipientSearch(e.target.value)} - /> -
- )} -
- {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */} - {(() => { - const query = recipientSearch.toLowerCase().trim(); - const filtered = query - ? members.filter((m) => m.name.toLowerCase().includes(query)) - : members; - if (filtered.length === 0) { - return ( -
- No results -
- ); - } - const sorted = [...filtered].sort((a, b) => { - const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0; - const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0; - return bIsLead - aIsLead; - }); - return sorted.map((m) => { - const resolvedColor = colorMap.get(m.name); - const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType); - const isSelected = m.name === recipient; - return ( - - ); - }); - })()} -
-
-
- )} + )} +
+ + {hasAttachmentPreviewContent ? ( + + ) : null}
) : null} {draft.isSaved ? ( - Draft saved + Saved ) : null}
} diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 783d4e92..9117f27c 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -32,7 +32,7 @@ import { MessagesFilterPopover } from './MessagesFilterPopover'; import type { MessagesFilterState } from './MessagesFilterPopover'; import type { ActionMode } from './ActionModeSelector'; -import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; +import type { InboxMessage, ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types'; interface TimeWindow { start: number; @@ -188,7 +188,8 @@ export const MessagesPanel = ({ attachments?: Parameters[1] extends { attachments?: infer A } ? A : never, - actionMode?: ActionMode + actionMode?: ActionMode, + taskRefs?: TaskRef[] ) => { const sentAtMs = Date.now(); onPendingReplyChange((prev) => ({ ...prev, [member]: sentAtMs })); @@ -198,6 +199,7 @@ export const MessagesPanel = ({ summary, attachments, actionMode, + taskRefs, }).catch(() => { onPendingReplyChange((prev) => { if (prev[member] !== sentAtMs) return prev; @@ -211,12 +213,19 @@ export const MessagesPanel = ({ ); const handleCrossTeamSend = useCallback( - (toTeam: string, text: string, summary?: string, actionMode?: ActionMode) => { + ( + toTeam: string, + text: string, + summary?: string, + actionMode?: ActionMode, + taskRefs?: TaskRef[] + ) => { void sendCrossTeamMessage({ fromTeam: teamName, fromMember: 'user', toTeam, text, + taskRefs, actionMode, summary, }); diff --git a/src/renderer/components/ui/MentionableTextarea.tsx b/src/renderer/components/ui/MentionableTextarea.tsx index d5525093..98ba46db 100644 --- a/src/renderer/components/ui/MentionableTextarea.tsx +++ b/src/renderer/components/ui/MentionableTextarea.tsx @@ -19,13 +19,18 @@ import { reconcileChips, removeChipTokenFromText, } from '@renderer/utils/chipUtils'; -import { Link2 } from 'lucide-react'; +import { + findUrlBoundary, + findUrlMatches, + removeUrlMatchFromText, +} from '@renderer/utils/urlMatchUtils'; import { AutoResizeTextarea } from './auto-resize-textarea'; import { ChipInteractionLayer } from './ChipInteractionLayer'; import { CodeChipBadge } from './CodeChipBadge'; import { MentionSuggestionList } from './MentionSuggestionList'; import { TaskReferenceInteractionLayer } from './TaskReferenceInteractionLayer'; +import { UrlInteractionLayer } from './UrlInteractionLayer'; import type { AutoResizeTextareaProps } from './auto-resize-textarea'; import type { InlineChip } from '@renderer/types/inlineChip'; @@ -66,40 +71,6 @@ interface ChipSegment { type Segment = TextSegment | MentionSegment | TaskSegment | UrlSegment | ChipSegment; -interface TextMatch { - start: number; - end: number; - value: string; -} - -const URL_REGEX = /https?:\/\/[^\s]+/g; - -function trimUrlMatch(rawUrl: string): string { - return rawUrl.replace(/[),.!?;:]+$/g, ''); -} - -function findUrlMatches(text: string): TextMatch[] { - if (!text) return []; - - const matches: TextMatch[] = []; - for (const match of text.matchAll(URL_REGEX)) { - const rawValue = match[0]; - const start = match.index ?? -1; - if (start < 0) continue; - - const trimmedValue = trimUrlMatch(rawValue); - if (!trimmedValue) continue; - - matches.push({ - start, - end: start + trimmedValue.length, - value: trimmedValue, - }); - } - - return matches; -} - // --------------------------------------------------------------------------- // Mention segment parsing (splits text into plain text + @mention segments) // --------------------------------------------------------------------------- @@ -308,9 +279,9 @@ function parseSegments( // Default fallback color for mentions without a team color const DEFAULT_MENTION_BG = 'rgba(59, 130, 246, 0.15)'; const DEFAULT_MENTION_TEXT = '#60a5fa'; -const URL_BADGE_BG = 'rgba(30, 58, 138, 0.32)'; -const URL_BADGE_BORDER = 'rgba(96, 165, 250, 0.28)'; -const URL_BADGE_TEXT = '#f8fafc'; +const URL_BADGE_BG = 'rgba(37, 99, 235, 0.12)'; +const URL_BADGE_BORDER = 'rgba(96, 165, 250, 0.22)'; +const URL_BADGE_TEXT = '#bfdbfe'; // --------------------------------------------------------------------------- // Component @@ -325,7 +296,7 @@ interface MentionableTextareaProps extends Omit< suggestions: MentionSuggestion[]; hintText?: string; showHint?: boolean; - /** Content rendered at the right side of the footer row (e.g. "Draft saved") */ + /** Content rendered at the right side of the footer row (e.g. "Saved") */ footerRight?: React.ReactNode; /** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */ cornerAction?: React.ReactNode; @@ -658,6 +629,11 @@ export const MentionableTextarea = React.forwardRef findUrlBoundary(value, cursorPos), + [value] + ); + const handleChipKeyDown = React.useCallback( (e: React.KeyboardEvent) => { const textarea = internalRef.current; @@ -670,6 +646,16 @@ export const MentionableTextarea = React.forwardRef { + textarea.setSelectionRange(urlBoundary.start, urlBoundary.start); + }); + return; + } const taskBoundary = findEncodedTaskBoundary(cursorPos); if (taskBoundary && cursorPos === taskBoundary.end) { e.preventDefault(); @@ -694,6 +680,16 @@ export const MentionableTextarea = React.forwardRef { + textarea.setSelectionRange(urlBoundary.start, urlBoundary.start); + }); + return; + } const taskBoundary = findEncodedTaskBoundary(cursorPos); if (taskBoundary && cursorPos === taskBoundary.start) { e.preventDefault(); @@ -717,6 +713,12 @@ export const MentionableTextarea = React.forwardRef { textarea.setSelectionRange(snapTo, snapTo); }); + return; + } + + const urlBoundary = findUrlTokenBoundary(selectionStart); + if (urlBoundary && selectionStart > urlBoundary.start && selectionStart < urlBoundary.end) { + const distToStart = selectionStart - urlBoundary.start; + const distToEnd = urlBoundary.end - selectionStart; + const snapTo = distToStart <= distToEnd ? urlBoundary.start : urlBoundary.end; + requestAnimationFrame(() => { + textarea.setSelectionRange(snapTo, snapTo); + }); } }, - [mentionHandleSelect, chips, value, findEncodedTaskBoundary] + [mentionHandleSelect, chips, value, findEncodedTaskBoundary, findUrlTokenBoundary] ); // --- Chip remove handler (from X button in interaction layer) --- @@ -981,14 +1012,13 @@ export const MentionableTextarea = React.forwardRef - {seg.value} ); @@ -1028,6 +1058,21 @@ export const MentionableTextarea = React.forwardRef ) : null} + {value.includes('http://') || value.includes('https://') ? ( + { + const newText = removeUrlMatchFromText(value, match); + onValueChange(newText); + requestAnimationFrame(() => { + internalRef.current?.setSelectionRange(match.start, match.start); + }); + }} + /> + ) : null} + ; + scrollTop: number; + onRemove: (match: TextMatch) => void; +} + +type PositionedUrlReference = InlineMatchPosition; + +export const UrlInteractionLayer = ({ + value, + textareaRef, + scrollTop, + onRemove, +}: UrlInteractionLayerProps): React.JSX.Element | null => { + const [positions, setPositions] = React.useState([]); + + React.useLayoutEffect(() => { + if (!value.includes('http://') && !value.includes('https://')) { + setPositions([]); + return; + } + + const textarea = textareaRef.current; + if (!textarea) return; + + const matches = findUrlMatches(value).map((match) => ({ + item: match, + start: match.start, + end: match.end, + token: match.value, + })); + + setPositions(calculateInlineMatchPositions(textarea, value, matches)); + }, [textareaRef, value]); + + if (positions.length === 0) return null; + + return ( +
+
+ {positions.map((position, index) => ( +
+ +
+ ))} +
+
+ ); +}; diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 9593923f..cb828c79 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -67,7 +67,7 @@ import type { AppState } from '../types'; import type { AppConfig } from '@renderer/types/data'; import type { AddMemberRequest, - CommentAttachmentPayload, + AddTaskCommentRequest, CreateTaskRequest, CrossTeamSendRequest, EffortLevel, @@ -346,8 +346,7 @@ export interface TeamSlice { addTaskComment: ( teamName: string, taskId: string, - text: string, - attachments?: CommentAttachmentPayload[] + request: AddTaskCommentRequest ) => Promise; addMember: (teamName: string, request: AddMemberRequest) => Promise; removeMember: (teamName: string, memberName: string) => Promise; @@ -1113,11 +1112,11 @@ export const createTeamSlice: StateCreator = (set, ); }, - addTaskComment: async (teamName, taskId, text, attachments) => { + addTaskComment: async (teamName, taskId, request) => { set({ addingComment: true, addCommentError: null }); try { const comment = await unwrapIpc('team:addTaskComment', () => - api.teams.addTaskComment(teamName, taskId, text, attachments) + api.teams.addTaskComment(teamName, taskId, request) ); set({ addingComment: false }); await get().refreshTeamData(teamName); diff --git a/src/renderer/utils/taskReferenceUtils.ts b/src/renderer/utils/taskReferenceUtils.ts index ad81a311..d1ceef72 100644 --- a/src/renderer/utils/taskReferenceUtils.ts +++ b/src/renderer/utils/taskReferenceUtils.ts @@ -1,6 +1,7 @@ import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions'; import type { MentionSuggestion } from '@renderer/types/mention'; +import type { TaskRef } from '@shared/types'; const TASK_REF_REGEX = /#([A-Za-z0-9-]+)\b/g; const TASK_META_START = '\u2063'; @@ -67,6 +68,12 @@ interface EncodedTaskMetadataMatch { end: number; } +interface ParsedTaskLinkHref { + taskId: string; + teamName?: string; + displayId?: string; +} + function encodeZeroWidthPayload(value: string): string { const bytes = new TextEncoder().encode(value); let encoded = ''; @@ -147,6 +154,20 @@ function buildTaskSuggestionFromMetadata( ); } +function buildTaskRefFromSuggestion( + suggestion: MentionSuggestion, + displayId: string +): TaskRef | null { + if (!suggestion.taskId || !suggestion.teamName) { + return null; + } + return { + taskId: suggestion.taskId, + displayId, + teamName: suggestion.teamName, + }; +} + export function createEncodedTaskReference( displayId: string, taskId: string, @@ -162,11 +183,71 @@ export function createEncodedTaskReference( return `#${displayId}${TASK_META_START}${encodedPayload}${TASK_META_END}`; } -export function linkifyTaskIdsInMarkdown(text: string): string { - return text.replace(TASK_REF_REGEX, (raw, ref: string, offset: number) => { - const preceding = offset > 0 ? text[offset - 1] : undefined; - return isAllowedTaskRefBoundary(preceding) ? `[${raw}](task://${ref})` : raw; - }); +export function buildTaskLinkHref(taskRef: TaskRef): string { + return `task://${encodeURIComponent(taskRef.taskId)}?team=${encodeURIComponent(taskRef.teamName)}&display=${encodeURIComponent(taskRef.displayId)}`; +} + +export function parseTaskLinkHref(href: string): ParsedTaskLinkHref | null { + if (!href.startsWith('task://')) return null; + try { + const raw = href.slice('task://'.length); + if (!raw) return null; + + const queryIndex = raw.indexOf('?'); + if (queryIndex === -1) { + return { + taskId: decodeURIComponent(raw), + }; + } + + const taskIdPart = raw.slice(0, queryIndex); + const search = new URLSearchParams(raw.slice(queryIndex + 1)); + const teamName = search.get('team'); + const displayId = search.get('display'); + return { + taskId: decodeURIComponent(taskIdPart), + teamName: teamName ? decodeURIComponent(teamName) : undefined, + displayId: displayId ? decodeURIComponent(displayId) : undefined, + }; + } catch { + return null; + } +} + +export function linkifyTaskIdsInMarkdown(text: string, taskRefs?: TaskRef[]): string { + if (!text) return text; + + const orderedTaskRefs = taskRefs ?? []; + let taskRefIndex = 0; + let result = ''; + let cursor = 0; + + for (const match of text.matchAll(TASK_REF_REGEX)) { + const raw = match[0]; + const ref = match[1]; + const start = match.index ?? -1; + if (start < 0) continue; + + result += text.slice(cursor, start); + const preceding = start > 0 ? text[start - 1] : undefined; + if (!isAllowedTaskRefBoundary(preceding)) { + result += raw; + cursor = start + raw.length; + continue; + } + + const structuredTaskRef = + taskRefIndex < orderedTaskRefs.length && + orderedTaskRefs[taskRefIndex]?.displayId.toLowerCase() === ref.toLowerCase() + ? orderedTaskRefs[taskRefIndex++] + : undefined; + const href = structuredTaskRef ? buildTaskLinkHref(structuredTaskRef) : `task://${ref}`; + result += `[${raw}](${href})`; + cursor = start + raw.length; + } + + result += text.slice(cursor); + return result; } export function stripEncodedTaskReferenceMetadata(text: string): string { @@ -193,12 +274,10 @@ export function findTaskReferenceMatches( text: string, taskSuggestions: MentionSuggestion[] ): TaskReferenceMatch[] { - if (!text || taskSuggestions.length === 0) return []; + if (!text) return []; const suggestionsByRef = buildSuggestionsByRef(taskSuggestions); - if (suggestionsByRef.size === 0) return []; - const matches: TaskReferenceMatch[] = []; for (const match of text.matchAll(TASK_REF_REGEX)) { const raw = match[0]; @@ -227,3 +306,26 @@ export function findTaskReferenceMatches( return matches; } + +export function extractTaskRefsFromText( + text: string, + taskSuggestions: MentionSuggestion[] +): TaskRef[] { + if (!text) return []; + + return findTaskReferenceMatches(text, taskSuggestions) + .map((match) => { + if (match.encoded) { + const metadataMatch = extractEncodedTaskMetadata(text, match.start + match.raw.length); + if (!metadataMatch) return null; + return { + taskId: metadataMatch.metadata.taskId, + displayId: metadataMatch.metadata.displayId, + teamName: metadataMatch.metadata.teamName, + } satisfies TaskRef; + } + + return buildTaskRefFromSuggestion(match.suggestion, match.ref); + }) + .filter((taskRef): taskRef is TaskRef => taskRef !== null); +} diff --git a/src/renderer/utils/urlMatchUtils.ts b/src/renderer/utils/urlMatchUtils.ts new file mode 100644 index 00000000..878164a9 --- /dev/null +++ b/src/renderer/utils/urlMatchUtils.ts @@ -0,0 +1,44 @@ +export interface TextMatch { + start: number; + end: number; + value: string; +} + +const URL_REGEX = /https?:\/\/[^\s]+/g; + +function trimUrlMatch(rawUrl: string): string { + return rawUrl.replace(/[),.!?;:]+$/g, ''); +} + +export function findUrlMatches(text: string): TextMatch[] { + if (!text) return []; + + const matches: TextMatch[] = []; + for (const match of text.matchAll(URL_REGEX)) { + const rawValue = match[0]; + const start = match.index ?? -1; + if (start < 0) continue; + + const trimmedValue = trimUrlMatch(rawValue); + if (!trimmedValue) continue; + + matches.push({ + start, + end: start + trimmedValue.length, + value: trimmedValue, + }); + } + + return matches; +} + +export function findUrlBoundary(text: string, cursorPos: number): TextMatch | null { + return ( + findUrlMatches(text).find((match) => cursorPos >= match.start && cursorPos <= match.end) ?? null + ); +} + +export function removeUrlMatchFromText(text: string, match: TextMatch): string { + const removeEnd = match.end < text.length && text[match.end] === '\n' ? match.end + 1 : match.end; + return text.slice(0, match.start) + text.slice(removeEnd); +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 978a8edf..f987861f 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -38,6 +38,7 @@ import type { } from './review'; import type { AddMemberRequest, + AddTaskCommentRequest, AttachmentFileData, CommentAttachmentPayload, CreateTaskRequest, @@ -472,8 +473,7 @@ export interface TeamsAPI { addTaskComment: ( teamName: string, taskId: string, - text: string, - attachments?: CommentAttachmentPayload[] + request: AddTaskCommentRequest ) => Promise; setTaskClarification: ( teamName: string, diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 6d485982..c9895305 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -117,12 +117,19 @@ export type TaskHistoryEvent = export type TaskCommentType = 'regular' | 'review_request' | 'review_approved'; +export interface TaskRef { + taskId: string; + displayId: string; + teamName: string; +} + export interface TaskComment { id: string; author: string; text: string; createdAt: string; type: TaskCommentType; + taskRefs?: TaskRef[]; /** Attachments on this comment. Metadata only — files stored on disk. */ attachments?: TaskAttachmentMeta[]; } @@ -135,7 +142,10 @@ export interface TeamTask { displayId?: string; subject: string; description?: string; + descriptionTaskRefs?: TaskRef[]; activeForm?: string; + prompt?: string; + promptTaskRefs?: TaskRef[]; owner?: string; createdBy?: string; status: TeamTaskStatus; @@ -244,6 +254,7 @@ export interface InboxMessage { text: string; timestamp: string; read: boolean; + taskRefs?: TaskRef[]; summary?: string; color?: string; messageId?: string; @@ -273,6 +284,7 @@ export type AgentActionMode = 'do' | 'ask' | 'delegate'; export interface SendMessageRequest { member: string; text: string; + taskRefs?: TaskRef[]; actionMode?: AgentActionMode; summary?: string; from?: string; @@ -298,6 +310,12 @@ export interface SendMessageResult { deduplicated?: boolean; } +export interface AddTaskCommentRequest { + text: string; + attachments?: CommentAttachmentPayload[]; + taskRefs?: TaskRef[]; +} + export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown'; /** @@ -329,7 +347,7 @@ export interface KanbanState { export type UpdateKanbanPatch = | { op: 'set_column'; column: Extract } | { op: 'remove' } - | { op: 'request_changes'; comment?: string }; + | { op: 'request_changes'; comment?: string; taskRefs?: TaskRef[] }; export interface ResolvedTeamMember { name: string; @@ -398,10 +416,12 @@ export interface TeamLaunchResponse { export interface CreateTaskRequest { subject: string; description?: string; + descriptionTaskRefs?: TaskRef[]; owner?: string; blockedBy?: string[]; related?: string[]; prompt?: string; + promptTaskRefs?: TaskRef[]; startImmediately?: boolean; } @@ -656,6 +676,7 @@ export interface CrossTeamMessage { conversationId?: string; replyToConversationId?: string; text: string; + taskRefs?: TaskRef[]; summary?: string; chainDepth: number; timestamp: string; @@ -670,6 +691,7 @@ export interface CrossTeamSendRequest { conversationId?: string; replyToConversationId?: string; text: string; + taskRefs?: TaskRef[]; actionMode?: AgentActionMode; summary?: string; chainDepth?: number;