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:
parent
f48b75cbc7
commit
6bcb81d337
43 changed files with 1212 additions and 518 deletions
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -86,6 +86,14 @@ Claude Code's "Orchestrate Teams" feature: multiple sessions coordinate as a tea
|
|||
- **Display summary** counts distinct teammates (by name) separately from regular subagents
|
||||
- **Team tools**: TeamCreate, TaskCreate, TaskUpdate, TaskList, TaskGet, SendMessage, TeamDelete — have readable summaries in `toolSummaryHelpers.ts`
|
||||
|
||||
### Structured Task References
|
||||
- **TaskRef**: `{ taskId, displayId, teamName }` — shared typed reference used to persist task mentions across UI and storage
|
||||
- **Persisted optional fields**: `InboxMessage.taskRefs`, `TaskComment.taskRefs`, `TeamTask.descriptionTaskRefs`, `TeamTask.promptTaskRefs`
|
||||
- **Request surfaces**: `SendMessageRequest.taskRefs`, `AddTaskCommentRequest.taskRefs`, `CreateTaskRequest.descriptionTaskRefs`, `CreateTaskRequest.promptTaskRefs`, `UpdateKanbanPatch` `request_changes.taskRefs`
|
||||
- **Renderer flow**: task-aware inputs use `useTaskSuggestions()` with `taskReferenceUtils.ts` to extract refs from text; encoded zero-width metadata preserves exact task identity while keeping visible text readable
|
||||
- **Main/IPC flow**: `src/main/ipc/teams.ts` and `src/main/ipc/crossTeam.ts` validate structured refs before `TeamDataService`, inbox stores, task stores, and readers persist/rehydrate them
|
||||
- **Rendering/navigation**: `linkifyTaskIdsInMarkdown()` and `parseTaskLinkHref()` turn persisted refs into stable `task://` links across messages, comments, task descriptions, and activity items
|
||||
|
||||
### Visible Context Tracking
|
||||
Tracks what consumes tokens in Claude's context window across 6 categories (discriminated union on `category` field):
|
||||
|
||||
|
|
@ -139,7 +147,7 @@ Check for changes in message parsing or chunk building logic.
|
|||
| Services/Components | PascalCase | `ProjectScanner.ts` |
|
||||
| Utilities | camelCase | `pathDecoder.ts` |
|
||||
| Constants | UPPER_SNAKE_CASE | `PARALLEL_WINDOW_MS` |
|
||||
| Type Guards | isXxx | `isRealUserMessage()` |
|
||||
| Type Guards | isXxx | `isParsedRealUserMessage()` |
|
||||
| Builders | buildXxx | `buildChunks()` |
|
||||
| Getters | getXxx | `getResponses()` |
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,23 @@ function normalizeAttachments(attachments) {
|
|||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeTaskRefs(taskRefs) {
|
||||
if (!Array.isArray(taskRefs) || taskRefs.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = taskRefs
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
taskId: String(item.taskId || '').trim(),
|
||||
displayId: String(item.displayId || '').trim(),
|
||||
teamName: String(item.teamName || '').trim(),
|
||||
}))
|
||||
.filter((item) => item.taskId && item.displayId && item.teamName);
|
||||
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function buildMessage(flags, defaults) {
|
||||
const timestamp =
|
||||
typeof flags.timestamp === 'string' && flags.timestamp.trim() ? flags.timestamp.trim() : nowIso();
|
||||
|
|
@ -59,6 +76,7 @@ function buildMessage(flags, defaults) {
|
|||
? flags.messageId.trim()
|
||||
: crypto.randomUUID();
|
||||
const attachments = normalizeAttachments(flags.attachments);
|
||||
const taskRefs = normalizeTaskRefs(flags.taskRefs);
|
||||
|
||||
return {
|
||||
from:
|
||||
|
|
@ -69,6 +87,7 @@ function buildMessage(flags, defaults) {
|
|||
text: String(flags.text || ''),
|
||||
timestamp,
|
||||
read: defaults.read,
|
||||
...(taskRefs ? { taskRefs } : {}),
|
||||
...(typeof flags.summary === 'string' && flags.summary.trim()
|
||||
? { summary: flags.summary.trim() }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -185,6 +185,7 @@ function requestChanges(context, taskId, flags = {}) {
|
|||
text: comment,
|
||||
from,
|
||||
type: 'review_request',
|
||||
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
|
||||
notifyOwner: false,
|
||||
});
|
||||
messages.sendMessage(context, {
|
||||
|
|
@ -193,6 +194,7 @@ function requestChanges(context, taskId, flags = {}) {
|
|||
text:
|
||||
`Task #${task.displayId || task.id} needs fixes.\n\n${comment}\n\n` +
|
||||
'The task has been moved back to pending. When you are ready to resume, review the task context, start it explicitly, implement the fixes, mark it completed, and request review again.',
|
||||
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
|
||||
summary: `Fix request for #${task.displayId || task.id}`,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
|
|
|
|||
|
|
@ -166,6 +166,23 @@ function parseRelationshipList(paths, value) {
|
|||
return rawValues.map((entry) => resolveTaskRef(paths, entry));
|
||||
}
|
||||
|
||||
function normalizeTaskRefs(taskRefs) {
|
||||
if (!Array.isArray(taskRefs) || taskRefs.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = taskRefs
|
||||
.filter((item) => item && typeof item === 'object')
|
||||
.map((item) => ({
|
||||
taskId: String(item.taskId || '').trim(),
|
||||
displayId: String(item.displayId || '').trim(),
|
||||
teamName: String(item.teamName || '').trim(),
|
||||
}))
|
||||
.filter((item) => item.taskId && item.displayId && item.teamName);
|
||||
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function computeInitialStatus(paths, input, owner, blockedByIds) {
|
||||
const explicit = normalizeStatus(input.status);
|
||||
if (explicit) return explicit;
|
||||
|
|
@ -270,6 +287,7 @@ function createTask(paths, input = {}) {
|
|||
typeof input.description === 'string' && input.description.length > 0
|
||||
? input.description
|
||||
: String(input.subject || '').trim(),
|
||||
descriptionTaskRefs: normalizeTaskRefs(input.descriptionTaskRefs),
|
||||
activeForm:
|
||||
typeof input.activeForm === 'string'
|
||||
? input.activeForm
|
||||
|
|
@ -301,6 +319,9 @@ function createTask(paths, input = {}) {
|
|||
? input.projectPath.trim()
|
||||
: undefined,
|
||||
comments: Array.isArray(input.comments) ? input.comments : undefined,
|
||||
prompt:
|
||||
typeof input.prompt === 'string' && input.prompt.trim() ? input.prompt.trim() : undefined,
|
||||
promptTaskRefs: normalizeTaskRefs(input.promptTaskRefs),
|
||||
needsClarification:
|
||||
input.needsClarification === 'lead' || input.needsClarification === 'user'
|
||||
? input.needsClarification
|
||||
|
|
@ -434,6 +455,7 @@ function addTaskComment(paths, taskRef, text, options = {}) {
|
|||
? options.createdAt.trim()
|
||||
: nowIso(),
|
||||
type: options.type || 'regular',
|
||||
...(normalizeTaskRefs(options.taskRefs) ? { taskRefs: normalizeTaskRefs(options.taskRefs) } : {}),
|
||||
...(Array.isArray(options.attachments) && options.attachments.length > 0
|
||||
? { attachments: options.attachments }
|
||||
: {}),
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ function maybeNotifyAssignedOwner(context, task, options = {}) {
|
|||
member: owner,
|
||||
from: sender,
|
||||
text: buildAssignmentMessage(context, task, options),
|
||||
taskRefs: Array.isArray(options.taskRefs) && options.taskRefs.length > 0 ? options.taskRefs : undefined,
|
||||
summary,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
|
|
@ -123,6 +124,7 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
|||
member: owner,
|
||||
from: normalizeActorName(comment.author) || leadName,
|
||||
text: buildCommentNotificationMessage(context, task, comment),
|
||||
taskRefs: Array.isArray(comment.taskRefs) ? comment.taskRefs : undefined,
|
||||
summary: `Comment on #${task.displayId || task.id}`,
|
||||
source: 'system_notification',
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
|
|
@ -135,6 +137,10 @@ function createTask(context, input) {
|
|||
maybeNotifyAssignedOwner(context, task, {
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
taskRefs: [
|
||||
...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []),
|
||||
...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []),
|
||||
],
|
||||
from: input.from,
|
||||
});
|
||||
}
|
||||
|
|
@ -221,6 +227,7 @@ function addTaskComment(context, taskId, flags) {
|
|||
...(flags.id ? { id: flags.id } : {}),
|
||||
...(flags.createdAt ? { createdAt: flags.createdAt } : {}),
|
||||
...(flags.type ? { type: flags.type } : {}),
|
||||
...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}),
|
||||
...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,10 @@ import {
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
|
||||
import { isAgentActionMode } from '../services/team/actionModeInstructions';
|
||||
import { validateTaskId, validateTeamName } from './guards';
|
||||
import type { CrossTeamService } from '../services/team/CrossTeamService';
|
||||
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
|
||||
import type { IpcResult } from '@shared/types';
|
||||
import type { IpcResult, TaskRef } from '@shared/types';
|
||||
|
||||
const logger = createLogger('IPC:crossTeam');
|
||||
|
||||
|
|
@ -19,6 +20,42 @@ export function initializeCrossTeamHandlers(service: CrossTeamService): void {
|
|||
crossTeamService = service;
|
||||
}
|
||||
|
||||
function validateTaskRefs(
|
||||
value: unknown
|
||||
): { valid: true; value: TaskRef[] | undefined } | { valid: false; error: string } {
|
||||
if (value === undefined) {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return { valid: false, error: 'taskRefs must be an array' };
|
||||
}
|
||||
|
||||
const taskRefs: TaskRef[] = [];
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return { valid: false, error: 'taskRefs entries must be objects' };
|
||||
}
|
||||
const row = entry as Partial<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 {
|
||||
if (!crossTeamService) {
|
||||
throw new Error('CrossTeamService not initialized');
|
||||
|
|
@ -52,6 +89,10 @@ async function handleSend(
|
|||
if (req.actionMode !== undefined && !isAgentActionMode(req.actionMode)) {
|
||||
throw new Error('actionMode must be one of: do, ask, delegate');
|
||||
}
|
||||
const taskRefs = validateTaskRefs(req.taskRefs);
|
||||
if (!taskRefs.valid) {
|
||||
throw new Error(taskRefs.error);
|
||||
}
|
||||
return getService().send({
|
||||
fromTeam: String(req.fromTeam ?? ''),
|
||||
fromMember: String(req.fromMember ?? ''),
|
||||
|
|
@ -60,6 +101,7 @@ async function handleSend(
|
|||
replyToConversationId:
|
||||
typeof req.replyToConversationId === 'string' ? req.replyToConversationId : undefined,
|
||||
text: String(req.text ?? ''),
|
||||
taskRefs: taskRefs.value,
|
||||
actionMode: isAgentActionMode(req.actionMode) ? req.actionMode : undefined,
|
||||
summary: typeof req.summary === 'string' ? req.summary : undefined,
|
||||
chainDepth: typeof req.chainDepth === 'number' ? req.chainDepth : undefined,
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ import type {
|
|||
TeamProvisioningService,
|
||||
} from '../services';
|
||||
import type {
|
||||
AddTaskCommentRequest,
|
||||
AgentActionMode,
|
||||
AttachmentFileData,
|
||||
AttachmentMeta,
|
||||
|
|
@ -115,6 +116,7 @@ import type {
|
|||
SendMessageResult,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TaskRef,
|
||||
TeamClaudeLogsQuery,
|
||||
TeamClaudeLogsResponse,
|
||||
TeamConfig,
|
||||
|
|
@ -927,12 +929,55 @@ function isUpdateKanbanPatch(value: unknown): value is UpdateKanbanPatch {
|
|||
}
|
||||
|
||||
if (patch.op === 'request_changes') {
|
||||
return patch.comment === undefined || typeof patch.comment === 'string';
|
||||
return (
|
||||
(patch.comment === undefined || typeof patch.comment === 'string') &&
|
||||
validateTaskRefs((patch as { taskRefs?: unknown }).taskRefs).valid
|
||||
);
|
||||
}
|
||||
|
||||
return patch.op === 'set_column' && (patch.column === 'review' || patch.column === 'approved');
|
||||
}
|
||||
|
||||
function validateTaskRefs(
|
||||
value: unknown
|
||||
): { valid: true; value: TaskRef[] | undefined } | { valid: false; error: string } {
|
||||
if (value === undefined) {
|
||||
return { valid: true, value: undefined };
|
||||
}
|
||||
if (!Array.isArray(value)) {
|
||||
return { valid: false, error: 'taskRefs must be an array' };
|
||||
}
|
||||
|
||||
const taskRefs: TaskRef[] = [];
|
||||
for (const entry of value) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return { valid: false, error: 'taskRefs entries must be objects' };
|
||||
}
|
||||
const row = entry as Partial<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(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
|
|
@ -1068,6 +1113,10 @@ async function handleSendMessage(
|
|||
if (payload.actionMode !== undefined && !isAgentActionMode(payload.actionMode)) {
|
||||
return { success: false, error: 'actionMode must be one of: do, ask, delegate' };
|
||||
}
|
||||
const validatedTaskRefs = validateTaskRefs(payload.taskRefs);
|
||||
if (!validatedTaskRefs.valid) {
|
||||
return { success: false, error: validatedTaskRefs.error };
|
||||
}
|
||||
|
||||
let validatedAttachments: AttachmentPayload[] | undefined;
|
||||
if (
|
||||
|
|
@ -1175,7 +1224,8 @@ async function handleSendMessage(
|
|||
resolvedLeadName,
|
||||
payload.text!,
|
||||
payload.summary,
|
||||
attachmentMeta
|
||||
attachmentMeta,
|
||||
validatedTaskRefs.value
|
||||
);
|
||||
} catch (persistError) {
|
||||
logger.warn(`Persistence failed after stdin delivery for ${tn}: ${String(persistError)}`);
|
||||
|
|
@ -1199,6 +1249,7 @@ async function handleSendMessage(
|
|||
messageId: result.messageId,
|
||||
source: 'user_sent',
|
||||
attachments: attachmentMeta,
|
||||
taskRefs: validatedTaskRefs.value,
|
||||
});
|
||||
|
||||
return result;
|
||||
|
|
@ -1217,6 +1268,7 @@ async function handleSendMessage(
|
|||
summary: payload.summary,
|
||||
from: payload.from,
|
||||
source: 'user_sent',
|
||||
taskRefs: validatedTaskRefs.value,
|
||||
});
|
||||
|
||||
// Best-effort live relay so active processes see the inbox row promptly.
|
||||
|
|
@ -1265,6 +1317,10 @@ async function handleCreateTask(
|
|||
if (payload.description !== undefined && typeof payload.description !== 'string') {
|
||||
return { success: false, error: 'description must be string' };
|
||||
}
|
||||
const validatedDescriptionTaskRefs = validateTaskRefs(payload.descriptionTaskRefs);
|
||||
if (!validatedDescriptionTaskRefs.valid) {
|
||||
return { success: false, error: validatedDescriptionTaskRefs.error };
|
||||
}
|
||||
if (payload.owner !== undefined) {
|
||||
const validatedOwner = validateMemberName(payload.owner);
|
||||
if (!validatedOwner.valid) {
|
||||
|
|
@ -1298,6 +1354,10 @@ async function handleCreateTask(
|
|||
return { success: false, error: 'prompt exceeds max length (5000)' };
|
||||
}
|
||||
}
|
||||
const validatedPromptTaskRefs = validateTaskRefs(payload.promptTaskRefs);
|
||||
if (!validatedPromptTaskRefs.valid) {
|
||||
return { success: false, error: validatedPromptTaskRefs.error };
|
||||
}
|
||||
if (payload.startImmediately !== undefined && typeof payload.startImmediately !== 'boolean') {
|
||||
return { success: false, error: 'startImmediately must be a boolean' };
|
||||
}
|
||||
|
|
@ -1309,7 +1369,9 @@ async function handleCreateTask(
|
|||
owner: payload.owner?.trim() || undefined,
|
||||
blockedBy: payload.blockedBy,
|
||||
related: payload.related,
|
||||
descriptionTaskRefs: validatedDescriptionTaskRefs.value,
|
||||
prompt: payload.prompt?.trim() || undefined,
|
||||
promptTaskRefs: validatedPromptTaskRefs.value,
|
||||
startImmediately: payload.startImmediately,
|
||||
})
|
||||
);
|
||||
|
|
@ -2222,19 +2284,27 @@ async function handleAddTaskComment(
|
|||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
taskId: unknown,
|
||||
text: unknown,
|
||||
attachments?: unknown
|
||||
request: unknown
|
||||
): Promise<IpcResult<TaskComment>> {
|
||||
const vTeam = validateTeamName(teamName);
|
||||
if (!vTeam.valid) return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||
const vTask = validateTaskId(taskId);
|
||||
if (!vTask.valid) return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||||
if (!request || typeof request !== 'object') {
|
||||
return { success: false, error: 'Invalid add task comment request' };
|
||||
}
|
||||
const payload = request as Partial<AddTaskCommentRequest>;
|
||||
const text = payload.text;
|
||||
if (typeof text !== 'string' || text.trim().length === 0)
|
||||
return { success: false, error: 'Comment text must be non-empty' };
|
||||
if (text.trim().length > MAX_TEXT_LENGTH)
|
||||
return { success: false, error: `Comment exceeds ${MAX_TEXT_LENGTH} characters` };
|
||||
const validatedTaskRefs = validateTaskRefs(payload.taskRefs);
|
||||
if (!validatedTaskRefs.valid) {
|
||||
return { success: false, error: validatedTaskRefs.error };
|
||||
}
|
||||
|
||||
const rawAttachments = Array.isArray(attachments) ? attachments : [];
|
||||
const rawAttachments = Array.isArray(payload.attachments) ? payload.attachments : [];
|
||||
if (rawAttachments.length > MAX_ATTACHMENTS) {
|
||||
return { success: false, error: `Maximum ${MAX_ATTACHMENTS} attachments per comment` };
|
||||
}
|
||||
|
|
@ -2248,7 +2318,7 @@ async function handleAddTaskComment(
|
|||
if (!att || typeof att !== 'object') {
|
||||
throw new Error('Invalid attachment data');
|
||||
}
|
||||
const a = att as Record<string, unknown>;
|
||||
const a = att as unknown as Record<string, unknown>;
|
||||
if (
|
||||
typeof a.id !== 'string' ||
|
||||
typeof a.filename !== 'string' ||
|
||||
|
|
@ -2279,7 +2349,8 @@ async function handleAddTaskComment(
|
|||
vTeam.value!,
|
||||
vTask.value!,
|
||||
text.trim(),
|
||||
savedAttachments
|
||||
savedAttachments,
|
||||
validatedTaskRefs.value
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export class CrossTeamService {
|
|||
) {}
|
||||
|
||||
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 messageId = request.messageId?.trim() || randomUUID();
|
||||
const timestamp = request.timestamp ?? new Date().toISOString();
|
||||
|
|
@ -105,6 +105,7 @@ export class CrossTeamService {
|
|||
conversationId,
|
||||
replyToConversationId,
|
||||
text,
|
||||
taskRefs,
|
||||
summary,
|
||||
chainDepth,
|
||||
timestamp,
|
||||
|
|
@ -127,6 +128,7 @@ export class CrossTeamService {
|
|||
source: CROSS_TEAM_SOURCE,
|
||||
conversationId,
|
||||
replyToConversationId,
|
||||
taskRefs,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -144,6 +146,7 @@ export class CrossTeamService {
|
|||
from: fromMember,
|
||||
to: `${toTeam}.${leadName}`,
|
||||
text,
|
||||
taskRefs,
|
||||
timestamp,
|
||||
messageId,
|
||||
summary: summary ?? `Cross-team message to ${toTeam}`,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import type {
|
|||
SendMessageResult,
|
||||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TaskRef,
|
||||
TeamConfig,
|
||||
TeamCreateConfigRequest,
|
||||
TeamData,
|
||||
|
|
@ -803,12 +804,16 @@ export class TeamDataService {
|
|||
const task = controller.tasks.createTask({
|
||||
subject: request.subject,
|
||||
...(request.description?.trim() ? { description: request.description.trim() } : {}),
|
||||
...(request.descriptionTaskRefs?.length
|
||||
? { descriptionTaskRefs: request.descriptionTaskRefs }
|
||||
: {}),
|
||||
...(request.owner ? { owner: request.owner } : {}),
|
||||
...(blockedBy.length > 0 ? { blockedBy } : {}),
|
||||
...(related.length > 0 ? { related } : {}),
|
||||
...(projectPath ? { projectPath } : {}),
|
||||
createdBy: 'user',
|
||||
...(request.prompt?.trim() ? { prompt: request.prompt.trim() } : {}),
|
||||
...(request.promptTaskRefs?.length ? { promptTaskRefs: request.promptTaskRefs } : {}),
|
||||
...(shouldStart ? { startImmediately: true } : {}),
|
||||
}) as TeamTask;
|
||||
|
||||
|
|
@ -847,6 +852,7 @@ export class TeamDataService {
|
|||
member: task.owner,
|
||||
from: leadName,
|
||||
text: parts.join('\n'),
|
||||
taskRefs: task.descriptionTaskRefs,
|
||||
summary: `Task ${this.getTaskLabel(task)} started`,
|
||||
source: 'system_notification',
|
||||
});
|
||||
|
|
@ -992,13 +998,15 @@ export class TeamDataService {
|
|||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
attachments?: TaskAttachmentMeta[]
|
||||
attachments?: TaskAttachmentMeta[],
|
||||
taskRefs?: TaskRef[]
|
||||
): Promise<TaskComment> {
|
||||
const controller = this.getController(teamName);
|
||||
const addResult = controller.tasks.addTaskComment(taskId, {
|
||||
from: 'user',
|
||||
text,
|
||||
attachments,
|
||||
taskRefs,
|
||||
}) as { task?: TeamTask; comment?: TaskComment };
|
||||
const comment =
|
||||
addResult.comment ??
|
||||
|
|
@ -1008,6 +1016,7 @@ export class TeamDataService {
|
|||
text,
|
||||
createdAt: new Date().toISOString(),
|
||||
type: 'regular',
|
||||
...(taskRefs && taskRefs.length > 0 ? { taskRefs } : {}),
|
||||
...(attachments && attachments.length > 0 ? { attachments } : {}),
|
||||
} as TaskComment);
|
||||
|
||||
|
|
@ -1031,6 +1040,15 @@ export class TeamDataService {
|
|||
member: enrichedRequest.member,
|
||||
from: enrichedRequest.from,
|
||||
text: enrichedRequest.text,
|
||||
timestamp: enrichedRequest.timestamp,
|
||||
messageId: enrichedRequest.messageId,
|
||||
to: enrichedRequest.to,
|
||||
color: enrichedRequest.color,
|
||||
conversationId: enrichedRequest.conversationId,
|
||||
replyToConversationId: enrichedRequest.replyToConversationId,
|
||||
toolSummary: enrichedRequest.toolSummary,
|
||||
toolCalls: enrichedRequest.toolCalls,
|
||||
taskRefs: enrichedRequest.taskRefs,
|
||||
summary: enrichedRequest.summary,
|
||||
source: enrichedRequest.source,
|
||||
leadSessionId: enrichedRequest.leadSessionId,
|
||||
|
|
@ -1078,7 +1096,8 @@ export class TeamDataService {
|
|||
leadName: string,
|
||||
text: string,
|
||||
summary?: string,
|
||||
attachments?: AttachmentMeta[]
|
||||
attachments?: AttachmentMeta[],
|
||||
taskRefs?: TaskRef[]
|
||||
): Promise<SendMessageResult> {
|
||||
let leadSessionId: string | undefined;
|
||||
try {
|
||||
|
|
@ -1092,6 +1111,7 @@ export class TeamDataService {
|
|||
from: 'user',
|
||||
to: leadName,
|
||||
text,
|
||||
taskRefs,
|
||||
summary,
|
||||
source: 'user_sent',
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
|
|
@ -1462,6 +1482,9 @@ export class TeamDataService {
|
|||
controller.review.requestChanges(taskId, {
|
||||
from: 'user',
|
||||
comment: patch.comment?.trim() || 'Reviewer requested changes.',
|
||||
...(patch.op === 'request_changes' && patch.taskRefs?.length
|
||||
? { taskRefs: patch.taskRefs }
|
||||
: {}),
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ export class TeamInboxReader {
|
|||
text: row.text,
|
||||
timestamp: row.timestamp,
|
||||
read: typeof row.read === 'boolean' ? row.read : false,
|
||||
taskRefs: Array.isArray(row.taskRefs) ? row.taskRefs : undefined,
|
||||
summary: typeof row.summary === 'string' ? row.summary : undefined,
|
||||
color: typeof row.color === 'string' ? row.color : undefined,
|
||||
messageId: row.messageId,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export class TeamInboxWriter {
|
|||
text: request.text,
|
||||
timestamp: request.timestamp ?? new Date().toISOString(),
|
||||
read: false,
|
||||
taskRefs: request.taskRefs?.length ? request.taskRefs : undefined,
|
||||
summary: request.summary,
|
||||
messageId,
|
||||
attachments: attachmentMeta?.length ? attachmentMeta : undefined,
|
||||
|
|
|
|||
|
|
@ -1648,6 +1648,7 @@ export class TeamProvisioningService {
|
|||
leadSessionId: message.leadSessionId,
|
||||
conversationId: message.conversationId,
|
||||
replyToConversationId: message.replyToConversationId,
|
||||
taskRefs: message.taskRefs,
|
||||
attachments: message.attachments,
|
||||
color: message.color,
|
||||
toolSummary: message.toolSummary,
|
||||
|
|
@ -1674,6 +1675,7 @@ export class TeamProvisioningService {
|
|||
leadSessionId: message.leadSessionId,
|
||||
conversationId: message.conversationId,
|
||||
replyToConversationId: message.replyToConversationId,
|
||||
taskRefs: message.taskRefs,
|
||||
attachments: message.attachments,
|
||||
color: message.color,
|
||||
toolSummary: message.toolSummary,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ export class TeamSentMessagesStore {
|
|||
text: row.text,
|
||||
timestamp: row.timestamp,
|
||||
read: typeof row.read === 'boolean' ? row.read : true,
|
||||
taskRefs: Array.isArray(row.taskRefs) ? row.taskRefs : undefined,
|
||||
summary: typeof row.summary === 'string' ? row.summary : undefined,
|
||||
messageId: row.messageId,
|
||||
color: typeof row.color === 'string' ? row.color : undefined,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type {
|
|||
TaskAttachmentMeta,
|
||||
TaskComment,
|
||||
TaskHistoryEvent,
|
||||
TaskRef,
|
||||
TaskWorkInterval,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
|
|
@ -34,6 +35,21 @@ function isValidMimeTypeString(value: unknown): value is string {
|
|||
return true;
|
||||
}
|
||||
|
||||
function normalizeTaskRefs(value: unknown): TaskRef[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const taskRefs = (value as unknown[])
|
||||
.filter(
|
||||
(entry): entry is Record<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 {
|
||||
/**
|
||||
* Returns the next available numeric task ID by scanning ALL task files
|
||||
|
|
@ -155,7 +171,10 @@ export class TeamTaskReader {
|
|||
),
|
||||
subject,
|
||||
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
||||
descriptionTaskRefs: normalizeTaskRefs(parsed.descriptionTaskRefs),
|
||||
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
|
||||
prompt: typeof parsed.prompt === 'string' ? parsed.prompt : undefined,
|
||||
promptTaskRefs: normalizeTaskRefs(parsed.promptTaskRefs),
|
||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||
status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
||||
|
|
@ -193,6 +212,7 @@ export class TeamTaskReader {
|
|||
type: (['regular', 'review_request', 'review_approved'] as const).includes(c.type)
|
||||
? c.type
|
||||
: ('regular' as const),
|
||||
taskRefs: normalizeTaskRefs((c as unknown as Record<string, unknown>).taskRefs),
|
||||
attachments: Array.isArray(c.attachments)
|
||||
? (() => {
|
||||
const filtered = (c.attachments as unknown[])
|
||||
|
|
|
|||
|
|
@ -106,7 +106,10 @@ interface ParsedTask {
|
|||
subject?: unknown;
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
descriptionTaskRefs?: unknown;
|
||||
activeForm?: unknown;
|
||||
prompt?: unknown;
|
||||
promptTaskRefs?: unknown;
|
||||
owner?: unknown;
|
||||
createdBy?: unknown;
|
||||
status?: unknown;
|
||||
|
|
@ -143,6 +146,7 @@ interface RawComment {
|
|||
text?: unknown;
|
||||
createdAt?: unknown;
|
||||
type?: unknown;
|
||||
taskRefs?: unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -526,6 +530,7 @@ function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
|
|||
author: c.author as string,
|
||||
text: c.text as string,
|
||||
createdAt: c.createdAt as string,
|
||||
taskRefs: Array.isArray(c.taskRefs) ? c.taskRefs : undefined,
|
||||
type:
|
||||
c.type === 'regular' || c.type === 'review_request' || c.type === 'review_approved'
|
||||
? (c.type as string)
|
||||
|
|
@ -626,7 +631,14 @@ async function readTasksDirForTeam(
|
|||
),
|
||||
subject,
|
||||
description: typeof parsed.description === 'string' ? parsed.description : undefined,
|
||||
descriptionTaskRefs: Array.isArray(parsed.descriptionTaskRefs)
|
||||
? (parsed.descriptionTaskRefs as unknown[])
|
||||
: undefined,
|
||||
activeForm: typeof parsed.activeForm === 'string' ? parsed.activeForm : undefined,
|
||||
prompt: typeof parsed.prompt === 'string' ? parsed.prompt : undefined,
|
||||
promptTaskRefs: Array.isArray(parsed.promptTaskRefs)
|
||||
? (parsed.promptTaskRefs as unknown[])
|
||||
: undefined,
|
||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||
status:
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ import {
|
|||
|
||||
import type {
|
||||
AddMemberRequest,
|
||||
AddTaskCommentRequest,
|
||||
AgentChangeSet,
|
||||
AppConfig,
|
||||
ApplyReviewRequest,
|
||||
|
|
@ -205,7 +206,6 @@ import type {
|
|||
ClaudeRootInfo,
|
||||
CliInstallationStatus,
|
||||
CliInstallerProgress,
|
||||
CommentAttachmentPayload,
|
||||
ConflictCheckResult,
|
||||
ContextInfo,
|
||||
CreateScheduleInput,
|
||||
|
|
@ -878,19 +878,8 @@ const electronAPI: ElectronAPI = {
|
|||
updateConfig: async (teamName: string, updates: TeamUpdateConfigRequest) => {
|
||||
return invokeIpcWithResult<TeamConfig>(TEAM_UPDATE_CONFIG, teamName, updates);
|
||||
},
|
||||
addTaskComment: async (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
attachments?: CommentAttachmentPayload[]
|
||||
) => {
|
||||
return invokeIpcWithResult<TaskComment>(
|
||||
TEAM_ADD_TASK_COMMENT,
|
||||
teamName,
|
||||
taskId,
|
||||
text,
|
||||
attachments
|
||||
);
|
||||
addTaskComment: async (teamName: string, taskId: string, request: AddTaskCommentRequest) => {
|
||||
return invokeIpcWithResult<TaskComment>(TEAM_ADD_TASK_COMMENT, teamName, taskId, request);
|
||||
},
|
||||
addMember: async (teamName: string, request: AddMemberRequest) => {
|
||||
return invokeIpcWithResult<void>(TEAM_ADD_MEMBER, teamName, request);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors'
|
|||
import { useTheme } from '@renderer/hooks/useTheme';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { REHYPE_PLUGINS, REHYPE_PLUGINS_NO_HIGHLIGHT } from '@renderer/utils/markdownPlugins';
|
||||
import { parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||
import { FileText, UsersRound } from 'lucide-react';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -269,9 +270,13 @@ function createViewerMarkdownComponents(
|
|||
);
|
||||
}
|
||||
if (href?.startsWith('task://')) {
|
||||
const taskId = href.slice('task://'.length);
|
||||
const parsedTaskLink = parseTaskLinkHref(href);
|
||||
const taskId = parsedTaskLink?.taskId;
|
||||
if (!taskId) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return (
|
||||
<TaskTooltip taskId={taskId}>
|
||||
<TaskTooltip taskId={taskId} teamName={parsedTaskLink?.teamName}>
|
||||
<a
|
||||
href={href}
|
||||
className="cursor-pointer font-medium no-underline hover:underline"
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export const SortableTab = ({
|
|||
const displayName = team?.displayName ?? tab.label;
|
||||
return nameColorSet(displayName);
|
||||
});
|
||||
const activeBorderColor = teamColorSet?.border ?? 'var(--color-accent, #6366f1)';
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: tab.id,
|
||||
|
|
@ -119,7 +120,17 @@ export const SortableTab = ({
|
|||
: 'var(--color-text-muted)',
|
||||
outline: isSelected ? '1px solid var(--color-border-emphasis)' : 'none',
|
||||
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];
|
||||
|
|
@ -144,7 +155,7 @@ export const SortableTab = ({
|
|||
role="tab"
|
||||
tabIndex={0}
|
||||
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}
|
||||
onClick={(e) => onTabClick(tab.id, e)}
|
||||
onMouseDown={(e) => onMouseDown(tab.id, e)}
|
||||
|
|
|
|||
|
|
@ -236,7 +236,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full items-center pr-2"
|
||||
className="flex h-full items-end pr-2"
|
||||
style={
|
||||
{
|
||||
paddingLeft:
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { REVIEW_STATE_DISPLAY, buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import { formatTaskDisplayLabel, taskMatchesRef } from '@shared/utils/taskIdentity';
|
||||
|
||||
|
|
@ -171,7 +172,11 @@ export const TaskTooltip = ({
|
|||
{/* Description — full markdown with scroll */}
|
||||
{task.description ? (
|
||||
<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>
|
||||
) : null}
|
||||
</TooltipContent>
|
||||
|
|
|
|||
|
|
@ -79,7 +79,12 @@ import type { KanbanSortState } from './kanban/KanbanSortPopover';
|
|||
import type { ContextInjection } from '@renderer/types/contextInjection';
|
||||
import type { Session } from '@renderer/types/data';
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { MemberSpawnStatusEntry, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
import type {
|
||||
MemberSpawnStatusEntry,
|
||||
ResolvedTeamMember,
|
||||
TaskRef,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
import type { EditorSelectionAction } from '@shared/types/editor';
|
||||
|
||||
interface TeamDetailViewProps {
|
||||
|
|
@ -796,7 +801,9 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
blockedBy?: string[],
|
||||
related?: string[],
|
||||
prompt?: string,
|
||||
startImmediately?: boolean
|
||||
startImmediately?: boolean,
|
||||
descriptionTaskRefs?: TaskRef[],
|
||||
promptTaskRefs?: TaskRef[]
|
||||
): void => {
|
||||
setCreatingTask(true);
|
||||
void (async () => {
|
||||
|
|
@ -808,6 +815,8 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
blockedBy,
|
||||
related,
|
||||
prompt,
|
||||
descriptionTaskRefs,
|
||||
promptTaskRefs,
|
||||
startImmediately,
|
||||
});
|
||||
|
||||
|
|
@ -1567,7 +1576,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
taskId={requestChangesTaskId}
|
||||
members={data?.members ?? []}
|
||||
onCancel={() => setRequestChangesTaskId(null)}
|
||||
onSubmit={(comment) => {
|
||||
onSubmit={(comment, taskRefs) => {
|
||||
if (!requestChangesTaskId) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -1576,6 +1585,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
await updateKanban(teamName, requestChangesTaskId, {
|
||||
op: 'request_changes',
|
||||
comment,
|
||||
taskRefs,
|
||||
});
|
||||
setRequestChangesTaskId(null);
|
||||
} catch {
|
||||
|
|
@ -1777,7 +1787,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
sending={sendingMessage}
|
||||
sendError={sendMessageError}
|
||||
lastResult={lastSendMessageResult}
|
||||
onSend={(member, text, summary, attachments, actionMode) => {
|
||||
onSend={(member, text, summary, attachments, actionMode, taskRefs) => {
|
||||
void (async () => {
|
||||
const sentAtMs = Date.now();
|
||||
setPendingRepliesByMember((prev) => ({ ...prev, [member]: sentAtMs }));
|
||||
|
|
@ -1788,6 +1798,7 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele
|
|||
summary,
|
||||
attachments,
|
||||
actionMode,
|
||||
taskRefs,
|
||||
});
|
||||
} catch {
|
||||
setPendingRepliesByMember((prev) => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Fragment, useMemo } from 'react';
|
||||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { CopyButton } from '@renderer/components/common/CopyButton';
|
||||
import { AttachmentDisplay } from '@renderer/components/team/attachments/AttachmentDisplay';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { TaskTooltip } from '@renderer/components/team/TaskTooltip';
|
||||
|
|
@ -24,7 +25,7 @@ import {
|
|||
} from '@renderer/utils/agentMessageFormatting';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import {
|
||||
CROSS_TEAM_SENT_SOURCE,
|
||||
|
|
@ -129,6 +130,8 @@ interface ActivityItemProps {
|
|||
zebraShade?: boolean;
|
||||
/** Explicit collapse state for timeline-controlled collapsed mode. */
|
||||
collapseState?: ActivityCollapseState;
|
||||
/** Compact header mode for narrow message lists. */
|
||||
compactHeader?: boolean;
|
||||
}
|
||||
|
||||
function getStringField(obj: StructuredMessage, key: string): string | null {
|
||||
|
|
@ -297,6 +300,7 @@ export const ActivityItem = ({
|
|||
onRestartTeam,
|
||||
zebraShade,
|
||||
collapseState,
|
||||
compactHeader = false,
|
||||
}: ActivityItemProps): React.JSX.Element => {
|
||||
const colors = getTeamColorSet(memberColor ?? message.color ?? '');
|
||||
const { isLight } = useTheme();
|
||||
|
|
@ -399,7 +403,7 @@ export const ActivityItem = ({
|
|||
const displayText = useMemo(() => {
|
||||
if (!strippedText) return null;
|
||||
let result = highlightSystemLabels(strippedText, !!systemLabel);
|
||||
result = linkifyTaskIdsInMarkdown(result);
|
||||
result = linkifyTaskIdsInMarkdown(result, message.taskRefs);
|
||||
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0)
|
||||
result = linkifyAllMentionsInMarkdown(result, memberColorMap ?? new Map(), teamNames);
|
||||
return result;
|
||||
|
|
@ -435,7 +439,7 @@ export const ActivityItem = ({
|
|||
};
|
||||
|
||||
const isHeaderClickable = isManaged ? collapseState.canToggle : false;
|
||||
const showChevron = isHeaderClickable;
|
||||
const showChevron = isHeaderClickable && !compactHeader;
|
||||
const isUserSent = message.source === 'user_sent' || isCrossTeamSent;
|
||||
const isSystemMessage = message.from === 'system';
|
||||
const onManagedToggle = isManaged ? collapseState.onToggle : undefined;
|
||||
|
|
@ -518,13 +522,13 @@ export const ActivityItem = ({
|
|||
<MemberBadge
|
||||
name={senderName}
|
||||
color={senderColor}
|
||||
hideAvatar={senderHideAvatar}
|
||||
hideAvatar={senderHideAvatar || compactHeader}
|
||||
onClick={onMemberNameClick}
|
||||
disableHoverCard={crossTeamOrigin != null}
|
||||
/>
|
||||
|
||||
{/* Role */}
|
||||
{formattedRole ? (
|
||||
{!compactHeader && formattedRole ? (
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{formattedRole}
|
||||
</span>
|
||||
|
|
@ -580,8 +584,9 @@ export const ActivityItem = ({
|
|||
name={crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to}
|
||||
color={crossTeamTarget ? undefined : recipientColor}
|
||||
hideAvatar={
|
||||
compactHeader ||
|
||||
(crossTeamSentMemberName ?? qualifiedRecipient?.memberName ?? message.to) ===
|
||||
'user'
|
||||
'user'
|
||||
}
|
||||
onClick={onMemberNameClick}
|
||||
disableHoverCard={crossTeamTarget != null}
|
||||
|
|
@ -595,44 +600,8 @@ export const ActivityItem = ({
|
|||
{onTaskIdClick ? linkifyTaskIds(summaryText, onTaskIdClick) : summaryText}
|
||||
</span>
|
||||
|
||||
{/* Timestamp + reply + create task */}
|
||||
{/* Timestamp */}
|
||||
<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 }}>
|
||||
{timestamp}
|
||||
</span>
|
||||
|
|
@ -660,29 +629,72 @@ export const ActivityItem = ({
|
|||
<ReplyQuoteBlock
|
||||
reply={parsedReply}
|
||||
memberColor={memberColorMap?.get(parsedReply.agentName)}
|
||||
replyTaskRefs={message.taskRefs}
|
||||
/>
|
||||
) : displayText ? (
|
||||
<ExpandableContent>
|
||||
<span
|
||||
onClickCapture={
|
||||
onTaskIdClick
|
||||
? (e) => {
|
||||
const link = (e.target as HTMLElement).closest<HTMLAnchorElement>(
|
||||
'a[href^="task://"]'
|
||||
);
|
||||
if (link) {
|
||||
e.preventDefault();
|
||||
<div className="group/message-body relative">
|
||||
<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">
|
||||
{onReply ? (
|
||||
<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();
|
||||
const taskId = link.getAttribute('href')?.replace('task://', '');
|
||||
if (taskId) onTaskIdClick(taskId);
|
||||
onReply(message);
|
||||
}}
|
||||
>
|
||||
<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
|
||||
}
|
||||
>
|
||||
<MarkdownViewer content={displayText} maxHeight="max-h-none" copyable bare />
|
||||
</span>
|
||||
</ExpandableContent>
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<MarkdownViewer content={displayText} maxHeight="max-h-none" bare />
|
||||
</span>
|
||||
</ExpandableContent>
|
||||
</div>
|
||||
) : summaryText ? (
|
||||
<p className="text-xs italic" style={{ color: CARD_TEXT_LIGHT }}>
|
||||
{summaryText}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ interface ActivityTimelineProps {
|
|||
|
||||
const VIEWPORT_THRESHOLD = 0.15;
|
||||
const MESSAGES_PAGE_SIZE = 30;
|
||||
const COMPACT_MESSAGES_WIDTH_PX = 400;
|
||||
|
||||
/** Inline compaction boundary divider — styled like session separators but with amber accent. */
|
||||
const CompactionDivider = ({ message }: { message: InboxMessage }): React.JSX.Element => (
|
||||
|
|
@ -98,6 +99,7 @@ const MessageRowWithObserver = ({
|
|||
onTaskIdClick,
|
||||
onRestartTeam,
|
||||
collapseState,
|
||||
compactHeader,
|
||||
}: {
|
||||
message: InboxMessage;
|
||||
teamName: string;
|
||||
|
|
@ -116,6 +118,7 @@ const MessageRowWithObserver = ({
|
|||
onTaskIdClick?: (taskId: string) => void;
|
||||
onRestartTeam?: () => void;
|
||||
collapseState?: ActivityCollapseState;
|
||||
compactHeader?: boolean;
|
||||
}): React.JSX.Element => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const reportedRef = useRef(false);
|
||||
|
|
@ -165,6 +168,7 @@ const MessageRowWithObserver = ({
|
|||
onTaskIdClick={onTaskIdClick}
|
||||
onRestartTeam={onRestartTeam}
|
||||
collapseState={collapseState}
|
||||
compactHeader={compactHeader}
|
||||
/>
|
||||
</AnimatedHeightReveal>
|
||||
);
|
||||
|
|
@ -188,6 +192,31 @@ export const ActivityTimeline = ({
|
|||
currentLeadSessionId,
|
||||
}: ActivityTimelineProps): React.JSX.Element => {
|
||||
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 localMemberNames = new Set((members ?? []).map((member) => member.name.trim()));
|
||||
|
|
@ -357,7 +386,7 @@ export const ActivityTimeline = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div ref={rootRef} className="space-y-1">
|
||||
{/* Pinned (newest) thought group — always at top */}
|
||||
{pinnedThoughtGroup &&
|
||||
(() => {
|
||||
|
|
@ -380,6 +409,7 @@ export const ActivityTimeline = ({
|
|||
onTaskIdClick={onTaskIdClick}
|
||||
memberColorMap={colorMap}
|
||||
onReply={onReplyToMessage}
|
||||
compactHeader={compactHeader}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
|
@ -440,6 +470,7 @@ export const ActivityTimeline = ({
|
|||
onTaskIdClick={onTaskIdClick}
|
||||
memberColorMap={colorMap}
|
||||
onReply={onReplyToMessage}
|
||||
compactHeader={compactHeader}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
@ -489,6 +520,7 @@ export const ActivityTimeline = ({
|
|||
onTaskIdClick={onTaskIdClick}
|
||||
onRestartTeam={onRestartTeam}
|
||||
collapseState={collapseState}
|
||||
compactHeader={compactHeader}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { getTeamColorSet } from '@renderer/constants/teamColors';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { agentAvatarUrl } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
|
||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||
import { formatToolSummary, parseToolSummary } from '@shared/utils/toolSummary';
|
||||
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
|
||||
|
|
@ -126,6 +126,8 @@ interface LeadThoughtsGroupRowProps {
|
|||
memberColorMap?: Map<string, string>;
|
||||
/** Called when user clicks the reply button on a thought. */
|
||||
onReply?: (message: InboxMessage) => void;
|
||||
/** Compact header mode for narrow message lists. */
|
||||
compactHeader?: boolean;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
|
|
@ -237,7 +239,7 @@ const LeadThoughtItem = ({
|
|||
|
||||
const displayContent = useMemo(() => {
|
||||
let text = thought.text.replace(/\n/g, ' \n');
|
||||
text = linkifyTaskIdsInMarkdown(text);
|
||||
text = linkifyTaskIdsInMarkdown(text, thought.taskRefs);
|
||||
if ((memberColorMap && memberColorMap.size > 0) || teamNames.length > 0) {
|
||||
text = linkifyAllMentionsInMarkdown(text, memberColorMap ?? new Map(), teamNames);
|
||||
}
|
||||
|
|
@ -393,8 +395,9 @@ const LeadThoughtItem = ({
|
|||
if (link) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const taskId = link.getAttribute('href')?.replace('task://', '');
|
||||
if (taskId) onTaskIdClick(taskId);
|
||||
const href = link.getAttribute('href');
|
||||
const parsedTaskLink = href ? parseTaskLinkHref(href) : null;
|
||||
if (parsedTaskLink?.taskId) onTaskIdClick(parsedTaskLink.taskId);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
|
@ -462,6 +465,7 @@ export const LeadThoughtsGroupRow = ({
|
|||
onTaskIdClick,
|
||||
memberColorMap,
|
||||
onReply,
|
||||
compactHeader = false,
|
||||
}: LeadThoughtsGroupRowProps): React.JSX.Element => {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -725,7 +729,7 @@ export const LeadThoughtsGroupRow = ({
|
|||
}
|
||||
>
|
||||
{/* Chevron for collapse mode */}
|
||||
{canToggleBodyVisibility ? (
|
||||
{canToggleBodyVisibility && !compactHeader ? (
|
||||
<ChevronRight
|
||||
className="size-3 shrink-0 transition-transform duration-150"
|
||||
style={{
|
||||
|
|
@ -735,20 +739,22 @@ export const LeadThoughtsGroupRow = ({
|
|||
/>
|
||||
) : null}
|
||||
{/* Lead avatar with optional live indicator */}
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(leadName, 24)}
|
||||
alt=""
|
||||
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">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-full rounded-full border-2 border-[var(--color-surface)] bg-emerald-400" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{!compactHeader ? (
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={agentAvatarUrl(leadName, 24)}
|
||||
alt=""
|
||||
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">
|
||||
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
||||
<span className="relative inline-flex size-full rounded-full border-2 border-[var(--color-surface)] bg-emerald-400" />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<MemberBadge name={leadName} color={memberColor} hideAvatar />
|
||||
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
|
||||
{thoughts.length} thoughts
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ import { useState } from 'react';
|
|||
|
||||
import { MarkdownViewer } from '@renderer/components/chat/viewers/MarkdownViewer';
|
||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
||||
import { linkifyTaskIdsInMarkdown } from '@renderer/utils/taskReferenceUtils';
|
||||
|
||||
import type { ParsedMessageReply } from '@renderer/utils/agentMessageFormatting';
|
||||
import type { TaskRef } from '@shared/types';
|
||||
|
||||
interface ReplyQuoteBlockProps {
|
||||
reply: ParsedMessageReply;
|
||||
|
|
@ -11,6 +13,8 @@ interface ReplyQuoteBlockProps {
|
|||
memberColor?: string;
|
||||
/** When set, limits height of the reply body (e.g. "max-h-56"). Omit to show full content. */
|
||||
bodyMaxHeight?: string;
|
||||
/** Structured task refs for the reply body, when available. */
|
||||
replyTaskRefs?: TaskRef[];
|
||||
}
|
||||
|
||||
/** Threshold (characters) above which the "more/less" toggle is shown. */
|
||||
|
|
@ -20,6 +24,7 @@ export const ReplyQuoteBlock = ({
|
|||
reply,
|
||||
memberColor,
|
||||
bodyMaxHeight = 'max-h-56',
|
||||
replyTaskRefs,
|
||||
}: ReplyQuoteBlockProps): React.JSX.Element => {
|
||||
const isLong = reply.originalText.length > LONG_QUOTE_THRESHOLD;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
|
@ -43,7 +48,11 @@ export const ReplyQuoteBlock = ({
|
|||
|
||||
{/* Quote text */}
|
||||
<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>
|
||||
|
||||
{/* More/less toggle */}
|
||||
|
|
@ -59,7 +68,12 @@ export const ReplyQuoteBlock = ({
|
|||
</div>
|
||||
|
||||
{/* Reply text */}
|
||||
<MarkdownViewer content={reply.replyText} maxHeight={bodyMaxHeight} copyable bare />
|
||||
<MarkdownViewer
|
||||
content={linkifyTaskIdsInMarkdown(reply.replyText, replyTaskRefs)}
|
||||
maxHeight={bodyMaxHeight}
|
||||
copyable
|
||||
bare
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ export const AddMemberDialog = ({
|
|||
placeholder="How this agent should behave, what tasks it handles..."
|
||||
footerRight={
|
||||
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
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -30,14 +30,17 @@ import { chipToken, serializeChipsWithText } from '@renderer/types/inlineChip';
|
|||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils';
|
||||
import {
|
||||
extractTaskRefsFromText,
|
||||
stripEncodedTaskReferenceMetadata,
|
||||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { AlertTriangle, Search } from 'lucide-react';
|
||||
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
import type { ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
interface CreateTaskDialogProps {
|
||||
open: boolean;
|
||||
|
|
@ -58,7 +61,9 @@ interface CreateTaskDialogProps {
|
|||
blockedBy?: string[],
|
||||
related?: string[],
|
||||
prompt?: string,
|
||||
startImmediately?: boolean
|
||||
startImmediately?: boolean,
|
||||
descriptionTaskRefs?: TaskRef[],
|
||||
promptTaskRefs?: TaskRef[]
|
||||
) => void;
|
||||
submitting?: boolean;
|
||||
}
|
||||
|
|
@ -175,18 +180,23 @@ export const CreateTaskDialog = ({
|
|||
|
||||
const handleSubmit = (): void => {
|
||||
if (!canSubmit) return;
|
||||
const serializedDesc = serializeChipsWithText(
|
||||
descriptionDraft.value.trim(),
|
||||
descChipDraft.chips
|
||||
);
|
||||
const trimmedDescription = stripEncodedTaskReferenceMetadata(descriptionDraft.value.trim());
|
||||
const trimmedPrompt = stripEncodedTaskReferenceMetadata(promptDraft.value.trim());
|
||||
const serializedDesc = serializeChipsWithText(trimmedDescription, descChipDraft.chips);
|
||||
const descriptionTaskRefs = extractTaskRefsFromText(descriptionDraft.value, taskSuggestions);
|
||||
const promptTaskRefs = trimmedPrompt
|
||||
? extractTaskRefsFromText(promptDraft.value, taskSuggestions)
|
||||
: [];
|
||||
onSubmit(
|
||||
subject.trim(),
|
||||
serializedDesc,
|
||||
owner || undefined,
|
||||
blockedBy.length > 0 ? blockedBy : undefined,
|
||||
related.length > 0 ? related : undefined,
|
||||
stripEncodedTaskReferenceMetadata(promptDraft.value.trim()) || undefined,
|
||||
startImmediately
|
||||
trimmedPrompt || undefined,
|
||||
startImmediately,
|
||||
descriptionTaskRefs,
|
||||
promptTaskRefs
|
||||
);
|
||||
descriptionDraft.clearDraft();
|
||||
descChipDraft.clearChipDraft();
|
||||
|
|
@ -303,7 +313,7 @@ export const CreateTaskDialog = ({
|
|||
maxRows={12}
|
||||
footerRight={
|
||||
descriptionDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Draft saved</span>
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">Saved</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
|
@ -325,7 +335,7 @@ export const CreateTaskDialog = ({
|
|||
maxRows={12}
|
||||
footerRight={
|
||||
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
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -912,7 +912,7 @@ export const CreateTeamDialog = ({
|
|||
footerRight={
|
||||
promptDraft.isSaved ? (
|
||||
<span className="text-[10px] text-[var(--color-text-muted)]">
|
||||
Draft saved
|
||||
Saved
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
|
|
@ -980,7 +980,7 @@ export const CreateTeamDialog = ({
|
|||
placeholder="Brief description of the team purpose"
|
||||
/>
|
||||
{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}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -926,9 +926,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
placeholder="Instructions for team lead..."
|
||||
footerRight={
|
||||
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
|
||||
}
|
||||
/>
|
||||
|
|
@ -1025,9 +1023,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
placeholder="Instructions for Claude to execute on schedule..."
|
||||
footerRight={
|
||||
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
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -13,13 +13,16 @@ import { useTaskSuggestions } from '@renderer/hooks/useTaskSuggestions';
|
|||
import { useStore } from '@renderer/store';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils';
|
||||
import {
|
||||
extractTaskRefsFromText,
|
||||
stripEncodedTaskReferenceMetadata,
|
||||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import { Send } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
import type { ResolvedTeamMember, TaskRef } from '@shared/types';
|
||||
|
||||
interface ReviewDialogProps {
|
||||
open: boolean;
|
||||
|
|
@ -27,7 +30,7 @@ interface ReviewDialogProps {
|
|||
taskId: string | null;
|
||||
members: ResolvedTeamMember[];
|
||||
onCancel: () => void;
|
||||
onSubmit: (comment?: string) => void;
|
||||
onSubmit: (comment?: string, taskRefs?: TaskRef[]) => void;
|
||||
}
|
||||
|
||||
export const ReviewDialog = ({
|
||||
|
|
@ -62,8 +65,9 @@ export const ReviewDialog = ({
|
|||
|
||||
const handleSubmit = (): void => {
|
||||
const comment = stripEncodedTaskReferenceMetadata(trimmed) || undefined;
|
||||
const taskRefs = trimmed ? extractTaskRefsFromText(draft.value, taskSuggestions) : [];
|
||||
draft.clearDraft();
|
||||
onSubmit(comment);
|
||||
onSubmit(comment, taskRefs);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -114,7 +118,7 @@ export const ReviewDialog = ({
|
|||
</span>
|
||||
) : null}
|
||||
{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}
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,10 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
|||
import { removeChipTokenFromText } from '@renderer/utils/chipUtils';
|
||||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils';
|
||||
import {
|
||||
extractTaskRefsFromText,
|
||||
stripEncodedTaskReferenceMetadata,
|
||||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
import { AlertCircle, ImagePlus, Send, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -35,7 +38,12 @@ import { MemberBadge } from '../MemberBadge';
|
|||
import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector';
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
||||
import type {
|
||||
AttachmentPayload,
|
||||
ResolvedTeamMember,
|
||||
SendMessageResult,
|
||||
TaskRef,
|
||||
} from '@shared/types';
|
||||
|
||||
interface QuotedMessage {
|
||||
from: string;
|
||||
|
|
@ -61,7 +69,8 @@ interface SendMessageDialogProps {
|
|||
text: string,
|
||||
summary?: string,
|
||||
attachments?: AttachmentPayload[],
|
||||
actionMode?: ActionMode
|
||||
actionMode?: ActionMode,
|
||||
taskRefs?: TaskRef[]
|
||||
) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
|
@ -237,12 +246,14 @@ export const SendMessageDialog = ({
|
|||
|
||||
const handleSubmit = (): void => {
|
||||
if (!canSend) return;
|
||||
const taskRefs = extractTaskRefsFromText(textDraft.value, taskSuggestions);
|
||||
onSend(
|
||||
member.trim(),
|
||||
finalText,
|
||||
trimmedText,
|
||||
attachments.length > 0 ? attachments : undefined,
|
||||
actionMode
|
||||
actionMode,
|
||||
taskRefs
|
||||
);
|
||||
textDraft.clearDraft();
|
||||
chipDraft.clearChipDraft();
|
||||
|
|
@ -512,9 +523,7 @@ export const SendMessageDialog = ({
|
|||
</span>
|
||||
) : null}
|
||||
{textDraft.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}
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting';
|
|||
import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
||||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { serializeChipsWithText } from '@renderer/types/inlineChip';
|
||||
import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils';
|
||||
import {
|
||||
extractTaskRefsFromText,
|
||||
stripEncodedTaskReferenceMetadata,
|
||||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
import { ImagePlus, Mic, Send, Trash2, X } from 'lucide-react';
|
||||
|
||||
|
|
@ -132,6 +135,7 @@ export const TaskCommentInput = ({
|
|||
const text = replyTo
|
||||
? buildReplyBlock(replyTo.author, replyTo.text, serialized || '(image)')
|
||||
: serialized || '(image)';
|
||||
const taskRefs = extractTaskRefsFromText(draft.value, taskSuggestions);
|
||||
const attachments: CommentAttachmentPayload[] | undefined =
|
||||
pendingAttachments.length > 0
|
||||
? pendingAttachments.map((a) => ({
|
||||
|
|
@ -141,7 +145,11 @@ export const TaskCommentInput = ({
|
|||
base64Data: a.base64Data,
|
||||
}))
|
||||
: undefined;
|
||||
await addTaskComment(teamName, taskId, text, attachments);
|
||||
await addTaskComment(teamName, taskId, {
|
||||
text,
|
||||
attachments,
|
||||
taskRefs,
|
||||
});
|
||||
draft.clearDraft();
|
||||
chipDraft.clearChipDraft();
|
||||
setPendingAttachments([]);
|
||||
|
|
@ -161,6 +169,7 @@ export const TaskCommentInput = ({
|
|||
replyTo,
|
||||
onClearReply,
|
||||
pendingAttachments,
|
||||
taskSuggestions,
|
||||
]);
|
||||
|
||||
// Handle paste from MentionableTextarea area
|
||||
|
|
@ -340,7 +349,7 @@ export const TaskCommentInput = ({
|
|||
</span>
|
||||
) : null}
|
||||
{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}
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,9 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
|||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { linkifyAllMentionsInMarkdown } from '@renderer/utils/mentionLinkify';
|
||||
import {
|
||||
extractTaskRefsFromText,
|
||||
linkifyTaskIdsInMarkdown,
|
||||
parseTaskLinkHref,
|
||||
stripEncodedTaskReferenceMetadata,
|
||||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
|
|
@ -160,14 +162,25 @@ export const TaskCommentsSection = ({
|
|||
try {
|
||||
const serialized = serializeChipsWithText(trimmed, chipDraft.chips);
|
||||
const text = replyTo ? buildReplyBlock(replyTo.author, replyTo.text, serialized) : serialized;
|
||||
await addTaskComment(teamName, taskId, text);
|
||||
const taskRefs = extractTaskRefsFromText(draft.value, taskSuggestions);
|
||||
await addTaskComment(teamName, taskId, { text, taskRefs });
|
||||
draft.clearDraft();
|
||||
chipDraft.clearChipDraft();
|
||||
setReplyTo(null);
|
||||
} catch {
|
||||
// Error is stored in addCommentError via store
|
||||
}
|
||||
}, [canSubmit, addTaskComment, teamName, taskId, trimmed, draft, chipDraft, replyTo]);
|
||||
}, [
|
||||
canSubmit,
|
||||
addTaskComment,
|
||||
teamName,
|
||||
taskId,
|
||||
trimmed,
|
||||
draft,
|
||||
chipDraft,
|
||||
replyTo,
|
||||
taskSuggestions,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div ref={commentsRef}>
|
||||
|
|
@ -281,6 +294,7 @@ export const TaskCommentsSection = ({
|
|||
replyText: stripAgentBlocks(reply.replyText),
|
||||
}}
|
||||
memberColor={colorMap.get(reply.agentName)}
|
||||
replyTaskRefs={comment.taskRefs}
|
||||
bodyMaxHeight="max-h-none"
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -294,8 +308,9 @@ export const TaskCommentsSection = ({
|
|||
if (link) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const id = link.getAttribute('href')?.replace('task://', '');
|
||||
if (id) onTaskIdClick(id);
|
||||
const href = link.getAttribute('href');
|
||||
const parsed = href ? parseTaskLinkHref(href) : null;
|
||||
if (parsed?.taskId) onTaskIdClick(parsed.taskId);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
|
|
@ -303,7 +318,7 @@ export const TaskCommentsSection = ({
|
|||
>
|
||||
<MarkdownViewer
|
||||
content={(() => {
|
||||
let t = linkifyTaskIdsInMarkdown(displayText);
|
||||
let t = linkifyTaskIdsInMarkdown(displayText, comment.taskRefs);
|
||||
if (colorMap.size > 0 || teamNamesForLinkify.length > 0)
|
||||
t = linkifyAllMentionsInMarkdown(
|
||||
t,
|
||||
|
|
@ -426,7 +441,7 @@ export const TaskCommentsSection = ({
|
|||
</span>
|
||||
) : null}
|
||||
{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}
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ export const MemberDraftRow = ({
|
|||
placeholder="How this agent should behave, interact with others..."
|
||||
footerRight={
|
||||
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
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -17,13 +17,21 @@ import { formatAgentRole } from '@renderer/utils/formatAgentRole';
|
|||
import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
|
||||
import { getTeamColorSet } from '@renderer/constants/teamColors';
|
||||
import { nameColorSet } from '@renderer/utils/projectColor';
|
||||
import { stripEncodedTaskReferenceMetadata } from '@renderer/utils/taskReferenceUtils';
|
||||
import {
|
||||
extractTaskRefsFromText,
|
||||
stripEncodedTaskReferenceMetadata,
|
||||
} from '@renderer/utils/taskReferenceUtils';
|
||||
import { MAX_TEXT_LENGTH } from '@shared/constants';
|
||||
import { AlertCircle, Check, ChevronDown, ImagePlus, Mic, Search, Send } from 'lucide-react';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector';
|
||||
import type { AttachmentPayload, ResolvedTeamMember, SendMessageResult } from '@shared/types';
|
||||
import type {
|
||||
AttachmentPayload,
|
||||
ResolvedTeamMember,
|
||||
SendMessageResult,
|
||||
TaskRef,
|
||||
} from '@shared/types';
|
||||
|
||||
interface MessageComposerProps {
|
||||
teamName: string;
|
||||
|
|
@ -37,13 +45,15 @@ interface MessageComposerProps {
|
|||
text: string,
|
||||
summary?: string,
|
||||
attachments?: AttachmentPayload[],
|
||||
actionMode?: ActionMode
|
||||
actionMode?: ActionMode,
|
||||
taskRefs?: TaskRef[]
|
||||
) => void;
|
||||
onCrossTeamSend?: (
|
||||
toTeam: string,
|
||||
text: string,
|
||||
summary?: string,
|
||||
actionMode?: ActionMode
|
||||
actionMode?: ActionMode,
|
||||
taskRefs?: TaskRef[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
|
|
@ -202,9 +212,10 @@ export const MessageComposer = ({
|
|||
const handleSend = useCallback(() => {
|
||||
if (!canSend) return;
|
||||
pendingSendRef.current = true;
|
||||
const taskRefs = extractTaskRefsFromText(draft.text, taskSuggestions);
|
||||
const serialized = serializeChipsWithText(trimmed, draft.chips);
|
||||
if (isCrossTeam && selectedTeam && onCrossTeamSend) {
|
||||
onCrossTeamSend(selectedTeam, serialized, trimmed, actionMode);
|
||||
onCrossTeamSend(selectedTeam, serialized, trimmed, actionMode, taskRefs);
|
||||
} else {
|
||||
// Summary should stay compact (no expanded chip markdown)
|
||||
onSend(
|
||||
|
|
@ -212,7 +223,8 @@ export const MessageComposer = ({
|
|||
serialized,
|
||||
trimmed,
|
||||
draft.attachments.length > 0 ? draft.attachments : undefined,
|
||||
actionMode
|
||||
actionMode,
|
||||
taskRefs
|
||||
);
|
||||
}
|
||||
}, [
|
||||
|
|
@ -226,6 +238,7 @@ export const MessageComposer = ({
|
|||
selectedTeam,
|
||||
draft.attachments,
|
||||
draft.chips,
|
||||
taskSuggestions,
|
||||
]);
|
||||
|
||||
// Clear draft only after send completes successfully (sending: true → false, no error)
|
||||
|
|
@ -323,10 +336,12 @@ export const MessageComposer = ({
|
|||
);
|
||||
|
||||
const remaining = MAX_TEXT_LENGTH - trimmed.length;
|
||||
const hasAttachmentPreviewContent =
|
||||
draft.attachments.length > 0 || Boolean(draft.attachmentError ?? imageRestrictionError);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mb-3 p-3"
|
||||
className="relative mb-3 pb-3"
|
||||
role="group"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
|
@ -336,208 +351,296 @@ export const MessageComposer = ({
|
|||
>
|
||||
<DropZoneOverlay active={isDragOver} rejected={!isLeadRecipient} />
|
||||
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
{isLeadRecipient ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
multiple
|
||||
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 className="mb-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLeadRecipient ? (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
<Tooltip>
|
||||
<TooltipTrigger 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)]'
|
||||
'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()}
|
||||
>
|
||||
{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>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
) : 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 ? (
|
||||
<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 */}
|
||||
<span className="truncate text-[var(--color-text)]">This team</span>
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
current
|
||||
</span>
|
||||
{!isCrossTeam ? (
|
||||
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
||||
) : null}
|
||||
</button>
|
||||
|
||||
{/* 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
|
||||
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)]'
|
||||
'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)]'
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedTeam(null);
|
||||
setTeamSelectorOpen(false);
|
||||
}}
|
||||
disabled={isCrossTeam}
|
||||
>
|
||||
{currentTeamColor ? (
|
||||
<span
|
||||
className="inline-block size-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: currentTeamColor }}
|
||||
{recipient ? (
|
||||
<MemberBadge
|
||||
name={recipient}
|
||||
color={selectedResolvedColor}
|
||||
size="sm"
|
||||
hideAvatar={recipient === 'user'}
|
||||
disableHoverCard
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate text-[var(--color-text)]">This team</span>
|
||||
<span className="shrink-0 text-[10px] text-[var(--color-text-muted)]">
|
||||
current
|
||||
</span>
|
||||
{!isCrossTeam ? (
|
||||
<Check size={12} className="ml-auto shrink-0 text-blue-400" />
|
||||
) : null}
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">Select...</span>
|
||||
)}
|
||||
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
||||
</button>
|
||||
|
||||
{/* 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}
|
||||
</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>
|
||||
{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}
|
||||
>
|
||||
);
|
||||
}
|
||||
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>
|
||||
) : (
|
||||
<Popover open={recipientOpen} onOpenChange={setRecipientOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'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}
|
||||
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
|
||||
|
|
@ -637,114 +740,20 @@ export const MessageComposer = ({
|
|||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</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>
|
||||
|
||||
<MentionableTextarea
|
||||
|
|
@ -836,7 +845,7 @@ export const MessageComposer = ({
|
|||
</span>
|
||||
) : null}
|
||||
{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}
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import { MessagesFilterPopover } from './MessagesFilterPopover';
|
|||
|
||||
import type { MessagesFilterState } from './MessagesFilterPopover';
|
||||
import type { ActionMode } from './ActionModeSelector';
|
||||
import type { InboxMessage, ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
||||
import type { InboxMessage, ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
interface TimeWindow {
|
||||
start: number;
|
||||
|
|
@ -188,7 +188,8 @@ export const MessagesPanel = ({
|
|||
attachments?: Parameters<typeof sendTeamMessage>[1] extends { attachments?: infer A }
|
||||
? A
|
||||
: never,
|
||||
actionMode?: ActionMode
|
||||
actionMode?: ActionMode,
|
||||
taskRefs?: TaskRef[]
|
||||
) => {
|
||||
const sentAtMs = Date.now();
|
||||
onPendingReplyChange((prev) => ({ ...prev, [member]: sentAtMs }));
|
||||
|
|
@ -198,6 +199,7 @@ export const MessagesPanel = ({
|
|||
summary,
|
||||
attachments,
|
||||
actionMode,
|
||||
taskRefs,
|
||||
}).catch(() => {
|
||||
onPendingReplyChange((prev) => {
|
||||
if (prev[member] !== sentAtMs) return prev;
|
||||
|
|
@ -211,12 +213,19 @@ export const MessagesPanel = ({
|
|||
);
|
||||
|
||||
const handleCrossTeamSend = useCallback(
|
||||
(toTeam: string, text: string, summary?: string, actionMode?: ActionMode) => {
|
||||
(
|
||||
toTeam: string,
|
||||
text: string,
|
||||
summary?: string,
|
||||
actionMode?: ActionMode,
|
||||
taskRefs?: TaskRef[]
|
||||
) => {
|
||||
void sendCrossTeamMessage({
|
||||
fromTeam: teamName,
|
||||
fromMember: 'user',
|
||||
toTeam,
|
||||
text,
|
||||
taskRefs,
|
||||
actionMode,
|
||||
summary,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,13 +19,18 @@ import {
|
|||
reconcileChips,
|
||||
removeChipTokenFromText,
|
||||
} from '@renderer/utils/chipUtils';
|
||||
import { Link2 } from 'lucide-react';
|
||||
import {
|
||||
findUrlBoundary,
|
||||
findUrlMatches,
|
||||
removeUrlMatchFromText,
|
||||
} from '@renderer/utils/urlMatchUtils';
|
||||
|
||||
import { AutoResizeTextarea } from './auto-resize-textarea';
|
||||
import { ChipInteractionLayer } from './ChipInteractionLayer';
|
||||
import { CodeChipBadge } from './CodeChipBadge';
|
||||
import { MentionSuggestionList } from './MentionSuggestionList';
|
||||
import { TaskReferenceInteractionLayer } from './TaskReferenceInteractionLayer';
|
||||
import { UrlInteractionLayer } from './UrlInteractionLayer';
|
||||
|
||||
import type { AutoResizeTextareaProps } from './auto-resize-textarea';
|
||||
import type { InlineChip } from '@renderer/types/inlineChip';
|
||||
|
|
@ -66,40 +71,6 @@ interface ChipSegment {
|
|||
|
||||
type Segment = TextSegment | MentionSegment | TaskSegment | UrlSegment | ChipSegment;
|
||||
|
||||
interface TextMatch {
|
||||
start: number;
|
||||
end: number;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const URL_REGEX = /https?:\/\/[^\s]+/g;
|
||||
|
||||
function trimUrlMatch(rawUrl: string): string {
|
||||
return rawUrl.replace(/[),.!?;:]+$/g, '');
|
||||
}
|
||||
|
||||
function findUrlMatches(text: string): TextMatch[] {
|
||||
if (!text) return [];
|
||||
|
||||
const matches: TextMatch[] = [];
|
||||
for (const match of text.matchAll(URL_REGEX)) {
|
||||
const rawValue = match[0];
|
||||
const start = match.index ?? -1;
|
||||
if (start < 0) continue;
|
||||
|
||||
const trimmedValue = trimUrlMatch(rawValue);
|
||||
if (!trimmedValue) continue;
|
||||
|
||||
matches.push({
|
||||
start,
|
||||
end: start + trimmedValue.length,
|
||||
value: trimmedValue,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mention segment parsing (splits text into plain text + @mention segments)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -308,9 +279,9 @@ function parseSegments(
|
|||
// Default fallback color for mentions without a team color
|
||||
const DEFAULT_MENTION_BG = 'rgba(59, 130, 246, 0.15)';
|
||||
const DEFAULT_MENTION_TEXT = '#60a5fa';
|
||||
const URL_BADGE_BG = 'rgba(30, 58, 138, 0.32)';
|
||||
const URL_BADGE_BORDER = 'rgba(96, 165, 250, 0.28)';
|
||||
const URL_BADGE_TEXT = '#f8fafc';
|
||||
const URL_BADGE_BG = 'rgba(37, 99, 235, 0.12)';
|
||||
const URL_BADGE_BORDER = 'rgba(96, 165, 250, 0.22)';
|
||||
const URL_BADGE_TEXT = '#bfdbfe';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
|
|
@ -325,7 +296,7 @@ interface MentionableTextareaProps extends Omit<
|
|||
suggestions: MentionSuggestion[];
|
||||
hintText?: string;
|
||||
showHint?: boolean;
|
||||
/** Content rendered at the right side of the footer row (e.g. "Draft saved") */
|
||||
/** Content rendered at the right side of the footer row (e.g. "Saved") */
|
||||
footerRight?: React.ReactNode;
|
||||
/** Content rendered in the bottom-right corner inside the textarea (e.g. send button) */
|
||||
cornerAction?: React.ReactNode;
|
||||
|
|
@ -658,6 +629,11 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
[taskSuggestions, value]
|
||||
);
|
||||
|
||||
const findUrlTokenBoundary = React.useCallback(
|
||||
(cursorPos: number) => findUrlBoundary(value, cursorPos),
|
||||
[value]
|
||||
);
|
||||
|
||||
const handleChipKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const textarea = internalRef.current;
|
||||
|
|
@ -670,6 +646,16 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
const cursorPos = selectionStart;
|
||||
|
||||
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);
|
||||
if (taskBoundary && cursorPos === taskBoundary.end) {
|
||||
e.preventDefault();
|
||||
|
|
@ -694,6 +680,16 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
});
|
||||
}
|
||||
} 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);
|
||||
if (taskBoundary && cursorPos === taskBoundary.start) {
|
||||
e.preventDefault();
|
||||
|
|
@ -717,6 +713,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
});
|
||||
}
|
||||
} 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);
|
||||
if (taskBoundary && cursorPos === taskBoundary.end) {
|
||||
e.preventDefault();
|
||||
|
|
@ -731,6 +733,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
textarea.setSelectionRange(boundary.start, boundary.start);
|
||||
}
|
||||
} 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);
|
||||
if (taskBoundary && cursorPos === taskBoundary.start) {
|
||||
e.preventDefault();
|
||||
|
|
@ -745,6 +753,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
textarea.setSelectionRange(boundary.end, boundary.end);
|
||||
}
|
||||
} 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);
|
||||
if (taskBoundary && cursorPos === taskBoundary.end) {
|
||||
e.preventDefault();
|
||||
|
|
@ -759,6 +773,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
textarea.setSelectionRange(boundary.start, selectionEnd);
|
||||
}
|
||||
} 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);
|
||||
if (taskBoundary && cursorPos === taskBoundary.start) {
|
||||
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
|
||||
|
|
@ -868,9 +888,20 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
requestAnimationFrame(() => {
|
||||
textarea.setSelectionRange(snapTo, snapTo);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const urlBoundary = findUrlTokenBoundary(selectionStart);
|
||||
if (urlBoundary && selectionStart > urlBoundary.start && selectionStart < urlBoundary.end) {
|
||||
const distToStart = selectionStart - urlBoundary.start;
|
||||
const distToEnd = urlBoundary.end - selectionStart;
|
||||
const snapTo = distToStart <= distToEnd ? urlBoundary.start : urlBoundary.end;
|
||||
requestAnimationFrame(() => {
|
||||
textarea.setSelectionRange(snapTo, snapTo);
|
||||
});
|
||||
}
|
||||
},
|
||||
[mentionHandleSelect, chips, value, findEncodedTaskBoundary]
|
||||
[mentionHandleSelect, chips, value, findEncodedTaskBoundary, findUrlTokenBoundary]
|
||||
);
|
||||
|
||||
// --- Chip remove handler (from X button in interaction layer) ---
|
||||
|
|
@ -981,14 +1012,13 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
return (
|
||||
<span
|
||||
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={{
|
||||
backgroundColor: URL_BADGE_BG,
|
||||
color: URL_BADGE_TEXT,
|
||||
boxShadow: `inset 0 0 0 1px ${URL_BADGE_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<Link2 size={11} className="shrink-0 opacity-80" />
|
||||
<span className="truncate">{seg.value}</span>
|
||||
</span>
|
||||
);
|
||||
|
|
@ -1028,6 +1058,21 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
|
|||
/>
|
||||
) : 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
|
||||
ref={setRefs}
|
||||
value={value}
|
||||
|
|
|
|||
102
src/renderer/components/ui/UrlInteractionLayer.tsx
Normal file
102
src/renderer/components/ui/UrlInteractionLayer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -67,7 +67,7 @@ import type { AppState } from '../types';
|
|||
import type { AppConfig } from '@renderer/types/data';
|
||||
import type {
|
||||
AddMemberRequest,
|
||||
CommentAttachmentPayload,
|
||||
AddTaskCommentRequest,
|
||||
CreateTaskRequest,
|
||||
CrossTeamSendRequest,
|
||||
EffortLevel,
|
||||
|
|
@ -346,8 +346,7 @@ export interface TeamSlice {
|
|||
addTaskComment: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
attachments?: CommentAttachmentPayload[]
|
||||
request: AddTaskCommentRequest
|
||||
) => Promise<TaskComment>;
|
||||
addMember: (teamName: string, request: AddMemberRequest) => 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 });
|
||||
try {
|
||||
const comment = await unwrapIpc('team:addTaskComment', () =>
|
||||
api.teams.addTaskComment(teamName, taskId, text, attachments)
|
||||
api.teams.addTaskComment(teamName, taskId, request)
|
||||
);
|
||||
set({ addingComment: false });
|
||||
await get().refreshTeamData(teamName);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { getSuggestionInsertionText } from '@renderer/utils/mentionSuggestions';
|
||||
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { TaskRef } from '@shared/types';
|
||||
|
||||
const TASK_REF_REGEX = /#([A-Za-z0-9-]+)\b/g;
|
||||
const TASK_META_START = '\u2063';
|
||||
|
|
@ -67,6 +68,12 @@ interface EncodedTaskMetadataMatch {
|
|||
end: number;
|
||||
}
|
||||
|
||||
interface ParsedTaskLinkHref {
|
||||
taskId: string;
|
||||
teamName?: string;
|
||||
displayId?: string;
|
||||
}
|
||||
|
||||
function encodeZeroWidthPayload(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value);
|
||||
let encoded = '';
|
||||
|
|
@ -147,6 +154,20 @@ function buildTaskSuggestionFromMetadata(
|
|||
);
|
||||
}
|
||||
|
||||
function buildTaskRefFromSuggestion(
|
||||
suggestion: MentionSuggestion,
|
||||
displayId: string
|
||||
): TaskRef | null {
|
||||
if (!suggestion.taskId || !suggestion.teamName) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
taskId: suggestion.taskId,
|
||||
displayId,
|
||||
teamName: suggestion.teamName,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEncodedTaskReference(
|
||||
displayId: string,
|
||||
taskId: string,
|
||||
|
|
@ -162,11 +183,71 @@ export function createEncodedTaskReference(
|
|||
return `#${displayId}${TASK_META_START}${encodedPayload}${TASK_META_END}`;
|
||||
}
|
||||
|
||||
export function linkifyTaskIdsInMarkdown(text: string): string {
|
||||
return text.replace(TASK_REF_REGEX, (raw, ref: string, offset: number) => {
|
||||
const preceding = offset > 0 ? text[offset - 1] : undefined;
|
||||
return isAllowedTaskRefBoundary(preceding) ? `[${raw}](task://${ref})` : raw;
|
||||
});
|
||||
export function buildTaskLinkHref(taskRef: TaskRef): string {
|
||||
return `task://${encodeURIComponent(taskRef.taskId)}?team=${encodeURIComponent(taskRef.teamName)}&display=${encodeURIComponent(taskRef.displayId)}`;
|
||||
}
|
||||
|
||||
export function parseTaskLinkHref(href: string): ParsedTaskLinkHref | null {
|
||||
if (!href.startsWith('task://')) return null;
|
||||
try {
|
||||
const raw = href.slice('task://'.length);
|
||||
if (!raw) return null;
|
||||
|
||||
const queryIndex = raw.indexOf('?');
|
||||
if (queryIndex === -1) {
|
||||
return {
|
||||
taskId: decodeURIComponent(raw),
|
||||
};
|
||||
}
|
||||
|
||||
const taskIdPart = raw.slice(0, queryIndex);
|
||||
const search = new URLSearchParams(raw.slice(queryIndex + 1));
|
||||
const teamName = search.get('team');
|
||||
const displayId = search.get('display');
|
||||
return {
|
||||
taskId: decodeURIComponent(taskIdPart),
|
||||
teamName: teamName ? decodeURIComponent(teamName) : undefined,
|
||||
displayId: displayId ? decodeURIComponent(displayId) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function linkifyTaskIdsInMarkdown(text: string, taskRefs?: TaskRef[]): string {
|
||||
if (!text) return text;
|
||||
|
||||
const orderedTaskRefs = taskRefs ?? [];
|
||||
let taskRefIndex = 0;
|
||||
let result = '';
|
||||
let cursor = 0;
|
||||
|
||||
for (const match of text.matchAll(TASK_REF_REGEX)) {
|
||||
const raw = match[0];
|
||||
const ref = match[1];
|
||||
const start = match.index ?? -1;
|
||||
if (start < 0) continue;
|
||||
|
||||
result += text.slice(cursor, start);
|
||||
const preceding = start > 0 ? text[start - 1] : undefined;
|
||||
if (!isAllowedTaskRefBoundary(preceding)) {
|
||||
result += raw;
|
||||
cursor = start + raw.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const structuredTaskRef =
|
||||
taskRefIndex < orderedTaskRefs.length &&
|
||||
orderedTaskRefs[taskRefIndex]?.displayId.toLowerCase() === ref.toLowerCase()
|
||||
? orderedTaskRefs[taskRefIndex++]
|
||||
: undefined;
|
||||
const href = structuredTaskRef ? buildTaskLinkHref(structuredTaskRef) : `task://${ref}`;
|
||||
result += `[${raw}](${href})`;
|
||||
cursor = start + raw.length;
|
||||
}
|
||||
|
||||
result += text.slice(cursor);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function stripEncodedTaskReferenceMetadata(text: string): string {
|
||||
|
|
@ -193,12 +274,10 @@ export function findTaskReferenceMatches(
|
|||
text: string,
|
||||
taskSuggestions: MentionSuggestion[]
|
||||
): TaskReferenceMatch[] {
|
||||
if (!text || taskSuggestions.length === 0) return [];
|
||||
if (!text) return [];
|
||||
|
||||
const suggestionsByRef = buildSuggestionsByRef(taskSuggestions);
|
||||
|
||||
if (suggestionsByRef.size === 0) return [];
|
||||
|
||||
const matches: TaskReferenceMatch[] = [];
|
||||
for (const match of text.matchAll(TASK_REF_REGEX)) {
|
||||
const raw = match[0];
|
||||
|
|
@ -227,3 +306,26 @@ export function findTaskReferenceMatches(
|
|||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function extractTaskRefsFromText(
|
||||
text: string,
|
||||
taskSuggestions: MentionSuggestion[]
|
||||
): TaskRef[] {
|
||||
if (!text) return [];
|
||||
|
||||
return findTaskReferenceMatches(text, taskSuggestions)
|
||||
.map((match) => {
|
||||
if (match.encoded) {
|
||||
const metadataMatch = extractEncodedTaskMetadata(text, match.start + match.raw.length);
|
||||
if (!metadataMatch) return null;
|
||||
return {
|
||||
taskId: metadataMatch.metadata.taskId,
|
||||
displayId: metadataMatch.metadata.displayId,
|
||||
teamName: metadataMatch.metadata.teamName,
|
||||
} satisfies TaskRef;
|
||||
}
|
||||
|
||||
return buildTaskRefFromSuggestion(match.suggestion, match.ref);
|
||||
})
|
||||
.filter((taskRef): taskRef is TaskRef => taskRef !== null);
|
||||
}
|
||||
|
|
|
|||
44
src/renderer/utils/urlMatchUtils.ts
Normal file
44
src/renderer/utils/urlMatchUtils.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ import type {
|
|||
} from './review';
|
||||
import type {
|
||||
AddMemberRequest,
|
||||
AddTaskCommentRequest,
|
||||
AttachmentFileData,
|
||||
CommentAttachmentPayload,
|
||||
CreateTaskRequest,
|
||||
|
|
@ -472,8 +473,7 @@ export interface TeamsAPI {
|
|||
addTaskComment: (
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
text: string,
|
||||
attachments?: CommentAttachmentPayload[]
|
||||
request: AddTaskCommentRequest
|
||||
) => Promise<TaskComment>;
|
||||
setTaskClarification: (
|
||||
teamName: string,
|
||||
|
|
|
|||
|
|
@ -117,12 +117,19 @@ export type TaskHistoryEvent =
|
|||
|
||||
export type TaskCommentType = 'regular' | 'review_request' | 'review_approved';
|
||||
|
||||
export interface TaskRef {
|
||||
taskId: string;
|
||||
displayId: string;
|
||||
teamName: string;
|
||||
}
|
||||
|
||||
export interface TaskComment {
|
||||
id: string;
|
||||
author: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
type: TaskCommentType;
|
||||
taskRefs?: TaskRef[];
|
||||
/** Attachments on this comment. Metadata only — files stored on disk. */
|
||||
attachments?: TaskAttachmentMeta[];
|
||||
}
|
||||
|
|
@ -135,7 +142,10 @@ export interface TeamTask {
|
|||
displayId?: string;
|
||||
subject: string;
|
||||
description?: string;
|
||||
descriptionTaskRefs?: TaskRef[];
|
||||
activeForm?: string;
|
||||
prompt?: string;
|
||||
promptTaskRefs?: TaskRef[];
|
||||
owner?: string;
|
||||
createdBy?: string;
|
||||
status: TeamTaskStatus;
|
||||
|
|
@ -244,6 +254,7 @@ export interface InboxMessage {
|
|||
text: string;
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
taskRefs?: TaskRef[];
|
||||
summary?: string;
|
||||
color?: string;
|
||||
messageId?: string;
|
||||
|
|
@ -273,6 +284,7 @@ export type AgentActionMode = 'do' | 'ask' | 'delegate';
|
|||
export interface SendMessageRequest {
|
||||
member: string;
|
||||
text: string;
|
||||
taskRefs?: TaskRef[];
|
||||
actionMode?: AgentActionMode;
|
||||
summary?: string;
|
||||
from?: string;
|
||||
|
|
@ -298,6 +310,12 @@ export interface SendMessageResult {
|
|||
deduplicated?: boolean;
|
||||
}
|
||||
|
||||
export interface AddTaskCommentRequest {
|
||||
text: string;
|
||||
attachments?: CommentAttachmentPayload[];
|
||||
taskRefs?: TaskRef[];
|
||||
}
|
||||
|
||||
export type MemberStatus = 'active' | 'idle' | 'terminated' | 'unknown';
|
||||
|
||||
/**
|
||||
|
|
@ -329,7 +347,7 @@ export interface KanbanState {
|
|||
export type UpdateKanbanPatch =
|
||||
| { op: 'set_column'; column: Extract<KanbanColumnId, 'review' | 'approved'> }
|
||||
| { op: 'remove' }
|
||||
| { op: 'request_changes'; comment?: string };
|
||||
| { op: 'request_changes'; comment?: string; taskRefs?: TaskRef[] };
|
||||
|
||||
export interface ResolvedTeamMember {
|
||||
name: string;
|
||||
|
|
@ -398,10 +416,12 @@ export interface TeamLaunchResponse {
|
|||
export interface CreateTaskRequest {
|
||||
subject: string;
|
||||
description?: string;
|
||||
descriptionTaskRefs?: TaskRef[];
|
||||
owner?: string;
|
||||
blockedBy?: string[];
|
||||
related?: string[];
|
||||
prompt?: string;
|
||||
promptTaskRefs?: TaskRef[];
|
||||
startImmediately?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -656,6 +676,7 @@ export interface CrossTeamMessage {
|
|||
conversationId?: string;
|
||||
replyToConversationId?: string;
|
||||
text: string;
|
||||
taskRefs?: TaskRef[];
|
||||
summary?: string;
|
||||
chainDepth: number;
|
||||
timestamp: string;
|
||||
|
|
@ -670,6 +691,7 @@ export interface CrossTeamSendRequest {
|
|||
conversationId?: string;
|
||||
replyToConversationId?: string;
|
||||
text: string;
|
||||
taskRefs?: TaskRef[];
|
||||
actionMode?: AgentActionMode;
|
||||
summary?: string;
|
||||
chainDepth?: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue