- {renderBody(message.bodyMd)}
+ {renderBody(message.bodyMd, messageMentions, context)}
{messageFiles.length > 0 && (
@@ -70,27 +83,50 @@ export function ChatMessageRow({
);
}
-function renderBody(body: string): ReactNode {
+function renderBody(body: string, mentions: ChatMentionRecord[], context: MessageContext): ReactNode {
+ if (mentions.length === 0) return body;
const parts: ReactNode[] = [];
- const regex = /@([A-Za-z][A-Za-z0-9_-]{1,63})/g;
+ const ordered = [...mentions].sort((a, b) => a.offsetStart - b.offsetStart);
let lastIndex = 0;
- let match;
- while ((match = regex.exec(body)) !== null) {
- if (match.index > lastIndex) parts.push(body.slice(lastIndex, match.index));
+ for (const mention of ordered) {
+ if (mention.offsetStart < lastIndex) throw new Error("Message mention offsets overlap.");
+ if (mention.offsetStart < 0 || mention.offsetEnd > body.length || mention.offsetStart >= mention.offsetEnd) {
+ throw new Error("Message mention offset is invalid.");
+ }
+ const token = body.slice(mention.offsetStart, mention.offsetEnd);
+ if (!token.startsWith("@")) throw new Error("Message mention token is invalid.");
+ if (mention.offsetStart > lastIndex) parts.push(body.slice(lastIndex, mention.offsetStart));
+ const label = mentionLabel(mention, context);
parts.push(
- @{match[1]}
+ @{label}
,
);
- lastIndex = match.index + match[0].length;
+ lastIndex = mention.offsetEnd;
}
if (lastIndex < body.length) parts.push(body.slice(lastIndex));
return parts;
}
+function mentionLabel(mention: ChatMentionRecord, context: MessageContext): string {
+ if (mention.targetType === "employee") {
+ const employee = context.employees.find((item) => item.id === mention.targetId);
+ if (!employee) throw new Error("Message mention employee target is missing.");
+ return employee.displayName;
+ }
+ if (mention.targetType === "agent") {
+ const agent = context.agents.find((item) => item.id === mention.targetId);
+ if (!agent) throw new Error("Message mention agent target is missing.");
+ return agent.name;
+ }
+ if (mention.targetType === "board") return "Board";
+ if (mention.targetType === "org_group" && mention.targetId.endsWith(":oncall")) return "OnCall";
+ throw new Error("Message mention target type is unsupported.");
+}
+
function AttachmentCard({ file }: { file: FileObjectRecord }) {
return (
void;
@@ -54,6 +60,8 @@ export function ChatThreadPanel({
{
diff --git a/AgentCompany-New/src/renderer/src/pages/chat/ChatTimeline.tsx b/AgentCompany-New/src/renderer/src/pages/chat/ChatTimeline.tsx
index b87f2ff8..19c5c188 100644
--- a/AgentCompany-New/src/renderer/src/pages/chat/ChatTimeline.tsx
+++ b/AgentCompany-New/src/renderer/src/pages/chat/ChatTimeline.tsx
@@ -3,7 +3,9 @@ import { Button, Icon, Pill } from "../../shared/ui";
import type {
ApprovalRecord,
ChatAttachmentRecord,
+ ChatMentionRecord,
ChatMessageRecord,
+ ChatSlashInvocationRecord,
ChatThreadRecord,
FileObjectRecord,
} from "../../../../shared/domain";
@@ -16,6 +18,8 @@ export function ChatTimeline({
threadsInSpace,
pendingApprovals,
attachments,
+ mentions,
+ slashInvocations,
files,
context,
onOpenThread,
@@ -27,6 +31,8 @@ export function ChatTimeline({
threadsInSpace: ChatThreadRecord[];
pendingApprovals: ApprovalRecord[];
attachments: ChatAttachmentRecord[];
+ mentions: ChatMentionRecord[];
+ slashInvocations: ChatSlashInvocationRecord[];
files: FileObjectRecord[];
context: MessageContext;
onOpenThread: (threadId: string) => void;
@@ -50,6 +56,8 @@ export function ChatTimeline({
key={message.id}
message={message}
attachments={attachments}
+ mentions={mentions}
+ slashInvocations={slashInvocations}
files={files}
replyCount={replyCount}
onOpenThread={thread ? () => onOpenThread(thread.id) : null}
diff --git a/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx b/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx
index 0fd2a922..8cfc7741 100644
--- a/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx
+++ b/AgentCompany-New/src/renderer/src/pages/settings/ProvidersPanel.tsx
@@ -179,7 +179,6 @@ function CloudFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; pro
name="cloudBaseUrl"
value={state.baseUrl}
onChange={(event) => update({ baseUrl: event.target.value })}
- placeholder="https://"
/>
diff --git a/AgentCompany-New/src/shared/domain.ts b/AgentCompany-New/src/shared/domain.ts
index 96708c44..50bb97ce 100644
--- a/AgentCompany-New/src/shared/domain.ts
+++ b/AgentCompany-New/src/shared/domain.ts
@@ -216,6 +216,23 @@ export interface ChatAttachmentRecord {
previewKey: string | null;
}
+export interface ChatMentionRecord {
+ messageId: string;
+ targetType: "employee" | "agent" | "board" | "org_group";
+ targetId: string;
+ offsetStart: number;
+ offsetEnd: number;
+}
+
+export interface ChatSlashInvocationRecord {
+ id: string;
+ messageId: string;
+ command: string;
+ argsJson: string;
+ resultRef: string | null;
+ createdAt: string;
+}
+
export interface ChatPresenceRecord {
principalType: string;
principalId: string;
@@ -426,6 +443,8 @@ export interface AppSnapshot {
chatMessages: ChatMessageRecord[];
chatThreads: ChatThreadRecord[];
chatAttachments: ChatAttachmentRecord[];
+ chatMentions: ChatMentionRecord[];
+ chatSlashInvocations: ChatSlashInvocationRecord[];
chatPresence: ChatPresenceRecord[];
chatReadStates: ChatReadStateRecord[];
files: FileObjectRecord[];
diff --git a/AgentCompany-New/tests/e2e/desktop.spec.ts b/AgentCompany-New/tests/e2e/desktop.spec.ts
index 3ab1fe22..502f84ae 100644
--- a/AgentCompany-New/tests/e2e/desktop.spec.ts
+++ b/AgentCompany-New/tests/e2e/desktop.spec.ts
@@ -57,9 +57,10 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await page.getByLabel("Topic").fill("Release coordination");
await page.locator(".modal").getByRole("button", { name: "Create" }).click();
await expect(page.getByText("release").first()).toBeVisible();
- await page.getByRole("textbox", { name: "Message" }).fill("Release channel opened.");
+ await page.getByRole("textbox", { name: "Message" }).fill("/handoff @Board review release plan");
await page.getByRole("button", { name: "Send" }).click();
- await expect(page.getByText("Release channel opened.")).toBeVisible();
+ await expect(page.getByText("@Board").first()).toBeVisible();
+ await expect(page.getByText("/handoff").first()).toBeVisible();
await page.getByRole("button", { name: "Social" }).click();
await page.getByRole("button", { name: "Connect account" }).click();
diff --git a/AgentCompany-New/tests/unit/domain-services.test.ts b/AgentCompany-New/tests/unit/domain-services.test.ts
index 226714f1..aca4cc3d 100644
--- a/AgentCompany-New/tests/unit/domain-services.test.ts
+++ b/AgentCompany-New/tests/unit/domain-services.test.ts
@@ -235,6 +235,8 @@ describe("domain services", () => {
expect(state.chatMessages).toHaveLength(1);
expect(state.chatThreads).toHaveLength(1);
expect(state.chatAttachments).toHaveLength(1);
+ expect(state.chatMentions[0]).toMatchObject({ targetType: "board", targetId: orgId });
+ expect(state.chatSlashInvocations[0]).toMatchObject({ command: "handoff" });
expect(state.chatPresence).toHaveLength(1);
const slash = connection.db.prepare("select command from chat_slash_invocations").get() as { command: string };
const mention = connection.db.prepare("select target_type, target_id from chat_mentions").get() as { target_type: string; target_id: string };