From dd42cf0069e026dae7bcbbdc212adbc5ad872a81 Mon Sep 17 00:00:00 2001 From: iliya Date: Fri, 27 Mar 2026 23:35:52 +0200 Subject: [PATCH] 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. --- README.md | 4 + src/main/ipc/teams.ts | 59 ++-- src/main/services/team/TeamDataService.ts | 106 ++++++- src/main/services/team/TeamInboxReader.ts | 31 ++ src/main/services/team/TeamInboxWriter.ts | 3 + .../services/team/TeamProvisioningService.ts | 53 +++- .../services/team/TeamSentMessagesStore.ts | 31 ++ .../team/leadSessionMessageExtractor.ts | 198 +++++++++++++ .../components/team/activity/ActivityItem.tsx | 269 +++++++++++++++--- .../team/activity/LeadThoughtsGroup.tsx | 1 + .../team/messages/MessageComposer.tsx | 39 ++- .../components/ui/MentionSuggestionList.tsx | 27 +- .../components/ui/MentionableTextarea.tsx | 99 ++++++- .../ui/SlashCommandInteractionLayer.tsx | 88 ++++++ src/renderer/hooks/useMentionDetection.ts | 20 +- src/renderer/types/mention.ts | 8 +- src/renderer/utils/mentionSuggestions.ts | 11 +- src/renderer/utils/messageRenderEquality.ts | 28 +- src/shared/types/team.ts | 23 ++ src/shared/utils/contentSanitizer.ts | 21 +- src/shared/utils/slashCommands.ts | 123 ++++++++ test/main/ipc/teams.test.ts | 95 +++++++ .../services/team/TeamDataService.test.ts | 113 ++++++++ .../team/TeamSentMessagesStore.test.ts | 91 ++++++ .../team/leadSessionMessageExtractor.test.ts | 148 ++++++++++ .../team/activity/ActivityItem.test.ts | 114 +++++++- .../team/activity/LeadThoughtsGroup.test.ts | 102 ++----- test/shared/utils/slashCommands.test.ts | 56 ++++ 28 files changed, 1783 insertions(+), 178 deletions(-) create mode 100644 src/main/services/team/leadSessionMessageExtractor.ts create mode 100644 src/renderer/components/ui/SlashCommandInteractionLayer.tsx create mode 100644 src/shared/utils/slashCommands.ts create mode 100644 test/main/services/team/TeamSentMessagesStore.test.ts create mode 100644 test/main/services/team/leadSessionMessageExtractor.test.ts create mode 100644 test/shared/utils/slashCommands.test.ts diff --git a/README.md b/README.md index 3e1e5fbd..daee46d2 100644 --- a/README.md +++ b/README.md @@ -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 --- diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index c3814203..c38aa3e1 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -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; diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index a79ef673..9db2a91c 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -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> { 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 { 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(); @@ -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; const tContent = tMessage.content; if (!Array.isArray(tContent)) continue; const tBlocks = tContent as Record[]; - 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; @@ -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 { + 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 { diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index 11e5f1f9..fb191740 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -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, }); } diff --git a/src/main/services/team/TeamInboxWriter.ts b/src/main/services/team/TeamInboxWriter.ts index 006ef14d..272f4d45 100644 --- a/src/main/services/team/TeamInboxWriter.ts +++ b/src/main/services/team/TeamInboxWriter.ts @@ -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 () => { diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 98b24a80..d35dd6ba 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -319,6 +319,8 @@ interface ProvisioningRun { } | null; /** Pending tool approval requests awaiting user response (control_request protocol). */ pendingApprovals: Map; + /** Teammate permission_request IDs already intercepted (prevents re-processing read messages). */ + processedPermissionRequestIds: Set; /** * 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 => { - 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(); + // 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> | 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(); + + // 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> = []; try { leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName); diff --git a/src/main/services/team/TeamSentMessagesStore.ts b/src/main/services/team/TeamSentMessagesStore.ts index a33ca76f..b7352446 100644 --- a/src/main/services/team/TeamSentMessagesStore.ts +++ b/src/main/services/team/TeamSentMessagesStore.ts @@ -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, }); } diff --git a/src/main/services/team/leadSessionMessageExtractor.ts b/src/main/services/team/leadSessionMessageExtractor.ts new file mode 100644 index 00000000..6fd9c23f --- /dev/null +++ b/src/main/services/team/leadSessionMessageExtractor.ts @@ -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 { + if (maxMessages <= 0) return []; + + const parsedMessagesReversed: ParsedMessage[] = []; + const seenScanKeys = new Set(); + 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; +} diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index a456b6cc..f9bf3dd6 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -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(
{/* Header — div with role=button (cannot use - {isProvisioning && !sending ? ( + {slashCommandRestrictionReason ? ( + {slashCommandRestrictionReason} + ) : isProvisioning && !sending ? ( Sending unavailable while team is launching @@ -928,7 +958,12 @@ export const MessageComposer = ({ } footerRight={
- {sendError ? ( + {slashCommandRestrictionReason ? ( + + + {slashCommandRestrictionReason} + + ) : sendError ? ( {sendError} diff --git a/src/renderer/components/ui/MentionSuggestionList.tsx b/src/renderer/components/ui/MentionSuggestionList.tsx index cd9d3fca..141c9460 100644 --- a/src/renderer/components/ui/MentionSuggestionList.tsx +++ b/src/renderer/components/ui/MentionSuggestionList.tsx @@ -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 = ({ ) : isTask ? ( + ) : isCommand ? ( + ) : isTeam ? ( - + {!isTask && !isFileOrFolder && s.subtitle ? ( {s.subtitle} @@ -208,6 +218,11 @@ export const MentionSuggestionList = ({ {isTask && s.subtitle ? (
{s.subtitle}
) : null} + {isCommand && s.description ? ( +
+ {s.description} +
+ ) : null}
{isTeam && s.isOnline !== undefined ? ( 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( @@ -347,9 +375,11 @@ export const MentionableTextarea = React.forwardRef 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 { if (!isOpen || !isAtTrigger) return []; @@ -434,6 +470,12 @@ export const MentionableTextarea = React.forwardRef 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 { if (!isOpen) return; @@ -607,6 +653,7 @@ export const MentionableTextarea = React.forwardRef 0 || teamSuggestions.length > 0 || taskSuggestions.length > 0 || @@ -617,6 +664,11 @@ export const MentionableTextarea = React.forwardRef (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 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; } + if (seg.type === 'slash_command') { + return ( + + {seg.value} + + ); + } if (seg.type === 'task') { return ( @@ -1110,6 +1187,16 @@ export const MentionableTextarea = React.forwardRef ) : null} + {slashCommand ? ( + + ) : 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 ( +
+
+ + +
+ + +
+
{definition.command}
+
+ {definition.description} +
+
+
+ +
+
+ ); +}; diff --git a/src/renderer/hooks/useMentionDetection.ts b/src/renderer/hooks/useMentionDetection.ts index a273bc69..b15bcbcb 100644 --- a/src/renderer/hooks/useMentionDetection.ts +++ b/src/renderer/hooks/useMentionDetection.ts @@ -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(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( diff --git a/src/renderer/types/mention.ts b/src/renderer/types/mention.ts index fd4bdca0..a708abc8 100644 --- a/src/renderer/types/mention.ts +++ b/src/renderer/types/mention.ts @@ -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) */ diff --git a/src/renderer/utils/mentionSuggestions.ts b/src/renderer/utils/mentionSuggestions.ts index c8c37d4f..a83e1bed 100644 --- a/src/renderer/utils/mentionSuggestions.ts +++ b/src/renderer/utils/mentionSuggestions.ts @@ -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()); diff --git a/src/renderer/utils/messageRenderEquality.ts b/src/renderer/utils/messageRenderEquality.ts index f9da51ad..ef25acb2 100644 --- a/src/renderer/utils/messageRenderEquality.ts +++ b/src/renderer/utils/messageRenderEquality.ts @@ -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) ); } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 74afbd92..1d4fa2d7 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -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`. // 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 { diff --git a/src/shared/utils/contentSanitizer.ts b/src/shared/utils/contentSanitizer.ts index e85c145d..06430637 100644 --- a/src/shared/utils/contentSanitizer.ts +++ b/src/shared/utils/contentSanitizer.ts @@ -21,18 +21,29 @@ const NOISE_TAG_PATTERNS = [ /[\s\S]*?<\/system-reminder>/gi, ]; +export interface CommandOutputInfo { + stream: 'stdout' | 'stderr'; + output: string; +} + /** * Extract content from tags. * Returns the command output without the wrapper tags. */ -function extractCommandOutput(content: string): string | null { +export function extractCommandOutputInfo(content: string): CommandOutputInfo | null { const match = /([\s\S]*?)<\/local-command-stdout>/i.exec(content); const matchStderr = /([\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; } } diff --git a/src/shared/utils/slashCommands.ts b/src/shared/utils/slashCommands.ts new file mode 100644 index 00000000..35e4f19b --- /dev/null +++ b/src/shared/utils/slashCommands.ts @@ -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, + }; +} diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 1dba68ff..025593a4 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -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(); diff --git a/test/main/services/team/TeamDataService.test.ts b/test/main/services/team/TeamDataService.test.ts index 42565b41..6595d69f 100644 --- a/test/main/services/team/TeamDataService.test.ts +++ b/test/main/services/team/TeamDataService.test.ts @@ -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', + }, + }); + }); }); diff --git a/test/main/services/team/TeamSentMessagesStore.test.ts b/test/main/services/team/TeamSentMessagesStore.test.ts new file mode 100644 index 00000000..b88175eb --- /dev/null +++ b/test/main/services/team/TeamSentMessagesStore.test.ts @@ -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', + }, + }); + }); +}); diff --git a/test/main/services/team/leadSessionMessageExtractor.test.ts b/test/main/services/team/leadSessionMessageExtractor.test.ts new file mode 100644 index 00000000..016e0d49 --- /dev/null +++ b/test/main/services/team/leadSessionMessageExtractor.test.ts @@ -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 { + 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 { + 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', + '/modelmodelsonnet' + ), + createUserEntry( + 'stdout-1', + '2026-03-27T12:00:01.000Z', + 'Model set to sonnet' + ), + createUserEntry( + 'stdout-2', + '2026-03-27T12:00:02.000Z', + 'Context usage reset' + ), + createAssistantEntry('assistant-1', '2026-03-27T12:00:03.000Z', 'Regular assistant text'), + createUserEntry( + 'stderr-1', + '2026-03-27T12:00:04.000Z', + 'Warning: using cached model alias' + ), + createUserEntry('user-plain-1', '2026-03-27T12:00:05.000Z', 'hello'), + createUserEntry( + 'stdout-3', + '2026-03-27T12:00:06.000Z', + 'Detached output' + ), + ].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', + }); + }); +}); diff --git a/test/renderer/components/team/activity/ActivityItem.test.ts b/test/renderer/components/team/activity/ActivityItem.test.ts index 8140a67e..7c96dc1a 100644 --- a/test/renderer/components/team/activity/ActivityItem.test.ts +++ b/test/renderer/components/team/activity/ActivityItem.test.ts @@ -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', () => { diff --git a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts index 526938cd..13cdf52f 100644 --- a/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts +++ b/test/renderer/components/team/activity/LeadThoughtsGroup.test.ts @@ -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 XML blocks from lead thoughts', () => { - expect( - isLeadThought({ - from: 'team-lead', - text: 'Task completed', - timestamp: '2026-03-08T00:00:00.000Z', - read: true, - source: 'lead_session', - }) - ).toBe(false); - }); - - it('filters out multiple blocks with whitespace', () => { - const text = [ - 'Hello', - '', - 'OK', - ].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, + }, + ]); }); }); diff --git a/test/shared/utils/slashCommands.test.ts b/test/shared/utils/slashCommands.test.ts new file mode 100644 index 00000000..29a48857 --- /dev/null +++ b/test/shared/utils/slashCommands.test.ts @@ -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(); + }); +});