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