From dde2b1f9dd0cd5227399fc32803352116de6fa4b Mon Sep 17 00:00:00 2001 From: rusackas Date: Thu, 2 Jul 2026 21:56:27 +0000 Subject: [PATCH 1/7] feat(core): add 'mention' MessageType for pings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New message type for human-to-human notes in the conversation stream (the ping/mention feature). Stores mentioned user IDs in metadata.mentions, mirroring BoardComment.mentions. No DB migration needed — the drizzle `enum` option on a text column is TS-level only. --- packages/core/src/db/schema.postgres.ts | 1 + packages/core/src/db/schema.sqlite.ts | 1 + packages/core/src/types/message.ts | 14 ++++++++++++-- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/core/src/db/schema.postgres.ts b/packages/core/src/db/schema.postgres.ts index 67407ad0a..a109c8d18 100644 --- a/packages/core/src/db/schema.postgres.ts +++ b/packages/core/src/db/schema.postgres.ts @@ -456,6 +456,7 @@ export const messages = pgTable( 'daemon_restart', 'daemon_crash', 'widget_request', + 'mention', ], }).notNull(), role: text('role', { diff --git a/packages/core/src/db/schema.sqlite.ts b/packages/core/src/db/schema.sqlite.ts index 1292a49d6..22a3d4658 100644 --- a/packages/core/src/db/schema.sqlite.ts +++ b/packages/core/src/db/schema.sqlite.ts @@ -447,6 +447,7 @@ export const messages = sqliteTable( 'daemon_restart', 'daemon_crash', 'widget_request', + 'mention', ], }).notNull(), role: text('role', { diff --git a/packages/core/src/types/message.ts b/packages/core/src/types/message.ts index 4501a3dc7..81f5ed985 100644 --- a/packages/core/src/types/message.ts +++ b/packages/core/src/types/message.ts @@ -5,7 +5,7 @@ * Messages are stored in a normalized table and referenced by tasks via message_range. */ -import type { MessageID, SessionID, TaskID } from './id'; +import type { MessageID, SessionID, TaskID, UserID } from './id'; import type { WidgetMessageMetadata } from './widget'; /** @@ -40,7 +40,8 @@ export type MessageType = | 'input_request' | 'daemon_restart' | 'daemon_crash' - | 'widget_request'; + | 'widget_request' + | 'mention'; /** * Content block (for multi-modal messages) @@ -263,6 +264,15 @@ export interface Message { */ widget?: WidgetMessageMetadata; + /** + * @mentioned user IDs. Only populated on `type === 'mention'` messages + * (the ping/comment compose action — see `context/explorations/` or PR + * description for design). Mirrors `BoardComment.mentions`. These + * messages are human-to-human notes: never dispatched to the agent and + * excluded from agent-facing message search (see mcp/tools/messages.ts). + */ + mentions?: UserID[]; + /** * Marks the user-role message / task as having been authored by the * daemon on behalf of the user, rather than typed by a human. From 2253998eada42a3d6e230bcb1bfe08ec08487f71 Mon Sep 17 00:00:00 2001 From: rusackas Date: Thu, 2 Jul 2026 21:56:43 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat(daemon):=20add=20POST=20/sessions/:id/?= =?UTF-8?q?ping=20=E2=80=94=20non-agent=20ping=20message?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New sibling route to /sessions/:id/prompt that writes a `mention`-type message via the existing messages service (same RBAC hooks as prompting) but never creates/queues a Task and never spawns the executor, so the ping structurally cannot reach the agent. Attaches to the session's host task (mirrors the widget_request pattern) so the transcript renderer picks it up. Also excludes 'mention' messages from the agent-facing agor_messages_list MCP tool, closing the one remaining path an agent could otherwise read pings back through. --- apps/agor-daemon/src/mcp/tools/messages.ts | 5 ++ apps/agor-daemon/src/register-routes.ts | 70 ++++++++++++++++++- .../src/utils/append-system-message.ts | 5 +- 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/apps/agor-daemon/src/mcp/tools/messages.ts b/apps/agor-daemon/src/mcp/tools/messages.ts index 0139c7b98..f871efeb9 100644 --- a/apps/agor-daemon/src/mcp/tools/messages.ts +++ b/apps/agor-daemon/src/mcp/tools/messages.ts @@ -151,6 +151,11 @@ export function registerMessageTools(server: McpServer, ctx: McpContext): void { ); } + // Pings ('mention' messages) are human-to-human notes, never part of the + // agent's own conversation — exclude unconditionally so the agent can't + // read them back through this tool either. + conditions.push(sql`${messagesTable.type} != 'mention'`); + // Search: parse "term1 term2 | term3 term4" into (t1 AND t2) OR (t3 AND t4) if (search) { const orGroups = search.split(/\s*\|\s*/).map((group) => { diff --git a/apps/agor-daemon/src/register-routes.ts b/apps/agor-daemon/src/register-routes.ts index a02cdbc9c..bb42050bf 100755 --- a/apps/agor-daemon/src/register-routes.ts +++ b/apps/agor-daemon/src/register-routes.ts @@ -58,6 +58,7 @@ import type { Task, TaskID, User, + UserID, UUID, } from '@agor/core/types'; import { @@ -126,6 +127,7 @@ import { } from './utils/mcp-header-secrets.js'; import { canControlCliSession } from './utils/mcp-token-authorization.js'; import { ensureScheduleRunsAsCaller } from './utils/schedule-hooks.js'; +import { findHostTaskForSession } from './utils/session-tasks.js'; import { stopSessionPreserveQueue } from './utils/session-stop.js'; import { sessionCanStartTask, @@ -272,7 +274,7 @@ export async function registerRoutes(ctx: RegisterRoutesContext): Promise messagesService, boardsService, branchRepository, - usersRepository: _usersRepository, + usersRepository, sessionsRepository, sessionMCPServersService, sessionEnvSelectionsService, @@ -1537,6 +1539,72 @@ export async function registerRoutes(ctx: RegisterRoutesContext): Promise requireAuth ); + // ============================================================================ + // Ping endpoint + // + // A "ping" is a human-to-human note in the conversation stream — Slack-style + // `@collaborator what do you think?`. Unlike /sessions/:id/prompt, this path + // NEVER creates/queues a Task and NEVER spawns the executor: it only writes + // a `type: 'mention'` message via the same messages service used elsewhere, + // so the same RBAC hooks (ensureCanPromptInSession) apply. Structurally this + // guarantees the ping can't reach the agent — no Task means the executor is + // never invoked with it. It's also excluded from the agent-facing + // `agor_messages_list` MCP tool (see mcp/tools/messages.ts) so the agent + // can't read it back that way either. + // ============================================================================ + + registerAuthenticatedRoute( + app, + '/sessions/:id/ping', + { + async create(data: { text: string; mentioned_user_ids?: string[] }, params: RouteParams) { + const id = params.route?.id; + if (!id) throw new Error('Session ID required'); + const text = data.text?.trim(); + if (!text) throw new Error('Ping text required'); + if (!params.user?.user_id) { + throw new NotAuthenticated('Authentication required to ping a session'); + } + + const session = await sessionsService.get(id, params); + + // Resolve mentioned user ids, silently dropping any that don't + // resolve to a real user (best-effort — the ping still posts). + const requestedMentionIds = Array.from(new Set(data.mentioned_user_ids ?? [])); + const mentions: UserID[] = []; + for (const userId of requestedMentionIds) { + const user = await usersRepository.findById(userId); + if (user) mentions.push(user.user_id as UserID); + } + + // Attach to the session's host task (mirrors the widget_request + // pattern) so the transcript renderer — which loads messages by + // task_id — picks it up. Sessions with no tasks yet (never prompted) + // have no host task; the ping is still stored but won't render until + // the session has at least one task. + const hostTask = await findHostTaskForSession(app, session.session_id, params); + + const created = await appendSystemMessage({ + app, + db, + sessionId: session.session_id, + taskId: hostTask?.task_id, + content: text, + type: 'mention', + role: MessageRole.USER, + metadata: { mentions }, + params, + }); + + return created; + }, + }, + { + create: { role: ROLES.MEMBER, action: 'ping session collaborators' }, + }, + requireAuth + ); + // ============================================================================ // Task run endpoint // diff --git a/apps/agor-daemon/src/utils/append-system-message.ts b/apps/agor-daemon/src/utils/append-system-message.ts index 48b40ec95..53d3cde7d 100644 --- a/apps/agor-daemon/src/utils/append-system-message.ts +++ b/apps/agor-daemon/src/utils/append-system-message.ts @@ -33,7 +33,10 @@ export interface AppendSystemMessageOptions { /** Falls back to the first 200 chars of string content when omitted */ contentPreview?: string; /** Defaults to 'system' */ - type?: Extract; + type?: Extract< + MessageType, + 'system' | 'daemon_restart' | 'daemon_crash' | 'widget_request' | 'mention' + >; /** Defaults to MessageRole.SYSTEM */ role?: Message['role']; metadata?: Message['metadata']; From 136f1f48f779ae9ac3c02889d7f2c79f18a01c2a Mon Sep 17 00:00:00 2001 From: rusackas Date: Thu, 2 Jul 2026 21:57:25 +0000 Subject: [PATCH 3/7] feat(client): add sessions.ping() helper Thin wrapper mirroring sessions.prompt(), calling POST /sessions/:id/ping. --- packages/core/src/api/index.ts | 36 ++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index d0eb2dd76..bd5a79bda 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -119,12 +119,34 @@ export interface SessionPromptOptions extends Omit; + ping( + sessionId: string, + text: string, + mentionedUserIds?: string[], + options?: SessionPingOptions + ): Promise; } /** @@ -884,6 +906,20 @@ function extendSessionsHelpers(client: AgorClient): void { .create({ prompt, ...requestOptions } as SessionPromptRequest, params); return response as SessionPromptResult; }, + ping: async ( + sessionId: string, + text: string, + mentionedUserIds?: string[], + options?: SessionPingOptions + ) => { + const response = await client + .service(`sessions/${sessionId}/ping`) + .create( + { text, mentioned_user_ids: mentionedUserIds } as SessionPingRequest, + options?.params + ); + return response as Message; + }, }; augmentedClient[CLIENT_SESSIONS_HELPERS_EXTENDED] = true; From 25fbfd852c67be9bca3ccaf3da81596750302a0a Mon Sep 17 00:00:00 2001 From: rusackas Date: Thu, 2 Jul 2026 21:58:16 +0000 Subject: [PATCH 4/7] feat(ui): add Ping compose action to session composer Adds a secondary "Ping" button next to Send in the composer's action bar. Pings post a human-to-human note (never sent to the agent) and carry any @mentioned collaborators, tracked via a new onMentionSelect callback on AutocompleteTextarea (reusing the existing @-user autocomplete rather than inferring intent from a leading @, which would collide with the file/user/KB autocomplete trigger already overloaded on @). Wires client.sessions.ping() through onSendPing end-to-end: App.tsx -> components/App/App.tsx -> AppActionsContext -> SessionPanel -> SessionFooter. Also fixes a latent bug where user-mention insertText wasn't quoted for multi-word names (quoteIfNeeded was only applied to file paths), which would have broken mention tracking for names with spaces. --- apps/agor-ui/src/App.tsx | 19 ++++++ apps/agor-ui/src/components/App/App.tsx | 8 +++ .../AutocompleteTextarea.tsx | 19 +++++- .../components/SessionPanel/SessionFooter.tsx | 22 +++++++ .../components/SessionPanel/SessionPanel.tsx | 66 ++++++++++++++++++- .../src/contexts/AppActionsContext.tsx | 9 +++ 6 files changed, 138 insertions(+), 5 deletions(-) diff --git a/apps/agor-ui/src/App.tsx b/apps/agor-ui/src/App.tsx index b3a4846e6..b96b03009 100644 --- a/apps/agor-ui/src/App.tsx +++ b/apps/agor-ui/src/App.tsx @@ -829,6 +829,24 @@ function AppContent() { } }; + // Handle send ping - human-to-human note, never reaches the agent + const handleSendPing = async ( + sessionId: string, + text: string, + mentionedUserIds?: string[] + ): Promise => { + if (!client) return false; + + try { + await client.sessions.ping(sessionId, text, mentionedUserIds); + return true; + } catch (error) { + showError(`Failed to send ping: ${error instanceof Error ? error.message : String(error)}`); + console.error('Ping error:', error); + return false; + } + }; + // Handle update session const handleUpdateSession = async (sessionId: string, updates: Partial) => { const session = await updateSession(sessionId as SessionID, updates); @@ -1605,6 +1623,7 @@ function AppContent() { onBtwForkSession={handleBtwForkSession} onSpawnSession={handleSpawnSession} onSendPrompt={handleSendPrompt} + onSendPing={handleSendPing} onUpdateSession={handleUpdateSession} onDeleteSession={handleDeleteSession} onCreateBoard={handleCreateBoard} diff --git a/apps/agor-ui/src/components/App/App.tsx b/apps/agor-ui/src/components/App/App.tsx index 76dca333a..5f06f53b1 100644 --- a/apps/agor-ui/src/components/App/App.tsx +++ b/apps/agor-ui/src/components/App/App.tsx @@ -124,6 +124,11 @@ export interface AppProps { prompt: string, permissionMode?: PermissionMode ) => boolean | undefined | Promise; + onSendPing?: ( + sessionId: string, + text: string, + mentionedUserIds?: string[] + ) => boolean | undefined | Promise; onUpdateSession?: (sessionId: string, updates: Partial) => void; onDeleteSession?: (sessionId: string) => void; onCreateBoard?: (board: Partial) => Promise; @@ -269,6 +274,7 @@ export const App: React.FC = ({ onBtwForkSession, onSpawnSession, onSendPrompt, + onSendPing, onUpdateSession, onDeleteSession, onCreateBoard, @@ -1066,6 +1072,7 @@ export const App: React.FC = ({ const appActionsValue = useMemo( () => ({ onSendPrompt, + onSendPing, onFork: onForkSession, onBtwFork: onBtwForkSession, onSubsession: onSpawnSession, @@ -1086,6 +1093,7 @@ export const App: React.FC = ({ }), [ onSendPrompt, + onSendPing, onForkSession, onBtwForkSession, onSpawnSession, diff --git a/apps/agor-ui/src/components/AutocompleteTextarea/AutocompleteTextarea.tsx b/apps/agor-ui/src/components/AutocompleteTextarea/AutocompleteTextarea.tsx index 8e7f978f7..b5dfcb0dc 100644 --- a/apps/agor-ui/src/components/AutocompleteTextarea/AutocompleteTextarea.tsx +++ b/apps/agor-ui/src/components/AutocompleteTextarea/AutocompleteTextarea.tsx @@ -48,6 +48,7 @@ interface FileResult { } interface UserResult { + userId: string; name: string; email: string; type: 'user'; @@ -121,6 +122,13 @@ interface AutocompleteTextareaProps { kbLinkTarget?: KbLinkTarget; /** Draw attention to the textarea while it is empty. */ highlightWhenEmpty?: boolean; + /** + * Fires when a user is chosen from the `@` autocomplete, with the userId + * and the exact `@name` / `@"name"` text inserted into the textarea. Lets + * callers (e.g. the ping composer) track mentioned user ids without + * re-parsing free text. + */ + onMentionSelect?: (userId: string, insertedText: string) => void; } // Minimum characters required after : before showing emoji picker (like Slack) @@ -380,6 +388,7 @@ export const AutocompleteTextarea = React.forwardRef< kbDocs, kbLinkTarget = 'stable-uri', highlightWhenEmpty = false, + onMentionSelect, }, ref ) => { @@ -621,6 +630,7 @@ export const AutocompleteTextarea = React.forwardRef< // If no query, show all users (up to MAX_USER_RESULTS) if (!searchQuery.trim()) { return allUsers.slice(0, MAX_USER_RESULTS).map((u: User) => ({ + userId: u.user_id, name: u.name || u.email, email: u.email, type: 'user' as const, @@ -637,6 +647,7 @@ export const AutocompleteTextarea = React.forwardRef< ) .slice(0, MAX_USER_RESULTS) .map((u: User) => ({ + userId: u.user_id, name: u.name || u.email, email: u.email, type: 'user' as const, @@ -927,8 +938,10 @@ export const AutocompleteTextarea = React.forwardRef< // File selection insertText = `@${quoteIfNeeded(item.path)}`; } else { - // User selection - insertText = `@${item.name}`; + // User selection. Quote multi-word names so the highlight regex + // (and any downstream mention parsing) captures the whole name. + insertText = `@${quoteIfNeeded(item.name)}`; + onMentionSelect?.(item.userId, insertText); } const newValue = @@ -958,7 +971,7 @@ export const AutocompleteTextarea = React.forwardRef< textareaRef.current.current?.focus(); }, 0); }, - [triggerIndex, value, onChange, kbLinkTarget] + [triggerIndex, value, onChange, kbLinkTarget, onMentionSelect] ); /** diff --git a/apps/agor-ui/src/components/SessionPanel/SessionFooter.tsx b/apps/agor-ui/src/components/SessionPanel/SessionFooter.tsx index 3415c46cf..9bfc86d9a 100644 --- a/apps/agor-ui/src/components/SessionPanel/SessionFooter.tsx +++ b/apps/agor-ui/src/components/SessionPanel/SessionFooter.tsx @@ -11,6 +11,7 @@ import type { import { BranchesOutlined, ClockCircleOutlined, + CommentOutlined, EllipsisOutlined, ForkOutlined, IdcardOutlined, @@ -82,6 +83,8 @@ export interface SessionFooterProps { onModelConfigChange: (config: ModelConfig) => void; onOpenSessionSettings?: (sessionId: string) => void; onSendPrompt: () => void; + /** Post a human-to-human ping note. Omit to hide the Ping button entirely. */ + onSendPing?: () => void; onStop: () => void; onFork: () => void; onBtwSend: () => void; @@ -124,6 +127,7 @@ export const SessionFooter: React.FC = ({ onModelConfigChange, onOpenSessionSettings, onSendPrompt, + onSendPing, onStop, onFork, onBtwSend, @@ -198,6 +202,9 @@ export const SessionFooter: React.FC = ({ const btwForkDisabled = connectionDisabled || !hasInput || composerAttachmentsPresent; const spawnDisabled = connectionDisabled || isRunning || composerAttachmentsPresent; const sendDisabled = connectionDisabled || composerAttachmentUploading || !hasInput; + // Pings never touch the executor, so unlike sendDisabled they don't care + // about composerAttachmentUploading (attachments aren't sent with a ping). + const pingDisabled = connectionDisabled || !hasInput; const sectionHeaderStyle: React.CSSProperties = { padding: '6px 12px 3px', @@ -1542,6 +1549,21 @@ export const SessionFooter: React.FC = ({ )} + {onSendPing && ( + + + + )} 0 ? queuedTasks.length : 0} diff --git a/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx b/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx index 30e1963ed..d1ae42224 100644 --- a/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx +++ b/apps/agor-ui/src/components/SessionPanel/SessionPanel.tsx @@ -99,6 +99,13 @@ export interface PromptInputHandle { getValue: () => string; clear: () => void; insertText: (text: string) => void; + /** + * User ids mentioned via the `@` autocomplete since the last clear(), + * filtered down to mentions whose inserted `@name` text is still present + * in the current value (so deleting a mention drops it). Used by the ping + * compose action — see `handleSendPing`. + */ + getMentionedUserIds: () => string[]; } interface PromptInputProps { @@ -153,6 +160,13 @@ const PromptInput = React.forwardRef( const [value, setValue] = React.useState(() => getDraft(sessionId)); const valueRef = React.useRef(value); const textareaElementRef = React.useRef(null); + // userId -> inserted "@name" text, tracked as the user types/selects + // mentions. Cleared on submit/session-switch alongside the draft. + const mentionsRef = React.useRef>(new Map()); + + const handleMentionSelect = React.useCallback((userId: string, insertedText: string) => { + mentionsRef.current.set(userId, insertedText); + }, []); // Keep refs in sync (zero-cost, no re-render) valueRef.current = value; @@ -190,6 +204,7 @@ const PromptInput = React.forwardRef( } setValue(''); deleteDraft(sessionId); + mentionsRef.current.clear(); }, insertText: (text: string) => { setValue((prev) => { @@ -201,6 +216,12 @@ const PromptInput = React.forwardRef( return nextValue; }); }, + getMentionedUserIds: () => { + const currentValue = textareaElementRef.current?.value ?? valueRef.current; + return Array.from(mentionsRef.current.entries()) + .filter(([, insertedText]) => currentValue.includes(insertedText)) + .map(([userId]) => userId); + }, }), [sessionId, deleteDraft, inputValueRef] ); @@ -211,6 +232,7 @@ const PromptInput = React.forwardRef( if (prevSessionId.current !== sessionId) { saveDraft(prevSessionId.current, valueRef.current); setValue(getDraft(sessionId)); + mentionsRef.current.clear(); prevSessionId.current = sessionId; } }, [sessionId, saveDraft, getDraft]); @@ -264,6 +286,7 @@ const PromptInput = React.forwardRef( enableKnowledgeMentions kbLinkTarget="absolute-route" highlightWhenEmpty + onMentionSelect={handleMentionSelect} /> ); } @@ -310,8 +333,15 @@ const SessionPanel: React.FC = ({ const userAuthenticatedMcpServerIds = useAgorStore(selectUserAuthenticatedMcpServerIds); // Get actions from context - const { onSendPrompt, onFork, onBtwFork, onOpenSettings, onUpdateSession, onOpenTerminal } = - useAppActions(); + const { + onSendPrompt, + onSendPing, + onFork, + onBtwFork, + onOpenSettings, + onUpdateSession, + onOpenTerminal, + } = useAppActions(); const { archiveSession } = useSessionActions(client); @@ -841,6 +871,37 @@ const SessionPanel: React.FC = ({ } }; + // Post a ping — a human-to-human note, never sent to the agent. Shares the + // composer textarea with handleSendPrompt (including the send-in-flight + // guard) but skips attachments/permission mode entirely since it never + // reaches the executor. + const handleSendPing = async () => { + if (composerSendInFlightRef.current || connectionDisabled) return; + + composerSendInFlightRef.current = true; + try { + const sendStartSessionId = session.session_id; + const value = promptRef.current?.getValue() ?? ''; + if (!value.trim()) return; + + if (!onSendPing) { + showError('Cannot send ping from this view.'); + return; + } + + const mentionedUserIds = promptRef.current?.getMentionedUserIds() ?? []; + const sendResult = await onSendPing(sendStartSessionId, value, mentionedUserIds); + if (sendResult === false) return; + + promptRef.current?.clear(); + } catch (error) { + console.error('Ping send failed — keeping text in composer:', error); + showError(error instanceof Error ? error.message : 'Failed to send ping'); + } finally { + composerSendInFlightRef.current = false; + } + }; + const handleStop = async () => { if (!session || !client || stopRequestInFlight) return; @@ -1089,6 +1150,7 @@ const SessionPanel: React.FC = ({ onModelConfigChange={handleModelConfigChange} onOpenSessionSettings={onOpenSettings} onSendPrompt={handleSendPrompt} + onSendPing={onSendPing ? handleSendPing : undefined} onStop={handleStop} onFork={handleFork} onBtwSend={handleBtwSend} diff --git a/apps/agor-ui/src/contexts/AppActionsContext.tsx b/apps/agor-ui/src/contexts/AppActionsContext.tsx index 3f2e6025b..3ca55730e 100644 --- a/apps/agor-ui/src/contexts/AppActionsContext.tsx +++ b/apps/agor-ui/src/contexts/AppActionsContext.tsx @@ -16,6 +16,15 @@ export interface AppActionsContextValue { prompt: string, permissionMode?: PermissionMode ) => boolean | undefined | Promise; + /** + * Post a human-to-human "ping" note — never sent to the agent, never + * creates a Task. See `client.sessions.ping` / `POST /sessions/:id/ping`. + */ + onSendPing?: ( + sessionId: string, + text: string, + mentionedUserIds?: string[] + ) => boolean | undefined | Promise; onFork?: (sessionId: string, prompt: string) => Promise; onBtwFork?: (sessionId: string, prompt: string) => Promise; onSubsession?: (sessionId: string, config: string | Partial) => Promise; From 89b67a8edb9a9612d254ba4870940e91369dd582 Mon Sep 17 00:00:00 2001 From: rusackas Date: Thu, 2 Jul 2026 22:00:16 +0000 Subject: [PATCH 5/7] feat(ui): render pings distinctly in the conversation stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a PingNote renderer for type === 'mention' messages: author avatar, "X pinged Y" header, and the note text — styled as a human-to-human comment rather than agent output. Author attribution needed a new metadata.author_user_id field since Message has no general created_by column (ordinary messages are attributed to the session's current viewer instead, which doesn't work for pings since any collaborator with access can post one). --- apps/agor-daemon/src/register-routes.ts | 2 +- .../components/MessageBlock/MessageBlock.tsx | 64 ++++++++++++++++++- packages/core/src/types/message.ts | 9 +++ 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/apps/agor-daemon/src/register-routes.ts b/apps/agor-daemon/src/register-routes.ts index bb42050bf..89e7643cc 100755 --- a/apps/agor-daemon/src/register-routes.ts +++ b/apps/agor-daemon/src/register-routes.ts @@ -1592,7 +1592,7 @@ export async function registerRoutes(ctx: RegisterRoutesContext): Promise content: text, type: 'mention', role: MessageRole.USER, - metadata: { mentions }, + metadata: { mentions, author_user_id: params.user.user_id as UserID }, params, }); diff --git a/apps/agor-ui/src/components/MessageBlock/MessageBlock.tsx b/apps/agor-ui/src/components/MessageBlock/MessageBlock.tsx index 9aa6eca1b..e20017259 100644 --- a/apps/agor-ui/src/components/MessageBlock/MessageBlock.tsx +++ b/apps/agor-ui/src/components/MessageBlock/MessageBlock.tsx @@ -20,7 +20,7 @@ import { shortId, type User, } from '@agor-live/client'; -import { RobotOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons'; +import { CommentOutlined, RobotOutlined, SyncOutlined, WarningOutlined } from '@ant-design/icons'; import { Bubble } from '@ant-design/x'; import { Button, Tooltip, theme } from 'antd'; @@ -305,6 +305,62 @@ function DaemonRestartNotice({ ); } +interface PingNoteProps { + message: Message; + userById: Map; +} + +/** + * Renders a `type === 'mention'` message — a human-to-human note posted via + * the composer's "Ping" action. Styled to read as a comment between + * collaborators, not as agent output: it never enters the agent's context + * (see MessageType docs / mcp/tools/messages.ts). + */ +function PingNote({ message, userById }: PingNoteProps) { + const { token } = theme.useToken(); + const authorId = message.metadata?.author_user_id; + const author = authorId ? userById.get(authorId) : undefined; + const mentionIds = message.metadata?.mentions ?? []; + const mentionedUsers = mentionIds.map((id) => userById.get(id)).filter((u): u is User => !!u); + const text = typeof message.content === 'string' ? message.content : ''; + + return ( +
+ +
+
+ + {author?.name || author?.email || 'Someone'} pinged + {mentionedUsers.length > 0 && + ` ${mentionedUsers.map((u) => u.name || u.email).join(', ')}`} +
+ +
+
+ ); +} + // Memoized: every text block / tool block of every message in the conversation // re-rendered on every streaming chunk because TaskBlock's `messages` array // gets a fresh reference each tick. Default shallow compare is sufficient @@ -393,6 +449,12 @@ const MessageBlockInner: React.FC = ({ ); } + // Ping — human-to-human note posted via the composer's "Ping" action. + // Never sent to the agent (see MessageType docs). + if (message.type === 'mention') { + return ; + } + // Check if this is a Task tool prompt or result (agent-generated, but has user role) const isTaskPrompt = isTaskToolPrompt(message); const isTaskResult = isTaskToolResult(message); diff --git a/packages/core/src/types/message.ts b/packages/core/src/types/message.ts index 81f5ed985..2e5473f63 100644 --- a/packages/core/src/types/message.ts +++ b/packages/core/src/types/message.ts @@ -273,6 +273,15 @@ export interface Message { */ mentions?: UserID[]; + /** + * The user who posted this message. Only populated on `type === + * 'mention'` messages — Message has no general `created_by` column, and + * ordinary user/assistant messages are attributed to the session's + * current viewer instead. Pings need explicit attribution because any + * collaborator with session access can post one. + */ + author_user_id?: UserID; + /** * Marks the user-role message / task as having been authored by the * daemon on behalf of the user, rather than typed by a human. From d4c0587764d5358e980a9661d9aef6b389e38399 Mon Sep 17 00:00:00 2001 From: rusackas Date: Thu, 2 Jul 2026 22:02:30 +0000 Subject: [PATCH 6/7] chore(daemon): fix import sort order (biome) --- apps/agor-daemon/src/register-routes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/agor-daemon/src/register-routes.ts b/apps/agor-daemon/src/register-routes.ts index 89e7643cc..a4ed36bd7 100755 --- a/apps/agor-daemon/src/register-routes.ts +++ b/apps/agor-daemon/src/register-routes.ts @@ -127,12 +127,12 @@ import { } from './utils/mcp-header-secrets.js'; import { canControlCliSession } from './utils/mcp-token-authorization.js'; import { ensureScheduleRunsAsCaller } from './utils/schedule-hooks.js'; -import { findHostTaskForSession } from './utils/session-tasks.js'; import { stopSessionPreserveQueue } from './utils/session-stop.js'; import { sessionCanStartTask, shouldReconcileSessionPromptState, } from './utils/session-task-state.js'; +import { findHostTaskForSession } from './utils/session-tasks.js'; import { type SessionTurnLocks, withSessionTurnLock } from './utils/session-turn-lock.js'; import { normalizeMessageSource, runExistingTask } from './utils/task-runner.js'; import { From b1d76cb74a6bda6ce030a6278ac1dd1037f821c7 Mon Sep 17 00:00:00 2001 From: rusackas Date: Thu, 2 Jul 2026 22:05:45 +0000 Subject: [PATCH 7/7] test: cover mention/ping exclusion and mention-select behavior - agor_messages_list: assert the 'mention' exclusion is applied even with includeToolCalls:true, proving it's unconditional (not just folded into the existing tool-call filter). - AutocompleteTextarea: assert multi-word user names are quoted on insert and onMentionSelect fires with the resolved userId. --- .../src/mcp/tools/messages.test.ts | 19 +++++++++++ .../AutocompleteTextarea.test.tsx | 33 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/apps/agor-daemon/src/mcp/tools/messages.test.ts b/apps/agor-daemon/src/mcp/tools/messages.test.ts index 5ba275222..10ba9eb74 100644 --- a/apps/agor-daemon/src/mcp/tools/messages.test.ts +++ b/apps/agor-daemon/src/mcp/tools/messages.test.ts @@ -217,6 +217,25 @@ describe('agor_messages_list MCP tool', () => { expect(mockAllSpy).toHaveBeenCalled(); }); + it('always excludes mention (ping) messages from agent-facing results', async () => { + const handler = await registerAndGetHandler({ userId: 'user-1' }); + + await handler({ sessionId: 'sess-0001', includeToolCalls: true }); + + expect(mockWhereSpy).toHaveBeenCalledTimes(1); + // Drizzle SQL fragments are circular; flatten to text to assert on content + // without depending on their internal shape. + const seen = new WeakSet(); + const flattened = JSON.stringify(mockWhereSpy.mock.calls[0]?.[0], (_key, v) => { + if (typeof v === 'object' && v !== null) { + if (seen.has(v)) return undefined; + seen.add(v); + } + return v; + }); + expect(flattened).toContain("!= 'mention'"); + }); + it('sets next_offset to the raw rows consumed for the returned page', async () => { mockAllSpy.mockResolvedValue([row(0), row(1), row(2)]); const handler = await registerAndGetHandler({ userId: 'user-1' }); diff --git a/apps/agor-ui/src/components/AutocompleteTextarea/AutocompleteTextarea.test.tsx b/apps/agor-ui/src/components/AutocompleteTextarea/AutocompleteTextarea.test.tsx index 85fc8d4f9..ee3bb30ac 100644 --- a/apps/agor-ui/src/components/AutocompleteTextarea/AutocompleteTextarea.test.tsx +++ b/apps/agor-ui/src/components/AutocompleteTextarea/AutocompleteTextarea.test.tsx @@ -281,6 +281,39 @@ describe('AutocompleteTextarea', () => { expect(kbDocumentsFind).not.toHaveBeenCalled(); }); + it('quotes multi-word user mentions and fires onMentionSelect', async () => { + const onMentionSelect = vi.fn(); + const userById = new Map([ + ['user-1', { user_id: 'user-1', name: 'Jane Smith', email: 'jane@example.com' } as never], + ]); + + const Harness = () => { + const [value, setValue] = useState(''); + return ( + + ); + }; + render(); + const textarea = screen.getByPlaceholderText('Prompt') as HTMLTextAreaElement; + + fireEvent.change(textarea, { target: { value: '@Jane', selectionStart: 5 } }); + + fireEvent.click(await screen.findByText(/Jane Smith/)); + + await waitFor(() => { + expect(textarea).toHaveValue('@"Jane Smith" '); + }); + expect(onMentionSelect).toHaveBeenCalledWith('user-1', '@"Jane Smith"'); + }); + it('routes pasted image files through file drop handling with screenshot names', () => { const { textarea, onFilesDrop } = renderFilePasteTextarea(); const imageFile = new File(['image'], 'clipboard.png', { type: 'image/png' });