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:
parent
df6a23e3a2
commit
dd42cf0069
28 changed files with 1783 additions and 178 deletions
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
198
src/main/services/team/leadSessionMessageExtractor.ts
Normal file
198
src/main/services/team/leadSessionMessageExtractor.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
88
src/renderer/components/ui/SlashCommandInteractionLayer.tsx
Normal file
88
src/renderer/components/ui/SlashCommandInteractionLayer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
123
src/shared/utils/slashCommands.ts
Normal file
123
src/shared/utils/slashCommands.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
91
test/main/services/team/TeamSentMessagesStore.test.ts
Normal file
91
test/main/services/team/TeamSentMessagesStore.test.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
148
test/main/services/team/leadSessionMessageExtractor.test.ts
Normal file
148
test/main/services/team/leadSessionMessageExtractor.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
56
test/shared/utils/slashCommands.test.ts
Normal file
56
test/shared/utils/slashCommands.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue