= {
},
/** Reserved for the human user — never assigned to team members. */
user: {
- border: '#f5f5f4',
- borderLight: '#78716c',
- badge: 'rgba(245, 245, 244, 0.12)',
- badgeLight: 'rgba(87, 83, 78, 0.18)',
+ border: '#a8a29e',
+ borderLight: '#57534e',
+ badge: 'rgba(168, 162, 158, 0.18)',
+ badgeLight: 'rgba(68, 64, 60, 0.14)',
text: '#d6d3d1',
- textLight: '#44403c',
+ textLight: '#292524',
},
};
diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts
index 91ef7aaf..af1995ea 100644
--- a/src/renderer/store/slices/teamSlice.ts
+++ b/src/renderer/store/slices/teamSlice.ts
@@ -7,6 +7,7 @@ import {
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc';
+import { stripAgentBlocks } from '@shared/constants/agentBlocks';
import { createLogger } from '@shared/utils/logger';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
@@ -145,8 +146,9 @@ function detectClarificationNotifications(
function fireClarificationNotification(task: GlobalTask, suppressToast: boolean): void {
// Delegate to main process for native OS notification (cross-platform, no permission needed)
const latestComment = task.comments?.length ? task.comments[task.comments.length - 1] : undefined;
- const body =
+ const rawBody =
latestComment?.text || task.description || `${formatTaskDisplayLabel(task)}: ${task.subject}`;
+ const body = stripAgentBlocks(rawBody).trim();
void api.teams
?.showMessageNotification({
@@ -295,7 +297,11 @@ function fireTaskCommentNotification(
comment: { author: string; text: string; id: string },
suppressToast: boolean
): void {
- const preview = comment.text.length > 100 ? comment.text.slice(0, 100) + '...' : comment.text;
+ // Double-check: never notify about user's own comments
+ if (comment.author === 'user') return;
+
+ const stripped = stripAgentBlocks(comment.text).trim();
+ const preview = stripped.length > 100 ? stripped.slice(0, 100) + '...' : stripped;
void api.teams
?.showMessageNotification({
@@ -337,7 +343,7 @@ function fireTaskCreatedNotification(task: GlobalTask, suppressToast: boolean):
from: task.owner ?? 'system',
to: 'user',
summary: `New task ${formatTaskDisplayLabel(task)}: ${task.subject}`,
- body: task.description || task.subject,
+ body: stripAgentBlocks(task.description || task.subject).trim(),
teamEventType: 'task_created',
dedupeKey: `created:${task.teamName}:${task.id}`,
suppressToast,
diff --git a/src/renderer/utils/chipUtils.ts b/src/renderer/utils/chipUtils.ts
index 94d2cecc..e479c843 100644
--- a/src/renderer/utils/chipUtils.ts
+++ b/src/renderer/utils/chipUtils.ts
@@ -317,8 +317,7 @@ export function calculateMentionPositions(
// Character after name must be boundary
if (end < text.length) {
const after = text[end];
- // eslint-disable-next-line no-useless-escape
- if (!/[\s,.:;!?\)\]\}\-]/.test(after)) continue;
+ if (!/[\s,.:;!?)\]}-]/.test(after)) continue;
}
matches.push({ item: suggestion, start: i, end, token: text.slice(i, end) });
i = end;
diff --git a/src/renderer/utils/mentionLinkify.ts b/src/renderer/utils/mentionLinkify.ts
index ca7466b4..24d51a38 100644
--- a/src/renderer/utils/mentionLinkify.ts
+++ b/src/renderer/utils/mentionLinkify.ts
@@ -25,11 +25,12 @@ export function linkifyMentionsInMarkdown(
const names = [...memberColorMap.keys()].sort((a, b) => b.length - a.length);
const escaped = names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(
+ // eslint-disable-next-line no-useless-escape -- backslash-quote and backslash-hyphen needed in template literal for RegExp
`(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`,
'gi'
);
- return text.replace(pattern, (_match, prefix: string, name: string) => {
+ return text.replace(pattern, (_match: string, prefix: string, name: string) => {
// Find the canonical name (case-insensitive lookup)
const canonical = names.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
const color = memberColorMap.get(canonical) ?? '';
@@ -49,18 +50,19 @@ export function linkifyTeamMentionsInMarkdown(
text: string,
teamNames: ReadonlySet | readonly string[]
): string {
- const names = Array.isArray(teamNames) ? teamNames : [...teamNames];
+ const names: readonly string[] = Array.isArray(teamNames) ? teamNames : [...teamNames];
if (names.length === 0) return text;
// Sort by name length descending for greedy matching
const sorted = [...names].sort((a, b) => b.length - a.length);
const escaped = sorted.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
const pattern = new RegExp(
+ // eslint-disable-next-line no-useless-escape -- backslash-quote and backslash-hyphen needed in template literal for RegExp
`(^|[\\s(\\[{"\'])@(${escaped.join('|')})(?=[\\s,.:;!?)\\]}\-]|$)`,
'gi'
);
- return text.replace(pattern, (_match, prefix: string, name: string) => {
+ return text.replace(pattern, (_match: string, prefix: string, name: string) => {
const canonical = sorted.find((n) => n.toLowerCase() === name.toLowerCase()) ?? name;
return `${prefix}[${canonical}](team://${encodeURIComponent(canonical)})`;
});
diff --git a/src/renderer/utils/taskChangeRequest.ts b/src/renderer/utils/taskChangeRequest.ts
index 2a8a398c..e384deca 100644
--- a/src/renderer/utils/taskChangeRequest.ts
+++ b/src/renderer/utils/taskChangeRequest.ts
@@ -47,7 +47,8 @@ export function deriveTaskSince(task: TaskChangeTaskLike | null): string | undef
}
if (sources.length === 0) return undefined;
- const earliest = sources.reduce((a, b) => (a < b ? a : b));
+ const [first, ...rest] = sources;
+ const earliest = rest.reduce((a, b) => (a < b ? a : b), first);
const date = new Date(earliest);
date.setTime(date.getTime() - TASK_SINCE_GRACE_MS);
return date.toISOString();
diff --git a/src/renderer/utils/taskReferenceUtils.ts b/src/renderer/utils/taskReferenceUtils.ts
index d1ceef72..7426a8cd 100644
--- a/src/renderer/utils/taskReferenceUtils.ts
+++ b/src/renderer/utils/taskReferenceUtils.ts
@@ -13,7 +13,7 @@ const ZERO_WIDTH_TO_BITS = new Map(
function isAllowedTaskRefBoundary(char: string | undefined): boolean {
if (!char) return true;
- return !/[A-Za-z0-9_]/.test(char);
+ return !/\w/.test(char);
}
function buildSuggestionsByRef(
diff --git a/src/renderer/utils/teamMessageFiltering.ts b/src/renderer/utils/teamMessageFiltering.ts
index cbae55f1..4eac6397 100644
--- a/src/renderer/utils/teamMessageFiltering.ts
+++ b/src/renderer/utils/teamMessageFiltering.ts
@@ -33,14 +33,14 @@ export function filterTeamMessages(
const hasTo = filter.to.size > 0;
if (hasFrom && hasTo) {
list = list.filter((m) => {
- const fromMatch = m.from?.trim() && filter.from.has(m.from.trim());
- const toMatch = m.to?.trim() && filter.to.has(m.to.trim());
+ const fromMatch = Boolean(m.from?.trim() && filter.from.has(m.from.trim()));
+ const toMatch = Boolean(m.to?.trim() && filter.to.has(m.to.trim()));
return fromMatch && toMatch;
});
} else if (hasFrom || hasTo) {
list = list.filter((m) => {
- if (hasFrom) return m.from?.trim() && filter.from.has(m.from.trim());
- if (hasTo) return m.to?.trim() && filter.to.has(m.to.trim());
+ if (hasFrom) return Boolean(m.from?.trim() && filter.from.has(m.from.trim()));
+ if (hasTo) return Boolean(m.to?.trim() && filter.to.has(m.to.trim()));
return true;
});
}
diff --git a/src/renderer/utils/urlMatchUtils.ts b/src/renderer/utils/urlMatchUtils.ts
index 878164a9..2d4b24ce 100644
--- a/src/renderer/utils/urlMatchUtils.ts
+++ b/src/renderer/utils/urlMatchUtils.ts
@@ -4,9 +4,10 @@ export interface TextMatch {
value: string;
}
-const URL_REGEX = /https?:\/\/[^\s]+/g;
+const URL_REGEX = /https?:\/\/\S+/g;
function trimUrlMatch(rawUrl: string): string {
+ // eslint-disable-next-line sonarjs/slow-regex -- trailing punctuation only, input bounded
return rawUrl.replace(/[),.!?;:]+$/g, '');
}
diff --git a/src/shared/constants/crossTeam.ts b/src/shared/constants/crossTeam.ts
index f4a949ce..c54decfd 100644
--- a/src/shared/constants/crossTeam.ts
+++ b/src/shared/constants/crossTeam.ts
@@ -38,7 +38,10 @@ function unescapeCrossTeamAttribute(value: string): string {
function parseCrossTeamAttributes(raw: string): Map {
const attrs = new Map();
- const matches = raw.matchAll(/([A-Za-z][A-Za-z0-9]*)="([^"]*)"/g);
+ const matches = raw.matchAll(
+ /* eslint-disable-next-line sonarjs/slow-regex -- attr values bounded by quotes, trusted prefix input */
+ /([A-Za-z][A-Za-z0-9]*)="([^"]*)"/g
+ );
for (const match of matches) {
const key = match[1]?.trim();
const value = match[2];
diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts
index 9b5bb57f..f19ba5f7 100644
--- a/src/shared/types/api.ts
+++ b/src/shared/types/api.ts
@@ -40,7 +40,6 @@ import type {
AddMemberRequest,
AddTaskCommentRequest,
AttachmentFileData,
- CommentAttachmentPayload,
CreateTaskRequest,
CrossTeamMessage,
CrossTeamSendRequest,
diff --git a/src/shared/types/extensions/mcp.ts b/src/shared/types/extensions/mcp.ts
index 577bd32a..cc971734 100644
--- a/src/shared/types/extensions/mcp.ts
+++ b/src/shared/types/extensions/mcp.ts
@@ -100,10 +100,12 @@ export interface McpServerDiagnostic {
// ── Install request (renderer → main, minimal trusted data) ────────────────
+export type McpInstallScope = 'local' | 'user' | 'project';
+
export interface McpInstallRequest {
registryId: string; // server ID from registry (NOT full catalog item)
serverName: string; // user-chosen name for `claude mcp add`
- scope: 'local' | 'user' | 'project';
+ scope: McpInstallScope;
projectPath?: string; // required for 'project' scope
envValues: Record;
headers: McpHeaderDef[]; // for HTTP/SSE servers (CLI --header flag)
@@ -113,7 +115,7 @@ export interface McpInstallRequest {
export interface McpCustomInstallRequest {
serverName: string;
- scope: 'local' | 'user' | 'project';
+ scope: McpInstallScope;
projectPath?: string;
installSpec: McpInstallSpec; // user provides directly
envValues: Record;
diff --git a/src/shared/types/notifications.ts b/src/shared/types/notifications.ts
index f5cc5e57..66bdb2a0 100644
--- a/src/shared/types/notifications.ts
+++ b/src/shared/types/notifications.ts
@@ -15,6 +15,24 @@ import type { TriggerColor } from '@shared/constants/triggerColors';
// Detected Error Types
// =============================================================================
+/**
+ * Team notification event sub-types.
+ * Single source of truth — used by DetectedError, TeamNotificationPayload, and TEAM_NOTIFICATION_CONFIG.
+ */
+export type TeamEventType =
+ | 'rate_limit'
+ | 'lead_inbox'
+ | 'user_inbox'
+ | 'task_clarification'
+ | 'task_status_change'
+ | 'task_comment'
+ | 'task_created'
+ | 'all_tasks_completed'
+ | 'cross_team_message'
+ | 'schedule_completed'
+ | 'schedule_failed'
+ | 'team_launched';
+
/**
* Detected error from session JSONL files.
* Used for notification display and deep linking to error locations.
@@ -53,18 +71,7 @@ export interface DetectedError {
/** Notification domain: 'error' (default/undefined) or 'team' */
category?: 'error' | 'team';
/** For team notifications: specific event sub-type */
- teamEventType?:
- | 'rate_limit'
- | 'lead_inbox'
- | 'user_inbox'
- | 'task_clarification'
- | 'task_status_change'
- | 'task_comment'
- | 'task_created'
- | 'all_tasks_completed'
- | 'cross_team_message'
- | 'schedule_completed'
- | 'schedule_failed';
+ teamEventType?: TeamEventType;
/** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */
dedupeKey?: string;
/** Additional context */
@@ -280,6 +287,8 @@ export interface AppConfig {
notifyOnAllTasksCompleted: boolean;
/** Whether to show native OS notifications for cross-team messages */
notifyOnCrossTeamMessage: boolean;
+ /** Whether to show native OS notifications when a team finishes launching */
+ notifyOnTeamLaunched: boolean;
/** Only notify on status changes in solo teams (no teammates) */
statusChangeOnlySolo: boolean;
/** Which target statuses to notify about (e.g. ['in_progress', 'completed']) */
diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts
index c13d9d24..1d016592 100644
--- a/src/shared/types/team.ts
+++ b/src/shared/types/team.ts
@@ -154,8 +154,14 @@ export interface SourceMessageSnapshot {
timestamp: string;
/** Message source type (e.g. "user_sent", "inbox"). */
source?: string;
- /** Attachment metadata references (IDs only, no blobs). */
- attachments?: { id: string; filename: string; mimeType: string; size: number }[];
+ /** Attachment metadata references (IDs only, no blobs). filePath present when file is stored on disk. */
+ attachments?: {
+ id: string;
+ filename: string;
+ mimeType: string;
+ size: number;
+ filePath?: string;
+ }[];
}
// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record`.
@@ -229,6 +235,8 @@ export interface TaskAttachmentMeta {
size: number;
/** ISO timestamp when the attachment was added. */
addedAt: string;
+ /** Absolute path to the file on disk. Null/absent for metadata-only references. */
+ filePath?: string | null;
}
/** Payload for uploading an attachment with base64 data (renderer → main). */
@@ -256,6 +264,8 @@ export interface AttachmentMeta {
filename: string;
mimeType: AttachmentMediaType;
size: number;
+ /** Absolute path to the file on disk. Absent for metadata-only references. */
+ filePath?: string;
}
export interface AttachmentPayload extends AttachmentMeta {
diff --git a/src/shared/utils/apiErrorDetector.ts b/src/shared/utils/apiErrorDetector.ts
new file mode 100644
index 00000000..c6683b2b
--- /dev/null
+++ b/src/shared/utils/apiErrorDetector.ts
@@ -0,0 +1,13 @@
+/**
+ * Detects API error messages from Claude CLI output.
+ * Pattern: "API Error: " at the beginning of the text.
+ */
+
+const API_ERROR_RE = /^API Error:\s*\d{3}/;
+
+/**
+ * Returns true if the message text starts with "API Error: ".
+ */
+export function isApiErrorMessage(text: string): boolean {
+ return API_ERROR_RE.test(text);
+}
diff --git a/src/shared/utils/extensionNormalizers.ts b/src/shared/utils/extensionNormalizers.ts
index e3f701e0..5f86ca7b 100644
--- a/src/shared/utils/extensionNormalizers.ts
+++ b/src/shared/utils/extensionNormalizers.ts
@@ -12,7 +12,11 @@ export function normalizeRepoUrl(url: string): string {
return url
.toLowerCase()
.replace(/\.git$/, '')
- .replace(/\/+$/, '');
+ .replace(
+ /* eslint-disable-next-line sonarjs/slow-regex -- trailing slashes only, URL length bounded */
+ /\/+$/,
+ ''
+ );
}
/**
@@ -112,7 +116,11 @@ export function parseGitHubOwnerRepo(url: string): { owner: string; repo: string
const parts = parsed.pathname
.replace(/^\//, '')
.replace(/\.git$/, '')
- .replace(/\/+$/, '')
+ .replace(
+ /* eslint-disable-next-line sonarjs/slow-regex -- trailing slashes only, pathname bounded */
+ /\/+$/,
+ ''
+ )
.split('/');
if (parts.length < 2 || !parts[0] || !parts[1]) return null;
return { owner: parts[0], repo: parts[1] };
diff --git a/src/shared/utils/pricing.ts b/src/shared/utils/pricing.ts
index 7a175df1..bf504472 100644
--- a/src/shared/utils/pricing.ts
+++ b/src/shared/utils/pricing.ts
@@ -1,4 +1,3 @@
-// eslint-disable-next-line no-restricted-imports -- resources/ is outside src/, no alias available
import pricingData from '../../../resources/pricing.json';
export interface LiteLLMPricing {
diff --git a/src/shared/utils/toolSummary.ts b/src/shared/utils/toolSummary.ts
index 779ce3a5..b2b490b2 100644
--- a/src/shared/utils/toolSummary.ts
+++ b/src/shared/utils/toolSummary.ts
@@ -34,7 +34,9 @@ export function parseToolSummary(summary: string | undefined): ToolSummaryData |
if (!match) return null;
const byName: Record = {};
for (const part of match[2].split(', ')) {
- const m = /^(\d+)\s+(\S+(?:\s+\S+)*)$/.exec(part);
+ const m =
+ // eslint-disable-next-line security/detect-unsafe-regex -- part from split, bounded by summary
+ /^(\d+)\s+(\S+(?:\s+\S+)*)$/.exec(part);
if (m) {
byName[m[2]] = parseInt(m[1], 10);
} else {
diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts
index c5b8a8d9..7c173299 100644
--- a/src/types/agent-teams-controller.d.ts
+++ b/src/types/agent-teams-controller.d.ts
@@ -86,7 +86,16 @@ declare module 'agent-teams-controller' {
crossTeam: ControllerCrossTeamApi;
}
+ /** Context-free protocol text builders, shared across lead and member prompts. */
+ export interface ProtocolsApi {
+ buildActionModeProtocolText(delegateDescription: string): string;
+ MEMBER_DELEGATE_DESCRIPTION: string;
+ buildProcessProtocolText(teamName: string): string;
+ }
+
export function createController(options: ControllerContextOptions): AgentTeamsController;
export const agentBlocks: AgentBlocksApi;
+
+ export const protocols: ProtocolsApi;
}
diff --git a/test/main/ipc/configValidation.test.ts b/test/main/ipc/configValidation.test.ts
index b3e343c7..b4d35a66 100644
--- a/test/main/ipc/configValidation.test.ts
+++ b/test/main/ipc/configValidation.test.ts
@@ -114,6 +114,7 @@ describe('configValidation', () => {
'notifyOnUserInbox',
'notifyOnClarifications',
'notifyOnStatusChange',
+ 'notifyOnTeamLaunched',
'statusChangeOnlySolo',
] as const)('accepts boolean %s toggle', (key) => {
const resultOn = validateConfigUpdatePayload('notifications', { [key]: true });
@@ -134,6 +135,7 @@ describe('configValidation', () => {
'notifyOnUserInbox',
'notifyOnClarifications',
'notifyOnStatusChange',
+ 'notifyOnTeamLaunched',
'statusChangeOnlySolo',
] as const)('rejects non-boolean %s', (key) => {
const result = validateConfigUpdatePayload('notifications', { [key]: 'yes' });
diff --git a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts
index 0f8b68ac..d9e68057 100644
--- a/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts
+++ b/test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts
@@ -103,6 +103,12 @@ vi.mock('agent-teams-controller', () => ({
hoisted.sendInboxMessage(teamName, message),
},
}),
+ protocols: {
+ buildActionModeProtocolText: (delegate: string) =>
+ `ACTION MODE PROTOCOL (mock, delegate: ${delegate})`,
+ buildProcessProtocolText: (teamName: string) =>
+ `BACKGROUND PROCESS REGISTRATION (mock for ${teamName})`,
+ },
}));
import type { TeamChangeEvent } from '@shared/types/team';
diff --git a/test/main/services/team/TeamProvisioningServiceRelay.test.ts b/test/main/services/team/TeamProvisioningServiceRelay.test.ts
index 355c4e89..38b65822 100644
--- a/test/main/services/team/TeamProvisioningServiceRelay.test.ts
+++ b/test/main/services/team/TeamProvisioningServiceRelay.test.ts
@@ -121,6 +121,12 @@ vi.mock('agent-teams-controller', () => ({
hoisted.sendInboxMessage(teamName, message),
},
}),
+ protocols: {
+ buildActionModeProtocolText: (delegate: string) =>
+ `ACTION MODE PROTOCOL (mock, delegate: ${delegate})`,
+ buildProcessProtocolText: (teamName: string) =>
+ `BACKGROUND PROCESS REGISTRATION (mock for ${teamName})`,
+ },
}));
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
diff --git a/test/main/utils/teamNotificationBuilder.test.ts b/test/main/utils/teamNotificationBuilder.test.ts
index 7765f604..0dee4cee 100644
--- a/test/main/utils/teamNotificationBuilder.test.ts
+++ b/test/main/utils/teamNotificationBuilder.test.ts
@@ -98,6 +98,7 @@ describe('buildDetectedErrorFromTeam', () => {
cross_team_message: { triggerName: 'Cross-Team', triggerColor: 'cyan' },
schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' },
schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' },
+ team_launched: { triggerName: 'Team Launched', triggerColor: 'green' },
};
for (const [eventType, expected] of Object.entries(EXPECTED_CONFIG)) {
diff --git a/test/renderer/utils/chipUtils.test.ts b/test/renderer/utils/chipUtils.test.ts
index 6e55be71..dab34a3c 100644
--- a/test/renderer/utils/chipUtils.test.ts
+++ b/test/renderer/utils/chipUtils.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { chipToken } from '@renderer/types/inlineChip';
import {
+ calculateMentionPositions,
createChipFromSelection,
findChipBoundary,
isInsideChip,
@@ -251,3 +252,54 @@ describe('removeChipTokenFromText', () => {
expect(removeChipTokenFromText(text, chip)).toBe('A\nB');
});
});
+
+describe('calculateMentionPositions boundary regex', () => {
+ function makeTextarea(): HTMLTextAreaElement {
+ const ta = document.createElement('textarea');
+ ta.style.cssText = 'font:16px monospace;width:400px;height:100px';
+ document.body.appendChild(ta);
+ return ta;
+ }
+
+ function makeMemberSuggestion(name: string) {
+ return { id: name, name, type: 'member' as const };
+ }
+
+ it('matches @mention when char after is boundary: space, comma, dot', () => {
+ const ta = makeTextarea();
+ const suggestions = [makeMemberSuggestion('Alice')];
+ expect(calculateMentionPositions(ta, '@Alice ', suggestions)).toHaveLength(1);
+ expect(calculateMentionPositions(ta, '@Alice,', suggestions)).toHaveLength(1);
+ expect(calculateMentionPositions(ta, '@Alice.', suggestions)).toHaveLength(1);
+ document.body.removeChild(ta);
+ });
+
+ it('matches @mention when char after is boundary: colon, semicolon, bang, question', () => {
+ const ta = makeTextarea();
+ const suggestions = [makeMemberSuggestion('Alice')];
+ expect(calculateMentionPositions(ta, '@Alice:', suggestions)).toHaveLength(1);
+ expect(calculateMentionPositions(ta, '@Alice;', suggestions)).toHaveLength(1);
+ expect(calculateMentionPositions(ta, '@Alice!', suggestions)).toHaveLength(1);
+ expect(calculateMentionPositions(ta, '@Alice?', suggestions)).toHaveLength(1);
+ document.body.removeChild(ta);
+ });
+
+ it('matches @mention when char after is boundary: ), ], }, -', () => {
+ const ta = makeTextarea();
+ const suggestions = [makeMemberSuggestion('Alice')];
+ expect(calculateMentionPositions(ta, '@Alice)', suggestions)).toHaveLength(1);
+ expect(calculateMentionPositions(ta, '@Alice]', suggestions)).toHaveLength(1);
+ expect(calculateMentionPositions(ta, '@Alice}', suggestions)).toHaveLength(1);
+ expect(calculateMentionPositions(ta, '@Alice-', suggestions)).toHaveLength(1);
+ document.body.removeChild(ta);
+ });
+
+ it('does NOT match @mention when char after is word char (letter, digit)', () => {
+ const ta = makeTextarea();
+ const suggestions = [makeMemberSuggestion('Alice')];
+ expect(calculateMentionPositions(ta, '@Alicex', suggestions)).toHaveLength(0);
+ expect(calculateMentionPositions(ta, '@Alice1', suggestions)).toHaveLength(0);
+ expect(calculateMentionPositions(ta, '@Alice_', suggestions)).toHaveLength(0);
+ document.body.removeChild(ta);
+ });
+});
diff --git a/test/renderer/utils/mentionLinkify.test.ts b/test/renderer/utils/mentionLinkify.test.ts
new file mode 100644
index 00000000..8ded692a
--- /dev/null
+++ b/test/renderer/utils/mentionLinkify.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ linkifyAllMentionsInMarkdown,
+ linkifyMentionsInMarkdown,
+ linkifyTeamMentionsInMarkdown,
+} from '@renderer/utils/mentionLinkify';
+
+describe('mentionLinkify', () => {
+ it('linkifies @member after space', () => {
+ const m = new Map([['Alice', 'blue']]);
+ const r = linkifyMentionsInMarkdown('hello @Alice world', m);
+ expect(r).toContain('mention://');
+ expect(r).toContain('Alice');
+ expect(r).not.toBe('hello @Alice world');
+ });
+
+ it('does NOT linkify @ in email', () => {
+ const m = new Map([['Alice', 'blue']]);
+ const r = linkifyMentionsInMarkdown('email@test.com', m);
+ expect(r).toBe('email@test.com');
+ });
+
+ it('linkifies @team after (', () => {
+ const r = linkifyTeamMentionsInMarkdown('(@TeamAlpha)', ['TeamAlpha']);
+ expect(r).toContain('team://');
+ expect(r).toContain('TeamAlpha');
+ });
+
+ it('linkifyAll applies both member and team', () => {
+ const m = new Map([['Alice', 'blue']]);
+ const r = linkifyAllMentionsInMarkdown('Hi @Alice from @TeamX', m, ['TeamX']);
+ expect(r).toContain('mention://');
+ expect(r).toContain('team://');
+ });
+
+ it('linkifies @ after start of string', () => {
+ const m = new Map([['Alice', 'blue']]);
+ const r = linkifyMentionsInMarkdown('@Alice hello', m);
+ expect(r).toContain('mention://');
+ });
+
+ it('linkifies @ after [ { (', () => {
+ const m = new Map([['Bob', 'red']]);
+ expect(linkifyMentionsInMarkdown('[@Bob]', m)).toContain('mention://');
+ expect(linkifyMentionsInMarkdown('{@Bob}', m)).toContain('mention://');
+ expect(linkifyMentionsInMarkdown('(@Bob)', m)).toContain('mention://');
+ });
+
+ it('does NOT linkify @ when followed by word char', () => {
+ const m = new Map([['Alice', 'blue']]);
+ expect(linkifyMentionsInMarkdown('@AliceX', m)).toBe('@AliceX');
+ expect(linkifyMentionsInMarkdown('@Alice123', m)).toBe('@Alice123');
+ });
+
+ it('linkifies when followed by boundary: space, comma, dot, ), ], }', () => {
+ const m = new Map([['Alice', 'blue']]);
+ expect(linkifyMentionsInMarkdown('@Alice ', m)).toContain('mention://');
+ expect(linkifyMentionsInMarkdown('@Alice,', m)).toContain('mention://');
+ expect(linkifyMentionsInMarkdown('@Alice.', m)).toContain('mention://');
+ expect(linkifyMentionsInMarkdown('@Alice)', m)).toContain('mention://');
+ expect(linkifyMentionsInMarkdown('@Alice]', m)).toContain('mention://');
+ expect(linkifyMentionsInMarkdown('@Alice}', m)).toContain('mention://');
+ });
+});
diff --git a/test/renderer/utils/taskReferenceUtils.test.ts b/test/renderer/utils/taskReferenceUtils.test.ts
new file mode 100644
index 00000000..bfdcabba
--- /dev/null
+++ b/test/renderer/utils/taskReferenceUtils.test.ts
@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildTaskLinkHref,
+ linkifyTaskIdsInMarkdown,
+ parseTaskLinkHref,
+} from '@renderer/utils/taskReferenceUtils';
+
+import type { TaskRef } from '@shared/types';
+
+describe('taskReferenceUtils', () => {
+ describe('TASK_REF_REGEX and isAllowedTaskRefBoundary', () => {
+ it('linkifies #ref when preceded by boundary (space, start)', () => {
+ const taskRef: TaskRef = {
+ taskId: 't1',
+ displayId: 'task-1',
+ teamName: 'my-team',
+ };
+ const r = linkifyTaskIdsInMarkdown('see #task-1 done', [taskRef]);
+ expect(r).toContain('task://');
+ expect(r).toContain('[#task-1]');
+ });
+
+ it('does NOT linkify #ref when preceded by word char', () => {
+ const taskRef: TaskRef = {
+ taskId: 't1',
+ displayId: 'task1',
+ teamName: 'my-team',
+ };
+ const r = linkifyTaskIdsInMarkdown('x#task1', [taskRef]);
+ expect(r).toBe('x#task1');
+ });
+
+ it('linkifies #ref with hyphen in id', () => {
+ const r = linkifyTaskIdsInMarkdown(' #abc-123 ');
+ expect(r).toContain('task://');
+ });
+ });
+
+ describe('buildTaskLinkHref and parseTaskLinkHref', () => {
+ it('roundtrips task ref', () => {
+ const ref: TaskRef = {
+ taskId: 'tid-1',
+ displayId: 'T-1',
+ teamName: 'team-a',
+ };
+ const href = buildTaskLinkHref(ref);
+ expect(href).toContain('task://');
+ expect(href).toContain('team=');
+ expect(href).toContain('display=');
+
+ const parsed = parseTaskLinkHref(href);
+ expect(parsed).toEqual({
+ taskId: 'tid-1',
+ teamName: 'team-a',
+ displayId: 'T-1',
+ });
+ });
+
+ it('parseTaskLinkHref returns null for non-task URL', () => {
+ expect(parseTaskLinkHref('https://example.com')).toBeNull();
+ expect(parseTaskLinkHref('mention://x')).toBeNull();
+ });
+
+ it('parseTaskLinkHref handles task:// without query', () => {
+ const r = parseTaskLinkHref('task://tid-1');
+ expect(r).toEqual({ taskId: 'tid-1' });
+ });
+ });
+});
diff --git a/test/renderer/utils/urlMatchUtils.test.ts b/test/renderer/utils/urlMatchUtils.test.ts
new file mode 100644
index 00000000..a3ea0e20
--- /dev/null
+++ b/test/renderer/utils/urlMatchUtils.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ findUrlBoundary,
+ findUrlMatches,
+ removeUrlMatchFromText,
+} from '@renderer/utils/urlMatchUtils';
+
+describe('urlMatchUtils', () => {
+ describe('findUrlMatches URL_REGEX', () => {
+ it('matches http and https URLs', () => {
+ const m1 = findUrlMatches('see https://example.com');
+ expect(m1).toHaveLength(1);
+ expect(m1[0].value).toBe('https://example.com');
+
+ const m2 = findUrlMatches('see http://foo.bar/path');
+ expect(m2).toHaveLength(1);
+ expect(m2[0].value).toBe('http://foo.bar/path');
+ });
+
+ it('matches URL with query and hash', () => {
+ const m = findUrlMatches('https://x.com?a=1#anchor');
+ expect(m).toHaveLength(1);
+ expect(m[0].value).toBe('https://x.com?a=1#anchor');
+ });
+
+ it('returns empty for text without URLs', () => {
+ expect(findUrlMatches('no url here')).toEqual([]);
+ expect(findUrlMatches('')).toEqual([]);
+ });
+
+ it('matches multiple URLs', () => {
+ const m = findUrlMatches('a https://a.com b https://b.com c');
+ expect(m).toHaveLength(2);
+ expect(m[0].value).toBe('https://a.com');
+ expect(m[1].value).toBe('https://b.com');
+ });
+ });
+
+ describe('trimUrlMatch trailing punctuation regex', () => {
+ it('strips trailing ), . ! ? ; :', () => {
+ const m = findUrlMatches('check (https://example.com).');
+ expect(m).toHaveLength(1);
+ expect(m[0].value).toBe('https://example.com');
+ });
+
+ it('strips trailing comma', () => {
+ const m = findUrlMatches('see https://x.com, and more');
+ expect(m).toHaveLength(1);
+ expect(m[0].value).toBe('https://x.com');
+ });
+
+ it('strips multiple trailing punctuation', () => {
+ const m = findUrlMatches('(https://x.com)...');
+ expect(m).toHaveLength(1);
+ expect(m[0].value).toBe('https://x.com');
+ });
+ });
+
+ describe('findUrlBoundary', () => {
+ it('returns match when cursor inside URL', () => {
+ const text = 'go to https://example.com now';
+ const m = findUrlBoundary(text, 12);
+ expect(m).not.toBeNull();
+ expect(m!.value).toBe('https://example.com');
+ });
+
+ it('returns null when cursor outside URL', () => {
+ const text = 'go to https://example.com now';
+ expect(findUrlBoundary(text, 0)).toBeNull();
+ expect(findUrlBoundary(text, 100)).toBeNull();
+ });
+ });
+
+ describe('removeUrlMatchFromText', () => {
+ it('removes URL from text', () => {
+ const text = 'see https://x.com here';
+ const matches = findUrlMatches(text);
+ expect(matches).toHaveLength(1);
+ const result = removeUrlMatchFromText(text, matches[0]);
+ expect(result).toBe('see here');
+ });
+ });
+});
diff --git a/test/shared/constants/crossTeam.test.ts b/test/shared/constants/crossTeam.test.ts
index 6d8351f7..04731701 100644
--- a/test/shared/constants/crossTeam.test.ts
+++ b/test/shared/constants/crossTeam.test.ts
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
-import { parseCrossTeamPrefix, stripCrossTeamPrefix } from '@shared/constants/crossTeam';
+import {
+ formatCrossTeamPrefix,
+ parseCrossTeamPrefix,
+ stripCrossTeamPrefix,
+} from '@shared/constants/crossTeam';
describe('crossTeam protocol helpers', () => {
it('parses canonical cross-team prefix metadata', () => {
@@ -19,8 +23,25 @@ describe('crossTeam protocol helpers', () => {
it('strips canonical prefix from UI text', () => {
expect(
stripCrossTeamPrefix('\nHello')
- ).toBe(
- 'Hello'
+ ).toBe('Hello');
+ });
+
+ it('parseCrossTeamAttributes regex: parses attr="value" pairs', () => {
+ const text = formatCrossTeamPrefix('team.user', 0, {
+ conversationId: 'c1',
+ replyToConversationId: 'c0',
+ });
+ const parsed = parseCrossTeamPrefix(text + '\nbody');
+ expect(parsed).not.toBeNull();
+ expect(parsed!.from).toBe('team.user');
+ expect(parsed!.conversationId).toBe('c1');
+ expect(parsed!.replyToConversationId).toBe('c0');
+ });
+
+ it('handles depth attribute', () => {
+ const parsed = parseCrossTeamPrefix(
+ '\nHi'
);
+ expect(parsed?.chainDepth).toBe(2);
});
});
diff --git a/test/shared/utils/extensionNormalizers.test.ts b/test/shared/utils/extensionNormalizers.test.ts
index ff28a938..ce40a00d 100644
--- a/test/shared/utils/extensionNormalizers.test.ts
+++ b/test/shared/utils/extensionNormalizers.test.ts
@@ -10,6 +10,7 @@ import {
inferCapabilities,
normalizeCategory,
normalizeRepoUrl,
+ parseGitHubOwnerRepo,
sanitizeMcpServerName,
} from '@shared/utils/extensionNormalizers';
@@ -168,3 +169,35 @@ describe('sanitizeMcpServerName', () => {
expect(sanitizeMcpServerName('Context7')).toBe('context7');
});
});
+
+describe('parseGitHubOwnerRepo', () => {
+ it('extracts owner/repo from https URL', () => {
+ expect(parseGitHubOwnerRepo('https://github.com/owner/repo')).toEqual({
+ owner: 'owner',
+ repo: 'repo',
+ });
+ });
+
+ it('strips .git suffix', () => {
+ expect(parseGitHubOwnerRepo('https://github.com/owner/repo.git')).toEqual({
+ owner: 'owner',
+ repo: 'repo',
+ });
+ });
+
+ it('strips trailing slashes', () => {
+ expect(parseGitHubOwnerRepo('https://github.com/owner/repo/')).toEqual({
+ owner: 'owner',
+ repo: 'repo',
+ });
+ });
+
+ it('returns null for non-GitHub URLs', () => {
+ expect(parseGitHubOwnerRepo('https://gitlab.com/owner/repo')).toBeNull();
+ expect(parseGitHubOwnerRepo('https://example.com')).toBeNull();
+ });
+
+ it('returns null for invalid URL', () => {
+ expect(parseGitHubOwnerRepo('not-a-url')).toBeNull();
+ });
+});
diff --git a/test/shared/utils/toolSummary.test.ts b/test/shared/utils/toolSummary.test.ts
new file mode 100644
index 00000000..2a243d52
--- /dev/null
+++ b/test/shared/utils/toolSummary.test.ts
@@ -0,0 +1,89 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ buildToolSummary,
+ formatToolSummary,
+ formatToolSummaryFromMap,
+ parseToolSummary,
+} from '@shared/utils/toolSummary';
+
+describe('toolSummary', () => {
+ describe('parseToolSummary simple format regex', () => {
+ it('parses "3 tools"', () => {
+ const r = parseToolSummary('3 tools');
+ expect(r).toEqual({ total: 3, byName: {} });
+ });
+
+ it('parses "1 tool"', () => {
+ const r = parseToolSummary('1 tool');
+ expect(r).toEqual({ total: 1, byName: {} });
+ });
+
+ it('returns null for invalid format', () => {
+ expect(parseToolSummary('invalid')).toBeNull();
+ expect(parseToolSummary('')).toBeNull();
+ expect(parseToolSummary(undefined)).toBeNull();
+ });
+ });
+
+ describe('parseToolSummary legacy format regex', () => {
+ it('parses "3 tools (Read, 2 Edit)"', () => {
+ const r = parseToolSummary('3 tools (Read, 2 Edit)');
+ expect(r).not.toBeNull();
+ expect(r!.total).toBe(3);
+ expect(r!.byName).toEqual({ Read: 1, Edit: 2 });
+ });
+
+ it('parses "1 tool (Bash)"', () => {
+ const r = parseToolSummary('1 tool (Bash)');
+ expect(r).not.toBeNull();
+ expect(r!.total).toBe(1);
+ expect(r!.byName).toEqual({ Bash: 1 });
+ });
+
+ it('parses tool names with spaces "2 tools (2 Web Search)"', () => {
+ const r = parseToolSummary('2 tools (2 Web Search)');
+ expect(r).not.toBeNull();
+ expect(r!.total).toBe(2);
+ expect(r!.byName['Web Search']).toBe(2);
+ });
+ });
+
+ describe('buildToolSummary', () => {
+ it('returns "1 tool" for single tool_use', () => {
+ const content = [{ type: 'tool_use', name: 'Read', input: {} }];
+ expect(buildToolSummary(content)).toBe('1 tool');
+ });
+
+ it('returns "3 tools" for multiple', () => {
+ const content = [
+ { type: 'tool_use', name: 'Read', input: {} },
+ { type: 'tool_use', name: 'Edit', input: {} },
+ { type: 'tool_use', name: 'Read', input: {} },
+ ];
+ expect(buildToolSummary(content)).toBe('3 tools');
+ });
+
+ it('returns undefined for empty', () => {
+ expect(buildToolSummary([])).toBeUndefined();
+ });
+ });
+
+ describe('formatToolSummary', () => {
+ it('formats singular and plural', () => {
+ expect(formatToolSummary({ total: 1, byName: {} })).toBe('1 tool');
+ expect(formatToolSummary({ total: 2, byName: {} })).toBe('2 tools');
+ });
+ });
+
+ describe('formatToolSummaryFromMap', () => {
+ it('returns undefined for empty map', () => {
+ expect(formatToolSummaryFromMap(new Map())).toBeUndefined();
+ });
+
+ it('formats from map', () => {
+ const m = new Map([['Read', 2], ['Edit', 1]]);
+ expect(formatToolSummaryFromMap(m)).toBe('3 tools');
+ });
+ });
+});