feat: implement structured task references and enhance task handling

- Introduced a new structured task reference format `{ taskId, displayId, teamName }` for consistent task mention persistence across UI and storage.
- Enhanced message handling in various components to support the new task reference structure, including normalization and validation.
- Updated task-related functions to accommodate optional task reference fields, improving task management and messaging capabilities.
- Improved rendering and navigation of task references in the UI, ensuring stable links across messages and comments.
- Refactored task reference utilities for better integration and usability within the application.
This commit is contained in:
iliya 2026-03-11 15:14:19 +02:00
parent f48b75cbc7
commit 6bcb81d337
43 changed files with 1212 additions and 518 deletions

View file

@ -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 - **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` - **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 ### Visible Context Tracking
Tracks what consumes tokens in Claude's context window across 6 categories (discriminated union on `category` field): 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` | | Services/Components | PascalCase | `ProjectScanner.ts` |
| Utilities | camelCase | `pathDecoder.ts` | | Utilities | camelCase | `pathDecoder.ts` |
| Constants | UPPER_SNAKE_CASE | `PARALLEL_WINDOW_MS` | | Constants | UPPER_SNAKE_CASE | `PARALLEL_WINDOW_MS` |
| Type Guards | isXxx | `isRealUserMessage()` | | Type Guards | isXxx | `isParsedRealUserMessage()` |
| Builders | buildXxx | `buildChunks()` | | Builders | buildXxx | `buildChunks()` |
| Getters | getXxx | `getResponses()` | | Getters | getXxx | `getResponses()` |

View file

@ -51,6 +51,23 @@ function normalizeAttachments(attachments) {
return normalized.length > 0 ? normalized : undefined; 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) { function buildMessage(flags, defaults) {
const timestamp = const timestamp =
typeof flags.timestamp === 'string' && flags.timestamp.trim() ? flags.timestamp.trim() : nowIso(); typeof flags.timestamp === 'string' && flags.timestamp.trim() ? flags.timestamp.trim() : nowIso();
@ -59,6 +76,7 @@ function buildMessage(flags, defaults) {
? flags.messageId.trim() ? flags.messageId.trim()
: crypto.randomUUID(); : crypto.randomUUID();
const attachments = normalizeAttachments(flags.attachments); const attachments = normalizeAttachments(flags.attachments);
const taskRefs = normalizeTaskRefs(flags.taskRefs);
return { return {
from: from:
@ -69,6 +87,7 @@ function buildMessage(flags, defaults) {
text: String(flags.text || ''), text: String(flags.text || ''),
timestamp, timestamp,
read: defaults.read, read: defaults.read,
...(taskRefs ? { taskRefs } : {}),
...(typeof flags.summary === 'string' && flags.summary.trim() ...(typeof flags.summary === 'string' && flags.summary.trim()
? { summary: flags.summary.trim() } ? { summary: flags.summary.trim() }
: {}), : {}),

View file

@ -185,6 +185,7 @@ function requestChanges(context, taskId, flags = {}) {
text: comment, text: comment,
from, from,
type: 'review_request', type: 'review_request',
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
notifyOwner: false, notifyOwner: false,
}); });
messages.sendMessage(context, { messages.sendMessage(context, {
@ -193,6 +194,7 @@ function requestChanges(context, taskId, flags = {}) {
text: text:
`Task #${task.displayId || task.id} needs fixes.\n\n${comment}\n\n` + `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.', '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}`, summary: `Fix request for #${task.displayId || task.id}`,
source: 'system_notification', source: 'system_notification',
...(leadSessionId ? { leadSessionId } : {}), ...(leadSessionId ? { leadSessionId } : {}),

View file

@ -166,6 +166,23 @@ function parseRelationshipList(paths, value) {
return rawValues.map((entry) => resolveTaskRef(paths, entry)); 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) { function computeInitialStatus(paths, input, owner, blockedByIds) {
const explicit = normalizeStatus(input.status); const explicit = normalizeStatus(input.status);
if (explicit) return explicit; if (explicit) return explicit;
@ -270,6 +287,7 @@ function createTask(paths, input = {}) {
typeof input.description === 'string' && input.description.length > 0 typeof input.description === 'string' && input.description.length > 0
? input.description ? input.description
: String(input.subject || '').trim(), : String(input.subject || '').trim(),
descriptionTaskRefs: normalizeTaskRefs(input.descriptionTaskRefs),
activeForm: activeForm:
typeof input.activeForm === 'string' typeof input.activeForm === 'string'
? input.activeForm ? input.activeForm
@ -301,6 +319,9 @@ function createTask(paths, input = {}) {
? input.projectPath.trim() ? input.projectPath.trim()
: undefined, : undefined,
comments: Array.isArray(input.comments) ? input.comments : 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: needsClarification:
input.needsClarification === 'lead' || input.needsClarification === 'user' input.needsClarification === 'lead' || input.needsClarification === 'user'
? input.needsClarification ? input.needsClarification
@ -434,6 +455,7 @@ function addTaskComment(paths, taskRef, text, options = {}) {
? options.createdAt.trim() ? options.createdAt.trim()
: nowIso(), : nowIso(),
type: options.type || 'regular', type: options.type || 'regular',
...(normalizeTaskRefs(options.taskRefs) ? { taskRefs: normalizeTaskRefs(options.taskRefs) } : {}),
...(Array.isArray(options.attachments) && options.attachments.length > 0 ...(Array.isArray(options.attachments) && options.attachments.length > 0
? { attachments: options.attachments } ? { attachments: options.attachments }
: {}), : {}),

View file

@ -91,6 +91,7 @@ function maybeNotifyAssignedOwner(context, task, options = {}) {
member: owner, member: owner,
from: sender, from: sender,
text: buildAssignmentMessage(context, task, options), text: buildAssignmentMessage(context, task, options),
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
summary, summary,
source: 'system_notification', source: 'system_notification',
...(leadSessionId ? { leadSessionId } : {}), ...(leadSessionId ? { leadSessionId } : {}),
@ -123,6 +124,7 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
member: owner, member: owner,
from: normalizeActorName(comment.author) || leadName, from: normalizeActorName(comment.author) || leadName,
text: buildCommentNotificationMessage(context, task, comment), text: buildCommentNotificationMessage(context, task, comment),
taskRefs: Array.isArray(comment.taskRefs) ? comment.taskRefs : undefined,
summary: `Comment on #${task.displayId || task.id}`, summary: `Comment on #${task.displayId || task.id}`,
source: 'system_notification', source: 'system_notification',
...(leadSessionId ? { leadSessionId } : {}), ...(leadSessionId ? { leadSessionId } : {}),
@ -135,6 +137,10 @@ function createTask(context, input) {
maybeNotifyAssignedOwner(context, task, { maybeNotifyAssignedOwner(context, task, {
description: input.description, description: input.description,
prompt: input.prompt, prompt: input.prompt,
taskRefs: [
...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []),
...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []),
],
from: input.from, from: input.from,
}); });
} }
@ -221,6 +227,7 @@ function addTaskComment(context, taskId, flags) {
...(flags.id ? { id: flags.id } : {}), ...(flags.id ? { id: flags.id } : {}),
...(flags.createdAt ? { createdAt: flags.createdAt } : {}), ...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
...(flags.type ? { type: flags.type } : {}), ...(flags.type ? { type: flags.type } : {}),
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}), ...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
}); });

View file

@ -7,9 +7,10 @@ import {
import { createLogger } from '@shared/utils/logger'; import { createLogger } from '@shared/utils/logger';
import { isAgentActionMode } from '../services/team/actionModeInstructions'; import { isAgentActionMode } from '../services/team/actionModeInstructions';
import { validateTaskId, validateTeamName } from './guards';
import type { CrossTeamService } from '../services/team/CrossTeamService'; import type { CrossTeamService } from '../services/team/CrossTeamService';
import type { IpcMain, IpcMainInvokeEvent } from 'electron'; import type { IpcMain, IpcMainInvokeEvent } from 'electron';
import type { IpcResult } from '@shared/types'; import type { IpcResult, TaskRef } from '@shared/types';
const logger = createLogger('IPC:crossTeam'); const logger = createLogger('IPC:crossTeam');
@ -19,6 +20,42 @@ export function initializeCrossTeamHandlers(service: CrossTeamService): void {
crossTeamService = service; 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<TaskRef>;
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 { function getService(): CrossTeamService {
if (!crossTeamService) { if (!crossTeamService) {
throw new Error('CrossTeamService not initialized'); throw new Error('CrossTeamService not initialized');
@ -52,6 +89,10 @@ async function handleSend(
if (req.actionMode !== undefined && !isAgentActionMode(req.actionMode)) { if (req.actionMode !== undefined && !isAgentActionMode(req.actionMode)) {
throw new Error('actionMode must be one of: do, ask, delegate'); 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({ return getService().send({
fromTeam: String(req.fromTeam ?? ''), fromTeam: String(req.fromTeam ?? ''),
fromMember: String(req.fromMember ?? ''), fromMember: String(req.fromMember ?? ''),
@ -60,6 +101,7 @@ async function handleSend(
replyToConversationId: replyToConversationId:
typeof req.replyToConversationId === 'string' ? req.replyToConversationId : undefined, typeof req.replyToConversationId === 'string' ? req.replyToConversationId : undefined,
text: String(req.text ?? ''), text: String(req.text ?? ''),
taskRefs: taskRefs.value,
actionMode: isAgentActionMode(req.actionMode) ? req.actionMode : undefined, actionMode: isAgentActionMode(req.actionMode) ? req.actionMode : undefined,
summary: typeof req.summary === 'string' ? req.summary : undefined, summary: typeof req.summary === 'string' ? req.summary : undefined,
chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined, chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined,

View file

@ -98,6 +98,7 @@ import type {
TeamProvisioningService, TeamProvisioningService,
} from '../services'; } from '../services';
import type { import type {
AddTaskCommentRequest,
AgentActionMode, AgentActionMode,
AttachmentFileData, AttachmentFileData,
AttachmentMeta, AttachmentMeta,
@ -115,6 +116,7 @@ import type {
SendMessageResult, SendMessageResult,
TaskAttachmentMeta, TaskAttachmentMeta,
TaskComment, TaskComment,
TaskRef,
TeamClaudeLogsQuery, TeamClaudeLogsQuery,
TeamClaudeLogsResponse, TeamClaudeLogsResponse,
TeamConfig, TeamConfig,
@ -927,12 +929,55 @@ function isUpdateKanbanPatch(value: unknown): value is UpdateKanbanPatch {
} }
if (patch.op === 'request_changes') { 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'); 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<TaskRef>;
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( async function handleGetAttachments(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown, teamName: unknown,
@ -1068,6 +1113,10 @@ async function handleSendMessage(
if (payload.actionMode !== undefined && !isAgentActionMode(payload.actionMode)) { if (payload.actionMode !== undefined && !isAgentActionMode(payload.actionMode)) {
return { success: false, error: 'actionMode must be one of: do, ask, delegate' }; 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; let validatedAttachments: AttachmentPayload[] | undefined;
if ( if (
@ -1175,7 +1224,8 @@ async function handleSendMessage(
resolvedLeadName, resolvedLeadName,
payload.text!, payload.text!,
payload.summary, payload.summary,
attachmentMeta attachmentMeta,
validatedTaskRefs.value
); );
} catch (persistError) { } catch (persistError) {
logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`); logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`);
@ -1199,6 +1249,7 @@ async function handleSendMessage(
messageId: result.messageId, messageId: result.messageId,
source: 'user_sent', source: 'user_sent',
attachments: attachmentMeta, attachments: attachmentMeta,
taskRefs: validatedTaskRefs.value,
}); });
return result; return result;
@ -1217,6 +1268,7 @@ async function handleSendMessage(
summary: payload.summary, summary: payload.summary,
from: payload.from, from: payload.from,
source: 'user_sent', source: 'user_sent',
taskRefs: validatedTaskRefs.value,
}); });
// Best-effort live relay so active processes see the inbox row promptly. // 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') { if (payload.description !== undefined && typeof payload.description !== 'string') {
return { success: false, error: 'description must be 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) { if (payload.owner !== undefined) {
const validatedOwner = validateMemberName(payload.owner); const validatedOwner = validateMemberName(payload.owner);
if (!validatedOwner.valid) { if (!validatedOwner.valid) {
@ -1298,6 +1354,10 @@ async function handleCreateTask(
return { success: false, error: 'prompt exceeds max length (5000)' }; 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') { if (payload.startImmediately !== undefined && typeof payload.startImmediately !== 'boolean') {
return { success: false, error: 'startImmediately must be a boolean' }; return { success: false, error: 'startImmediately must be a boolean' };
} }
@ -1309,7 +1369,9 @@ async function handleCreateTask(
owner: payload.owner?.trim() || undefined, owner: payload.owner?.trim() || undefined,
blockedBy: payload.blockedBy, blockedBy: payload.blockedBy,
related: payload.related, related: payload.related,
descriptionTaskRefs: validatedDescriptionTaskRefs.value,
prompt: payload.prompt?.trim() || undefined, prompt: payload.prompt?.trim() || undefined,
promptTaskRefs: validatedPromptTaskRefs.value,
startImmediately: payload.startImmediately, startImmediately: payload.startImmediately,
}) })
); );
@ -2222,19 +2284,27 @@ async function handleAddTaskComment(
_event: IpcMainInvokeEvent, _event: IpcMainInvokeEvent,
teamName: unknown, teamName: unknown,
taskId: unknown, taskId: unknown,
text: unknown, request: unknown
attachments?: unknown
): Promise<IpcResult<TaskComment>> { ): Promise<IpcResult<TaskComment>> {
const vTeam = validateTeamName(teamName); const vTeam = validateTeamName(teamName);
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' }; if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
const vTask = validateTaskId(taskId); const vTask = validateTaskId(taskId);
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid 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<AddTaskCommentRequest>;
const text = payload.text;
if (typeof text !== 'string' || text.trim().length === 0) if (typeof text !== 'string' || text.trim().length === 0)
return { success: false, error: 'Comment text must be non-empty' }; return { success: false, error: 'Comment text must be non-empty' };
if (text.trim().length > MAX_TEXT_LENGTH) if (text.trim().length > MAX_TEXT_LENGTH)
return { success: false, error: `Comment exceeds ${MAX_TEXT_LENGTH} characters` }; 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) { if (rawAttachments.length > MAX_ATTACHMENTS) {
return { success: false, error: `Maximum ${MAX_ATTACHMENTS} attachments per comment` }; return { success: false, error: `Maximum ${MAX_ATTACHMENTS} attachments per comment` };
} }
@ -2248,7 +2318,7 @@ async function handleAddTaskComment(
if (!att || typeof att !== 'object') { if (!att || typeof att !== 'object') {
throw new Error('Invalid attachment data'); throw new Error('Invalid attachment data');
} }
const a = att as Record<string, unknown>; const a = att as unknown as Record<string, unknown>;
if ( if (
typeof a.id !== 'string' || typeof a.id !== 'string' ||
typeof a.filename !== 'string' || typeof a.filename !== 'string' ||
@ -2279,7 +2349,8 @@ async function handleAddTaskComment(
vTeam.value!, vTeam.value!,
vTask.value!, vTask.value!,
text.trim(), text.trim(),
savedAttachments savedAttachments,
validatedTaskRefs.value
); );
}); });
} }

View file

@ -46,7 +46,7 @@ export class CrossTeamService {
) {} ) {}
async send(request: CrossTeamSendRequest): Promise<CrossTeamSendResult> { async send(request: CrossTeamSendRequest): Promise<CrossTeamSendResult> {
const { fromTeam, fromMember, toTeam, text, summary, actionMode } = request; const { fromTeam, fromMember, toTeam, text, taskRefs, summary, actionMode } = request;
const chainDepth = request.chainDepth ?? 0; const chainDepth = request.chainDepth ?? 0;
const messageId = request.messageId?.trim() || randomUUID(); const messageId = request.messageId?.trim() || randomUUID();
const timestamp = request.timestamp ?? new Date().toISOString(); const timestamp = request.timestamp ?? new Date().toISOString();
@ -105,6 +105,7 @@ export class CrossTeamService {
conversationId, conversationId,
replyToConversationId, replyToConversationId,
text, text,
taskRefs,
summary, summary,
chainDepth, chainDepth,
timestamp, timestamp,
@ -127,6 +128,7 @@ export class CrossTeamService {
source: CROSS_TEAM_SOURCE, source: CROSS_TEAM_SOURCE,
conversationId, conversationId,
replyToConversationId, replyToConversationId,
taskRefs,
}); });
}); });
@ -144,6 +146,7 @@ export class CrossTeamService {
from: fromMember, from: fromMember,
to: `${toTeam}.${leadName}`, to: `${toTeam}.${leadName}`,
text, text,
taskRefs,
timestamp, timestamp,
messageId, messageId,
summary: summary ?? `Cross-team message to ${toTeam}`, summary: summary ?? `Cross-team message to ${toTeam}`,

View file

@ -50,6 +50,7 @@ import type {
SendMessageResult, SendMessageResult,
TaskAttachmentMeta, TaskAttachmentMeta,
TaskComment, TaskComment,
TaskRef,
TeamConfig, TeamConfig,
TeamCreateConfigRequest, TeamCreateConfigRequest,
TeamData, TeamData,
@ -803,12 +804,16 @@ export class TeamDataService {
const task = controller.tasks.createTask({ const task = controller.tasks.createTask({
subject: request.subject, subject: request.subject,
...(request.description?.trim() ? { description: request.description.trim() } : {}), ...(request.description?.trim() ? { description: request.description.trim() } : {}),
...(request.descriptionTaskRefs?.length
? { descriptionTaskRefs: request.descriptionTaskRefs }
: {}),
...(request.owner ? { owner: request.owner } : {}), ...(request.owner ? { owner: request.owner } : {}),
...(blockedBy.length > 0 ? { blockedBy } : {}), ...(blockedBy.length > 0 ? { blockedBy } : {}),
...(related.length > 0 ? { related } : {}), ...(related.length > 0 ? { related } : {}),
...(projectPath ? { projectPath } : {}), ...(projectPath ? { projectPath } : {}),
createdBy: 'user', createdBy: 'user',
...(request.prompt?.trim() ? { prompt: request.prompt.trim() } : {}), ...(request.prompt?.trim() ? { prompt: request.prompt.trim() } : {}),
...(request.promptTaskRefs?.length ? { promptTaskRefs: request.promptTaskRefs } : {}),
...(shouldStart ? { startImmediately: true } : {}), ...(shouldStart ? { startImmediately: true } : {}),
}) as TeamTask; }) as TeamTask;
@ -847,6 +852,7 @@ export class TeamDataService {
member: task.owner, member: task.owner,
from: leadName, from: leadName,
text: parts.join('\n'), text: parts.join('\n'),
taskRefs: task.descriptionTaskRefs,
summary: `Task ${this.getTaskLabel(task)} started`, summary: `Task ${this.getTaskLabel(task)} started`,
source: 'system_notification', source: 'system_notification',
}); });
@ -992,13 +998,15 @@ export class TeamDataService {
teamName: string, teamName: string,
taskId: string, taskId: string,
text: string, text: string,
attachments?: TaskAttachmentMeta[] attachments?: TaskAttachmentMeta[],
taskRefs?: TaskRef[]
): Promise<TaskComment> { ): Promise<TaskComment> {
const controller = this.getController(teamName); const controller = this.getController(teamName);
const addResult = controller.tasks.addTaskComment(taskId, { const addResult = controller.tasks.addTaskComment(taskId, {
from: 'user', from: 'user',
text, text,
attachments, attachments,
taskRefs,
}) as { task?: TeamTask; comment?: TaskComment }; }) as { task?: TeamTask; comment?: TaskComment };
const comment = const comment =
addResult.comment ?? addResult.comment ??
@ -1008,6 +1016,7 @@ export class TeamDataService {
text, text,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
type: 'regular', type: 'regular',
...(taskRefs && taskRefs.length > 0 ? { taskRefs } : {}),
...(attachments && attachments.length > 0 ? { attachments } : {}), ...(attachments && attachments.length > 0 ? { attachments } : {}),
} as TaskComment); } as TaskComment);
@ -1031,6 +1040,15 @@ export class TeamDataService {
member: enrichedRequest.member, member: enrichedRequest.member,
from: enrichedRequest.from, from: enrichedRequest.from,
text: enrichedRequest.text, 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, summary: enrichedRequest.summary,
source: enrichedRequest.source, source: enrichedRequest.source,
leadSessionId: enrichedRequest.leadSessionId, leadSessionId: enrichedRequest.leadSessionId,
@ -1078,7 +1096,8 @@ export class TeamDataService {
leadName: string, leadName: string,
text: string, text: string,
summary?: string, summary?: string,
attachments?: AttachmentMeta[] attachments?: AttachmentMeta[],
taskRefs?: TaskRef[]
): Promise<SendMessageResult> { ): Promise<SendMessageResult> {
let leadSessionId: string | undefined; let leadSessionId: string | undefined;
try { try {
@ -1092,6 +1111,7 @@ export class TeamDataService {
from: 'user', from: 'user',
to: leadName, to: leadName,
text, text,
taskRefs,
summary, summary,
source: 'user_sent', source: 'user_sent',
attachments: attachments?.length ? attachments : undefined, attachments: attachments?.length ? attachments : undefined,
@ -1462,6 +1482,9 @@ export class TeamDataService {
controller.review.requestChanges(taskId, { controller.review.requestChanges(taskId, {
from: 'user', from: 'user',
comment: patch.comment?.trim() || 'Reviewer requested changes.', comment: patch.comment?.trim() || 'Reviewer requested changes.',
...(patch.op === 'request_changes' && patch.taskRefs?.length
? { taskRefs: patch.taskRefs }
: {}),
...(leadSessionId ? { leadSessionId } : {}), ...(leadSessionId ? { leadSessionId } : {}),
}); });
} }

View file

@ -98,6 +98,7 @@ export class TeamInboxReader {
text: row.text, text: row.text,
timestamp: row.timestamp, timestamp: row.timestamp,
read: typeof row.read === 'boolean' ? row.read : false, read: typeof row.read === 'boolean' ? row.read : false,
taskRefs: Array.isArray(row.taskRefs) ? row.taskRefs : undefined,
summary: typeof row.summary === 'string' ? row.summary : undefined, summary: typeof row.summary === 'string' ? row.summary : undefined,
color: typeof row.color === 'string' ? row.color : undefined, color: typeof row.color === 'string' ? row.color : undefined,
messageId: row.messageId, messageId: row.messageId,

View file

@ -27,6 +27,7 @@ export class TeamInboxWriter {
text: request.text, text: request.text,
timestamp: request.timestamp ?? new Date().toISOString(), timestamp: request.timestamp ?? new Date().toISOString(),
read: false, read: false,
taskRefs: request.taskRefs?.length ? request.taskRefs : undefined,
summary: request.summary, summary: request.summary,
messageId, messageId,
attachments: attachmentMeta?.length ? attachmentMeta : undefined, attachments: attachmentMeta?.length ? attachmentMeta : undefined,

View file

@ -1648,6 +1648,7 @@ export class TeamProvisioningService {
leadSessionId: message.leadSessionId, leadSessionId: message.leadSessionId,
conversationId: message.conversationId, conversationId: message.conversationId,
replyToConversationId: message.replyToConversationId, replyToConversationId: message.replyToConversationId,
taskRefs: message.taskRefs,
attachments: message.attachments, attachments: message.attachments,
color: message.color, color: message.color,
toolSummary: message.toolSummary, toolSummary: message.toolSummary,
@ -1674,6 +1675,7 @@ export class TeamProvisioningService {
leadSessionId: message.leadSessionId, leadSessionId: message.leadSessionId,
conversationId: message.conversationId, conversationId: message.conversationId,
replyToConversationId: message.replyToConversationId, replyToConversationId: message.replyToConversationId,
taskRefs: message.taskRefs,
attachments: message.attachments, attachments: message.attachments,
color: message.color, color: message.color,
toolSummary: message.toolSummary, toolSummary: message.toolSummary,

View file

@ -72,6 +72,7 @@ export class TeamSentMessagesStore {
text: row.text, text: row.text,
timestamp: row.timestamp, timestamp: row.timestamp,
read: typeof row.read === 'boolean' ? row.read : true, read: typeof row.read === 'boolean' ? row.read : true,
taskRefs: Array.isArray(row.taskRefs) ? row.taskRefs : undefined,
summary: typeof row.summary === 'string' ? row.summary : undefined, summary: typeof row.summary === 'string' ? row.summary : undefined,
messageId: row.messageId, messageId: row.messageId,
color: typeof row.color === 'string' ? row.color : undefined, color: typeof row.color === 'string' ? row.color : undefined,

View file

@ -13,6 +13,7 @@ import type {
TaskAttachmentMeta, TaskAttachmentMeta,
TaskComment, TaskComment,
TaskHistoryEvent, TaskHistoryEvent,
TaskRef,
TaskWorkInterval, TaskWorkInterval,
TeamTask, TeamTask,
TeamTaskStatus, TeamTaskStatus,
@ -34,6 +35,21 @@ function isValidMimeTypeString(value: unknown): value is string {
return true; return true;
} }
function normalizeTaskRefs(value: unknown): TaskRef[] | undefined {
if (!Array.isArray(value)) return undefined;
const taskRefs = (value as unknown[])
.filter(
(entry): entry is Record<string, unknown> => 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 { export class TeamTaskReader {
/** /**
* Returns the next available numeric task ID by scanning ALL task files * Returns the next available numeric task ID by scanning ALL task files
@ -155,7 +171,10 @@ export class TeamTaskReader {
), ),
subject, subject,
description: typeof parsed.description === 'string' ? parsed.description : undefined, description: typeof parsed.description === 'string' ? parsed.description : undefined,
descriptionTaskRefs: normalizeTaskRefs(parsed.descriptionTaskRefs),
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined, 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, owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes( 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) type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type)
? c.type ? c.type
: ('regular' as const), : ('regular' as const),
taskRefs: normalizeTaskRefs((c as unknown as Record<string, unknown>).taskRefs),
attachments: Array.isArray(c.attachments) attachments: Array.isArray(c.attachments)
? (() => { ? (() => {
const filtered = (c.attachments as unknown[]) const filtered = (c.attachments as unknown[])

View file

@ -106,7 +106,10 @@ interface ParsedTask {
subject?: unknown; subject?: unknown;
title?: unknown; title?: unknown;
description?: unknown; description?: unknown;
descriptionTaskRefs?: unknown;
activeForm?: unknown; activeForm?: unknown;
prompt?: unknown;
promptTaskRefs?: unknown;
owner?: unknown; owner?: unknown;
createdBy?: unknown; createdBy?: unknown;
status?: unknown; status?: unknown;
@ -143,6 +146,7 @@ interface RawComment {
text?: unknown; text?: unknown;
createdAt?: unknown; createdAt?: unknown;
type?: unknown; type?: unknown;
taskRefs?: unknown;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -526,6 +530,7 @@ function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
author: c.author as string, author: c.author as string,
text: c.text as string, text: c.text as string,
createdAt: c.createdAt as string, createdAt: c.createdAt as string,
taskRefs: Array.isArray(c.taskRefs) ? c.taskRefs : undefined,
type: type:
c.type === 'regular' || c.type === 'review_request' || c.type === 'review_approved' c.type === 'regular' || c.type === 'review_request' || c.type === 'review_approved'
? (c.type as string) ? (c.type as string)
@ -626,7 +631,14 @@ async function readTasksDirForTeam(
), ),
subject, subject,
description: typeof parsed.description === 'string' ? parsed.description : undefined, 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, 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, owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined, createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
status: status:

View file

@ -195,6 +195,7 @@ import {
import type { import type {
AddMemberRequest, AddMemberRequest,
AddTaskCommentRequest,
AgentChangeSet, AgentChangeSet,
AppConfig, AppConfig,
ApplyReviewRequest, ApplyReviewRequest,
@ -205,7 +206,6 @@ import type {
ClaudeRootInfo, ClaudeRootInfo,
CliInstallationStatus, CliInstallationStatus,
CliInstallerProgress, CliInstallerProgress,
CommentAttachmentPayload,
ConflictCheckResult, ConflictCheckResult,
ContextInfo, ContextInfo,
CreateScheduleInput, CreateScheduleInput,
@ -878,19 +878,8 @@ const electronAPI: ElectronAPI = {
updateConfig: async (teamName: string, updates: TeamUpdateConfigRequest) => { updateConfig: async (teamName: string, updates: TeamUpdateConfigRequest) => {
return invokeIpcWithResult<TeamConfig>(TEAM_UPDATE_CONFIG, teamName, updates); return invokeIpcWithResult<TeamConfig>(TEAM_UPDATE_CONFIG, teamName, updates);
}, },
addTaskComment: async ( addTaskComment: async (teamName: string, taskId: string, request: AddTaskCommentRequest) => {
teamName: string, return invokeIpcWithResult<TaskComment>(TEAM_ADD_TASK_COMMENT, teamName, taskId, request);
taskId: string,
text: string,
attachments?: CommentAttachmentPayload[]
) => {
return invokeIpcWithResult<TaskComment>(
TEAM_ADD_TASK_COMMENT,
teamName,
taskId,
text,
attachments
);
}, },
addMember: async (teamName: string, request: AddMemberRequest) => { addMember: async (teamName: string, request: AddMemberRequest) => {
return invokeIpcWithResult<void>(TEAM_ADD_MEMBER, teamName, request); return invokeIpcWithResult<void>(TEAM_ADD_MEMBER, teamName, request);

View file

@ -28,6 +28,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins'; import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { FileText, UsersRound } from 'lucide-react'; import { FileText, UsersRound } from 'lucide-react';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@ -269,9 +270,13 @@ function createViewerMarkdownComponents(
); );
} }
if (href?.startsWith('task://')) { if (href?.startsWith('task://')) {
const taskId = href.slice('task://'.length); const parsedTaskLink = parseTaskLinkHref(href);
const taskId = parsedTaskLink?.taskId;
if (!taskId) {
return <>{children}</>;
}
return ( return (
<TaskTooltip taskId={taskId}> <TaskTooltip taskId={taskId} teamName={parsedTaskLink?.teamName}>
<a <a
href={href} href={href}
className="cursor-pointer font-medium no-underline hover:underline" className="cursor-pointer font-medium no-underline hover:underline"

View file

@ -85,6 +85,7 @@ export const SortableTab = ({
const displayName = team?.displayName ?? tab.label; const displayName = team?.displayName ?? tab.label;
return nameColorSet(displayName); return nameColorSet(displayName);
}); });
const activeBorderColor = teamColorSet?.border ?? 'var(--color-accent, #6366f1)';
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: tab.id, id: tab.id,
@ -119,7 +120,17 @@ export const SortableTab = ({
: 'var(--color-text-muted)', : 'var(--color-text-muted)',
outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none', outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none',
outlineOffset: '-1px', outlineOffset: '-1px',
borderLeft: isActive && teamColorSet ? `2px solid ${teamColorSet.border}` : undefined, borderTop: isActive ? `1px solid ${activeBorderColor}` : '1px solid transparent',
borderLeft: isActive ? `1px solid ${activeBorderColor}` : '1px solid transparent',
borderRight: isActive ? `1px solid ${activeBorderColor}` : '1px solid transparent',
borderBottom: isActive ? '1px solid var(--color-surface-raised)' : '1px solid transparent',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
borderBottomLeftRadius: isActive ? 0 : '8px',
borderBottomRightRadius: isActive ? 0 : '8px',
marginBottom: isActive ? '-1px' : 0,
position: 'relative' as const,
zIndex: isActive ? 1 : 0,
}; };
const Icon = TAB_ICONS[tab.type]; const Icon = TAB_ICONS[tab.type];
@ -144,7 +155,7 @@ export const SortableTab = ({
role="tab" role="tab"
tabIndex={0} tabIndex={0}
aria-selected={isActive} aria-selected={isActive}
className="group flex shrink-0 cursor-grab items-center gap-2 rounded-md px-3 py-1.5" className="group flex shrink-0 cursor-grab items-center gap-2 px-3 py-1.5"
style={style} style={style}
onClick={(e) => onTabClick(tab.id, e)} onClick={(e) => onTabClick(tab.id, e)}
onMouseDown={(e) => onMouseDown(tab.id, e)} onMouseDown={(e) => onMouseDown(tab.id, e)}

View file

@ -236,7 +236,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
return ( return (
<div <div
className="flex h-full items-center pr-2" className="flex h-full items-end pr-2"
style={ style={
{ {
paddingLeft: paddingLeft:

View file

@ -5,6 +5,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { REVIEW_STATE_DISPLAY, buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { REVIEW_STATE_DISPLAY, buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import { getTaskKanbanColumn } from '@shared/utils/reviewState'; import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
@ -171,7 +172,11 @@ export const TaskTooltip = ({
{/* Description — full markdown with scroll */} {/* Description — full markdown with scroll */}
{task.description ? ( {task.description ? (
<div className="max-h-[200px] overflow-y-auto text-[10px]"> <div className="max-h-[200px] overflow-y-auto text-[10px]">
<MarkdownViewer content={task.description} maxHeight="max-h-none" bare /> <MarkdownViewer
content={linkifyTaskIdsInMarkdown(task.description, task.descriptionTaskRefs)}
maxHeight="max-h-none"
bare
/>
</div> </div>
) : null} ) : null}
</TooltipContent> </TooltipContent>

View file

@ -79,7 +79,12 @@ import type { KanbanSortState } from './kanban/KanbanSortPopover';
import type { ContextInjection } from '@renderer/types/contextInjection'; import type { ContextInjection } from '@renderer/types/contextInjection';
import type { Session } from '@renderer/types/data'; import type { Session } from '@renderer/types/data';
import type { InlineChip } from '@renderer/types/inlineChip'; 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'; import type { EditorSelectionAction } from '@shared/types/editor';
interface TeamDetailViewProps { interface TeamDetailViewProps {
@ -796,7 +801,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
blockedBy?: string[], blockedBy?: string[],
related?: string[], related?: string[],
prompt?: string, prompt?: string,
startImmediately?: boolean startImmediately?: boolean,
descriptionTaskRefs?: TaskRef[],
promptTaskRefs?: TaskRef[]
): void => { ): void => {
setCreatingTask(true); setCreatingTask(true);
void (async () => { void (async () => {
@ -808,6 +815,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
blockedBy, blockedBy,
related, related,
prompt, prompt,
descriptionTaskRefs,
promptTaskRefs,
startImmediately, startImmediately,
}); });
@ -1567,7 +1576,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
taskId={requestChangesTaskId} taskId={requestChangesTaskId}
members={data?.members ?? []} members={data?.members ?? []}
onCancel={() => setRequestChangesTaskId(null)} onCancel={() => setRequestChangesTaskId(null)}
onSubmit={(comment) => { onSubmit={(comment, taskRefs) => {
if (!requestChangesTaskId) { if (!requestChangesTaskId) {
return; return;
} }
@ -1576,6 +1585,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
await updateKanban(teamName, requestChangesTaskId, { await updateKanban(teamName, requestChangesTaskId, {
op: 'request_changes', op: 'request_changes',
comment, comment,
taskRefs,
}); });
setRequestChangesTaskId(null); setRequestChangesTaskId(null);
} catch { } catch {
@ -1777,7 +1787,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
sending={sendingMessage} sending={sendingMessage}
sendError={sendMessageError} sendError={sendMessageError}
lastResult={lastSendMessageResult} lastResult={lastSendMessageResult}
onSend={(member, text, summary, attachments, actionMode) => { onSend={(member, text, summary, attachments, actionMode, taskRefs) => {
void (async () => { void (async () => {
const sentAtMs = Date.now(); const sentAtMs = Date.now();
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs })); setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
@ -1788,6 +1798,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
summary, summary,
attachments, attachments,
actionMode, actionMode,
taskRefs,
}); });
} catch { } catch {
setPendingRepliesByMember((prev) => { setPendingRepliesByMember((prev) => {

View file

@ -1,6 +1,7 @@
import { Fragment, useMemo } from 'react'; import { Fragment, useMemo } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { CopyButton } from '@renderer/components/common/CopyButton';
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay'; import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { TaskTooltip } from '@renderer/components/team/TaskTooltip'; import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
@ -24,7 +25,7 @@ import {
} from '@renderer/utils/agentMessageFormatting'; } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; 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 { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { import {
CROSS_TEAM_SENT_SOURCE, CROSS_TEAM_SENT_SOURCE,
@ -129,6 +130,8 @@ interface ActivityItemProps {
zebraShade?: boolean; zebraShade?: boolean;
/** Explicit collapse state for timeline-controlled collapsed mode. */ /** Explicit collapse state for timeline-controlled collapsed mode. */
collapseState?: ActivityCollapseState; collapseState?: ActivityCollapseState;
/** Compact header mode for narrow message lists. */
compactHeader?: boolean;
} }
function getStringField(obj: StructuredMessage, key: string): string | null { function getStringField(obj: StructuredMessage, key: string): string | null {
@ -297,6 +300,7 @@ export const ActivityItem = ({
onRestartTeam, onRestartTeam,
zebraShade, zebraShade,
collapseState, collapseState,
compactHeader = false,
}: ActivityItemProps): React.JSX.Element => { }: ActivityItemProps): React.JSX.Element => {
const colors = getTeamColorSet(memberColor ?? message.color ?? ''); const colors = getTeamColorSet(memberColor ?? message.color ?? '');
const { isLight } = useTheme(); const { isLight } = useTheme();
@ -399,7 +403,7 @@ export const ActivityItem = ({
const displayText = useMemo(() => { const displayText = useMemo(() => {
if (!strippedText) return null; if (!strippedText) return null;
let result = highlightSystemLabels(strippedText, !!systemLabel); let result = highlightSystemLabels(strippedText, !!systemLabel);
result = linkifyTaskIdsInMarkdown(result); result = linkifyTaskIdsInMarkdown(result, message.taskRefs);
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0)
result = linkifyAllMentionsInMarkdown(result, memberColorMap ?? new Map(), teamNames); result = linkifyAllMentionsInMarkdown(result, memberColorMap ?? new Map(), teamNames);
return result; return result;
@ -435,7 +439,7 @@ export const ActivityItem = ({
}; };
const isHeaderClickable = isManaged ? collapseState.canToggle : false; const isHeaderClickable = isManaged ? collapseState.canToggle : false;
const showChevron = isHeaderClickable; const showChevron = isHeaderClickable && !compactHeader;
const isUserSent = message.source === 'user_sent' || isCrossTeamSent; const isUserSent = message.source === 'user_sent' || isCrossTeamSent;
const isSystemMessage = message.from === 'system'; const isSystemMessage = message.from === 'system';
const onManagedToggle = isManaged ? collapseState.onToggle : undefined; const onManagedToggle = isManaged ? collapseState.onToggle : undefined;
@ -518,13 +522,13 @@ export const ActivityItem = ({
<MemberBadge <MemberBadge
name={senderName} name={senderName}
color={senderColor} color={senderColor}
hideAvatar={senderHideAvatar} hideAvatar={senderHideAvatar || compactHeader}
onClick={onMemberNameClick} onClick={onMemberNameClick}
disableHoverCard={crossTeamOrigin != null} disableHoverCard={crossTeamOrigin != null}
/> />
{/* Role */} {/* Role */}
{formattedRole ? ( {!compactHeader && formattedRole ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}> <span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formattedRole} {formattedRole}
</span> </span>
@ -580,8 +584,9 @@ export const ActivityItem = ({
name={crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to} name={crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to}
color={crossTeamTarget ? undefined : recipientColor} color={crossTeamTarget ? undefined : recipientColor}
hideAvatar={ hideAvatar={
compactHeader ||
(crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to) === (crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to) ===
'user' 'user'
} }
onClick={onMemberNameClick} onClick={onMemberNameClick}
disableHoverCard={crossTeamTarget != null} disableHoverCard={crossTeamTarget != null}
@ -595,44 +600,8 @@ export const ActivityItem = ({
{onTaskIdClick ? linkifyTaskIds(summaryText, onTaskIdClick) : summaryText} {onTaskIdClick ? linkifyTaskIds(summaryText, onTaskIdClick) : summaryText}
</span> </span>
{/* Timestamp + reply + create task */} {/* Timestamp */}
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
{onReply && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
onReply(message);
}}
>
<Reply size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Reply to message</TooltipContent>
</Tooltip>
)}
{onCreateTask && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-0.5 opacity-0 transition-opacity hover:bg-[var(--color-surface-raised)] group-hover:opacity-100"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
handleCreateTask();
}}
>
<ListPlus size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Create task from message</TooltipContent>
</Tooltip>
)}
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}> <span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{timestamp} {timestamp}
</span> </span>
@ -660,29 +629,72 @@ export const ActivityItem = ({
<ReplyQuoteBlock <ReplyQuoteBlock
reply={parsedReply} reply={parsedReply}
memberColor={memberColorMap?.get(parsedReply.agentName)} memberColor={memberColorMap?.get(parsedReply.agentName)}
replyTaskRefs={message.taskRefs}
/> />
) : displayText ? ( ) : displayText ? (
<ExpandableContent> <div className="group/message-body relative">
<span <div className="absolute right-1 top-1 z-10 flex items-center gap-0.5 opacity-0 transition-opacity group-hover/message-body:opacity-100">
onClickCapture={ {onReply ? (
onTaskIdClick <Tooltip>
? (e) => { <TooltipTrigger asChild>
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>( <button
'a[href^="task://"]' type="button"
); className="rounded p-1 transition-colors hover:bg-[var(--color-surface-raised)]"
if (link) { style={{ color: CARD_ICON_MUTED }}
e.preventDefault(); onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
const taskId = link.getAttribute('href')?.replace('task://', ''); onReply(message);
if (taskId) onTaskIdClick(taskId); }}
>
<Reply size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Reply to message</TooltipContent>
</Tooltip>
) : null}
{onCreateTask ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="rounded p-1 transition-colors hover:bg-[var(--color-surface-raised)]"
style={{ color: CARD_ICON_MUTED }}
onClick={(e) => {
e.stopPropagation();
handleCreateTask();
}}
>
<ListPlus size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top">Create task from message</TooltipContent>
</Tooltip>
) : null}
<CopyButton text={displayText} inline />
</div>
<ExpandableContent>
<span
onClickCapture={
onTaskIdClick
? (e) => {
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
'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 }
} >
> <MarkdownViewer content={displayText} maxHeight="max-h-none" bare />
<MarkdownViewer content={displayText} maxHeight="max-h-none" copyable bare /> </span>
</span> </ExpandableContent>
</ExpandableContent> </div>
) : summaryText ? ( ) : summaryText ? (
<p className="text-xs italic" style={{ color: CARD_TEXT_LIGHT }}> <p className="text-xs italic" style={{ color: CARD_TEXT_LIGHT }}>
{summaryText} {summaryText}

View file

@ -56,6 +56,7 @@ interface ActivityTimelineProps {
const VIEWPORT_THRESHOLD = 0.15; const VIEWPORT_THRESHOLD = 0.15;
const MESSAGES_PAGE_SIZE = 30; const MESSAGES_PAGE_SIZE = 30;
const COMPACT_MESSAGES_WIDTH_PX = 400;
/** Inline compaction boundary divider — styled like session separators but with amber accent. */ /** Inline compaction boundary divider — styled like session separators but with amber accent. */
const CompactionDivider = ({ message }: { message: InboxMessage }): React.JSX.Element => ( const CompactionDivider = ({ message }: { message: InboxMessage }): React.JSX.Element => (
@ -98,6 +99,7 @@ const MessageRowWithObserver = ({
onTaskIdClick, onTaskIdClick,
onRestartTeam, onRestartTeam,
collapseState, collapseState,
compactHeader,
}: { }: {
message: InboxMessage; message: InboxMessage;
teamName: string; teamName: string;
@ -116,6 +118,7 @@ const MessageRowWithObserver = ({
onTaskIdClick?: (taskId: string) => void; onTaskIdClick?: (taskId: string) => void;
onRestartTeam?: () => void; onRestartTeam?: () => void;
collapseState?: ActivityCollapseState; collapseState?: ActivityCollapseState;
compactHeader?: boolean;
}): React.JSX.Element => { }): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const reportedRef = useRef(false); const reportedRef = useRef(false);
@ -165,6 +168,7 @@ const MessageRowWithObserver = ({
onTaskIdClick={onTaskIdClick} onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam} onRestartTeam={onRestartTeam}
collapseState={collapseState} collapseState={collapseState}
compactHeader={compactHeader}
/> />
</AnimatedHeightReveal> </AnimatedHeightReveal>
); );
@ -188,6 +192,31 @@ export const ActivityTimeline = ({
currentLeadSessionId, currentLeadSessionId,
}: ActivityTimelineProps): React.JSX.Element => { }: ActivityTimelineProps): React.JSX.Element => {
const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE); const [visibleCount, setVisibleCount] = useState(MESSAGES_PAGE_SIZE);
const rootRef = useRef<HTMLDivElement>(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<string, string>(); const colorMap = members ? buildMemberColorMap(members) : new Map<string, string>();
const localMemberNames = new Set((members ?? []).map((member) => member.name.trim())); const localMemberNames = new Set((members ?? []).map((member) => member.name.trim()));
@ -357,7 +386,7 @@ export const ActivityTimeline = ({
} }
return ( return (
<div className="space-y-1"> <div ref={rootRef} className="space-y-1">
{/* Pinned (newest) thought group — always at top */} {/* Pinned (newest) thought group — always at top */}
{pinnedThoughtGroup && {pinnedThoughtGroup &&
(() => { (() => {
@ -380,6 +409,7 @@ export const ActivityTimeline = ({
onTaskIdClick={onTaskIdClick} onTaskIdClick={onTaskIdClick}
memberColorMap={colorMap} memberColorMap={colorMap}
onReply={onReplyToMessage} onReply={onReplyToMessage}
compactHeader={compactHeader}
/> />
); );
})()} })()}
@ -440,6 +470,7 @@ export const ActivityTimeline = ({
onTaskIdClick={onTaskIdClick} onTaskIdClick={onTaskIdClick}
memberColorMap={colorMap} memberColorMap={colorMap}
onReply={onReplyToMessage} onReply={onReplyToMessage}
compactHeader={compactHeader}
/> />
</React.Fragment> </React.Fragment>
); );
@ -489,6 +520,7 @@ export const ActivityTimeline = ({
onTaskIdClick={onTaskIdClick} onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam} onRestartTeam={onRestartTeam}
collapseState={collapseState} collapseState={collapseState}
compactHeader={compactHeader}
/> />
</React.Fragment> </React.Fragment>
); );

View file

@ -15,7 +15,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; 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 { toMessageKey } from '@renderer/utils/teamMessageKey';
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary'; import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch'; import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
@ -126,6 +126,8 @@ interface LeadThoughtsGroupRowProps {
memberColorMap?: Map<string, string>; memberColorMap?: Map<string, string>;
/** Called when user clicks the reply button on a thought. */ /** Called when user clicks the reply button on a thought. */
onReply?: (message: InboxMessage) => void; onReply?: (message: InboxMessage) => void;
/** Compact header mode for narrow message lists. */
compactHeader?: boolean;
} }
function formatTime(timestamp: string): string { function formatTime(timestamp: string): string {
@ -237,7 +239,7 @@ const LeadThoughtItem = ({
const displayContent = useMemo(() => { const displayContent = useMemo(() => {
let text = thought.text.replace(/\n/g, ' \n'); let text = thought.text.replace(/\n/g, ' \n');
text = linkifyTaskIdsInMarkdown(text); text = linkifyTaskIdsInMarkdown(text, thought.taskRefs);
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) { if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) {
text = linkifyAllMentionsInMarkdown(text, memberColorMap ?? new Map(), teamNames); text = linkifyAllMentionsInMarkdown(text, memberColorMap ?? new Map(), teamNames);
} }
@ -393,8 +395,9 @@ const LeadThoughtItem = ({
if (link) { if (link) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const taskId = link.getAttribute('href')?.replace('task://', ''); const href = link.getAttribute('href');
if (taskId) onTaskIdClick(taskId); const parsedTaskLink = href ? parseTaskLinkHref(href) : null;
if (parsedTaskLink?.taskId) onTaskIdClick(parsedTaskLink.taskId);
} }
} }
: undefined : undefined
@ -462,6 +465,7 @@ export const LeadThoughtsGroupRow = ({
onTaskIdClick, onTaskIdClick,
memberColorMap, memberColorMap,
onReply, onReply,
compactHeader = false,
}: LeadThoughtsGroupRowProps): React.JSX.Element => { }: LeadThoughtsGroupRowProps): React.JSX.Element => {
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@ -725,7 +729,7 @@ export const LeadThoughtsGroupRow = ({
} }
> >
{/* Chevron for collapse mode */} {/* Chevron for collapse mode */}
{canToggleBodyVisibility ? ( {canToggleBodyVisibility && !compactHeader ? (
<ChevronRight <ChevronRight
className="size-3 shrink-0 transition-transform duration-150" className="size-3 shrink-0 transition-transform duration-150"
style={{ style={{
@ -735,20 +739,22 @@ export const LeadThoughtsGroupRow = ({
/> />
) : null} ) : null}
{/* Lead avatar with optional live indicator */} {/* Lead avatar with optional live indicator */}
<div className="relative shrink-0"> {!compactHeader ? (
<img <div className="relative shrink-0">
src={agentAvatarUrl(leadName, 24)} <img
alt="" src={agentAvatarUrl(leadName, 24)}
className="size-5 rounded-full bg-[var(--color-surface-raised)]" alt=""
loading="lazy" className="size-5 rounded-full bg-[var(--color-surface-raised)]"
/> loading="lazy"
{isLive ? ( />
<span className="absolute -bottom-0.5 -right-0.5 flex size-2.5"> {isLive ? (
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" /> <span className="absolute -bottom-0.5 -right-0.5 flex size-2.5">
<span className="relative inline-flex size-full rounded-full border-2 border-[var(--color-surface)] bg-emerald-400" /> <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
</span> <span className="relative inline-flex size-full rounded-full border-2 border-[var(--color-surface)] bg-emerald-400" />
) : null} </span>
</div> ) : null}
</div>
) : null}
<MemberBadge name={leadName} color={memberColor} hideAvatar /> <MemberBadge name={leadName} color={memberColor} hideAvatar />
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}> <span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{thoughts.length} thoughts {thoughts.length} thoughts

View file

@ -2,8 +2,10 @@ import { useState } from 'react';
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer'; import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
import { MemberBadge } from '@renderer/components/team/MemberBadge'; import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting'; import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting';
import type { TaskRef } from '@shared/types';
interface ReplyQuoteBlockProps { interface ReplyQuoteBlockProps {
reply: ParsedMessageReply; reply: ParsedMessageReply;
@ -11,6 +13,8 @@ interface ReplyQuoteBlockProps {
memberColor?: string; memberColor?: string;
/** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */ /** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */
bodyMaxHeight?: string; bodyMaxHeight?: string;
/** Structured task refs for the reply body, when available. */
replyTaskRefs?: TaskRef[];
} }
/** Threshold (characters) above which the "more/less" toggle is shown. */ /** Threshold (characters) above which the "more/less" toggle is shown. */
@ -20,6 +24,7 @@ export const ReplyQuoteBlock = ({
reply, reply,
memberColor, memberColor,
bodyMaxHeight = 'max-h-56', bodyMaxHeight = 'max-h-56',
replyTaskRefs,
}: ReplyQuoteBlockProps): React.JSX.Element => { }: ReplyQuoteBlockProps): React.JSX.Element => {
const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD; const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@ -43,7 +48,11 @@ export const ReplyQuoteBlock = ({
{/* Quote text */} {/* Quote text */}
<div className={`pr-5 opacity-50 ${expanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}> <div className={`pr-5 opacity-50 ${expanded ? '' : 'max-h-[3.75rem] overflow-hidden'}`}>
<MarkdownViewer content={reply.originalText} bare maxHeight={quoteMaxHeight} /> <MarkdownViewer
content={linkifyTaskIdsInMarkdown(reply.originalText)}
bare
maxHeight={quoteMaxHeight}
/>
</div> </div>
{/* More/less toggle */} {/* More/less toggle */}
@ -59,7 +68,12 @@ export const ReplyQuoteBlock = ({
</div> </div>
{/* Reply text */} {/* Reply text */}
<MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable bare /> <MarkdownViewer
content={linkifyTaskIdsInMarkdown(reply.replyText, replyTaskRefs)}
maxHeight={bodyMaxHeight}
copyable
bare
/>
</div> </div>
); );
}; };

View file

@ -180,7 +180,7 @@ export const AddMemberDialog = ({
placeholder="How this agent should behave, what tasks it handles..." placeholder="How this agent should behave, what tasks it handles..."
footerRight={ footerRight={
workflowDraft.isSaved ? ( workflowDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null ) : null
} }
/> />

View file

@ -30,14 +30,17 @@ import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; 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 { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { AlertTriangle, Search } from 'lucide-react'; import { AlertTriangle, Search } from 'lucide-react';
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types';
interface CreateTaskDialogProps { interface CreateTaskDialogProps {
open: boolean; open: boolean;
@ -58,7 +61,9 @@ interface CreateTaskDialogProps {
blockedBy?: string[], blockedBy?: string[],
related?: string[], related?: string[],
prompt?: string, prompt?: string,
startImmediately?: boolean startImmediately?: boolean,
descriptionTaskRefs?: TaskRef[],
promptTaskRefs?: TaskRef[]
) => void; ) => void;
submitting?: boolean; submitting?: boolean;
} }
@ -175,18 +180,23 @@ export const CreateTaskDialog = ({
const handleSubmit = (): void => { const handleSubmit = (): void => {
if (!canSubmit) return; if (!canSubmit) return;
const serializedDesc = serializeChipsWithText( const trimmedDescription = stripEncodedTaskReferenceMetadata(descriptionDraft.value.trim());
descriptionDraft.value.trim(), const trimmedPrompt = stripEncodedTaskReferenceMetadata(promptDraft.value.trim());
descChipDraft.chips const serializedDesc = serializeChipsWithText(trimmedDescription, descChipDraft.chips);
); const descriptionTaskRefs = extractTaskRefsFromText(descriptionDraft.value, taskSuggestions);
const promptTaskRefs = trimmedPrompt
? extractTaskRefsFromText(promptDraft.value, taskSuggestions)
: [];
onSubmit( onSubmit(
subject.trim(), subject.trim(),
serializedDesc, serializedDesc,
owner || undefined, owner || undefined,
blockedBy.length > 0 ? blockedBy : undefined, blockedBy.length > 0 ? blockedBy : undefined,
related.length > 0 ? related : undefined, related.length > 0 ? related : undefined,
stripEncodedTaskReferenceMetadata(promptDraft.value.trim()) || undefined, trimmedPrompt || undefined,
startImmediately startImmediately,
descriptionTaskRefs,
promptTaskRefs
); );
descriptionDraft.clearDraft(); descriptionDraft.clearDraft();
descChipDraft.clearChipDraft(); descChipDraft.clearChipDraft();
@ -303,7 +313,7 @@ export const CreateTaskDialog = ({
maxRows={12} maxRows={12}
footerRight={ footerRight={
descriptionDraft.isSaved ? ( descriptionDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null ) : null
} }
/> />
@ -325,7 +335,7 @@ export const CreateTaskDialog = ({
maxRows={12} maxRows={12}
footerRight={ footerRight={
promptDraft.isSaved ? ( promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null ) : null
} }
/> />

View file

@ -912,7 +912,7 @@ export const CreateTeamDialog = ({
footerRight={ footerRight={
promptDraft.isSaved ? ( promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]"> <span className="text-[10px] text-[var(--color-text-muted)]">
Draft saved Saved
</span> </span>
) : null ) : null
} }
@ -980,7 +980,7 @@ export const CreateTeamDialog = ({
placeholder="Brief description of the team purpose" placeholder="Brief description of the team purpose"
/> />
{descriptionDraft.isSaved ? ( {descriptionDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null} ) : null}
</div> </div>

View file

@ -926,9 +926,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
placeholder="Instructions for team lead..." placeholder="Instructions for team lead..."
footerRight={ footerRight={
promptDraft.isSaved ? ( promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]"> <span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
Draft saved
</span>
) : null ) : null
} }
/> />
@ -1025,9 +1023,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
placeholder="Instructions for Claude to execute on schedule..." placeholder="Instructions for Claude to execute on schedule..."
footerRight={ footerRight={
promptDraft.isSaved ? ( promptDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]"> <span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
Draft saved
</span>
) : null ) : null
} }
/> />

View file

@ -13,13 +13,16 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
import { useStore } from '@renderer/store'; import { useStore } from '@renderer/store';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; 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 { MAX_TEXT_LENGTH } from '@shared/constants';
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity'; import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
import { Send } from 'lucide-react'; import { Send } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { ResolvedTeamMember } from '@shared/types'; import type { ResolvedTeamMember, TaskRef } from '@shared/types';
interface ReviewDialogProps { interface ReviewDialogProps {
open: boolean; open: boolean;
@ -27,7 +30,7 @@ interface ReviewDialogProps {
taskId: string | null; taskId: string | null;
members: ResolvedTeamMember[]; members: ResolvedTeamMember[];
onCancel: () => void; onCancel: () => void;
onSubmit: (comment?: string) => void; onSubmit: (comment?: string, taskRefs?: TaskRef[]) => void;
} }
export const ReviewDialog = ({ export const ReviewDialog = ({
@ -62,8 +65,9 @@ export const ReviewDialog = ({
const handleSubmit = (): void => { const handleSubmit = (): void => {
const comment = stripEncodedTaskReferenceMetadata(trimmed) || undefined; const comment = stripEncodedTaskReferenceMetadata(trimmed) || undefined;
const taskRefs = trimmed ? extractTaskRefsFromText(draft.value, taskSuggestions) : [];
draft.clearDraft(); draft.clearDraft();
onSubmit(comment); onSubmit(comment, taskRefs);
}; };
return ( return (
@ -114,7 +118,7 @@ export const ReviewDialog = ({
</span> </span>
) : null} ) : null}
{draft.isSaved ? ( {draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null} ) : null}
</div> </div>
} }

View file

@ -26,7 +26,10 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; 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 { MAX_TEXT_LENGTH } from '@shared/constants';
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react'; 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 { ActionMode } from '@renderer/components/team/messages/ActionModeSelector';
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
import type { MentionSuggestion } from '@renderer/types/mention'; 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 { interface QuotedMessage {
from: string; from: string;
@ -61,7 +69,8 @@ interface SendMessageDialogProps {
text: string, text: string,
summary?: string, summary?: string,
attachments?: AttachmentPayload[], attachments?: AttachmentPayload[],
actionMode?: ActionMode actionMode?: ActionMode,
taskRefs?: TaskRef[]
) => void; ) => void;
onClose: () => void; onClose: () => void;
} }
@ -237,12 +246,14 @@ export const SendMessageDialog = ({
const handleSubmit = (): void => { const handleSubmit = (): void => {
if (!canSend) return; if (!canSend) return;
const taskRefs = extractTaskRefsFromText(textDraft.value, taskSuggestions);
onSend( onSend(
member.trim(), member.trim(),
finalText, finalText,
trimmedText, trimmedText,
attachments.length > 0 ? attachments : undefined, attachments.length > 0 ? attachments : undefined,
actionMode actionMode,
taskRefs
); );
textDraft.clearDraft(); textDraft.clearDraft();
chipDraft.clearChipDraft(); chipDraft.clearChipDraft();
@ -512,9 +523,7 @@ export const SendMessageDialog = ({
</span> </span>
) : null} ) : null}
{textDraft.isSaved ? ( {textDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]"> <span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
Draft saved
</span>
) : null} ) : null}
</div> </div>
} }

View file

@ -12,7 +12,10 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { serializeChipsWithText } from '@renderer/types/inlineChip'; 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 { MAX_TEXT_LENGTH } from '@shared/constants';
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react'; import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
@ -132,6 +135,7 @@ export const TaskCommentInput = ({
const text = replyTo const text = replyTo
? buildReplyBlock(replyTo.author, replyTo.text, serialized || '(image)') ? buildReplyBlock(replyTo.author, replyTo.text, serialized || '(image)')
: serialized || '(image)'; : serialized || '(image)';
const taskRefs = extractTaskRefsFromText(draft.value, taskSuggestions);
const attachments: CommentAttachmentPayload[] | undefined = const attachments: CommentAttachmentPayload[] | undefined =
pendingAttachments.length > 0 pendingAttachments.length > 0
? pendingAttachments.map((a) => ({ ? pendingAttachments.map((a) => ({
@ -141,7 +145,11 @@ export const TaskCommentInput = ({
base64Data: a.base64Data, base64Data: a.base64Data,
})) }))
: undefined; : undefined;
await addTaskComment(teamName, taskId, text, attachments); await addTaskComment(teamName, taskId, {
text,
attachments,
taskRefs,
});
draft.clearDraft(); draft.clearDraft();
chipDraft.clearChipDraft(); chipDraft.clearChipDraft();
setPendingAttachments([]); setPendingAttachments([]);
@ -161,6 +169,7 @@ export const TaskCommentInput = ({
replyTo, replyTo,
onClearReply, onClearReply,
pendingAttachments, pendingAttachments,
taskSuggestions,
]); ]);
// Handle paste from MentionableTextarea area // Handle paste from MentionableTextarea area
@ -340,7 +349,7 @@ export const TaskCommentInput = ({
</span> </span>
) : null} ) : null}
{draft.isSaved ? ( {draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null} ) : null}
</div> </div>
} }

View file

@ -23,7 +23,9 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify'; import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
import { import {
extractTaskRefsFromText,
linkifyTaskIdsInMarkdown, linkifyTaskIdsInMarkdown,
parseTaskLinkHref,
stripEncodedTaskReferenceMetadata, stripEncodedTaskReferenceMetadata,
} from '@renderer/utils/taskReferenceUtils'; } from '@renderer/utils/taskReferenceUtils';
import { MAX_TEXT_LENGTH } from '@shared/constants'; import { MAX_TEXT_LENGTH } from '@shared/constants';
@ -160,14 +162,25 @@ export const TaskCommentsSection = ({
try { try {
const serialized = serializeChipsWithText(trimmed, chipDraft.chips); const serialized = serializeChipsWithText(trimmed, chipDraft.chips);
const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, serialized) : serialized; 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(); draft.clearDraft();
chipDraft.clearChipDraft(); chipDraft.clearChipDraft();
setReplyTo(null); setReplyTo(null);
} catch { } catch {
// Error is stored in addCommentError via store // 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 ( return (
<div ref={commentsRef}> <div ref={commentsRef}>
@ -281,6 +294,7 @@ export const TaskCommentsSection = ({
replyText: stripAgentBlocks(reply.replyText), replyText: stripAgentBlocks(reply.replyText),
}} }}
memberColor={colorMap.get(reply.agentName)} memberColor={colorMap.get(reply.agentName)}
replyTaskRefs={comment.taskRefs}
bodyMaxHeight="max-h-none" bodyMaxHeight="max-h-none"
/> />
) : ( ) : (
@ -294,8 +308,9 @@ export const TaskCommentsSection = ({
if (link) { if (link) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const id = link.getAttribute('href')?.replace('task://', ''); const href = link.getAttribute('href');
if (id) onTaskIdClick(id); const parsed = href ? parseTaskLinkHref(href) : null;
if (parsed?.taskId) onTaskIdClick(parsed.taskId);
} }
} }
: undefined : undefined
@ -303,7 +318,7 @@ export const TaskCommentsSection = ({
> >
<MarkdownViewer <MarkdownViewer
content={(() => { content={(() => {
let t = linkifyTaskIdsInMarkdown(displayText); let t = linkifyTaskIdsInMarkdown(displayText, comment.taskRefs);
if (colorMap.size > 0 || teamNamesForLinkify.length > 0) if (colorMap.size > 0 || teamNamesForLinkify.length > 0)
t = linkifyAllMentionsInMarkdown( t = linkifyAllMentionsInMarkdown(
t, t,
@ -426,7 +441,7 @@ export const TaskCommentsSection = ({
</span> </span>
) : null} ) : null}
{draft.isSaved ? ( {draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null} ) : null}
</div> </div>
} }

View file

@ -222,7 +222,7 @@ export const MemberDraftRow = ({
placeholder="How this agent should behave, interact with others..." placeholder="How this agent should behave, interact with others..."
footerRight={ footerRight={
workflowDraft.isSaved ? ( workflowDraft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null ) : null
} }
/> />

View file

@ -17,13 +17,21 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { getTeamColorSet } from '@renderer/constants/teamColors'; import { getTeamColorSet } from '@renderer/constants/teamColors';
import { nameColorSet } from '@renderer/utils/projectColor'; 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 { MAX_TEXT_LENGTH } from '@shared/constants';
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react'; import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention'; import type { MentionSuggestion } from '@renderer/types/mention';
import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; 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 { interface MessageComposerProps {
teamName: string; teamName: string;
@ -37,13 +45,15 @@ interface MessageComposerProps {
text: string, text: string,
summary?: string, summary?: string,
attachments?: AttachmentPayload[], attachments?: AttachmentPayload[],
actionMode?: ActionMode actionMode?: ActionMode,
taskRefs?: TaskRef[]
) => void; ) => void;
onCrossTeamSend?: ( onCrossTeamSend?: (
toTeam: string, toTeam: string,
text: string, text: string,
summary?: string, summary?: string,
actionMode?: ActionMode actionMode?: ActionMode,
taskRefs?: TaskRef[]
) => void; ) => void;
} }
@ -202,9 +212,10 @@ export const MessageComposer = ({
const handleSend = useCallback(() => { const handleSend = useCallback(() => {
if (!canSend) return; if (!canSend) return;
pendingSendRef.current = true; pendingSendRef.current = true;
const taskRefs = extractTaskRefsFromText(draft.text, taskSuggestions);
const serialized = serializeChipsWithText(trimmed, draft.chips); const serialized = serializeChipsWithText(trimmed, draft.chips);
if (isCrossTeam && selectedTeam && onCrossTeamSend) { if (isCrossTeam && selectedTeam && onCrossTeamSend) {
onCrossTeamSend(selectedTeam, serialized, trimmed, actionMode); onCrossTeamSend(selectedTeam, serialized, trimmed, actionMode, taskRefs);
} else { } else {
// Summary should stay compact (no expanded chip markdown) // Summary should stay compact (no expanded chip markdown)
onSend( onSend(
@ -212,7 +223,8 @@ export const MessageComposer = ({
serialized, serialized,
trimmed, trimmed,
draft.attachments.length > 0 ? draft.attachments : undefined, draft.attachments.length > 0 ? draft.attachments : undefined,
actionMode actionMode,
taskRefs
); );
} }
}, [ }, [
@ -226,6 +238,7 @@ export const MessageComposer = ({
selectedTeam, selectedTeam,
draft.attachments, draft.attachments,
draft.chips, draft.chips,
taskSuggestions,
]); ]);
// Clear draft only after send completes successfully (sending: true → false, no error) // 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 remaining = MAX_TEXT_LENGTH - trimmed.length;
const hasAttachmentPreviewContent =
draft.attachments.length > 0 || Boolean(draft.attachmentError ?? imageRestrictionError);
return ( return (
<div <div
className="relative mb-3 p-3" className="relative mb-3 pb-3"
role="group" role="group"
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
@ -336,208 +351,296 @@ export const MessageComposer = ({
> >
<DropZoneOverlay active={isDragOver} rejected={!isLeadRecipient} /> <DropZoneOverlay active={isDragOver} rejected={!isLeadRecipient} />
<div className="mb-1 flex items-center gap-2"> <div className="mb-1 space-y-2">
{isLeadRecipient ? ( <div className="flex items-center gap-2">
<> {isLeadRecipient ? (
<input <>
ref={fileInputRef} <input
type="file" ref={fileInputRef}
accept="image/png,image/jpeg,image/gif,image/webp" type="file"
multiple accept="image/png,image/jpeg,image/gif,image/webp"
className="hidden" multiple
onChange={handleFileInputChange} className="hidden"
/> onChange={handleFileInputChange}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
'inline-flex shrink-0 items-center gap-1 rounded p-1 transition-colors',
canAttach
? 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
: 'text-[var(--color-text-muted)] opacity-40'
)}
disabled={!canAttach}
onClick={() => fileInputRef.current?.click()}
>
<ImagePlus size={14} />
</button>
</TooltipTrigger>
<TooltipContent side="top">
{!isTeamAlive
? 'Team must be online to attach images'
: !draft.canAddMore
? 'Maximum attachments reached'
: 'Attach images (paste or drag & drop)'}
</TooltipContent>
</Tooltip>
<div className="min-w-0 flex-1">
<AttachmentPreviewList
attachments={draft.attachments}
onRemove={draft.removeAttachment}
error={draft.attachmentError ?? imageRestrictionError}
onDismissError={draft.clearAttachmentError}
disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
/> />
</div> <Tooltip>
</> <TooltipTrigger asChild>
) : (
<AttachmentPreviewList
attachments={draft.attachments}
onRemove={draft.removeAttachment}
error={draft.attachmentError ?? imageRestrictionError}
onDismissError={draft.clearAttachmentError}
disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
/>
)}
<div className="ml-auto flex shrink-0 items-center gap-2">
{!isTeamAlive && !isProvisioning && (
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
Team offline
</span>
)}
{/* Combined team + member selector */}
{crossTeamTargets.length > 0 ? (
<div
className={cn(
'inline-flex items-center rounded-full border text-xs transition-colors',
isCrossTeam ? 'border-[var(--cross-team-border)]' : 'border-[var(--color-border)]'
)}
>
<Popover open={teamSelectorOpen} onOpenChange={setTeamSelectorOpen}>
<PopoverTrigger asChild>
<button <button
type="button" type="button"
className={cn( className={cn(
'inline-flex items-center gap-1.5 rounded-l-full border-r border-r-[var(--color-border)] px-2.5 py-1 text-xs transition-colors', 'inline-flex shrink-0 items-center gap-1 rounded p-1 transition-colors',
isCrossTeam canAttach
? 'hover:bg-[var(--cross-team-bg)]/80 bg-[var(--cross-team-bg)] text-purple-400' ? 'text-[var(--color-text-secondary)] hover:text-[var(--color-text)]'
: 'hover:bg-[var(--color-surface-raised)]' : 'text-[var(--color-text-muted)] opacity-40'
)} )}
disabled={!canAttach}
onClick={() => fileInputRef.current?.click()}
> >
{isCrossTeam ? ( <ImagePlus size={14} />
<> </button>
<span </TooltipTrigger>
className="inline-block size-2 shrink-0 rounded-full" <TooltipContent side="top">
style={{ {!isTeamAlive
backgroundColor: selectedTarget ? 'Team must be online to attach images'
? selectedTarget.color : !draft.canAddMore
? getTeamColorSet(selectedTarget.color).border ? 'Maximum attachments reached'
: nameColorSet(selectedTarget.displayName).border : 'Attach images (paste or drag & drop)'}
: undefined, </TooltipContent>
}} </Tooltip>
/> </>
<span className="max-w-[100px] truncate">{targetDisplayName}</span> ) : null}
</>
) : ( <div className="ml-auto flex shrink-0 items-center gap-2">
<> {!isTeamAlive && !isProvisioning && (
<span className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
Team offline
</span>
)}
{/* Combined team + member selector */}
{crossTeamTargets.length > 0 ? (
<div
className={cn(
'inline-flex items-center rounded-full border text-xs transition-colors',
isCrossTeam ? 'border-[var(--cross-team-border)]' : 'border-[var(--color-border)]'
)}
>
<Popover open={teamSelectorOpen} onOpenChange={setTeamSelectorOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
'inline-flex items-center gap-1.5 rounded-l-full border-r border-r-[var(--color-border)] px-2.5 py-1 text-xs transition-colors',
isCrossTeam
? 'hover:bg-[var(--cross-team-bg)]/80 bg-[var(--cross-team-bg)] text-purple-400'
: 'hover:bg-[var(--color-surface-raised)]'
)}
>
{isCrossTeam ? (
<>
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{
backgroundColor: selectedTarget
? selectedTarget.color
? getTeamColorSet(selectedTarget.color).border
: nameColorSet(selectedTarget.displayName).border
: undefined,
}}
/>
<span className="max-w-[100px] truncate">{targetDisplayName}</span>
</>
) : (
<>
{currentTeamColor ? (
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: currentTeamColor }}
/>
) : null}
<span className="text-[var(--color-text-secondary)]">This team</span>
</>
)}
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-1.5">
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{/* Current team option */}
<button
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
!isCrossTeam && 'bg-[var(--color-surface-raised)]'
)}
onClick={() => {
setSelectedTeam(null);
setTeamSelectorOpen(false);
}}
>
{currentTeamColor ? ( {currentTeamColor ? (
<span <span
className="inline-block size-2 shrink-0 rounded-full" className="inline-block size-2 shrink-0 rounded-full"
style={{ backgroundColor: currentTeamColor }} style={{ backgroundColor: currentTeamColor }}
/> />
) : null} ) : null}
<span className="text-[var(--color-text-secondary)]">This team</span> <span className="truncate text-[var(--color-text)]">This team</span>
</> <span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
)} current
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" /> </span>
</button> {!isCrossTeam ? (
</PopoverTrigger> <Check size={12} className="ml-auto shrink-0 text-blue-400" />
<PopoverContent align="end" className="w-56 p-1.5"> ) : null}
<div className="max-h-48 space-y-0.5 overflow-y-auto"> </button>
{/* Current team option */}
{/* Separator */}
<div className="my-1 h-px bg-[var(--color-border)]" />
{/* Other teams */}
{crossTeamTargets.map((target) => {
const isSelected = selectedTeam === target.teamName;
return (
<button
key={target.teamName}
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
isSelected && 'bg-[var(--cross-team-bg)]'
)}
onClick={() => {
setSelectedTeam(target.teamName);
setRecipient('team-lead');
setTeamSelectorOpen(false);
}}
>
<span
className="inline-block size-2 shrink-0 rounded-full"
style={{
backgroundColor: target.color
? getTeamColorSet(target.color).border
: nameColorSet(target.displayName).border,
}}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-[var(--color-text)]">
{target.displayName}
</div>
{target.description ? (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">
{target.description}
</div>
) : null}
</div>
{isSelected ? (
<Check size={12} className="ml-auto shrink-0 text-purple-400" />
) : null}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
<Popover
open={isCrossTeam ? false : recipientOpen}
onOpenChange={isCrossTeam ? undefined : setRecipientOpen}
>
<PopoverTrigger asChild>
<button <button
type="button" type="button"
className={cn( className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]', 'inline-flex items-center gap-1.5 rounded-r-full px-2.5 py-1 text-xs transition-colors',
!isCrossTeam && 'bg-[var(--color-surface-raised)]' isCrossTeam
? 'cursor-default bg-[var(--cross-team-bg)] opacity-60'
: 'hover:bg-[var(--color-surface-raised)]'
)} )}
onClick={() => { disabled={isCrossTeam}
setSelectedTeam(null);
setTeamSelectorOpen(false);
}}
> >
{currentTeamColor ? ( {recipient ? (
<span <MemberBadge
className="inline-block size-2 shrink-0 rounded-full" name={recipient}
style={{ backgroundColor: currentTeamColor }} color={selectedResolvedColor}
size="sm"
hideAvatar={recipient === 'user'}
disableHoverCard
/> />
) : null} ) : (
<span className="truncate text-[var(--color-text)]">This team</span> <span className="text-[var(--color-text-muted)]">Select...</span>
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]"> )}
current <ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</span>
{!isCrossTeam ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</button> </button>
</PopoverTrigger>
{/* Separator */} <PopoverContent
<div className="my-1 h-px bg-[var(--color-border)]" /> align="end"
className="w-56 p-1.5"
{/* Other teams */} onOpenAutoFocus={(e) => {
{crossTeamTargets.map((target) => { e.preventDefault();
const isSelected = selectedTeam === target.teamName; setRecipientSearch('');
return ( setTimeout(() => recipientSearchRef.current?.focus(), 0);
<button }}
key={target.teamName} >
type="button" {members.length > 5 && (
className={cn( <div className="relative mb-1">
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]', <Search
isSelected && 'bg-[var(--cross-team-bg)]' size={12}
)} className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
onClick={() => { />
setSelectedTeam(target.teamName); <input
setRecipient('team-lead'); ref={recipientSearchRef}
setTeamSelectorOpen(false); type="text"
}} className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
> placeholder="Search..."
<span value={recipientSearch}
className="inline-block size-2 shrink-0 rounded-full" onChange={(e) => setRecipientSearch(e.target.value)}
style={{ />
backgroundColor: target.color </div>
? getTeamColorSet(target.color).border )}
: nameColorSet(target.displayName).border, <div className="max-h-48 space-y-0.5 overflow-y-auto">
}} {/* eslint-disable-next-line sonarjs/function-return-type -- IIFE rendering mixed elements/null */}
/> {(() => {
<div className="min-w-0 flex-1"> const query = recipientSearch.toLowerCase().trim();
<div className="truncate text-[var(--color-text)]"> const filtered = query
{target.displayName} ? members.filter((m) => m.name.toLowerCase().includes(query))
: members;
if (filtered.length === 0) {
return (
<div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
No results
</div> </div>
{target.description ? ( );
<div className="truncate text-[10px] text-[var(--color-text-muted)]"> }
{target.description} const sorted = [...filtered].sort((a, b) => {
</div> const aIsLead = a.role === 'lead' || a.name === 'team-lead' ? 1 : 0;
) : null} const bIsLead = b.role === 'lead' || b.name === 'team-lead' ? 1 : 0;
</div> return bIsLead - aIsLead;
{isSelected ? ( });
<Check size={12} className="ml-auto shrink-0 text-purple-400" /> return sorted.map((m) => {
) : null} const resolvedColor = colorMap.get(m.name);
</button> const role = formatAgentRole(m.role) ?? formatAgentRole(m.agentType);
); const isSelected = m.name === recipient;
})} return (
</div> <button
</PopoverContent> key={m.name}
</Popover> type="button"
className={cn(
<Popover 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
open={isCrossTeam ? false : recipientOpen} isSelected && 'bg-[var(--color-surface-raised)]'
onOpenChange={isCrossTeam ? undefined : setRecipientOpen} )}
> onClick={() => {
setRecipient(m.name);
setRecipientOpen(false);
setRecipientSearch('');
}}
>
<MemberBadge
name={m.name}
color={resolvedColor}
size="sm"
hideAvatar={m.name === 'user'}
disableHoverCard
/>
{role ? (
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{role}
</span>
) : null}
{isSelected ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</button>
);
});
})()}
</div>
</PopoverContent>
</Popover>
</div>
) : (
<Popover open={recipientOpen} onOpenChange={setRecipientOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<button <button
type="button" type="button"
className={cn( className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] px-2.5 py-1 text-xs transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]"
'inline-flex items-center gap-1.5 rounded-r-full px-2.5 py-1 text-xs transition-colors',
isCrossTeam
? 'cursor-default bg-[var(--cross-team-bg)] opacity-60'
: 'hover:bg-[var(--color-surface-raised)]'
)}
disabled={isCrossTeam}
> >
{recipient ? ( {recipient ? (
<MemberBadge <MemberBadge
@ -637,114 +740,20 @@ export const MessageComposer = ({
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> )}
) : ( </div>
<Popover open={recipientOpen} onOpenChange={setRecipientOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-border)] px-2.5 py-1 text-xs transition-colors hover:border-[var(--color-border-emphasis)] hover:bg-[var(--color-surface-raised)]"
>
{recipient ? (
<MemberBadge
name={recipient}
color={selectedResolvedColor}
size="sm"
hideAvatar={recipient === 'user'}
disableHoverCard
/>
) : (
<span className="text-[var(--color-text-muted)]">Select...</span>
)}
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-56 p-1.5"
onOpenAutoFocus={(e) => {
e.preventDefault();
setRecipientSearch('');
setTimeout(() => recipientSearchRef.current?.focus(), 0);
}}
>
{members.length > 5 && (
<div className="relative mb-1">
<Search
size={12}
className="absolute left-2 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)]"
/>
<input
ref={recipientSearchRef}
type="text"
className="w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] py-1 pl-6 pr-2 text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:border-[var(--color-border-emphasis)] focus:outline-none"
placeholder="Search..."
value={recipientSearch}
onChange={(e) => setRecipientSearch(e.target.value)}
/>
</div>
)}
<div className="max-h-48 space-y-0.5 overflow-y-auto">
{/* 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 (
<div className="px-2 py-3 text-center text-xs text-[var(--color-text-muted)]">
No results
</div>
);
}
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 (
<button
key={m.name}
type="button"
className={cn(
'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors hover:bg-[var(--color-surface-raised)]',
isSelected && 'bg-[var(--color-surface-raised)]'
)}
onClick={() => {
setRecipient(m.name);
setRecipientOpen(false);
setRecipientSearch('');
}}
>
<MemberBadge
name={m.name}
color={resolvedColor}
size="sm"
hideAvatar={m.name === 'user'}
disableHoverCard
/>
{role ? (
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
{role}
</span>
) : null}
{isSelected ? (
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
) : null}
</button>
);
});
})()}
</div>
</PopoverContent>
</Popover>
)}
</div> </div>
{hasAttachmentPreviewContent ? (
<AttachmentPreviewList
attachments={draft.attachments}
onRemove={draft.removeAttachment}
error={draft.attachmentError ?? imageRestrictionError}
onDismissError={draft.clearAttachmentError}
disabled={attachmentsBlocked}
disabledHint="Image attachments are only supported when sending to the team lead while the team is online. Remove attachments or switch recipient."
/>
) : null}
</div> </div>
<MentionableTextarea <MentionableTextarea
@ -836,7 +845,7 @@ export const MessageComposer = ({
</span> </span>
) : null} ) : null}
{draft.isSaved ? ( {draft.isSaved ? (
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span> <span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
) : null} ) : null}
</div> </div>
} }

View file

@ -32,7 +32,7 @@ import { MessagesFilterPopover } from './MessagesFilterPopover';
import type { MessagesFilterState } from './MessagesFilterPopover'; import type { MessagesFilterState } from './MessagesFilterPopover';
import type { ActionMode } from './ActionModeSelector'; import type { ActionMode } from './ActionModeSelector';
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types'; import type { InboxMessage, ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types';
interface TimeWindow { interface TimeWindow {
start: number; start: number;
@ -188,7 +188,8 @@ export const MessagesPanel = ({
attachments?: Parameters<typeof sendTeamMessage>[1] extends { attachments?: infer A } attachments?: Parameters<typeof sendTeamMessage>[1] extends { attachments?: infer A }
? A ? A
: never, : never,
actionMode?: ActionMode actionMode?: ActionMode,
taskRefs?: TaskRef[]
) => { ) => {
const sentAtMs = Date.now(); const sentAtMs = Date.now();
onPendingReplyChange((prev) => ({ ...prev, [member]: sentAtMs })); onPendingReplyChange((prev) => ({ ...prev, [member]: sentAtMs }));
@ -198,6 +199,7 @@ export const MessagesPanel = ({
summary, summary,
attachments, attachments,
actionMode, actionMode,
taskRefs,
}).catch(() => { }).catch(() => {
onPendingReplyChange((prev) => { onPendingReplyChange((prev) => {
if (prev[member] !== sentAtMs) return prev; if (prev[member] !== sentAtMs) return prev;
@ -211,12 +213,19 @@ export const MessagesPanel = ({
); );
const handleCrossTeamSend = useCallback( const handleCrossTeamSend = useCallback(
(toTeam: string, text: string, summary?: string, actionMode?: ActionMode) => { (
toTeam: string,
text: string,
summary?: string,
actionMode?: ActionMode,
taskRefs?: TaskRef[]
) => {
void sendCrossTeamMessage({ void sendCrossTeamMessage({
fromTeam: teamName, fromTeam: teamName,
fromMember: 'user', fromMember: 'user',
toTeam, toTeam,
text, text,
taskRefs,
actionMode, actionMode,
summary, summary,
}); });

View file

@ -19,13 +19,18 @@ import {
reconcileChips, reconcileChips,
removeChipTokenFromText, removeChipTokenFromText,
} from '@renderer/utils/chipUtils'; } from '@renderer/utils/chipUtils';
import { Link2 } from 'lucide-react'; import {
findUrlBoundary,
findUrlMatches,
removeUrlMatchFromText,
} from '@renderer/utils/urlMatchUtils';
import { AutoResizeTextarea } from './auto-resize-textarea'; import { AutoResizeTextarea } from './auto-resize-textarea';
import { ChipInteractionLayer } from './ChipInteractionLayer'; import { ChipInteractionLayer } from './ChipInteractionLayer';
import { CodeChipBadge } from './CodeChipBadge'; import { CodeChipBadge } from './CodeChipBadge';
import { MentionSuggestionList } from './MentionSuggestionList'; import { MentionSuggestionList } from './MentionSuggestionList';
import { TaskReferenceInteractionLayer } from './TaskReferenceInteractionLayer'; import { TaskReferenceInteractionLayer } from './TaskReferenceInteractionLayer';
import { UrlInteractionLayer } from './UrlInteractionLayer';
import type { AutoResizeTextareaProps } from './auto-resize-textarea'; import type { AutoResizeTextareaProps } from './auto-resize-textarea';
import type { InlineChip } from '@renderer/types/inlineChip'; import type { InlineChip } from '@renderer/types/inlineChip';
@ -66,40 +71,6 @@ interface ChipSegment {
type Segment = TextSegment | MentionSegment | TaskSegment | UrlSegment | 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) // 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 // Default fallback color for mentions without a team color
const DEFAULT_MENTION_BG = 'rgba(59, 130, 246, 0.15)'; const DEFAULT_MENTION_BG = 'rgba(59, 130, 246, 0.15)';
const DEFAULT_MENTION_TEXT = '#60a5fa'; const DEFAULT_MENTION_TEXT = '#60a5fa';
const URL_BADGE_BG = 'rgba(30, 58, 138, 0.32)'; const URL_BADGE_BG = 'rgba(37, 99, 235, 0.12)';
const URL_BADGE_BORDER = 'rgba(96, 165, 250, 0.28)'; const URL_BADGE_BORDER = 'rgba(96, 165, 250, 0.22)';
const URL_BADGE_TEXT = '#f8fafc'; const URL_BADGE_TEXT = '#bfdbfe';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Component // Component
@ -325,7 +296,7 @@ interface MentionableTextareaProps extends Omit<
suggestions: MentionSuggestion[]; suggestions: MentionSuggestion[];
hintText?: string; hintText?: string;
showHint?: boolean; 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; footerRight?: React.ReactNode;
/** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */ /** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */
cornerAction?: React.ReactNode; cornerAction?: React.ReactNode;
@ -658,6 +629,11 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
[taskSuggestions, value] [taskSuggestions, value]
); );
const findUrlTokenBoundary = React.useCallback(
(cursorPos: number) => findUrlBoundary(value, cursorPos),
[value]
);
const handleChipKeyDown = React.useCallback( const handleChipKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => { (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const textarea = internalRef.current; const textarea = internalRef.current;
@ -670,6 +646,16 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
const cursorPos = selectionStart; const cursorPos = selectionStart;
if (e.key === 'Backspace') { if (e.key === 'Backspace') {
const urlBoundary = findUrlTokenBoundary(cursorPos);
if (urlBoundary && cursorPos === urlBoundary.end) {
e.preventDefault();
const newText = removeUrlMatchFromText(value, urlBoundary);
onValueChange(newText);
requestAnimationFrame(() => {
textarea.setSelectionRange(urlBoundary.start, urlBoundary.start);
});
return;
}
const taskBoundary = findEncodedTaskBoundary(cursorPos); const taskBoundary = findEncodedTaskBoundary(cursorPos);
if (taskBoundary && cursorPos === taskBoundary.end) { if (taskBoundary && cursorPos === taskBoundary.end) {
e.preventDefault(); e.preventDefault();
@ -694,6 +680,16 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
}); });
} }
} else if (e.key === 'Delete') { } else if (e.key === 'Delete') {
const urlBoundary = findUrlTokenBoundary(cursorPos);
if (urlBoundary && cursorPos === urlBoundary.start) {
e.preventDefault();
const newText = removeUrlMatchFromText(value, urlBoundary);
onValueChange(newText);
requestAnimationFrame(() => {
textarea.setSelectionRange(urlBoundary.start, urlBoundary.start);
});
return;
}
const taskBoundary = findEncodedTaskBoundary(cursorPos); const taskBoundary = findEncodedTaskBoundary(cursorPos);
if (taskBoundary && cursorPos === taskBoundary.start) { if (taskBoundary && cursorPos === taskBoundary.start) {
e.preventDefault(); e.preventDefault();
@ -717,6 +713,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
}); });
} }
} else if (e.key === 'ArrowLeft' && !e.shiftKey) { } else if (e.key === 'ArrowLeft' && !e.shiftKey) {
const urlBoundary = findUrlTokenBoundary(cursorPos);
if (urlBoundary && cursorPos === urlBoundary.end) {
e.preventDefault();
textarea.setSelectionRange(urlBoundary.start, urlBoundary.start);
return;
}
const taskBoundary = findEncodedTaskBoundary(cursorPos); const taskBoundary = findEncodedTaskBoundary(cursorPos);
if (taskBoundary && cursorPos === taskBoundary.end) { if (taskBoundary && cursorPos === taskBoundary.end) {
e.preventDefault(); e.preventDefault();
@ -731,6 +733,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
textarea.setSelectionRange(boundary.start, boundary.start); textarea.setSelectionRange(boundary.start, boundary.start);
} }
} else if (e.key === 'ArrowRight' && !e.shiftKey) { } else if (e.key === 'ArrowRight' && !e.shiftKey) {
const urlBoundary = findUrlTokenBoundary(cursorPos);
if (urlBoundary && cursorPos === urlBoundary.start) {
e.preventDefault();
textarea.setSelectionRange(urlBoundary.end, urlBoundary.end);
return;
}
const taskBoundary = findEncodedTaskBoundary(cursorPos); const taskBoundary = findEncodedTaskBoundary(cursorPos);
if (taskBoundary && cursorPos === taskBoundary.start) { if (taskBoundary && cursorPos === taskBoundary.start) {
e.preventDefault(); e.preventDefault();
@ -745,6 +753,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
textarea.setSelectionRange(boundary.end, boundary.end); textarea.setSelectionRange(boundary.end, boundary.end);
} }
} else if (e.key === 'ArrowLeft' && e.shiftKey) { } else if (e.key === 'ArrowLeft' && e.shiftKey) {
const urlBoundary = findUrlTokenBoundary(cursorPos);
if (urlBoundary && cursorPos === urlBoundary.end) {
e.preventDefault();
textarea.setSelectionRange(urlBoundary.start, selectionEnd);
return;
}
const taskBoundary = findEncodedTaskBoundary(cursorPos); const taskBoundary = findEncodedTaskBoundary(cursorPos);
if (taskBoundary && cursorPos === taskBoundary.end) { if (taskBoundary && cursorPos === taskBoundary.end) {
e.preventDefault(); e.preventDefault();
@ -759,6 +773,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
textarea.setSelectionRange(boundary.start, selectionEnd); textarea.setSelectionRange(boundary.start, selectionEnd);
} }
} else if (e.key === 'ArrowRight' && e.shiftKey) { } else if (e.key === 'ArrowRight' && e.shiftKey) {
const urlBoundary = findUrlTokenBoundary(cursorPos);
if (urlBoundary && cursorPos === urlBoundary.start) {
e.preventDefault();
textarea.setSelectionRange(selectionStart, urlBoundary.end);
return;
}
const taskBoundary = findEncodedTaskBoundary(cursorPos); const taskBoundary = findEncodedTaskBoundary(cursorPos);
if (taskBoundary && cursorPos === taskBoundary.start) { if (taskBoundary && cursorPos === taskBoundary.start) {
e.preventDefault(); e.preventDefault();
@ -773,7 +793,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
} }
} }
}, },
[chips, findEncodedTaskBoundary, onChipRemove, value, onValueChange] [chips, findEncodedTaskBoundary, findUrlTokenBoundary, onChipRemove, value, onValueChange]
); );
// Composed key handler: suggestion logic first (when open) → Mod+Enter submit → chip logic // Composed key handler: suggestion logic first (when open) → Mod+Enter submit → chip logic
@ -868,9 +888,20 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
requestAnimationFrame(() => { requestAnimationFrame(() => {
textarea.setSelectionRange(snapTo, snapTo); 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) --- // --- Chip remove handler (from X button in interaction layer) ---
@ -981,14 +1012,13 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
return ( return (
<span <span
key={idx} key={idx}
className="inline-flex max-w-full items-center gap-1 rounded-full px-2 py-0.5 align-baseline" className="inline-flex max-w-full items-center rounded-full px-1.5 py-0 align-baseline text-[0.92em] font-medium"
style={{ style={{
backgroundColor: URL_BADGE_BG, backgroundColor: URL_BADGE_BG,
color: URL_BADGE_TEXT, color: URL_BADGE_TEXT,
boxShadow: `inset 0 0 0 1px ${URL_BADGE_BORDER}`, boxShadow: `inset 0 0 0 1px ${URL_BADGE_BORDER}`,
}} }}
> >
<Link2 size={11} className="shrink-0 opacity-80" />
<span className="truncate">{seg.value}</span> <span className="truncate">{seg.value}</span>
</span> </span>
); );
@ -1028,6 +1058,21 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
/> />
) : null} ) : null}
{value.includes('http://') || value.includes('https://') ? (
<UrlInteractionLayer
value={value}
textareaRef={internalRef}
scrollTop={scrollTop}
onRemove={(match) => {
const newText = removeUrlMatchFromText(value, match);
onValueChange(newText);
requestAnimationFrame(() => {
internalRef.current?.setSelectionRange(match.start, match.start);
});
}}
/>
) : null}
<AutoResizeTextarea <AutoResizeTextarea
ref={setRefs} ref={setRefs}
value={value} value={value}

View file

@ -0,0 +1,102 @@
import * as React from 'react';
import { api } from '@renderer/api';
import { calculateInlineMatchPositions } from '@renderer/utils/chipUtils';
import { findUrlMatches } from '@renderer/utils/urlMatchUtils';
import { X } from 'lucide-react';
import type { InlineMatchPosition } from '@renderer/utils/chipUtils';
import type { TextMatch } from '@renderer/utils/urlMatchUtils';
interface UrlInteractionLayerProps {
value: string;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
scrollTop: number;
onRemove: (match: TextMatch) => void;
}
type PositionedUrlReference = InlineMatchPosition<TextMatch>;
export const UrlInteractionLayer = ({
value,
textareaRef,
scrollTop,
onRemove,
}: UrlInteractionLayerProps): React.JSX.Element | null => {
const [positions, setPositions] = React.useState<PositionedUrlReference[]>([]);
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 (
<div className="pointer-events-none absolute inset-0 z-20 overflow-hidden">
<div style={{ transform: `translateY(-${scrollTop}px)` }}>
{positions.map((position, index) => (
<div
key={`${position.start}:${position.end}:${index}`}
className="group pointer-events-auto absolute"
style={{
top: position.top,
left: position.left,
width: position.width,
height: position.height,
}}
>
<button
type="button"
className="absolute inset-0 cursor-pointer rounded-full bg-transparent p-0"
onMouseDown={(e) => {
if (e.metaKey || e.ctrlKey) return;
e.preventDefault();
const textarea = textareaRef.current;
if (!textarea) return;
textarea.focus();
const clickOffsetX = e.clientX - e.currentTarget.getBoundingClientRect().left;
const snapTo = clickOffsetX < position.width / 2 ? position.start : position.end;
textarea.setSelectionRange(snapTo, snapTo);
}}
onClick={(e) => {
if (!e.metaKey && !e.ctrlKey) return;
e.preventDefault();
e.stopPropagation();
void api.openExternal(position.item.value);
}}
aria-label={`Open URL ${position.item.value}`}
/>
<button
type="button"
className="pointer-events-none absolute -right-1 -top-1.5 z-30 flex size-3.5 items-center justify-center rounded-full border border-[var(--color-border-emphasis)] bg-[var(--color-surface-raised)] opacity-0 shadow-sm transition-opacity group-hover:pointer-events-auto group-hover:opacity-100"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemove(position.item);
}}
aria-label={`Remove URL ${position.item.value}`}
>
<X size={8} className="text-[var(--color-text-muted)]" />
</button>
</div>
))}
</div>
</div>
);
};

View file

@ -67,7 +67,7 @@ import type { AppState } from '../types';
import type { AppConfig } from '@renderer/types/data'; import type { AppConfig } from '@renderer/types/data';
import type { import type {
AddMemberRequest, AddMemberRequest,
CommentAttachmentPayload, AddTaskCommentRequest,
CreateTaskRequest, CreateTaskRequest,
CrossTeamSendRequest, CrossTeamSendRequest,
EffortLevel, EffortLevel,
@ -346,8 +346,7 @@ export interface TeamSlice {
addTaskComment: ( addTaskComment: (
teamName: string, teamName: string,
taskId: string, taskId: string,
text: string, request: AddTaskCommentRequest
attachments?: CommentAttachmentPayload[]
) => Promise<TaskComment>; ) => Promise<TaskComment>;
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>; addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
removeMember: (teamName: string, memberName: string) => Promise<void>; removeMember: (teamName: string, memberName: string) => Promise<void>;
@ -1113,11 +1112,11 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
); );
}, },
addTaskComment: async (teamName, taskId, text, attachments) => { addTaskComment: async (teamName, taskId, request) => {
set({ addingComment: true, addCommentError: null }); set({ addingComment: true, addCommentError: null });
try { try {
const comment = await unwrapIpc('team:addTaskComment', () => const comment = await unwrapIpc('team:addTaskComment', () =>
api.teams.addTaskComment(teamName, taskId, text, attachments) api.teams.addTaskComment(teamName, taskId, request)
); );
set({ addingComment: false }); set({ addingComment: false });
await get().refreshTeamData(teamName); await get().refreshTeamData(teamName);

View file

@ -1,6 +1,7 @@
import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions'; import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions';
import type { MentionSuggestion } from '@renderer/types/mention'; 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_REF_REGEX = /#([A-Za-z0-9-]+)\b/g;
const TASK_META_START = '\u2063'; const TASK_META_START = '\u2063';
@ -67,6 +68,12 @@ interface EncodedTaskMetadataMatch {
end: number; end: number;
} }
interface ParsedTaskLinkHref {
taskId: string;
teamName?: string;
displayId?: string;
}
function encodeZeroWidthPayload(value: string): string { function encodeZeroWidthPayload(value: string): string {
const bytes = new TextEncoder().encode(value); const bytes = new TextEncoder().encode(value);
let encoded = ''; 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( export function createEncodedTaskReference(
displayId: string, displayId: string,
taskId: string, taskId: string,
@ -162,11 +183,71 @@ export function createEncodedTaskReference(
return `#${displayId}${TASK_META_START}${encodedPayload}${TASK_META_END}`; return `#${displayId}${TASK_META_START}${encodedPayload}${TASK_META_END}`;
} }
export function linkifyTaskIdsInMarkdown(text: string): string { export function buildTaskLinkHref(taskRef: TaskRef): string {
return text.replace(TASK_REF_REGEX, (raw, ref: string, offset: number) => { return `task://${encodeURIComponent(taskRef.taskId)}?team=${encodeURIComponent(taskRef.teamName)}&display=${encodeURIComponent(taskRef.displayId)}`;
const preceding = offset > 0 ? text[offset - 1] : undefined; }
return isAllowedTaskRefBoundary(preceding) ? `[${raw}](task://${ref})` : raw;
}); 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 { export function stripEncodedTaskReferenceMetadata(text: string): string {
@ -193,12 +274,10 @@ export function findTaskReferenceMatches(
text: string, text: string,
taskSuggestions: MentionSuggestion[] taskSuggestions: MentionSuggestion[]
): TaskReferenceMatch[] { ): TaskReferenceMatch[] {
if (!text || taskSuggestions.length === 0) return []; if (!text) return [];
const suggestionsByRef = buildSuggestionsByRef(taskSuggestions); const suggestionsByRef = buildSuggestionsByRef(taskSuggestions);
if (suggestionsByRef.size === 0) return [];
const matches: TaskReferenceMatch[] = []; const matches: TaskReferenceMatch[] = [];
for (const match of text.matchAll(TASK_REF_REGEX)) { for (const match of text.matchAll(TASK_REF_REGEX)) {
const raw = match[0]; const raw = match[0];
@ -227,3 +306,26 @@ export function findTaskReferenceMatches(
return matches; 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);
}

View file

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

View file

@ -38,6 +38,7 @@ import type {
} from './review'; } from './review';
import type { import type {
AddMemberRequest, AddMemberRequest,
AddTaskCommentRequest,
AttachmentFileData, AttachmentFileData,
CommentAttachmentPayload, CommentAttachmentPayload,
CreateTaskRequest, CreateTaskRequest,
@ -472,8 +473,7 @@ export interface TeamsAPI {
addTaskComment: ( addTaskComment: (
teamName: string, teamName: string,
taskId: string, taskId: string,
text: string, request: AddTaskCommentRequest
attachments?: CommentAttachmentPayload[]
) => Promise<TaskComment>; ) => Promise<TaskComment>;
setTaskClarification: ( setTaskClarification: (
teamName: string, teamName: string,

View file

@ -117,12 +117,19 @@ export type TaskHistoryEvent =
export type TaskCommentType = 'regular' | 'review_request' | 'review_approved'; export type TaskCommentType = 'regular' | 'review_request' | 'review_approved';
export interface TaskRef {
taskId: string;
displayId: string;
teamName: string;
}
export interface TaskComment { export interface TaskComment {
id: string; id: string;
author: string; author: string;
text: string; text: string;
createdAt: string; createdAt: string;
type: TaskCommentType; type: TaskCommentType;
taskRefs?: TaskRef[];
/** Attachments on this comment. Metadata only — files stored on disk. */ /** Attachments on this comment. Metadata only — files stored on disk. */
attachments?: TaskAttachmentMeta[]; attachments?: TaskAttachmentMeta[];
} }
@ -135,7 +142,10 @@ export interface TeamTask {
displayId?: string; displayId?: string;
subject: string; subject: string;
description?: string; description?: string;
descriptionTaskRefs?: TaskRef[];
activeForm?: string; activeForm?: string;
prompt?: string;
promptTaskRefs?: TaskRef[];
owner?: string; owner?: string;
createdBy?: string; createdBy?: string;
status: TeamTaskStatus; status: TeamTaskStatus;
@ -244,6 +254,7 @@ export interface InboxMessage {
text: string; text: string;
timestamp: string; timestamp: string;
read: boolean; read: boolean;
taskRefs?: TaskRef[];
summary?: string; summary?: string;
color?: string; color?: string;
messageId?: string; messageId?: string;
@ -273,6 +284,7 @@ export type AgentActionMode = 'do' | 'ask' | 'delegate';
export interface SendMessageRequest { export interface SendMessageRequest {
member: string; member: string;
text: string; text: string;
taskRefs?: TaskRef[];
actionMode?: AgentActionMode; actionMode?: AgentActionMode;
summary?: string; summary?: string;
from?: string; from?: string;
@ -298,6 +310,12 @@ export interface SendMessageResult {
deduplicated?: boolean; deduplicated?: boolean;
} }
export interface AddTaskCommentRequest {
text: string;
attachments?: CommentAttachmentPayload[];
taskRefs?: TaskRef[];
}
export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown'; export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown';
/** /**
@ -329,7 +347,7 @@ export interface KanbanState {
export type UpdateKanbanPatch = export type UpdateKanbanPatch =
| { op: 'set_column'; column: Extract<KanbanColumnId, 'review' | 'approved'> } | { op: 'set_column'; column: Extract<KanbanColumnId, 'review' | 'approved'> }
| { op: 'remove' } | { op: 'remove' }
| { op: 'request_changes'; comment?: string }; | { op: 'request_changes'; comment?: string; taskRefs?: TaskRef[] };
export interface ResolvedTeamMember { export interface ResolvedTeamMember {
name: string; name: string;
@ -398,10 +416,12 @@ export interface TeamLaunchResponse {
export interface CreateTaskRequest { export interface CreateTaskRequest {
subject: string; subject: string;
description?: string; description?: string;
descriptionTaskRefs?: TaskRef[];
owner?: string; owner?: string;
blockedBy?: string[]; blockedBy?: string[];
related?: string[]; related?: string[];
prompt?: string; prompt?: string;
promptTaskRefs?: TaskRef[];
startImmediately?: boolean; startImmediately?: boolean;
} }
@ -656,6 +676,7 @@ export interface CrossTeamMessage {
conversationId?: string; conversationId?: string;
replyToConversationId?: string; replyToConversationId?: string;
text: string; text: string;
taskRefs?: TaskRef[];
summary?: string; summary?: string;
chainDepth: number; chainDepth: number;
timestamp: string; timestamp: string;
@ -670,6 +691,7 @@ export interface CrossTeamSendRequest {
conversationId?: string; conversationId?: string;
replyToConversationId?: string; replyToConversationId?: string;
text: string; text: string;
taskRefs?: TaskRef[];
actionMode?: AgentActionMode; actionMode?: AgentActionMode;
summary?: string; summary?: string;
chainDepth?: number; chainDepth?: number;