fix(team): scan inbox for permission_request during provisioning

relayLeadInboxMessages only processes unread messages after
provisioningComplete, but CLI marks permission_request messages as
read after native delivery -- before our relay runs.

Move permission_request inbox scan BEFORE provisioningComplete check.
Scan ALL messages (including read=true), track processed IDs via
processedPermissionRequestIds Set on ProvisioningRun to prevent
re-emitting. Also look up both alive and provisioning runs so the
scan works during team bootstrap.
This commit is contained in:
iliya 2026-03-27 23:35:52 +02:00
parent df6a23e3a2
commit dd42cf0069
28 changed files with 1783 additions and 178 deletions

View file

@ -303,6 +303,10 @@ pnpm dist # macOS + Windows + Linux
- [ ] 2 modes: current (agent teams), and a new mode: regular subagents (no communication between them)
- [ ] Curate what context each agent sees (files, docs, MCP servers, skills)
- [ ] Slash commands
- [ ] Outgoing message queue — queue user messages while the lead (or agent) is busy; clear agent-busy status in the UI; flush to stdin or relay from inbox when idle (durable queue on disk for the lead inbox path)
- [ ] `createTasksBatch` — IPC/service API to create many team tasks in one call (playbooks, markdown checklist import, scripts); complements single `createTask`
- [ ] Command palette — extend Cmd/Ctrl+K beyond project/session search to runnable actions (quick commands, navigation shortcuts, team/task operations) in a keyboard-first flow
- [ ] Custom kanban columns
---

View file

@ -78,6 +78,10 @@ import {
} from '@shared/utils/cliArgsParser';
import { createLogger } from '@shared/utils/logger';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import {
buildStandaloneSlashCommandMeta,
parseStandaloneSlashCommand,
} from '@shared/utils/slashCommands';
import crypto from 'crypto';
import { BrowserWindow, type IpcMain, type IpcMainInvokeEvent, Notification } from 'electron';
import * as fs from 'fs';
@ -1412,25 +1416,38 @@ async function handleSendMessage(
const preGeneratedMessageId = crypto.randomUUID();
// Separate try blocks: stdin delivery vs persistence
// If stdin succeeds but persistence fails, do NOT fallback to inbox (would duplicate)
// Wrap with instructions so lead responds with visible text (not just agent-only blocks)
const wrappedText = [
`You received a direct message from the user.`,
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
AGENT_BLOCK_OPEN,
`MessageId: ${preGeneratedMessageId}`,
`When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`,
AGENT_BLOCK_CLOSE,
``,
`Message from user:`,
buildMessageDeliveryText(payload.text!, {
actionMode,
isLeadRecipient: true,
}),
].join('\n');
const standaloneSlashCommand = !validatedAttachments?.length
? parseStandaloneSlashCommand(payload.text!)
: null;
const slashCommandMeta = standaloneSlashCommand
? buildStandaloneSlashCommandMeta(standaloneSlashCommand.raw)
: null;
const rawSlashCommandText = standaloneSlashCommand?.raw;
const stdinTextForLead = rawSlashCommandText
? rawSlashCommandText
: [
`You received a direct message from the user.`,
`IMPORTANT: Your text response here is shown to the user in the Messages panel. Always include a brief human-readable reply. Do NOT respond with only an agent-only block.`,
AGENT_BLOCK_OPEN,
`MessageId: ${preGeneratedMessageId}`,
`When creating a task from this user message, prefer task_create_from_message with messageId="${preGeneratedMessageId}" for reliable provenance. Only use this exact messageId — never guess or fabricate one.`,
AGENT_BLOCK_CLOSE,
``,
`Message from user:`,
buildMessageDeliveryText(payload.text!, {
actionMode,
isLeadRecipient: true,
}),
].join('\n');
const persistTextForLead = rawSlashCommandText ?? payload.text!;
let stdinSent = false;
try {
await provisioning.sendMessageToTeam(tn, wrappedText, validatedAttachments);
await provisioning.sendMessageToTeam(
tn,
stdinTextForLead,
rawSlashCommandText ? undefined : validatedAttachments
);
stdinSent = true;
} catch (stdinError: unknown) {
// Stdin failed (process died between check and write)
@ -1477,7 +1494,7 @@ async function handleSendMessage(
result = await getTeamDataService().sendDirectToLead(
tn,
resolvedLeadName,
payload.text!,
persistTextForLead,
payload.summary,
attachmentMeta,
validatedTaskRefs.value,
@ -1493,7 +1510,7 @@ async function handleSendMessage(
provisioning.pushLiveLeadProcessMessage(tn, {
from: 'user',
to: resolvedLeadName,
text: payload.text!,
text: persistTextForLead,
timestamp: new Date().toISOString(),
read: true,
summary: payload.summary,
@ -1501,6 +1518,12 @@ async function handleSendMessage(
source: 'user_sent',
attachments: attachmentMeta,
taskRefs: validatedTaskRefs.value,
...(slashCommandMeta
? {
messageKind: 'slash_command' as const,
slashCommand: slashCommandMeta,
}
: {}),
});
return result;

View file

@ -18,6 +18,7 @@ import { getMemberColorByName } from '@shared/constants/memberColors';
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
import { createLogger } from '@shared/utils/logger';
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { parseNumericSuffixName } from '@shared/utils/teamMemberName';
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
@ -40,6 +41,7 @@ import { TeamSentMessagesStore } from './TeamSentMessagesStore';
import { TeamTaskCommentNotificationJournal } from './TeamTaskCommentNotificationJournal';
import { TeamTaskReader } from './TeamTaskReader';
import { TeamTaskWriter } from './TeamTaskWriter';
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
import type {
@ -251,6 +253,47 @@ export class TeamDataService {
return result;
}
private isLeadThoughtCandidateForSlashResult(message: InboxMessage): boolean {
if (typeof message.to === 'string' && message.to.trim().length > 0) return false;
if (message.from === 'system') return false;
return message.source === 'lead_session' || message.source === 'lead_process';
}
private annotateSlashCommandResponses(messages: InboxMessage[]): void {
let pendingSlash = null as InboxMessage['slashCommand'] | null;
for (const message of messages) {
const slashCommand =
message.source === 'user_sent'
? (message.slashCommand ?? buildStandaloneSlashCommandMeta(message.text))
: null;
if (slashCommand) {
pendingSlash = slashCommand;
continue;
}
if (!pendingSlash) {
continue;
}
if (message.messageKind === 'slash_command_result') {
continue;
}
if (this.isLeadThoughtCandidateForSlashResult(message)) {
message.messageKind = 'slash_command_result';
message.commandOutput = {
stream: 'stdout',
commandLabel: pendingSlash.command,
};
continue;
}
pendingSlash = null;
}
}
async getTaskChangePresence(teamName: string): Promise<Record<string, TaskChangePresenceState>> {
const config = await this.configReader.getConfig(teamName);
if (!config) {
@ -593,6 +636,9 @@ export class TeamDataService {
}
}
messages.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
this.annotateSlashCommandResponses(messages);
messages.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp));
let metaMembers: TeamConfig['members'] = [];
@ -1342,6 +1388,15 @@ export class TeamDataService {
// non-critical
}
}
const slashCommandMeta =
enrichedRequest.slashCommand ?? buildStandaloneSlashCommandMeta(enrichedRequest.text);
if (slashCommandMeta) {
enrichedRequest = {
...enrichedRequest,
messageKind: 'slash_command',
slashCommand: slashCommandMeta,
};
}
return this.getController(teamName).messages.sendMessage({
member: enrichedRequest.member,
from: enrichedRequest.from,
@ -1354,6 +1409,9 @@ export class TeamDataService {
replyToConversationId: enrichedRequest.replyToConversationId,
toolSummary: enrichedRequest.toolSummary,
toolCalls: enrichedRequest.toolCalls,
messageKind: enrichedRequest.messageKind,
slashCommand: enrichedRequest.slashCommand,
commandOutput: enrichedRequest.commandOutput,
taskRefs: enrichedRequest.taskRefs,
summary: enrichedRequest.summary,
source: enrichedRequest.source,
@ -1782,6 +1840,7 @@ export class TeamDataService {
// non-critical — proceed without sessionId
}
const slashCommandMeta = buildStandaloneSlashCommandMeta(text);
const msg = this.getController(teamName).messages.appendSentMessage({
from: 'user',
to: leadName,
@ -1791,6 +1850,12 @@ export class TeamDataService {
source: 'user_sent',
attachments: attachments?.length ? attachments : undefined,
leadSessionId,
...(slashCommandMeta
? {
messageKind: 'slash_command',
slashCommand: slashCommandMeta,
}
: {}),
...(messageId ? { messageId } : {}),
}) as InboxMessage;
return {
@ -1961,7 +2026,7 @@ export class TeamDataService {
return sessionIds;
}
private async extractLeadSessionTextsFromJsonl(
private async extractLeadAssistantTextsFromJsonl(
jsonlPath: string,
leadName: string,
leadSessionId: string,
@ -1969,10 +2034,8 @@ export class TeamDataService {
): Promise<InboxMessage[]> {
if (maxTexts <= 0) return [];
// Optimization: read from the end of the JSONL file (we only need the last N texts).
// The full file can be huge; scanning from the start causes long stalls on Windows.
const MAX_SCAN_BYTES = 8 * 1024 * 1024; // 8MB tail cap
const INITIAL_SCAN_BYTES = 256 * 1024; // 256KB
const MAX_SCAN_BYTES = 8 * 1024 * 1024;
const INITIAL_SCAN_BYTES = 256 * 1024;
const textsReversed: InboxMessage[] = [];
const seenMessageIds = new Set<string>();
@ -1989,7 +2052,6 @@ export class TeamDataService {
const chunk = buffer.toString('utf8');
const lines = chunk.split(/\r?\n/);
// If we started mid-file, the first line may be partial — drop it.
const fromIndex = start > 0 ? 1 : 0;
for (let i = lines.length - 1; i >= fromIndex; i--) {
@ -2022,8 +2084,6 @@ export class TeamDataService {
const combined = stripAgentBlocks(textParts.join('\n')).trim();
if (combined.length < MIN_TEXT_LENGTH) continue;
// Collect tool_use details from following lines (text and tool_use are separate in JSONL).
// tool_result (type=user) lines are interleaved between tool_use lines — skip them.
const toolCallsList: ToolCallMeta[] = [];
const lookaheadLimit = Math.min(i + 200, lines.length);
for (let j = i + 1; j < lookaheadLimit; j++) {
@ -2035,12 +2095,12 @@ export class TeamDataService {
} catch {
continue;
}
if (tMsg.type !== 'assistant') continue; // skip tool_result (type=user) lines
if (tMsg.type !== 'assistant') continue;
const tMessage = (tMsg.message ?? tMsg) as Record<string, unknown>;
const tContent = tMessage.content;
if (!Array.isArray(tContent)) continue;
const tBlocks = tContent as Record<string, unknown>[];
if (tBlocks.some((b) => b.type === 'text')) break; // next text = stop
if (tBlocks.some((b) => b.type === 'text')) break;
for (const b of tBlocks) {
if (b.type === 'tool_use' && typeof b.name === 'string' && b.name !== 'SendMessage') {
const input = (b.input ?? {}) as Record<string, unknown>;
@ -2062,7 +2122,6 @@ export class TeamDataService {
? `lead-thought-msg-${assistantMessageId}`
: null;
// Fallback messageId: timestamp + text prefix (survives tail-scan range changes)
const textPrefix = combined
.slice(0, 50)
.replace(/[^\p{L}\p{N}]/gu, '')
@ -2095,10 +2154,29 @@ export class TeamDataService {
await handle.close();
}
// Convert back to chronological order (old behavior) and keep the last N texts.
textsReversed.reverse();
const texts = textsReversed;
return texts.length > maxTexts ? texts.slice(-maxTexts) : texts;
return textsReversed.length > maxTexts ? textsReversed.slice(-maxTexts) : textsReversed;
}
private async extractLeadSessionTextsFromJsonl(
jsonlPath: string,
leadName: string,
leadSessionId: string,
maxTexts: number
): Promise<InboxMessage[]> {
const [assistantTexts, commandResults] = await Promise.all([
this.extractLeadAssistantTextsFromJsonl(jsonlPath, leadName, leadSessionId, maxTexts),
extractLeadSessionMessagesFromJsonl({
jsonlPath,
leadName,
leadSessionId,
maxMessages: maxTexts,
}),
]);
const combined = [...assistantTexts, ...commandResults];
combined.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
return combined.length > maxTexts ? combined.slice(-maxTexts) : combined;
}
private async extractLeadSessionTexts(config: TeamConfig): Promise<InboxMessage[]> {

View file

@ -131,6 +131,37 @@ export class TeamInboxReader {
preview: typeof tc.preview === 'string' ? tc.preview : undefined,
}))
: undefined,
messageKind:
row.messageKind === 'slash_command' || row.messageKind === 'slash_command_result'
? row.messageKind
: row.messageKind === 'default'
? 'default'
: undefined,
slashCommand:
row.slashCommand &&
typeof row.slashCommand === 'object' &&
typeof row.slashCommand.name === 'string' &&
typeof row.slashCommand.command === 'string'
? {
name: row.slashCommand.name,
command: row.slashCommand.command as `/${string}`,
args: typeof row.slashCommand.args === 'string' ? row.slashCommand.args : undefined,
knownDescription:
typeof row.slashCommand.knownDescription === 'string'
? row.slashCommand.knownDescription
: undefined,
}
: undefined,
commandOutput:
row.commandOutput &&
typeof row.commandOutput === 'object' &&
(row.commandOutput.stream === 'stdout' || row.commandOutput.stream === 'stderr') &&
typeof row.commandOutput.commandLabel === 'string'
? {
stream: row.commandOutput.stream,
commandLabel: row.commandOutput.commandLabel,
}
: undefined,
});
}

View file

@ -41,6 +41,9 @@ export class TeamInboxWriter {
}),
...(request.toolSummary && { toolSummary: request.toolSummary }),
...(request.toolCalls && { toolCalls: request.toolCalls }),
...(request.messageKind && { messageKind: request.messageKind }),
...(request.slashCommand && { slashCommand: request.slashCommand }),
...(request.commandOutput && { commandOutput: request.commandOutput }),
};
await withFileLock(inboxPath, async () => {

View file

@ -319,6 +319,8 @@ interface ProvisioningRun {
} | null;
/** Pending tool approval requests awaiting user response (control_request protocol). */
pendingApprovals: Map<string, ToolApprovalRequest>;
/** Teammate permission_request IDs already intercepted (prevents re-processing read messages). */
processedPermissionRequestIds: Set<string>;
/**
* Post-compact context reinjection lifecycle.
* - pendingPostCompactReminder: compact_boundary was received; waiting for idle to inject.
@ -1837,6 +1839,9 @@ export class TeamProvisioningService {
color: message.color,
toolSummary: message.toolSummary,
toolCalls: message.toolCalls,
messageKind: message.messageKind,
slashCommand: message.slashCommand,
commandOutput: message.commandOutput,
});
} catch (error) {
logger.warn(`[${teamName}] sent-message persist failed: ${String(error)}`);
@ -1865,6 +1870,9 @@ export class TeamProvisioningService {
color: message.color,
toolSummary: message.toolSummary,
toolCalls: message.toolCalls,
messageKind: message.messageKind,
slashCommand: message.slashCommand,
commandOutput: message.commandOutput,
});
} catch (error) {
logger.warn(`[${teamName}] inbox-message persist for ${recipient} failed: ${String(error)}`);
@ -2956,6 +2964,7 @@ export class TeamProvisioningService {
authRetryInProgress: false,
spawnContext: null,
pendingApprovals: new Map(),
processedPermissionRequestIds: new Set(),
pendingPostCompactReminder: false,
postCompactReminderInFlight: false,
suppressPostCompactReminderOutput: false,
@ -3388,6 +3397,7 @@ export class TeamProvisioningService {
authRetryInProgress: false,
spawnContext: null,
pendingApprovals: new Map(),
processedPermissionRequestIds: new Set(),
pendingPostCompactReminder: false,
postCompactReminderInFlight: false,
suppressPostCompactReminderOutput: false,
@ -3904,24 +3914,55 @@ export class TeamProvisioningService {
}
const work = (async (): Promise<number> => {
const runId = this.getAliveRunId(teamName);
const runId = this.getAliveRunId(teamName) ?? this.getProvisioningRunId(teamName);
if (!runId) return 0;
const run = this.runs.get(runId);
if (!run?.child || run.processKilled || run.cancelRequested) return 0;
if (!run.provisioningComplete) return 0;
const relayedIds = this.relayedLeadInboxMessageIds.get(teamName) ?? new Set<string>();
// Permission request scan runs even during provisioning — teammates may need
// tool approval before the lead's first turn completes. CLI marks inbox messages
// as read after native delivery, so we must scan ALL messages (including read).
let config: Awaited<ReturnType<TeamConfigReader['getConfig']>> | null = null;
try {
config = await this.configReader.getConfig(teamName);
} catch {
return 0;
// config not ready yet during early provisioning — skip scan
}
if (config) {
const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead';
try {
const leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName);
for (const msg of leadInboxMessages) {
if (typeof msg.text !== 'string') continue;
const perm = parsePermissionRequest(msg.text);
if (!perm) continue;
if (run.processedPermissionRequestIds.has(perm.requestId)) continue;
run.processedPermissionRequestIds.add(perm.requestId);
logger.warn(
`[${run.teamName}] [PERM-TRACE] Intercepted permission_request from inbox scan (read=${String(msg.read)}): agent=${perm.agentId} tool=${perm.toolName} requestId=${perm.requestId}`
);
this.handleTeammatePermissionRequest(run, perm, msg.timestamp);
}
} catch {
// best-effort — inbox may not exist yet
}
}
if (!run.provisioningComplete) return 0;
const relayedIds = this.relayedLeadInboxMessageIds.get(teamName) ?? new Set<string>();
// Re-read config if needed (already fetched above but guard provisioningComplete path)
if (!config) {
try {
config = await this.configReader.getConfig(teamName);
} catch {
return 0;
}
}
if (!config) return 0;
const leadName = config.members?.find((m) => isLeadMember(m))?.name?.trim() || 'team-lead';
let leadInboxMessages: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
try {
leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName);

View file

@ -98,6 +98,37 @@ export class TeamSentMessagesStore {
preview: typeof tc.preview === 'string' ? tc.preview : undefined,
}))
: undefined,
messageKind:
row.messageKind === 'slash_command' || row.messageKind === 'slash_command_result'
? row.messageKind
: row.messageKind === 'default'
? 'default'
: undefined,
slashCommand:
row.slashCommand &&
typeof row.slashCommand === 'object' &&
typeof row.slashCommand.name === 'string' &&
typeof row.slashCommand.command === 'string'
? {
name: row.slashCommand.name,
command: row.slashCommand.command as `/${string}`,
args: typeof row.slashCommand.args === 'string' ? row.slashCommand.args : undefined,
knownDescription:
typeof row.slashCommand.knownDescription === 'string'
? row.slashCommand.knownDescription
: undefined,
}
: undefined,
commandOutput:
row.commandOutput &&
typeof row.commandOutput === 'object' &&
(row.commandOutput.stream === 'stdout' || row.commandOutput.stream === 'stderr') &&
typeof row.commandOutput.commandLabel === 'string'
? {
stream: row.commandOutput.stream,
commandLabel: row.commandOutput.commandLabel,
}
: undefined,
});
}

View file

@ -0,0 +1,198 @@
import { isParsedSystemChunkMessage, isParsedUserChunkMessage, isTextContent } from '@main/types';
import { parseJsonlLine } from '@main/utils/jsonl';
import { createHash } from 'crypto';
import * as fs from 'fs';
import { extractCommandOutputInfo, extractSlashInfo } from '@shared/utils/contentSanitizer';
import { buildSlashCommandMeta } from '@shared/utils/slashCommands';
import type { ParsedMessage } from '@main/types';
import type { CommandOutputMeta, InboxMessage, SlashCommandMeta } from '@shared/types';
const MAX_SCAN_BYTES = 8 * 1024 * 1024;
const INITIAL_SCAN_BYTES = 256 * 1024;
interface LeadSessionMessageExtractorOptions {
jsonlPath: string;
leadName: string;
leadSessionId: string;
maxMessages: number;
}
function getMessageText(message: ParsedMessage): string {
if (typeof message.content === 'string') {
return message.content.trim();
}
if (!Array.isArray(message.content)) {
return '';
}
return message.content
.filter(isTextContent)
.map((block) => block.text)
.join('\n')
.trim();
}
function buildScanKey(message: ParsedMessage, rawLine: string): string {
if (typeof message.uuid === 'string' && message.uuid.trim()) {
return message.uuid.trim();
}
return `${message.timestamp.toISOString()}\0${rawLine}`;
}
function summarizeCommandOutput(output: string): string {
const firstLine = output
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
if (!firstLine) return '';
return firstLine.length > 120 ? `${firstLine.slice(0, 120)}` : firstLine;
}
function buildSlashMetaFromParsedMessage(message: ParsedMessage): SlashCommandMeta | null {
const slash = extractSlashInfo(getMessageText(message));
if (!slash) return null;
return buildSlashCommandMeta(slash.name, slash.args, `/${slash.name}`);
}
function buildCommandOutputMeta(
pendingSlash: SlashCommandMeta | null,
stream: CommandOutputMeta['stream']
): CommandOutputMeta {
return {
stream,
commandLabel: pendingSlash?.command ?? '/command',
};
}
function buildResultMessageId(message: ParsedMessage, output: string): string {
const uuid = typeof message.uuid === 'string' ? message.uuid.trim() : '';
if (uuid) {
return `lead-command-result-${uuid}`;
}
return `lead-command-result-${createHash('sha256').update(`${message.timestamp.toISOString()}\n${output}`).digest('hex').slice(0, 16)}`;
}
function canMergeCommandOutput(
previousMessage: InboxMessage | undefined,
commandOutput: CommandOutputMeta,
previousWasCommandOutput: boolean
): previousMessage is InboxMessage & { commandOutput: CommandOutputMeta } {
if (!previousWasCommandOutput || !previousMessage?.commandOutput) {
return false;
}
return (
previousMessage.messageKind === 'slash_command_result' &&
previousMessage.commandOutput.stream === commandOutput.stream &&
previousMessage.commandOutput.commandLabel === commandOutput.commandLabel
);
}
export async function extractLeadSessionMessagesFromJsonl({
jsonlPath,
leadName,
leadSessionId,
maxMessages,
}: LeadSessionMessageExtractorOptions): Promise<InboxMessage[]> {
if (maxMessages <= 0) return [];
const parsedMessagesReversed: ParsedMessage[] = [];
const seenScanKeys = new Set<string>();
const handle = await fs.promises.open(jsonlPath, 'r');
try {
const stat = await handle.stat();
const fileSize = stat.size;
let scanBytes = Math.min(INITIAL_SCAN_BYTES, fileSize);
while (scanBytes <= MAX_SCAN_BYTES) {
const start = Math.max(0, fileSize - scanBytes);
const buffer = Buffer.alloc(scanBytes);
await handle.read(buffer, 0, scanBytes, start);
const chunk = buffer.toString('utf8');
const lines = chunk.split(/\r?\n/);
const fromIndex = start > 0 ? 1 : 0;
for (let i = lines.length - 1; i >= fromIndex; i--) {
const trimmed = lines[i]?.trim();
if (!trimmed) continue;
let parsed: ParsedMessage | null = null;
try {
parsed = parseJsonlLine(trimmed);
} catch {
parsed = null;
}
if (!parsed || parsed.isSidechain) continue;
const scanKey = buildScanKey(parsed, trimmed);
if (seenScanKeys.has(scanKey)) continue;
seenScanKeys.add(scanKey);
parsedMessagesReversed.push(parsed);
}
if (scanBytes === fileSize) break;
scanBytes = Math.min(fileSize, scanBytes * 2);
}
} finally {
await handle.close();
}
const parsedMessages = parsedMessagesReversed.reverse();
const extractedMessages: InboxMessage[] = [];
let pendingSlash: SlashCommandMeta | null = null;
let previousWasCommandOutput = false;
for (const message of parsedMessages) {
if (isParsedUserChunkMessage(message)) {
pendingSlash = buildSlashMetaFromParsedMessage(message);
previousWasCommandOutput = false;
continue;
}
if (!isParsedSystemChunkMessage(message)) {
previousWasCommandOutput = false;
continue;
}
const outputInfo = extractCommandOutputInfo(getMessageText(message));
if (!outputInfo?.output) {
previousWasCommandOutput = false;
continue;
}
const commandOutput = buildCommandOutputMeta(pendingSlash, outputInfo.stream);
const previousMessage = extractedMessages[extractedMessages.length - 1];
if (canMergeCommandOutput(previousMessage, commandOutput, previousWasCommandOutput)) {
previousMessage.text = `${previousMessage.text}\n${outputInfo.output}`;
previousMessage.summary = summarizeCommandOutput(previousMessage.text) || undefined;
previousWasCommandOutput = true;
continue;
}
extractedMessages.push({
from: leadName,
text: outputInfo.output,
timestamp: message.timestamp.toISOString(),
read: true,
source: 'lead_session',
leadSessionId,
messageId: buildResultMessageId(message, outputInfo.output),
messageKind: 'slash_command_result',
commandOutput,
summary: summarizeCommandOutput(outputInfo.output) || undefined,
});
previousWasCommandOutput = true;
}
return extractedMessages.length > maxMessages
? extractedMessages.slice(-maxMessages)
: extractedMessages;
}

View file

@ -39,8 +39,21 @@ import {
} from '@shared/constants/crossTeam';
import { extractMarkdownPlainText } from '@shared/utils/markdownTextSearch';
import { isRateLimitMessage } from '@shared/utils/rateLimitDetector';
import {
buildStandaloneSlashCommandMeta,
getKnownSlashCommand,
parseStandaloneSlashCommand,
} from '@shared/utils/slashCommands';
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { AlertTriangle, ChevronRight, ListPlus, Maximize2, RefreshCw, Reply } from 'lucide-react';
import {
AlertTriangle,
ChevronRight,
Command,
ListPlus,
Maximize2,
RefreshCw,
Reply,
} from 'lucide-react';
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
@ -70,6 +83,16 @@ function parseCrossTeamPseudoRecipient(value: string | undefined): string | null
return teamName.length > 0 ? teamName : null;
}
function getCommandOutputSummary(text: string): string {
const firstLine = text
.split(/\r?\n/)
.map((line) => line.trim())
.find(Boolean);
if (!firstLine) return '';
return firstLine.length > 120 ? `${firstLine.slice(0, 120)}` : firstLine;
}
export function isQualifiedExternalRecipient(
value: string | undefined,
teamName: string,
@ -475,6 +498,8 @@ export const ActivityItem = memo(
message.from === 'user' ||
message.from === 'system' ||
crossTeamOrigin?.memberName === 'user';
const isUserSent = message.source === 'user_sent' || isCrossTeamSent;
const isSystemMessage = message.from === 'system';
// Strip agent-only blocks + normalize escape sequences (before linkification)
const strippedText = useMemo(() => {
@ -489,6 +514,28 @@ export const ActivityItem = memo(
// Normalize literal \n from historical CLI-produced text to real newlines
return stripped.replace(/\\n/g, '\n').replace(/\\t/g, '\t');
}, [structured, message.text, isCrossTeamAny]);
const standaloneSlashCommand = useMemo(
() => (strippedText ? parseStandaloneSlashCommand(strippedText) : null),
[strippedText]
);
const slashCommandMeta = useMemo(
() =>
message.slashCommand ??
(standaloneSlashCommand
? buildStandaloneSlashCommandMeta(standaloneSlashCommand.raw)
: null),
[message.slashCommand, standaloneSlashCommand]
);
const knownSlashCommand = useMemo(
() => (slashCommandMeta?.name ? (getKnownSlashCommand(slashCommandMeta.name) ?? null) : null),
[slashCommandMeta]
);
const isSlashCommandResult =
message.messageKind === 'slash_command_result' && !!message.commandOutput;
const isSlashCommandMessage =
!isSlashCommandResult &&
(message.messageKind === 'slash_command' || (isUserSent && standaloneSlashCommand !== null));
const isCommandOutputError = isSlashCommandResult && message.commandOutput?.stream === 'stderr';
// Parse reply BEFORE linkification — linkifyAllMentionsInMarkdown transforms @name
// into markdown links which breaks the reply regex matcher
@ -515,6 +562,16 @@ export const ActivityItem = memo(
}, [isCrossTeamAny, strippedText]);
const rawSummary = useMemo(() => {
if (isSlashCommandResult && message.commandOutput) {
return message.summary || getCommandOutputSummary(message.text);
}
if (isSlashCommandMessage && slashCommandMeta) {
if (slashCommandMeta.args) {
const oneLine = slashCommandMeta.args.replace(/\n+/g, ' ').trim();
return `${slashCommandMeta.command} ${oneLine}`;
}
return slashCommandMeta.command;
}
if (crossTeamPreview) return crossTeamPreview;
const s =
message.summary || (structured ? getStructuredMessageSummary(structured) : '') || '';
@ -524,7 +581,17 @@ export const ActivityItem = memo(
if (!plain) return '';
const oneLine = plain.replace(/\n+/g, ' ');
return oneLine.length > 80 ? oneLine.slice(0, 80) + '…' : oneLine;
}, [crossTeamPreview, message.summary, structured, message.text]);
}, [
crossTeamPreview,
isSlashCommandMessage,
isSlashCommandResult,
message.commandOutput,
message.summary,
message.text,
slashCommandMeta,
standaloneSlashCommand,
structured,
]);
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
// Noise messages: minimal inline row
@ -557,8 +624,6 @@ export const ActivityItem = memo(
const isHeaderClickable = isManaged && canToggleCollapse;
const showChevron = isHeaderClickable && !compactHeader;
const isUserSent = message.source === 'user_sent' || isCrossTeamSent;
const isSystemMessage = message.from === 'system';
const handleHeaderToggle = useCallback(() => {
if (isHeaderClickable && collapseToggleKey) {
onToggleCollapse?.(collapseToggleKey);
@ -569,33 +634,45 @@ export const ActivityItem = memo(
<article
className="group overflow-hidden rounded-md"
style={{
marginLeft: isUserSent ? 15 : undefined,
marginLeft: isSlashCommandResult ? 26 : isUserSent ? 15 : undefined,
backgroundColor:
rateLimited || isApiError
? 'var(--tool-result-error-bg)'
: isCrossTeamAny
? 'var(--cross-team-bg)'
: isSystemMessage
? 'var(--system-activity-bg)'
: zebraShade
? CARD_BG_ZEBRA
: CARD_BG,
: isSlashCommandResult
? 'rgba(245, 158, 11, 0.08)'
: isSlashCommandMessage
? 'rgba(245, 158, 11, 0.08)'
: isCrossTeamAny
? 'var(--cross-team-bg)'
: isSystemMessage
? 'var(--system-activity-bg)'
: zebraShade
? CARD_BG_ZEBRA
: CARD_BG,
border:
rateLimited || isApiError
? '1px solid var(--tool-result-error-border)'
: isCrossTeamAny
? '1px solid var(--cross-team-border)'
: isSystemMessage
? '1px solid var(--system-activity-border)'
: CARD_BORDER_STYLE,
: isSlashCommandResult
? '1px solid rgba(245, 158, 11, 0.22)'
: isSlashCommandMessage
? '1px solid rgba(245, 158, 11, 0.22)'
: isCrossTeamAny
? '1px solid var(--cross-team-border)'
: isSystemMessage
? '1px solid var(--system-activity-border)'
: CARD_BORDER_STYLE,
borderLeft:
rateLimited || isApiError
? '3px solid var(--tool-result-error-text)'
: isCrossTeamAny
? '3px solid var(--cross-team-accent)'
: isSystemMessage
? '3px solid var(--system-activity-accent)'
: `3px solid ${getThemedBorder(colors, isLight)}`,
: isSlashCommandResult
? '3px solid rgba(245, 158, 11, 0.85)'
: isSlashCommandMessage
? '3px solid rgba(245, 158, 11, 0.85)'
: isCrossTeamAny
? '3px solid var(--cross-team-accent)'
: isSystemMessage
? '3px solid var(--system-activity-accent)'
: `3px solid ${getThemedBorder(colors, isLight)}`,
}}
>
{/* Header — div with role=button (cannot use <button> due to nested buttons inside) */}
@ -637,16 +714,22 @@ export const ActivityItem = memo(
{crossTeamOrigin ? (
<CrossTeamTeamBadge teamName={crossTeamOrigin.teamName} onClick={onTeamClick} />
) : null}
<MemberBadge
name={senderName}
color={senderColor}
hideAvatar={senderHideAvatar || compactHeader}
onClick={onMemberNameClick}
disableHoverCard={crossTeamOrigin != null}
/>
{isSlashCommandResult ? (
<span className="inline-flex items-center rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300">
result
</span>
) : (
<MemberBadge
name={senderName}
color={senderColor}
hideAvatar={senderHideAvatar || compactHeader}
onClick={onMemberNameClick}
disableHoverCard={crossTeamOrigin != null}
/>
)}
{/* Role */}
{!compactHeader && formattedRole ? (
{!compactHeader && formattedRole && !isSlashCommandResult ? (
<span className="text-[10px]" style={{ color: CARD_ICON_MUTED }}>
{formattedRole}
</span>
@ -660,6 +743,19 @@ export const ActivityItem = memo(
>
{systemLabel}
</span>
) : isSlashCommandResult && message.commandOutput ? (
<span
className={[
'inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] uppercase tracking-wide',
isCommandOutputError
? 'bg-rose-500/15 text-rose-300'
: 'bg-amber-500/15 text-amber-300',
].join(' ')}
>
{message.commandOutput.stream}
</span>
) : isSlashCommandMessage ? (
<span className="text-[10px] uppercase tracking-wide text-amber-400">command</span>
) : messageType ? (
<span
className="text-[10px] uppercase tracking-wide"
@ -670,14 +766,14 @@ export const ActivityItem = memo(
) : null}
{/* Lead session marker */}
{message.source === 'lead_session' ? (
{message.source === 'lead_session' && !isSlashCommandResult ? (
<span
className="text-[10px] uppercase tracking-wide"
style={{ color: CARD_ICON_MUTED }}
>
session
</span>
) : message.source === 'lead_process' ? (
) : message.source === 'lead_process' && !isSlashCommandResult ? (
<span
className="text-[10px] uppercase tracking-wide"
style={{ color: CARD_ICON_MUTED }}
@ -729,9 +825,48 @@ export const ActivityItem = memo(
{/* Summary */}
<span className="min-w-0 flex-1 truncate text-xs" style={{ color: CARD_TEXT_LIGHT }}>
{onTaskIdClick
? renderInlineBoldSummary(rawSummary, onTaskIdClick)
: renderInlineBoldSummary(rawSummary)}
{isSlashCommandResult && message.commandOutput ? (
<span className="inline-flex items-center gap-1.5">
<Command
size={12}
className={[
'shrink-0',
isCommandOutputError ? 'text-rose-400' : 'text-amber-400',
].join(' ')}
/>
<span
className={[
'font-mono text-[11px]',
isCommandOutputError ? 'text-rose-300' : 'text-amber-300',
].join(' ')}
>
{message.commandOutput.commandLabel}
</span>
<span className="truncate text-[11px] text-[var(--color-text-secondary)]">
{message.summary || getCommandOutputSummary(message.text) || rawSummary}
</span>
</span>
) : isSlashCommandMessage && slashCommandMeta ? (
<span className="inline-flex items-center gap-1.5">
<Command size={12} className="shrink-0 text-amber-400" />
<span className="font-mono text-[11px] text-amber-300">
{slashCommandMeta.command}
</span>
{slashCommandMeta.args ? (
<span className="truncate text-[11px] text-[var(--color-text-secondary)]">
{slashCommandMeta.args.replace(/\n+/g, ' ')}
</span>
) : (slashCommandMeta.knownDescription ?? knownSlashCommand?.description) ? (
<span className="truncate text-[11px] text-[var(--color-text-secondary)]">
{slashCommandMeta.knownDescription ?? knownSlashCommand?.description}
</span>
) : null}
</span>
) : onTaskIdClick ? (
renderInlineBoldSummary(rawSummary, onTaskIdClick)
) : (
renderInlineBoldSummary(rawSummary)
)}
</span>
{/* Timestamp / expand */}
@ -781,6 +916,70 @@ export const ActivityItem = memo(
</pre>
</details>
</div>
) : isSlashCommandResult && message.commandOutput ? (
<div
className={[
'rounded-md px-3 py-2',
isCommandOutputError
? 'border border-rose-400/20 bg-rose-500/5'
: 'border border-amber-400/20 bg-amber-500/5',
].join(' ')}
>
<div className="flex items-center gap-2">
<Command
size={14}
className={[
'shrink-0',
isCommandOutputError ? 'text-rose-400' : 'text-amber-400',
].join(' ')}
/>
<span
className={[
'font-mono text-xs',
isCommandOutputError ? 'text-rose-300' : 'text-amber-300',
].join(' ')}
>
{message.commandOutput.commandLabel}
</span>
<span
className={[
'rounded-full px-1.5 py-0.5 text-[10px] uppercase tracking-wide',
isCommandOutputError
? 'bg-rose-500/15 text-rose-300'
: 'bg-amber-500/15 text-amber-300',
].join(' ')}
>
{message.commandOutput.stream}
</span>
<div className="ml-auto">
<CopyButton text={message.text} inline />
</div>
</div>
<ExpandableContent className="mt-2" collapsedHeight={160}>
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 font-mono text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
{message.text}
</pre>
</ExpandableContent>
</div>
) : isSlashCommandMessage && slashCommandMeta ? (
<div className="rounded-md border border-amber-400/20 bg-amber-500/5 px-3 py-2">
<div className="flex items-center gap-2">
<Command size={14} className="shrink-0 text-amber-400" />
<span className="font-mono text-xs text-amber-300">
{slashCommandMeta.command}
</span>
</div>
{(slashCommandMeta.knownDescription ?? knownSlashCommand?.description) ? (
<p className="mt-1 text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
{slashCommandMeta.knownDescription ?? knownSlashCommand?.description}
</p>
) : null}
{slashCommandMeta.args ? (
<div className="mt-2 rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1.5 font-mono text-[11px] text-[var(--color-text-secondary)]">
{slashCommandMeta.args}
</div>
) : null}
</div>
) : parsedReply ? (
<ReplyQuoteBlock
reply={parsedReply}

View file

@ -61,6 +61,7 @@ export function isLeadThought(msg: InboxMessage): boolean {
if (typeof msg.to === 'string' && msg.to.trim().length > 0) return false;
// Compaction boundary events are system messages, not lead thoughts
if (isCompactionMessage(msg)) return false;
if (msg.messageKind === 'slash_command_result') return false;
// Protocol noise (JSON coordination signals, raw teammate-message XML) should be hidden
if (isThoughtProtocolNoise(msg.text)) return false;
if (msg.source === 'lead_session') return true;

View file

@ -25,6 +25,7 @@ import {
} from '@renderer/utils/taskReferenceUtils';
import { MAX_TEXT_LENGTH } from '@shared/constants';
import { isLeadMember } from '@shared/utils/leadDetection';
import { KNOWN_SLASH_COMMANDS, parseStandaloneSlashCommand } from '@shared/utils/slashCommands';
import { AlertCircle, Check, ChevronDown, Mic, Paperclip, Search, Send } from 'lucide-react';
import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector';
@ -198,8 +199,21 @@ export const MessageComposer = ({
const { suggestions: teamMentionSuggestions } = useTeamSuggestions(teamName);
const { suggestions: taskSuggestions } = useTaskSuggestions(teamName);
const slashCommandSuggestions = useMemo<MentionSuggestion[]>(
() =>
KNOWN_SLASH_COMMANDS.map((command) => ({
id: `command:${command.name}`,
name: command.name,
command: command.command,
description: command.description,
subtitle: command.description,
type: 'command',
})),
[]
);
const trimmed = stripEncodedTaskReferenceMetadata(draft.text).trim();
const standaloneSlashCommand = useMemo(() => parseStandaloneSlashCommand(trimmed), [trimmed]);
const selectedMember = members.find((m) => m.name === recipient);
const selectedResolvedColor = selectedMember ? colorMap.get(selectedMember.name) : undefined;
@ -265,6 +279,17 @@ export const MessageComposer = ({
: 'Team must be online to attach files'
: undefined;
const attachmentsBlocked = draft.attachments.length > 0 && !supportsAttachments;
const slashCommandRestrictionReason = standaloneSlashCommand
? draft.attachments.length > 0
? 'Slash commands require a live team lead and cannot be sent with attachments'
: isCrossTeam
? 'Slash commands can only be run on the current team lead'
: !isLeadRecipient
? 'Slash commands can only be sent to the team lead'
: !isTeamAlive
? 'Slash commands require the team lead to be online'
: null
: null;
const canSend =
recipient.length > 0 &&
trimmed.length > 0 &&
@ -272,6 +297,7 @@ export const MessageComposer = ({
!sending &&
!isProvisioning &&
!attachmentsBlocked &&
!slashCommandRestrictionReason &&
(!isCrossTeam || onCrossTeamSend !== undefined);
// Track whether we initiated a send — clear draft only on confirmed success
@ -870,6 +896,7 @@ export const MessageComposer = ({
suggestions={mentionSuggestions}
teamSuggestions={teamMentionSuggestions}
taskSuggestions={taskSuggestions}
commandSuggestions={slashCommandSuggestions}
chips={draft.chips}
onChipRemove={draft.removeChip}
projectPath={projectPath}
@ -877,6 +904,7 @@ export const MessageComposer = ({
onModEnter={handleSend}
onShiftTab={handleCycleActionMode}
dismissMentionsRef={dismissMentionsRef}
extraTips={['Tip: You can use "/" to run any Claude commands.']}
minRows={2}
maxRows={6}
maxLength={MAX_TEXT_LENGTH}
@ -918,7 +946,9 @@ export const MessageComposer = ({
</button>
</span>
</TooltipTrigger>
{isProvisioning && !sending ? (
{slashCommandRestrictionReason ? (
<TooltipContent side="top">{slashCommandRestrictionReason}</TooltipContent>
) : isProvisioning && !sending ? (
<TooltipContent side="top">
Sending unavailable while team is launching
</TooltipContent>
@ -928,7 +958,12 @@ export const MessageComposer = ({
}
footerRight={
<div className="flex items-center gap-2">
{sendError ? (
{slashCommandRestrictionReason ? (
<span className="inline-flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-[10px] text-amber-300">
<AlertCircle size={10} className="shrink-0" />
{slashCommandRestrictionReason}
</span>
) : sendError ? (
<span className="inline-flex items-center gap-1 rounded bg-red-500/10 px-1.5 py-0.5 text-[10px] text-red-400">
<AlertCircle size={10} className="shrink-0" />
{sendError}

View file

@ -5,7 +5,7 @@ import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { getTeamColorSet, getThemedText } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { nameColorSet } from '@renderer/utils/projectColor';
import { Folder, Hash, Loader2, UsersRound } from 'lucide-react';
import { Command, Folder, Hash, Loader2, UsersRound } from 'lucide-react';
import type { MentionSuggestion } from '@renderer/types/mention';
@ -83,10 +83,11 @@ export const MentionSuggestionList = ({
}
// Categorize suggestions (folders are grouped with files)
type Section = 'member' | 'team' | 'task' | 'file';
type Section = 'member' | 'team' | 'task' | 'file' | 'command';
const getSuggestionSection = (s: MentionSuggestion): Section => {
if (s.type === 'file' || s.type === 'folder') return 'file';
if (s.type === 'task') return 'task';
if (s.type === 'command') return 'command';
if (s.type === 'team') return 'team';
return 'member';
};
@ -96,6 +97,7 @@ export const MentionSuggestionList = ({
team: 'Teams',
task: 'Tasks',
file: 'Files',
command: 'Commands',
};
// Determine which sections are present
@ -114,6 +116,7 @@ export const MentionSuggestionList = ({
const isFileOrFolder = isFile || isFolder;
const isTeam = section === 'team';
const isTask = section === 'task';
const isCommand = section === 'command';
const taskTeamColorSet =
isTask && s.color
? getTeamColorSet(s.color)
@ -160,6 +163,8 @@ export const MentionSuggestionList = ({
<FileIcon fileName={s.name} className="size-3.5" />
) : isTask ? (
<Hash size={13} className="shrink-0 text-blue-500 dark:text-blue-400" />
) : isCommand ? (
<Command size={13} className="shrink-0 text-amber-500 dark:text-amber-400" />
) : isTeam ? (
<UsersRound
size={13}
@ -181,12 +186,17 @@ export const MentionSuggestionList = ({
style={
isTask
? { color: 'var(--color-link, #60a5fa)' }
: colorSet
? { color: getThemedText(colorSet, isLight) }
: undefined
: isCommand
? { color: 'rgb(245 158 11)' }
: colorSet
? { color: getThemedText(colorSet, isLight) }
: undefined
}
>
<HighlightedName name={isTask ? `#${s.name}` : s.name} query={query} />
<HighlightedName
name={isTask ? `#${s.name}` : isCommand ? (s.command ?? `/${s.name}`) : s.name}
query={query}
/>
</span>
{!isTask && !isFileOrFolder && s.subtitle ? (
<span className="truncate text-[var(--color-text-muted)]">{s.subtitle}</span>
@ -208,6 +218,11 @@ export const MentionSuggestionList = ({
{isTask && s.subtitle ? (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">{s.subtitle}</div>
) : null}
{isCommand && s.description ? (
<div className="truncate text-[10px] text-[var(--color-text-muted)]">
{s.description}
</div>
) : null}
</div>
{isTeam && s.isOnline !== undefined ? (
<span

View file

@ -24,12 +24,14 @@ import {
findUrlMatches,
removeUrlMatchFromText,
} from '@renderer/utils/urlMatchUtils';
import { getKnownSlashCommand, parseStandaloneSlashCommand } from '@shared/utils/slashCommands';
import { AutoResizeTextarea } from './auto-resize-textarea';
import { ChipInteractionLayer } from './ChipInteractionLayer';
import { CodeChipBadge } from './CodeChipBadge';
import { MentionInteractionLayer } from './MentionInteractionLayer';
import { MentionSuggestionList } from './MentionSuggestionList';
import { SlashCommandInteractionLayer } from './SlashCommandInteractionLayer';
import { TaskReferenceInteractionLayer } from './TaskReferenceInteractionLayer';
import { UrlInteractionLayer } from './UrlInteractionLayer';
@ -72,7 +74,19 @@ interface ChipSegment {
chip: InlineChip;
}
type Segment = TextSegment | MentionSegment | TaskSegment | UrlSegment | ChipSegment;
interface SlashCommandSegment {
type: 'slash_command';
value: string;
known: boolean;
}
type Segment =
| TextSegment
| MentionSegment
| TaskSegment
| UrlSegment
| ChipSegment
| SlashCommandSegment;
// ---------------------------------------------------------------------------
// Mention segment parsing (splits text into plain text + @mention segments)
@ -236,6 +250,16 @@ function parseSegments(
chips: InlineChip[]
): Segment[] {
if (!text) return [{ type: 'text', value: text }];
const slashCommand = parseStandaloneSlashCommand(text);
if (slashCommand) {
return [
{
type: 'slash_command',
value: slashCommand.raw,
known: getKnownSlashCommand(slashCommand.name) !== null,
},
];
}
if (chips.length === 0) return parseSuggestionSegments(text, mentionSuggestions, taskSuggestions);
// Build a map of chip tokens for fast lookup
@ -322,12 +346,16 @@ interface MentionableTextareaProps extends Omit<
teamSuggestions?: MentionSuggestion[];
/** Task suggestions for #task references */
taskSuggestions?: MentionSuggestion[];
/** Slash command suggestions for /command autocomplete */
commandSuggestions?: MentionSuggestion[];
/** Called when Enter (without Shift) is pressed. */
onModEnter?: () => void;
/** Called when Shift+Tab is pressed. */
onShiftTab?: () => void;
/** Ref that receives the dismiss callback to close mention dropdown from outside */
dismissMentionsRef?: React.MutableRefObject<(() => void) | null>;
/** Additional rotating tips to append after the defaults */
extraTips?: string[];
}
export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, MentionableTextareaProps>(
@ -347,9 +375,11 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
onFileChipInsert,
teamSuggestions = [],
taskSuggestions = [],
commandSuggestions = [],
onModEnter,
onShiftTab,
dismissMentionsRef,
extraTips = [],
style,
className,
...textareaProps
@ -394,11 +424,17 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
value,
onValueChange,
textareaRef: internalRef,
triggerChars: enableTaskSearch ? ['@', '#'] : ['@'],
triggerChars:
commandSuggestions.length > 0 ? ['@', '#', '/'] : enableTaskSearch ? ['@', '#'] : ['@'],
isTriggerEnabled: (triggerChar) => {
if (triggerChar === '#') return enableTaskSearch;
if (triggerChar === '/') return commandSuggestions.length > 0;
return suggestions.length > 0 || enableFiles || teamSuggestions.length > 0;
},
isTriggerMatchValid: (trigger, text) => {
if (trigger.triggerChar !== '/') return true;
return text.slice(0, trigger.triggerIndex).trim().length === 0;
},
});
// Expose dismiss to parent via ref for external close (e.g. Send button click)
@ -413,7 +449,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
isOpen && enableFiles && activeTriggerChar === '@'
);
const isAtTrigger = activeTriggerChar !== '#';
const isAtTrigger = activeTriggerChar !== '#' && activeTriggerChar !== '/';
const memberSuggestions = React.useMemo(() => {
if (!isOpen || !isAtTrigger) return [];
@ -434,6 +470,12 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
return taskSuggestions.filter((task) => doesSuggestionMatchQuery(task, query));
}, [taskSuggestions, activeTriggerChar, isOpen, query]);
const filteredCommandSuggestions = React.useMemo(() => {
if (commandSuggestions.length === 0 || !isOpen || activeTriggerChar !== '/') return [];
if (!query) return commandSuggestions;
return commandSuggestions.filter((command) => doesSuggestionMatchQuery(command, query));
}, [commandSuggestions, activeTriggerChar, isOpen, query]);
// Merged suggestion list: members → online teams → offline teams → files
const atSuggestions = React.useMemo(() => {
const onlineTeams = filteredTeamSuggestions.filter((t) => t.isOnline);
@ -444,7 +486,11 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
return [...merged, ...fileSuggestions];
}, [memberSuggestions, filteredTeamSuggestions, enableFiles, fileSuggestions]);
const effectiveSuggestions =
activeTriggerChar === '#' ? filteredTaskSuggestions : atSuggestions;
activeTriggerChar === '/'
? filteredCommandSuggestions
: activeTriggerChar === '#'
? filteredTaskSuggestions
: atSuggestions;
React.useEffect(() => {
if (!isOpen) return;
@ -607,6 +653,7 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
const hasOverlay =
value.includes('http://') ||
value.includes('https://') ||
parseStandaloneSlashCommand(value) !== null ||
suggestions.length > 0 ||
teamSuggestions.length > 0 ||
taskSuggestions.length > 0 ||
@ -617,6 +664,11 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
() => (teamSuggestions.length > 0 ? [...suggestions, ...teamSuggestions] : suggestions),
[suggestions, teamSuggestions]
);
const slashCommand = React.useMemo(() => parseStandaloneSlashCommand(value), [value]);
const knownSlashCommand = React.useMemo(
() => (slashCommand ? getKnownSlashCommand(slashCommand.name) : null),
[slashCommand]
);
const segments = React.useMemo(
() =>
@ -962,8 +1014,9 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
'Tip: Use @ for members/files and # for tasks',
'Tip: Mention "create a task" to add it to the kanban',
"Tip: Don't overload the team lead with tasks — ask them to delegate to teammates",
...extraTips,
],
[]
[extraTips]
);
const [tipIndex, setTipIndex] = React.useState(0);
const [tipVisible, setTipVisible] = React.useState(true);
@ -984,7 +1037,11 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
const resolvedHintText = hintText ?? rotatingTips[tipIndex];
const showHintRow =
showHint &&
(suggestions.length > 0 || enableFiles || teamSuggestions.length > 0 || enableTaskSearch);
(suggestions.length > 0 ||
enableFiles ||
teamSuggestions.length > 0 ||
enableTaskSearch ||
commandSuggestions.length > 0);
const showFooter = showHintRow || footerRight;
return (
@ -1012,6 +1069,26 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
if (seg.type === 'chip') {
return <CodeChipBadge key={idx} chip={seg.chip} tokenText={seg.value} />;
}
if (seg.type === 'slash_command') {
return (
<span
key={idx}
style={{
backgroundColor: seg.known
? 'rgba(245, 158, 11, 0.18)'
: 'rgba(148, 163, 184, 0.16)',
color: seg.known ? '#f59e0b' : 'var(--color-text-secondary)',
borderRadius: '4px',
boxShadow: `inset 0 0 0 1px ${
seg.known ? 'rgba(245, 158, 11, 0.3)' : 'rgba(148, 163, 184, 0.24)'
}`,
padding: '2px 0',
}}
>
{seg.value}
</span>
);
}
if (seg.type === 'task') {
return (
<React.Fragment key={idx}>
@ -1110,6 +1187,16 @@ export const MentionableTextarea = React.forwardRef<HTMLTextAreaElement, Mention
/>
) : null}
{slashCommand ? (
<SlashCommandInteractionLayer
command={slashCommand}
definition={knownSlashCommand}
value={value}
textareaRef={internalRef}
scrollTop={scrollTop}
/>
) : null}
<AutoResizeTextarea
ref={setRefs}
value={value}

View file

@ -0,0 +1,88 @@
import * as React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { calculateInlineMatchPositions } from '@renderer/utils/chipUtils';
import type {
KnownSlashCommandDefinition,
ParsedStandaloneSlashCommand,
} from '@shared/utils/slashCommands';
interface SlashCommandInteractionLayerProps {
command: ParsedStandaloneSlashCommand;
definition: KnownSlashCommandDefinition | null;
value: string;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
scrollTop: number;
}
export const SlashCommandInteractionLayer = ({
command,
definition,
value,
textareaRef,
scrollTop,
}: SlashCommandInteractionLayerProps): React.JSX.Element | null => {
const [position, setPosition] = React.useState<{
top: number;
left: number;
width: number;
height: number;
} | null>(null);
React.useLayoutEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
const [match] = calculateInlineMatchPositions(textarea, value, [
{
item: command,
start: command.startIndex,
end: command.endIndex,
token: command.raw,
},
]);
if (!match) {
setPosition(null);
return;
}
setPosition({
top: match.top,
left: match.left,
width: match.width,
height: match.height,
});
}, [command, textareaRef, value]);
if (!definition || !position) return null;
return (
<div className="pointer-events-none absolute inset-0 z-20 overflow-hidden">
<div style={{ transform: `translateY(-${scrollTop}px)` }}>
<Tooltip>
<TooltipTrigger asChild>
<div
className="pointer-events-auto absolute cursor-help"
style={{
top: position.top,
left: position.left,
width: position.width,
height: position.height,
}}
/>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="space-y-1">
<div className="text-xs font-medium text-amber-400">{definition.command}</div>
<div className="text-[11px] text-[var(--color-text-muted)]">
{definition.description}
</div>
</div>
</TooltipContent>
</Tooltip>
</div>
</div>
);
};

View file

@ -15,6 +15,8 @@ interface UseMentionDetectionOptions {
triggerChars?: string[];
/** Enable or disable individual triggers dynamically. */
isTriggerEnabled?: (triggerChar: string) => boolean;
/** Additional validation for trigger matches before opening the dropdown. */
isTriggerMatchValid?: (trigger: MentionTrigger, text: string) => boolean;
}
export interface DropdownPosition {
@ -176,6 +178,7 @@ export function useMentionDetection({
textareaRef,
triggerChars = ['@'],
isTriggerEnabled,
isTriggerMatchValid,
}: UseMentionDetectionOptions): UseMentionDetectionResult {
const [isOpen, setIsOpen] = useState(false);
const [activeTriggerChar, setActiveTriggerChar] = useState<string | null>(null);
@ -253,7 +256,8 @@ export function useMentionDetection({
(cursorPos: number) => {
const trigger = findMentionTrigger(value, cursorPos, triggerChars);
const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false;
if (trigger && isEnabled) {
const isValid = trigger ? (isTriggerMatchValid?.(trigger, value) ?? true) : false;
if (trigger && isEnabled && isValid) {
const sameQuery =
triggerIndexRef.current === trigger.triggerIndex &&
activeTriggerCharRef.current === trigger.triggerChar &&
@ -274,7 +278,7 @@ export function useMentionDetection({
dismiss();
}
},
[value, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition]
[value, triggerChars, isTriggerEnabled, isTriggerMatchValid, dismiss, computeDropdownPosition]
);
const handleChange = useCallback(
@ -286,7 +290,8 @@ export function useMentionDetection({
const cursorPos = e.target.selectionStart;
const trigger = findMentionTrigger(newValue, cursorPos, triggerChars);
const isEnabled = trigger ? (isTriggerEnabled?.(trigger.triggerChar) ?? true) : false;
if (trigger && isEnabled) {
const isValid = trigger ? (isTriggerMatchValid?.(trigger, newValue) ?? true) : false;
if (trigger && isEnabled && isValid) {
triggerIndexRef.current = trigger.triggerIndex;
activeTriggerCharRef.current = trigger.triggerChar;
queryRef.current = trigger.query;
@ -300,7 +305,14 @@ export function useMentionDetection({
dismiss();
}
},
[onValueChange, triggerChars, isTriggerEnabled, dismiss, computeDropdownPosition]
[
onValueChange,
triggerChars,
isTriggerEnabled,
isTriggerMatchValid,
dismiss,
computeDropdownPosition,
]
);
const handleSelect = useCallback(

View file

@ -5,10 +5,12 @@ export interface MentionSuggestion {
name: string;
/** Role displayed in suggestion list */
subtitle?: string;
/** Optional description for command and rich suggestion tooltips */
description?: string;
/** Color name from TeamColorSet palette */
color?: string;
/** Suggestion type — 'member' (default), 'team', 'file', 'folder', or 'task' */
type?: 'member' | 'team' | 'file' | 'folder' | 'task';
/** Suggestion type — 'member' (default), 'team', 'file', 'folder', 'task', or 'command' */
type?: 'member' | 'team' | 'file' | 'folder' | 'task' | 'command';
/** Whether the team is currently online (team suggestions only) */
isOnline?: boolean;
/** Absolute file/folder path (file/folder suggestions only) */
@ -19,6 +21,8 @@ export interface MentionSuggestion {
insertText?: string;
/** Optional extra searchable text (subject, team name, path, etc.) */
searchText?: string;
/** Optional slash command string including leading slash (command suggestions only) */
command?: `/${string}`;
/** Canonical task id (task suggestions only) */
taskId?: string;
/** Owning team name (task suggestions only) */

View file

@ -1,10 +1,15 @@
import type { MentionSuggestion } from '@renderer/types/mention';
export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' {
return suggestion.type === 'task' ? '#' : '@';
export function getSuggestionTriggerChar(suggestion: MentionSuggestion): '@' | '#' | '/' {
if (suggestion.type === 'task') return '#';
if (suggestion.type === 'command') return '/';
return '@';
}
export function getSuggestionInsertionText(suggestion: MentionSuggestion): string {
if (suggestion.type === 'command') {
return suggestion.command?.slice(1) ?? suggestion.insertText ?? suggestion.name;
}
return suggestion.insertText ?? suggestion.name;
}
@ -15,10 +20,12 @@ export function doesSuggestionMatchQuery(suggestion: MentionSuggestion, query: s
const haystacks = [
suggestion.name,
suggestion.subtitle,
suggestion.description,
suggestion.relativePath,
suggestion.searchText,
suggestion.teamDisplayName,
suggestion.teamName,
suggestion.command,
]
.filter(Boolean)
.map((value) => value!.toLowerCase());

View file

@ -89,6 +89,29 @@ export function areToolCallsEqual(
return true;
}
export function areSlashCommandsEqual(
prev?: InboxMessage['slashCommand'],
next?: InboxMessage['slashCommand']
): boolean {
if (prev === next) return true;
if (!prev || !next) return !prev && !next;
return (
prev.name === next.name &&
prev.command === next.command &&
prev.args === next.args &&
prev.knownDescription === next.knownDescription
);
}
export function areCommandOutputsEqual(
prev?: InboxMessage['commandOutput'],
next?: InboxMessage['commandOutput']
): boolean {
if (prev === next) return true;
if (!prev || !next) return !prev && !next;
return prev.stream === next.stream && prev.commandLabel === next.commandLabel;
}
export function areInboxMessagesEquivalentForRender(
prev: InboxMessage,
next: InboxMessage
@ -107,10 +130,13 @@ export function areInboxMessagesEquivalentForRender(
if (prev.source !== next.source) return false;
if (prev.leadSessionId !== next.leadSessionId) return false;
if (prev.toolSummary !== next.toolSummary) return false;
if (prev.messageKind !== next.messageKind) return false;
return (
areTaskRefsEqual(prev.taskRefs, next.taskRefs) &&
areAttachmentsEqual(prev.attachments, next.attachments)
areAttachmentsEqual(prev.attachments, next.attachments) &&
areSlashCommandsEqual(prev.slashCommand, next.slashCommand) &&
areCommandOutputsEqual(prev.commandOutput, next.commandOutput)
);
}

View file

@ -166,6 +166,20 @@ export interface SourceMessageSnapshot {
}[];
}
export type InboxMessageKind = 'default' | 'slash_command' | 'slash_command_result';
export interface SlashCommandMeta {
name: string;
command: `/${string}`;
args?: string;
knownDescription?: string;
}
export interface CommandOutputMeta {
stream: 'stdout' | 'stderr';
commandLabel: string;
}
// Fields are validated in TeamTaskReader.getTasks() using `satisfies Record<keyof TeamTask, unknown>`.
// Adding a field here without mapping it there will cause a compile error.
export interface TeamTask {
@ -323,6 +337,12 @@ export interface InboxMessage {
toolSummary?: string;
/** Structured tool call details for tooltip display. */
toolCalls?: ToolCallMeta[];
/** Renderer-friendly semantic kind. Defaults to "default" when absent. */
messageKind?: InboxMessageKind;
/** Structured slash-command metadata for sent command rows. */
slashCommand?: SlashCommandMeta;
/** Structured command-output metadata for session-derived result rows. */
commandOutput?: CommandOutputMeta;
}
export type AgentActionMode = 'do' | 'ask' | 'delegate';
@ -348,6 +368,9 @@ export interface SendMessageRequest {
replyToConversationId?: string;
toolSummary?: string;
toolCalls?: ToolCallMeta[];
messageKind?: InboxMessageKind;
slashCommand?: SlashCommandMeta;
commandOutput?: CommandOutputMeta;
}
export interface SendMessageResult {

View file

@ -21,18 +21,29 @@ const NOISE_TAG_PATTERNS = [
/<system-reminder>[\s\S]*?<\/system-reminder>/gi,
];
export interface CommandOutputInfo {
stream: 'stdout' | 'stderr';
output: string;
}
/**
* Extract content from <local-command-stdout> tags.
* Returns the command output without the wrapper tags.
*/
function extractCommandOutput(content: string): string | null {
export function extractCommandOutputInfo(content: string): CommandOutputInfo | null {
const match = /<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/i.exec(content);
const matchStderr = /<local-command-stderr>([\s\S]*?)<\/local-command-stderr>/i.exec(content);
if (match) {
return match[1].trim();
return {
stream: 'stdout',
output: match[1].trim(),
};
}
if (matchStderr) {
return matchStderr[1].trim();
return {
stream: 'stderr',
output: matchStderr[1].trim(),
};
}
return null;
}
@ -84,9 +95,9 @@ export function isCommandOutputContent(content: string): boolean {
export function sanitizeDisplayContent(content: string): string {
// If it's a command output message, extract the output content
if (isCommandOutputContent(content)) {
const commandOutput = extractCommandOutput(content);
const commandOutput = extractCommandOutputInfo(content);
if (commandOutput) {
return commandOutput;
return commandOutput.output;
}
}

View file

@ -0,0 +1,123 @@
import type { SlashCommandMeta } from '@shared/types/team';
export interface KnownSlashCommandDefinition {
name: string;
command: `/${string}`;
description: string;
}
export interface ParsedStandaloneSlashCommand {
name: string;
command: `/${string}`;
args?: string;
raw: string;
startIndex: number;
endIndex: number;
}
const STANDALONE_SLASH_COMMAND_PATTERN = /^\/([a-z][a-z0-9:-]{0,63})(?:\s+([\s\S]*\S))?$/i;
export const KNOWN_SLASH_COMMANDS: readonly KnownSlashCommandDefinition[] = [
{
name: 'compact',
command: '/compact',
description: 'Compact conversation with optional focus instructions.',
},
{
name: 'clear',
command: '/clear',
description: 'Clear conversation history and free up context.',
},
{
name: 'reset',
command: '/reset',
description: 'Alias of /clear. Clear conversation history and free up context.',
},
{
name: 'new',
command: '/new',
description: 'Alias of /clear. Start a fresh conversation.',
},
{
name: 'plan',
command: '/plan',
description: 'Enter plan mode with an optional task description.',
},
{
name: 'model',
command: '/model',
description: 'Select or change the Claude model.',
},
{
name: 'effort',
command: '/effort',
description: 'Set reasoning effort for the current session.',
},
{
name: 'fast',
command: '/fast',
description: 'Toggle fast mode on or off.',
},
{
name: 'cost',
command: '/cost',
description: 'Show token usage statistics.',
},
{
name: 'usage',
command: '/usage',
description: 'Show plan usage limits and rate-limit status.',
},
] as const;
const KNOWN_SLASH_COMMANDS_BY_NAME = new Map(
KNOWN_SLASH_COMMANDS.map((command) => [command.name.toLowerCase(), command] as const)
);
export function getKnownSlashCommand(name: string): KnownSlashCommandDefinition | null {
return KNOWN_SLASH_COMMANDS_BY_NAME.get(name.trim().toLowerCase()) ?? null;
}
export function buildSlashCommandMeta(
name: string,
args?: string,
command?: `/${string}`
): SlashCommandMeta {
const normalizedName = name.trim().toLowerCase();
const normalizedCommand = command ?? `/${normalizedName}`;
const known = getKnownSlashCommand(normalizedName);
return {
name: normalizedName,
command: normalizedCommand,
...(args ? { args } : {}),
...(known ? { knownDescription: known.description } : {}),
};
}
export function buildStandaloneSlashCommandMeta(text: string): SlashCommandMeta | null {
const parsed = parseStandaloneSlashCommand(text);
if (!parsed) return null;
return buildSlashCommandMeta(parsed.name, parsed.args, parsed.command);
}
export function parseStandaloneSlashCommand(text: string): ParsedStandaloneSlashCommand | null {
const trimmed = text.trim();
if (!trimmed) return null;
const match = STANDALONE_SLASH_COMMAND_PATTERN.exec(trimmed);
if (!match) return null;
const name = match[1].toLowerCase();
const args = match[2]?.trim();
const startIndex = text.indexOf(trimmed);
const endIndex = startIndex + trimmed.length;
return {
name,
command: `/${name}`,
args: args || undefined,
raw: trimmed,
startIndex,
endIndex,
};
}

View file

@ -293,6 +293,101 @@ describe('ipc teams handlers', () => {
);
});
it('sends standalone slash commands to lead stdin without the UI routing wrapper', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
const result = (await sendHandler!({} as never, 'my-team', {
member: 'team-lead',
text: ' /COMPACT keep kanban ',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
'/COMPACT keep kanban',
undefined
);
const compactCall = vi.mocked(provisioningService.sendMessageToTeam).mock
.calls as unknown[][];
expect(String(compactCall[0]?.[1] ?? '')).not.toContain('You received a direct message from the user');
expect(service.sendDirectToLead).toHaveBeenCalledWith(
'my-team',
'team-lead',
'/COMPACT keep kanban',
undefined,
undefined,
undefined,
expect.any(String)
);
});
it('routes unknown standalone slash commands through the same raw stdin path', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
const result = (await sendHandler!({} as never, 'my-team', {
member: 'team-lead',
text: ' /foo bar ',
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
'/foo bar',
undefined
);
const unknownSlashCall = vi.mocked(provisioningService.sendMessageToTeam).mock
.calls as unknown[][];
expect(String(unknownSlashCall[0]?.[1] ?? '')).not.toContain(
'You received a direct message from the user'
);
expect(service.sendDirectToLead).toHaveBeenCalledWith(
'my-team',
'team-lead',
'/foo bar',
undefined,
undefined,
undefined,
expect.any(String)
);
});
it('does not route slash commands through raw stdin when attachments are present', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
vi.stubEnv('HOME', os.tmpdir());
try {
const result = (await sendHandler!({} as never, 'my-team', {
member: 'team-lead',
text: '/compact keep kanban',
attachments: [
{
id: 'att-1',
filename: 'note.txt',
mimeType: 'text/plain',
size: 4,
data: Buffer.from('test').toString('base64'),
},
],
})) as { success: boolean };
expect(result.success).toBe(true);
expect(provisioningService.sendMessageToTeam).toHaveBeenCalledWith(
'my-team',
expect.stringContaining('You received a direct message from the user'),
expect.arrayContaining([
expect.objectContaining({
id: 'att-1',
filename: 'note.txt',
}),
])
);
} finally {
vi.unstubAllEnvs();
}
});
it('rejects delegate mode when recipient is not the team lead', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();

View file

@ -1945,4 +1945,117 @@ describe('TeamDataService', () => {
expect(data).toEqual({ 'task-1': 'has_changes' });
expect(getMessages).not.toHaveBeenCalled();
});
it('persists standalone slash metadata when sending directly to the live lead', async () => {
const appendSentMessage = vi.fn((payload) => payload);
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({
name: 'My team',
members: [{ name: 'team-lead', role: 'Lead' }],
leadSessionId: 'lead-1',
})),
} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
{} as never,
() =>
({
messages: {
appendSentMessage,
},
}) as never
);
const result = await service.sendDirectToLead(
'my-team',
'team-lead',
'/compact keep only kanban context'
);
expect(result.deliveredViaStdin).toBe(true);
expect(appendSentMessage).toHaveBeenCalledWith(
expect.objectContaining({
text: '/compact keep only kanban context',
messageKind: 'slash_command',
slashCommand: expect.objectContaining({
name: 'compact',
command: '/compact',
args: 'keep only kanban context',
}),
})
);
});
it('annotates immediate lead replies after slash commands as command results', async () => {
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({
name: 'My team',
members: [{ name: 'team-lead', role: 'Lead' }],
leadSessionId: 'lead-1',
})),
} as never,
{
getTasks: vi.fn(async () => []),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => [
{
from: 'team-lead',
text: 'Total cost: $1.05',
timestamp: '2026-03-27T22:17:01.000Z',
read: true,
source: 'lead_process',
leadSessionId: 'lead-1',
messageId: 'lead-thought-1',
},
]),
} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
} as never,
{} as never,
{} as never,
{
readMessages: vi.fn(async () => [
{
from: 'user',
to: 'team-lead',
text: '/cost',
timestamp: '2026-03-27T22:17:00.000Z',
read: true,
source: 'user_sent',
leadSessionId: 'lead-1',
messageId: 'user-cost-1',
},
]),
} as never
);
const data = await service.getTeamData('my-team');
const costResult = data.messages.find((message) => message.messageId === 'lead-thought-1');
expect(costResult).toMatchObject({
messageKind: 'slash_command_result',
commandOutput: {
stream: 'stdout',
commandLabel: '/cost',
},
});
});
});

View file

@ -0,0 +1,91 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TeamSentMessagesStore } from '../../../../src/main/services/team/TeamSentMessagesStore';
const tempDirs: string[] = [];
vi.mock('@main/utils/pathDecoder', () => ({
getTeamsBasePath: () => tempDirs[tempDirs.length - 1],
}));
describe('TeamSentMessagesStore', () => {
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map(async (dir) => {
await fs.rm(dir, { recursive: true, force: true });
})
);
});
it('preserves slash-command metadata when reading sent messages', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'team-sent-store-'));
tempDirs.push(root);
const teamDir = path.join(root, 'my-team');
await fs.mkdir(teamDir, { recursive: true });
await fs.writeFile(
path.join(teamDir, 'sentMessages.json'),
JSON.stringify(
[
{
from: 'user',
to: 'team-lead',
text: '/model sonnet',
timestamp: '2026-03-27T12:00:00.000Z',
read: true,
messageId: 'msg-1',
source: 'user_sent',
messageKind: 'slash_command',
slashCommand: {
name: 'model',
command: '/model',
args: 'sonnet',
knownDescription: 'Select or change the Claude model.',
},
},
{
from: 'team-lead',
text: 'Model set to sonnet',
timestamp: '2026-03-27T12:00:01.000Z',
read: true,
messageId: 'msg-2',
source: 'lead_session',
messageKind: 'slash_command_result',
commandOutput: {
stream: 'stdout',
commandLabel: '/model',
},
},
],
null,
2
),
'utf8'
);
const store = new TeamSentMessagesStore();
const messages = await store.readMessages('my-team');
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
messageKind: 'slash_command',
slashCommand: {
name: 'model',
command: '/model',
args: 'sonnet',
knownDescription: 'Select or change the Claude model.',
},
});
expect(messages[1]).toMatchObject({
messageKind: 'slash_command_result',
commandOutput: {
stream: 'stdout',
commandLabel: '/model',
},
});
});
});

View file

@ -0,0 +1,148 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { extractLeadSessionMessagesFromJsonl } from '../../../../src/main/services/team/leadSessionMessageExtractor';
function createUserEntry(
uuid: string,
timestamp: string,
content: string
): Record<string, unknown> {
return {
uuid,
parentUuid: null,
type: 'user',
timestamp,
isSidechain: false,
userType: 'external',
cwd: '/repo',
sessionId: 'lead-1',
version: '1.0.0',
gitBranch: 'main',
message: {
role: 'user',
content,
},
};
}
function createAssistantEntry(
uuid: string,
timestamp: string,
text: string
): Record<string, unknown> {
return {
uuid,
parentUuid: null,
type: 'assistant',
timestamp,
isSidechain: false,
userType: 'external',
cwd: '/repo',
sessionId: 'lead-1',
version: '1.0.0',
gitBranch: 'main',
requestId: `req-${uuid}`,
message: {
role: 'assistant',
model: 'claude-sonnet',
id: `msg-${uuid}`,
type: 'message',
stop_reason: 'end_turn',
stop_sequence: null,
usage: {
input_tokens: 1,
output_tokens: 1,
},
content: [{ type: 'text', text }],
},
};
}
describe('extractLeadSessionMessagesFromJsonl', () => {
const tempPaths: string[] = [];
afterEach(async () => {
await Promise.all(
tempPaths.splice(0).map(async (tempPath) => {
await fs.rm(tempPath, { recursive: true, force: true });
})
);
});
it('extracts and merges command outputs without duplicating command rows', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lead-session-extractor-'));
tempPaths.push(dir);
const jsonlPath = path.join(dir, 'lead-1.jsonl');
const lines = [
createUserEntry(
'user-slash-1',
'2026-03-27T12:00:00.000Z',
'<command-name>/model</command-name><command-message>model</command-message><command-args>sonnet</command-args>'
),
createUserEntry(
'stdout-1',
'2026-03-27T12:00:01.000Z',
'<local-command-stdout>Model set to sonnet</local-command-stdout>'
),
createUserEntry(
'stdout-2',
'2026-03-27T12:00:02.000Z',
'<local-command-stdout>Context usage reset</local-command-stdout>'
),
createAssistantEntry('assistant-1', '2026-03-27T12:00:03.000Z', 'Regular assistant text'),
createUserEntry(
'stderr-1',
'2026-03-27T12:00:04.000Z',
'<local-command-stderr>Warning: using cached model alias</local-command-stderr>'
),
createUserEntry('user-plain-1', '2026-03-27T12:00:05.000Z', 'hello'),
createUserEntry(
'stdout-3',
'2026-03-27T12:00:06.000Z',
'<local-command-stdout>Detached output</local-command-stdout>'
),
].map((entry) => JSON.stringify(entry));
await fs.writeFile(jsonlPath, `${lines.join('\n')}\n`, 'utf8');
const messages = await extractLeadSessionMessagesFromJsonl({
jsonlPath,
leadName: 'team-lead',
leadSessionId: 'lead-1',
maxMessages: 20,
});
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
from: 'team-lead',
messageKind: 'slash_command_result',
commandOutput: {
stream: 'stdout',
commandLabel: '/model',
},
text: 'Model set to sonnet\nContext usage reset',
summary: 'Model set to sonnet',
});
expect(messages[1]).toMatchObject({
messageKind: 'slash_command_result',
commandOutput: {
stream: 'stderr',
commandLabel: '/model',
},
text: 'Warning: using cached model alias',
});
expect(messages[2]).toMatchObject({
messageKind: 'slash_command_result',
commandOutput: {
stream: 'stdout',
commandLabel: '/command',
},
text: 'Detached output',
});
});
});

View file

@ -1,11 +1,123 @@
import { describe, expect, it } from 'vitest';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('@renderer/hooks/useTheme', () => ({
useTheme: () => ({ theme: 'dark', resolvedTheme: 'dark', isDark: true, isLight: false }),
}));
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
MarkdownViewer: ({ content }: { content: string }) => React.createElement('div', null, content),
}));
vi.mock('@renderer/components/common/CopyButton', () => ({
CopyButton: () => null,
}));
vi.mock('@renderer/components/team/attachments/AttachmentDisplay', () => ({
AttachmentDisplay: () => null,
}));
vi.mock('@renderer/components/team/MemberBadge', () => ({
MemberBadge: ({ name }: { name: string }) => React.createElement('span', null, name),
}));
vi.mock('@renderer/components/team/TaskTooltip', () => ({
TaskTooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
}));
vi.mock('@renderer/components/ui/ExpandableContent', () => ({
ExpandableContent: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
}));
vi.mock('@renderer/components/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
React.createElement(React.Fragment, null, children),
TooltipContent: ({ children }: { children: React.ReactNode }) =>
React.createElement('div', null, children),
}));
vi.mock('@renderer/components/team/activity/ReplyQuoteBlock', () => ({
ReplyQuoteBlock: () => null,
}));
import {
ActivityItem,
getCrossTeamSentMemberName,
getCrossTeamSentTarget,
getSystemMessageLabel,
isQualifiedExternalRecipient,
} from '@renderer/components/team/activity/ActivityItem';
import type { InboxMessage } from '@shared/types';
describe('ActivityItem slash command rendering', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.unstubAllGlobals();
});
it('renders standalone sent slash commands with command-specific styling content', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const message: InboxMessage = {
from: 'user',
text: '/compact keep kanban aligned',
timestamp: new Date('2026-03-27T12:00:00.000Z').toISOString(),
read: true,
source: 'user_sent',
};
await act(async () => {
root.render(React.createElement(ActivityItem, { message, teamName: 'my-team' }));
await Promise.resolve();
});
expect(host.textContent).toContain('command');
expect(host.textContent).toContain('/compact');
expect(host.textContent).toContain('Compact conversation with optional focus instructions.');
expect(host.textContent).toContain('keep kanban aligned');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('renders slash command results as a distinct command output row', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const message: InboxMessage = {
from: 'team-lead',
text: 'Model set to sonnet\nContext usage reset',
timestamp: new Date('2026-03-27T12:01:00.000Z').toISOString(),
read: true,
source: 'lead_session',
messageKind: 'slash_command_result',
commandOutput: {
stream: 'stdout',
commandLabel: '/model',
},
summary: 'Model set to sonnet',
};
await act(async () => {
root.render(React.createElement(ActivityItem, { message, teamName: 'my-team' }));
await Promise.resolve();
});
expect(host.textContent).toContain('result');
expect(host.textContent).toContain('stdout');
expect(host.textContent).toContain('/model');
expect(host.textContent).toContain('Model set to sonnet');
expect(host.textContent).toContain('Context usage reset');
expect(host.textContent).not.toContain('team-lead');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});
describe('ActivityItem legacy system message fallback', () => {
it('recognizes historical assignment and review message wording', () => {

View file

@ -1,83 +1,33 @@
import { describe, expect, it } from 'vitest';
import { isLeadThought } from '../../../../../src/renderer/components/team/activity/LeadThoughtsGroup';
import {
groupTimelineItems,
isLeadThought,
} from '../../../../../src/renderer/components/team/activity/LeadThoughtsGroup';
import type { InboxMessage } from '../../../../../src/shared/types';
describe('LeadThoughtsGroup', () => {
it('does not classify outbound runtime messages with recipients as lead thoughts', () => {
expect(
isLeadThought({
from: 'team-lead',
to: 'alice',
text: 'Please check task #abcd1234',
timestamp: '2026-03-08T00:00:00.000Z',
read: true,
source: 'lead_process',
})
).toBe(false);
});
it('does not classify slash command results as lead thoughts', () => {
const resultMessage: InboxMessage = {
from: 'team-lead',
text: 'Total cost: $1.05',
timestamp: '2026-03-27T22:06:00.000Z',
read: true,
source: 'lead_session',
messageKind: 'slash_command_result',
commandOutput: {
stream: 'stdout',
commandLabel: '/cost',
},
};
it('filters out idle_notification JSON noise from lead thoughts', () => {
expect(
isLeadThought({
from: 'team-lead',
text: '{"type":"idle_notification","message":"alice is idle"}',
timestamp: '2026-03-08T00:00:00.000Z',
read: true,
source: 'lead_session',
})
).toBe(false);
});
it('filters out shutdown_request JSON noise from lead thoughts', () => {
expect(
isLeadThought({
from: 'team-lead',
text: '{"type":"shutdown_request","reason":"Task complete"}',
timestamp: '2026-03-08T00:00:00.000Z',
read: true,
source: 'lead_process',
})
).toBe(false);
});
it('filters out pure <teammate-message> XML blocks from lead thoughts', () => {
expect(
isLeadThought({
from: 'team-lead',
text: '<teammate-message teammate_id="researcher" color="#4CAF50" summary="Done">Task completed</teammate-message>',
timestamp: '2026-03-08T00:00:00.000Z',
read: true,
source: 'lead_session',
})
).toBe(false);
});
it('filters out multiple <teammate-message> blocks with whitespace', () => {
const text = [
'<teammate-message teammate_id="alice" color="#f00" summary="hi">Hello</teammate-message>',
'',
'<teammate-message teammate_id="bob" color="#0f0" summary="ok">OK</teammate-message>',
].join('\n');
expect(
isLeadThought({
from: 'team-lead',
text,
timestamp: '2026-03-08T00:00:00.000Z',
read: true,
source: 'lead_process',
})
).toBe(false);
});
it('keeps normal lead thoughts with real content', () => {
expect(
isLeadThought({
from: 'team-lead',
text: 'Reviewing the implementation plan for the new feature.',
timestamp: '2026-03-08T00:00:00.000Z',
read: true,
source: 'lead_session',
})
).toBe(true);
expect(isLeadThought(resultMessage)).toBe(false);
expect(groupTimelineItems([resultMessage])).toEqual([
{
type: 'message',
message: resultMessage,
},
]);
});
});

View file

@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import {
getKnownSlashCommand,
KNOWN_SLASH_COMMANDS,
parseStandaloneSlashCommand,
} from '@shared/utils/slashCommands';
describe('slashCommands', () => {
it('exposes exactly the curated known commands', () => {
expect(KNOWN_SLASH_COMMANDS.map((command) => command.command)).toEqual([
'/compact',
'/clear',
'/reset',
'/new',
'/plan',
'/model',
'/effort',
'/fast',
'/cost',
'/usage',
]);
});
it('parses known standalone slash commands', () => {
expect(parseStandaloneSlashCommand(' /compact keep kanban ')).toEqual({
name: 'compact',
command: '/compact',
args: 'keep kanban',
raw: '/compact keep kanban',
startIndex: 2,
endIndex: 22,
});
});
it('parses unknown standalone slash commands', () => {
expect(parseStandaloneSlashCommand('/foo bar')).toEqual({
name: 'foo',
command: '/foo',
args: 'bar',
raw: '/foo bar',
startIndex: 0,
endIndex: 8,
});
});
it('rejects slash-like text that is not a standalone command', () => {
expect(parseStandaloneSlashCommand('please run /compact now')).toBeNull();
expect(parseStandaloneSlashCommand('/')).toBeNull();
});
it('returns metadata for known commands only', () => {
expect(getKnownSlashCommand('MODEL')?.description).toContain('Claude model');
expect(getKnownSlashCommand('foo')).toBeNull();
});
});