Make chat mentions inline

This commit is contained in:
Mohamed Boudra
2026-03-28 10:51:22 +07:00
parent 05a4e8f5a3
commit 7ca428677c
12 changed files with 123 additions and 45 deletions

View File

@@ -1,6 +1,6 @@
import { Command } from "commander"; import { Command } from "commander";
import { withOutput } from "../../output/index.js"; 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 { runCreateCommand } from "./create.js";
import { runLsCommand } from "./ls.js"; import { runLsCommand } from "./ls.js";
import { runInspectCommand } from "./inspect.js"; import { runInspectCommand } from "./inspect.js";
@@ -44,13 +44,7 @@ export function createChatCommand(): Command {
.description("Post a chat message") .description("Post a chat message")
.argument("<name-or-id>", "Room name or ID") .argument("<name-or-id>", "Room name or ID")
.argument("<message>", "Message body") .argument("<message>", "Message body")
.option("--reply-to <msg-id>", "Reply to a specific message ID") .option("--reply-to <msg-id>", "Reply to a specific message ID"),
.option(
"--mention <agent-id>",
"Mention an agent ID (repeatable)",
collectMultiple,
[],
),
).action(withOutput(runPostCommand)); ).action(withOutput(runPostCommand));
addJsonAndDaemonHostOptions( addJsonAndDaemonHostOptions(

View File

@@ -9,7 +9,6 @@ import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./sche
export interface ChatPostOptions extends ChatCommandOptions { export interface ChatPostOptions extends ChatCommandOptions {
replyTo?: string; replyTo?: string;
mention?: string[];
} }
export async function runPostCommand( export async function runPostCommand(
@@ -24,7 +23,6 @@ export async function runPostCommand(
room, room,
body, body,
replyToMessageId: options.replyTo, replyToMessageId: options.replyTo,
mentionAgentIds: options.mention ?? [],
}); });
return { return {
type: "single", type: "single",

View File

@@ -31,9 +31,7 @@ try {
"chat", "chat",
"post", "post",
"coord-room", "coord-room",
"first message", "first message for @agent-1",
"--mention",
"agent-1",
]); ]);
assert.strictEqual(posted.exitCode, 0, posted.stderr); assert.strictEqual(posted.exitCode, 0, posted.stderr);
assert(posted.stdout.includes("first message"), posted.stdout); assert(posted.stdout.includes("first message"), posted.stdout);

View File

@@ -4,7 +4,7 @@ log.initialize({ spyRendererConsole: true });
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { existsSync } from "node:fs"; 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 { registerDaemonManager } from "./daemon/daemon-manager.js";
import { import {
parseCliPassthroughArgsFromArgv, parseCliPassthroughArgsFromArgv,
@@ -32,6 +32,17 @@ protocol.registerSchemesAsPrivileged([
{ scheme: APP_SCHEME, privileges: { standard: true, secure: true, supportFetchAPI: true } }, { 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 // Window creation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -96,11 +107,7 @@ async function createMainWindow(): Promise<void> {
...(isMac ...(isMac
? { trafficLightPosition: { x: 16, y: 14 } } ? { trafficLightPosition: { x: 16, y: 14 } }
: { : {
titleBarOverlay: { titleBarOverlay: getTitleBarOverlayColors(),
color: "#18181c",
symbolColor: "#e4e4e7",
height: 48,
},
autoHideMenuBar: true, autoHideMenuBar: true,
}), }),
webPreferences: { webPreferences: {
@@ -113,6 +120,12 @@ async function createMainWindow(): Promise<void> {
setupWindowResizeEvents(mainWindow); setupWindowResizeEvents(mainWindow);
setupDragDropPrevention(mainWindow); setupDragDropPrevention(mainWindow);
if (!isMac) {
nativeTheme.on("updated", () => {
mainWindow.setTitleBarOverlay(getTitleBarOverlayColors());
});
}
mainWindow.once("ready-to-show", () => { mainWindow.once("ready-to-show", () => {
mainWindow.show(); mainWindow.show();
}); });

View File

@@ -370,7 +370,6 @@ export type PostChatMessageOptions = {
room: string; room: string;
body: string; body: string;
replyToMessageId?: string | null; replyToMessageId?: string | null;
mentionAgentIds?: string[];
requestId?: string; requestId?: string;
}; };
export type ReadChatMessagesOptions = { export type ReadChatMessagesOptions = {
@@ -2913,9 +2912,6 @@ export class DaemonClient {
room: options.room, room: options.room,
body: options.body, body: options.body,
...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}), ...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}),
...(options.mentionAgentIds && options.mentionAgentIds.length > 0
? { mentionAgentIds: options.mentionAgentIds }
: {}),
}, },
responseType: "chat/post/response", responseType: "chat/post/response",
timeout: 10000, timeout: 10000,

View File

@@ -31,7 +31,6 @@ export const ChatPostRequestSchema = z.object({
room: z.string(), room: z.string(),
body: z.string(), body: z.string(),
replyToMessageId: z.string().optional(), replyToMessageId: z.string().optional(),
mentionAgentIds: z.array(z.string()).optional(),
}); });
export const ChatReadRequestSchema = z.object({ export const ChatReadRequestSchema = z.object({

View File

@@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import pino from "pino"; import pino from "pino";
import { ChatServiceError, FileBackedChatService } from "./chat-service.js"; import { ChatServiceError, FileBackedChatService, parseMentionAgentIds } from "./chat-service.js";
describe("FileBackedChatService", () => { describe("FileBackedChatService", () => {
let paseoHome: string; let paseoHome: string;
@@ -48,8 +48,7 @@ describe("FileBackedChatService", () => {
const first = await service.postMessage({ const first = await service.postMessage({
room: room.name, room: room.name,
authorAgentId: "agent-a", authorAgentId: "agent-a",
body: "first message", body: "first message for @agent-b and @agent-c and again @agent-b",
mentionAgentIds: ["agent-b", "agent-b", "agent-c"],
}); });
await service.postMessage({ await service.postMessage({
room: room.id, room: room.id,
@@ -141,4 +140,11 @@ describe("FileBackedChatService", () => {
code: "chat_room_not_found", 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([]);
});
}); });

View File

@@ -31,6 +31,19 @@ function trimToNull(value: string | null | undefined): string | null {
return trimmed.length > 0 ? trimmed : 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<string>();
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 { export class ChatServiceError extends Error {
readonly code: string; readonly code: string;
@@ -67,7 +80,6 @@ export interface PostChatMessageInput {
authorAgentId: string; authorAgentId: string;
body: string; body: string;
replyToMessageId?: string | null; replyToMessageId?: string | null;
mentionAgentIds?: string[];
} }
export interface ReadChatMessagesInput { export interface ReadChatMessagesInput {
@@ -195,9 +207,7 @@ export class FileBackedChatService {
authorAgentId, authorAgentId,
body, body,
replyToMessageId, replyToMessageId,
mentionAgentIds: [...new Set((input.mentionAgentIds ?? []).map((value) => value.trim()))] mentionAgentIds: parseMentionAgentIds(body),
.filter((value) => value.length > 0)
.sort(),
createdAt, createdAt,
}); });

View File

@@ -7730,7 +7730,6 @@ export class Session {
authorAgentId: this.clientId, authorAgentId: this.clientId,
body: request.body, body: request.body,
replyToMessageId: request.replyToMessageId, replyToMessageId: request.replyToMessageId,
mentionAgentIds: request.mentionAgentIds,
}); });
this.emit({ this.emit({
type: "chat/post/response", type: "chat/post/response",
@@ -7740,11 +7739,79 @@ export class Session {
error: null, error: null,
}, },
}); });
void this.notifyMentionedAgents({
room: request.room,
authorAgentId: this.clientId,
body: request.body,
mentionAgentIds: message.mentionAgentIds,
});
} catch (error) { } catch (error) {
this.emitChatRpcError(request, error); this.emitChatRpcError(request, error);
} }
} }
private async notifyMentionedAgents(input: {
room: string;
authorAgentId: string;
body: string;
mentionAgentIds: string[];
}): Promise<void> {
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( private async handleChatReadRequest(
request: Extract<SessionInboundMessage, { type: "chat/read" }>, request: Extract<SessionInboundMessage, { type: "chat/read" }>,
): Promise<void> { ): Promise<void> {

View File

@@ -27,10 +27,8 @@ When using chat:
- check chat often while working - check chat often while working
Mentions are active: Mentions are active:
- use `--mention <agent-id>` on your post to notify a specific agent immediately - write mentions inline in the message body as `@<agent-id>` to notify a specific agent immediately
- if you use `--reply-to`, the author of the replied-to message is notified - notifications are sent to the target agent without blocking the chat post
- 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
- if a normal post is enough and no one needs to act right now, skip the mention - if a normal post is enough and no one needs to act right now, skip the mention
## Command Surface ## Command Surface
@@ -68,7 +66,7 @@ paseo chat post issue-456 "I can take that next." --reply-to msg-001
With a direct mention: With a direct mention:
```bash ```bash
paseo chat post issue-456 "Can you verify the relay path next?" --mention <agent-id> paseo chat post issue-456 "@<agent-id> Can you verify the relay path next?"
``` ```
### Read recent messages ### Read recent messages
@@ -101,7 +99,7 @@ When using a room:
- read only a bounded window before acting - read only a bounded window before acting
- post updates when they would help another agent or your future self - post updates when they would help another agent or your future self
- use `--reply-to` when responding to a specific message - 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 `@<agent-id>` mentions when you want to get a specific agent's attention
- check chat frequently enough that shared coordination actually works - check chat frequently enough that shared coordination actually works
- your own agent ID is available via `$PASEO_AGENT_ID` - 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 3. Read the room with bounded history
4. Post clearly 4. Post clearly
5. Use `--reply-to` when replying to a specific message 5. Use `--reply-to` when replying to a specific message
6. Use `--mention` when you want to notify someone directly 6. Use inline `@<agent-id>` mentions when you want to notify someone directly

View File

@@ -97,11 +97,10 @@ paseo chat post <room> "Focus on implementing the API layer. Acceptance criteria
- all new endpoints have tests - all new endpoints have tests
- typecheck passes - typecheck passes
Post your progress here. @mention me ($PASEO_AGENT_ID) when done." \ Post your progress here. @$PASEO_AGENT_ID when done, and start on this now @<agent-id>."
--mention <agent-id>
``` ```
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 ### Role-based provider selection
@@ -148,13 +147,13 @@ paseo chat read <room> --limit 10
### Directing work ### Directing work
```bash ```bash
paseo chat post <room> "The API is done. Now focus on the frontend integration." --mention <agent-id> paseo chat post <room> "@<agent-id> The API is done. Now focus on the frontend integration."
``` ```
### Course-correcting ### Course-correcting
```bash ```bash
paseo chat post <room> "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 <agent-id> paseo chat post <room> "@<agent-id> 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 ### Challenging agents

View File

@@ -133,7 +133,7 @@ paseo chat inspect <name-or-id>
# Post a message # Post a message
paseo chat post <room> "<message>" paseo chat post <room> "<message>"
paseo chat post <room> "<message>" --reply-to <msg-id> paseo chat post <room> "<message>" --reply-to <msg-id>
paseo chat post <room> "<message>" --mention <agent-id> # Repeatable paseo chat post <room> "@<agent-id> <message>"
# Read messages # Read messages
paseo chat read <room> paseo chat read <room>