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 { 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("<name-or-id>", "Room name or ID")
.argument("<message>", "Message body")
.option("--reply-to <msg-id>", "Reply to a specific message ID")
.option(
"--mention <agent-id>",
"Mention an agent ID (repeatable)",
collectMultiple,
[],
),
.option("--reply-to <msg-id>", "Reply to a specific message ID"),
).action(withOutput(runPostCommand));
addJsonAndDaemonHostOptions(

View File

@@ -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",

View File

@@ -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);

View File

@@ -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<void> {
...(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<void> {
setupWindowResizeEvents(mainWindow);
setupDragDropPrevention(mainWindow);
if (!isMac) {
nativeTheme.on("updated", () => {
mainWindow.setTitleBarOverlay(getTitleBarOverlayColors());
});
}
mainWindow.once("ready-to-show", () => {
mainWindow.show();
});

View File

@@ -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,

View File

@@ -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({

View File

@@ -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([]);
});
});

View File

@@ -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<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 {
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,
});

View File

@@ -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<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(
request: Extract<SessionInboundMessage, { type: "chat/read" }>,
): Promise<void> {

View File

@@ -27,10 +27,8 @@ When using chat:
- check chat often while working
Mentions are active:
- use `--mention <agent-id>` 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 `@<agent-id>` 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 <agent-id>
paseo chat post issue-456 "@<agent-id> 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 `@<agent-id>` 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 `@<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
- typecheck passes
Post your progress here. @mention me ($PASEO_AGENT_ID) when done." \
--mention <agent-id>
Post your progress here. @$PASEO_AGENT_ID when done, and start on this now @<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
@@ -148,13 +147,13 @@ paseo chat read <room> --limit 10
### Directing work
```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
```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

View File

@@ -133,7 +133,7 @@ paseo chat inspect <name-or-id>
# Post a message
paseo chat post <room> "<message>"
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
paseo chat read <room>