From 7ca428677cd5bcc297199d044479aada8084727f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 28 Mar 2026 10:51:22 +0700 Subject: [PATCH] Make chat mentions inline --- packages/cli/src/commands/chat/index.ts | 10 +-- packages/cli/src/commands/chat/post.ts | 2 - packages/cli/tests/30-chat.test.ts | 4 +- packages/desktop/src/main.ts | 25 +++++-- packages/server/src/client/daemon-client.ts | 4 -- .../src/server/chat/chat-rpc-schemas.ts | 1 - .../src/server/chat/chat-service.test.ts | 12 +++- .../server/src/server/chat/chat-service.ts | 18 +++-- packages/server/src/server/session.ts | 69 ++++++++++++++++++- skills/paseo-chat/SKILL.md | 12 ++-- skills/paseo-orchestrator/SKILL.md | 9 ++- skills/paseo/SKILL.md | 2 +- 12 files changed, 123 insertions(+), 45 deletions(-) diff --git a/packages/cli/src/commands/chat/index.ts b/packages/cli/src/commands/chat/index.ts index 2bc490089..9b6f363e9 100644 --- a/packages/cli/src/commands/chat/index.ts +++ b/packages/cli/src/commands/chat/index.ts @@ -1,6 +1,6 @@ import { Command } from "commander"; import { withOutput } from "../../output/index.js"; -import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js"; +import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; import { runCreateCommand } from "./create.js"; import { runLsCommand } from "./ls.js"; import { runInspectCommand } from "./inspect.js"; @@ -44,13 +44,7 @@ export function createChatCommand(): Command { .description("Post a chat message") .argument("", "Room name or ID") .argument("", "Message body") - .option("--reply-to ", "Reply to a specific message ID") - .option( - "--mention ", - "Mention an agent ID (repeatable)", - collectMultiple, - [], - ), + .option("--reply-to ", "Reply to a specific message ID"), ).action(withOutput(runPostCommand)); addJsonAndDaemonHostOptions( diff --git a/packages/cli/src/commands/chat/post.ts b/packages/cli/src/commands/chat/post.ts index edbb36249..f9b39f63a 100644 --- a/packages/cli/src/commands/chat/post.ts +++ b/packages/cli/src/commands/chat/post.ts @@ -9,7 +9,6 @@ import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./sche export interface ChatPostOptions extends ChatCommandOptions { replyTo?: string; - mention?: string[]; } export async function runPostCommand( @@ -24,7 +23,6 @@ export async function runPostCommand( room, body, replyToMessageId: options.replyTo, - mentionAgentIds: options.mention ?? [], }); return { type: "single", diff --git a/packages/cli/tests/30-chat.test.ts b/packages/cli/tests/30-chat.test.ts index 6fc8cb252..441cbda49 100644 --- a/packages/cli/tests/30-chat.test.ts +++ b/packages/cli/tests/30-chat.test.ts @@ -31,9 +31,7 @@ try { "chat", "post", "coord-room", - "first message", - "--mention", - "agent-1", + "first message for @agent-1", ]); assert.strictEqual(posted.exitCode, 0, posted.stderr); assert(posted.stdout.includes("first message"), posted.stdout); diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index e0a476968..f652a4eb7 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -4,7 +4,7 @@ log.initialize({ spyRendererConsole: true }); import path from "node:path"; import { pathToFileURL } from "node:url"; import { existsSync } from "node:fs"; -import { app, BrowserWindow, nativeImage, net, protocol } from "electron"; +import { app, BrowserWindow, nativeImage, nativeTheme, net, protocol } from "electron"; import { registerDaemonManager } from "./daemon/daemon-manager.js"; import { parseCliPassthroughArgsFromArgv, @@ -32,6 +32,17 @@ protocol.registerSchemesAsPrivileged([ { scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } }, ]); +// --------------------------------------------------------------------------- +// Window chrome +// --------------------------------------------------------------------------- + +function getTitleBarOverlayColors(): Electron.TitleBarOverlayOptions { + if (nativeTheme.shouldUseDarkColors) { + return { color: "#18181c", symbolColor: "#e4e4e7", height: 48 }; + } + return { color: "#ffffff", symbolColor: "#09090b", height: 48 }; +} + // --------------------------------------------------------------------------- // Window creation // --------------------------------------------------------------------------- @@ -96,11 +107,7 @@ async function createMainWindow(): Promise { ...(isMac ? { trafficLightPosition: { x: 16, y: 14 } } : { - titleBarOverlay: { - color: "#18181c", - symbolColor: "#e4e4e7", - height: 48, - }, + titleBarOverlay: getTitleBarOverlayColors(), autoHideMenuBar: true, }), webPreferences: { @@ -113,6 +120,12 @@ async function createMainWindow(): Promise { setupWindowResizeEvents(mainWindow); setupDragDropPrevention(mainWindow); + if (!isMac) { + nativeTheme.on("updated", () => { + mainWindow.setTitleBarOverlay(getTitleBarOverlayColors()); + }); + } + mainWindow.once("ready-to-show", () => { mainWindow.show(); }); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 2eb3546fd..335c7636e 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -370,7 +370,6 @@ export type PostChatMessageOptions = { room: string; body: string; replyToMessageId?: string | null; - mentionAgentIds?: string[]; requestId?: string; }; export type ReadChatMessagesOptions = { @@ -2913,9 +2912,6 @@ export class DaemonClient { room: options.room, body: options.body, ...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}), - ...(options.mentionAgentIds && options.mentionAgentIds.length > 0 - ? { mentionAgentIds: options.mentionAgentIds } - : {}), }, responseType: "chat/post/response", timeout: 10000, diff --git a/packages/server/src/server/chat/chat-rpc-schemas.ts b/packages/server/src/server/chat/chat-rpc-schemas.ts index 9bef27705..6dd91fdf2 100644 --- a/packages/server/src/server/chat/chat-rpc-schemas.ts +++ b/packages/server/src/server/chat/chat-rpc-schemas.ts @@ -31,7 +31,6 @@ export const ChatPostRequestSchema = z.object({ room: z.string(), body: z.string(), replyToMessageId: z.string().optional(), - mentionAgentIds: z.array(z.string()).optional(), }); export const ChatReadRequestSchema = z.object({ diff --git a/packages/server/src/server/chat/chat-service.test.ts b/packages/server/src/server/chat/chat-service.test.ts index 04b7320ac..dfec8009d 100644 --- a/packages/server/src/server/chat/chat-service.test.ts +++ b/packages/server/src/server/chat/chat-service.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import pino from "pino"; -import { ChatServiceError, FileBackedChatService } from "./chat-service.js"; +import { ChatServiceError, FileBackedChatService, parseMentionAgentIds } from "./chat-service.js"; describe("FileBackedChatService", () => { let paseoHome: string; @@ -48,8 +48,7 @@ describe("FileBackedChatService", () => { const first = await service.postMessage({ room: room.name, authorAgentId: "agent-a", - body: "first message", - mentionAgentIds: ["agent-b", "agent-b", "agent-c"], + body: "first message for @agent-b and @agent-c and again @agent-b", }); await service.postMessage({ room: room.id, @@ -141,4 +140,11 @@ describe("FileBackedChatService", () => { code: "chat_room_not_found", }); }); + + test("extracts inline mentions from chat bodies", () => { + expect( + parseMentionAgentIds("Checking with @agent-a, (@agent_b), and duplicate @agent-a again."), + ).toEqual(["agent-a", "agent_b"]); + expect(parseMentionAgentIds("email@example.com is not a mention")).toEqual([]); + }); }); diff --git a/packages/server/src/server/chat/chat-service.ts b/packages/server/src/server/chat/chat-service.ts index 520a99ff9..3bf31006a 100644 --- a/packages/server/src/server/chat/chat-service.ts +++ b/packages/server/src/server/chat/chat-service.ts @@ -31,6 +31,19 @@ function trimToNull(value: string | null | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +const CHAT_MENTION_PATTERN = /(?:^|[\s(])@([A-Za-z0-9][A-Za-z0-9._-]*)/g; + +export function parseMentionAgentIds(body: string): string[] { + const mentionAgentIds = new Set(); + for (const match of body.matchAll(CHAT_MENTION_PATTERN)) { + const agentId = match[1]?.trim(); + if (agentId) { + mentionAgentIds.add(agentId); + } + } + return Array.from(mentionAgentIds).sort(); +} + export class ChatServiceError extends Error { readonly code: string; @@ -67,7 +80,6 @@ export interface PostChatMessageInput { authorAgentId: string; body: string; replyToMessageId?: string | null; - mentionAgentIds?: string[]; } export interface ReadChatMessagesInput { @@ -195,9 +207,7 @@ export class FileBackedChatService { authorAgentId, body, replyToMessageId, - mentionAgentIds: [...new Set((input.mentionAgentIds ?? []).map((value) => value.trim()))] - .filter((value) => value.length > 0) - .sort(), + mentionAgentIds: parseMentionAgentIds(body), createdAt, }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 265a3b133..0abbd5072 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -7730,7 +7730,6 @@ export class Session { authorAgentId: this.clientId, body: request.body, replyToMessageId: request.replyToMessageId, - mentionAgentIds: request.mentionAgentIds, }); this.emit({ type: "chat/post/response", @@ -7740,11 +7739,79 @@ export class Session { error: null, }, }); + void this.notifyMentionedAgents({ + room: request.room, + authorAgentId: this.clientId, + body: request.body, + mentionAgentIds: message.mentionAgentIds, + }); } catch (error) { this.emitChatRpcError(request, error); } } + private async notifyMentionedAgents(input: { + room: string; + authorAgentId: string; + body: string; + mentionAgentIds: string[]; + }): Promise { + const mentionAgentIds = input.mentionAgentIds.filter( + (agentId) => agentId !== input.authorAgentId, + ); + if (mentionAgentIds.length === 0) { + return; + } + + const notification = this.buildChatMentionNotification({ + ...input, + mentionAgentIds, + }); + + await Promise.all( + mentionAgentIds.map(async (mentionedAgentId) => { + const resolved = await this.resolveAgentIdentifier(mentionedAgentId); + if (!resolved.ok) { + this.sessionLogger.warn( + { mentionedAgentId, room: input.room, error: resolved.error }, + "Failed to resolve chat mention target", + ); + return; + } + + try { + await this.handleSendAgentMessage(resolved.agentId, notification); + } catch (error) { + this.sessionLogger.warn( + { err: error, mentionedAgentId: resolved.agentId, room: input.room }, + "Failed to notify mentioned agent about chat message", + ); + } + }), + ); + } + + private buildChatMentionNotification(input: { + room: string; + authorAgentId: string; + body: string; + mentionAgentIds: string[]; + }): string { + const mentioned = input.mentionAgentIds.map((agentId) => `@${agentId}`).join(", "); + const bodyWithoutMentions = input.body + .replace(/(^|\s)@[A-Za-z0-9][A-Za-z0-9._-]*/g, "$1") + .trim(); + const body = bodyWithoutMentions.length > 0 ? bodyWithoutMentions : input.body; + + return [ + `Chat mention from ${input.authorAgentId} in room "${input.room}".`, + `Mentioned agents: ${mentioned}.`, + "Message:", + body, + `Read the room with: paseo chat read ${input.room} --limit 20`, + ].join("\n"); + } + private async handleChatReadRequest( request: Extract, ): Promise { diff --git a/skills/paseo-chat/SKILL.md b/skills/paseo-chat/SKILL.md index 83edc8ef5..ead8f1e48 100644 --- a/skills/paseo-chat/SKILL.md +++ b/skills/paseo-chat/SKILL.md @@ -27,10 +27,8 @@ When using chat: - check chat often while working Mentions are active: -- use `--mention ` on your post to notify a specific agent immediately -- if you use `--reply-to`, the author of the replied-to message is notified -- notifications are sent via `paseo send --no-wait` under the hood -- mentions **interrupt** the target agent, so only mention when they need to act now +- write mentions inline in the message body as `@` to notify a specific agent immediately +- notifications are sent to the target agent without blocking the chat post - if a normal post is enough and no one needs to act right now, skip the mention ## Command Surface @@ -68,7 +66,7 @@ paseo chat post issue-456 "I can take that next." --reply-to msg-001 With a direct mention: ```bash -paseo chat post issue-456 "Can you verify the relay path next?" --mention +paseo chat post issue-456 "@ Can you verify the relay path next?" ``` ### Read recent messages @@ -101,7 +99,7 @@ When using a room: - read only a bounded window before acting - post updates when they would help another agent or your future self - use `--reply-to` when responding to a specific message -- use `--mention` when you want to get a specific agent's attention — but know that mentions **interrupt** the target agent +- use inline `@` mentions when you want to get a specific agent's attention - check chat frequently enough that shared coordination actually works - your own agent ID is available via `$PASEO_AGENT_ID` @@ -119,4 +117,4 @@ Typical things to post: 3. Read the room with bounded history 4. Post clearly 5. Use `--reply-to` when replying to a specific message -6. Use `--mention` when you want to notify someone directly +6. Use inline `@` mentions when you want to notify someone directly diff --git a/skills/paseo-orchestrator/SKILL.md b/skills/paseo-orchestrator/SKILL.md index adf2a9ba2..bf8fcb76e 100644 --- a/skills/paseo-orchestrator/SKILL.md +++ b/skills/paseo-orchestrator/SKILL.md @@ -97,11 +97,10 @@ paseo chat post "Focus on implementing the API layer. Acceptance criteria - all new endpoints have tests - typecheck passes -Post your progress here. @mention me ($PASEO_AGENT_ID) when done." \ - --mention +Post your progress here. @$PASEO_AGENT_ID when done, and start on this now @." ``` -The agent gets interrupted with the message and starts working. When done, it @mentions you back. +The agent gets notified with the message and starts working. When done, it mentions you back in chat. ### Role-based provider selection @@ -148,13 +147,13 @@ paseo chat read --limit 10 ### Directing work ```bash -paseo chat post "The API is done. Now focus on the frontend integration." --mention +paseo chat post "@ The API is done. Now focus on the frontend integration." ``` ### Course-correcting ```bash -paseo chat post "The tests you wrote are asserting the mock, not the real implementation. Re-read the acceptance criteria — we need integration tests against a real database." --mention +paseo chat post "@ The tests you wrote are asserting the mock, not the real implementation. Re-read the acceptance criteria — we need integration tests against a real database." ``` ### Challenging agents diff --git a/skills/paseo/SKILL.md b/skills/paseo/SKILL.md index c38de3b44..77c483526 100644 --- a/skills/paseo/SKILL.md +++ b/skills/paseo/SKILL.md @@ -133,7 +133,7 @@ paseo chat inspect # Post a message paseo chat post "" paseo chat post "" --reply-to -paseo chat post "" --mention # Repeatable +paseo chat post "@ " # Read messages paseo chat read