Expose chat command metadata in snapshots

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 15:39:54 -04:00
1 parent 0b19a152e6
commit d89bbf708e
13 files changed
+113 -24

No files matched your search

@@ -63,6 +63,8 @@ export class SnapshotService {
chatMessages: list(this.db, "chat_messages", "created_at asc"),
chatThreads: list(this.db, "chat_threads", "updated_at desc"),
chatAttachments: list(this.db, "chat_attachments", "message_id asc"),
chatMentions: list(this.db, "chat_mentions", "message_id asc, offset_start asc"),
chatSlashInvocations: list(this.db, "chat_slash_invocations", "created_at asc"),
chatPresence: list(this.db, "chat_presence", "last_seen_at desc"),
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
files,
@@ -129,8 +129,7 @@ export function Onboarding({
</div>
<h1 className="page-h1" style={{ fontSize: 34, marginTop: 32, letterSpacing: "-0.02em" }}>Start your AI company.</h1>
<p className="page-sub" style={{ maxWidth: 460, fontSize: 14, lineHeight: 1.55 }}>
Stand up the workspace, bind a runtime, and queue the first operating loop. Real data
from your provider, hairline UI, zero placeholder noise.
Stand up the workspace, bind the provider, and queue the first operating loop.
</p>
<div className="onboarding-steps">
{STEPS.map((entry) => (
@@ -326,7 +325,7 @@ function CompanyStep({ draft, onChange, onBack, onNext }: StepProps) {
<textarea className="input" value={draft.companyDescription} onChange={(event) => onChange({ companyDescription: event.target.value })} required />
</Field>
<Field label="Workspace path (optional)">
<input className="input" value={draft.workspacePath} onChange={(event) => onChange({ workspacePath: event.target.value })} placeholder="/Users/yu/Projects/orbitops" />
<input className="input" value={draft.workspacePath} onChange={(event) => onChange({ workspacePath: event.target.value })} />
</Field>
<Actions onBack={onBack} onNext={onNext} />
</div>
@@ -28,6 +28,15 @@ export function ChatPage({ snapshot, actions }: PageProps) {
const files = useMemo(() => snapshot.files.filter((item) => item.orgId === orgId), [snapshot.files, orgId]);
const messagesAll = useMemo(() => snapshot.chatMessages.filter((m) => m.orgId === orgId), [snapshot.chatMessages, orgId]);
const threadsAll = useMemo(() => snapshot.chatThreads.filter((t) => t.orgId === orgId), [snapshot.chatThreads, orgId]);
const messageIds = useMemo(() => new Set(messagesAll.map((message) => message.id)), [messagesAll]);
const mentionsAll = useMemo(
() => snapshot.chatMentions.filter((mention) => messageIds.has(mention.messageId)),
[snapshot.chatMentions, messageIds],
);
const slashInvocationsAll = useMemo(
() => snapshot.chatSlashInvocations.filter((invocation) => messageIds.has(invocation.messageId)),
[snapshot.chatSlashInvocations, messageIds],
);
const approvalsAll = useMemo(
() => snapshot.approvals.filter((a) => a.orgId === orgId && a.state === "pending"),
[snapshot.approvals, orgId],
@@ -163,6 +172,8 @@ export function ChatPage({ snapshot, actions }: PageProps) {
threadsInSpace={threadsInSpace}
pendingApprovals={pendingApprovalsInSpace}
attachments={snapshot.chatAttachments}
mentions={mentionsAll}
slashInvocations={slashInvocationsAll}
files={files}
context={{ employees, agents }}
attachmentFileId={attachmentFileId}
@@ -179,6 +190,8 @@ export function ChatPage({ snapshot, actions }: PageProps) {
rootMessage={messagesInSpace.find((m) => m.id === selectedThread.rootMessageId) ?? null}
messages={threadMessages}
attachments={snapshot.chatAttachments}
mentions={mentionsAll}
slashInvocations={slashInvocationsAll}
files={files}
context={{ employees, agents }}
onClose={() => setOpenThreadId(null)}
@@ -105,7 +105,7 @@ export function TeamPage({ snapshot, actions }: PageProps) {
<Dialog title="Hire agent" open={hireOpen} submitLabel="Hire" onClose={() => setHireOpen(false)} onSubmit={submit}>
<Field label="Name"><input className="input" name="name" required /></Field>
<Field label="Role"><input className="input" name="role" required /></Field>
<Field label="Title"><input className="input" name="title" placeholder="Optional" /></Field>
<Field label="Title"><input className="input" name="title" /></Field>
<Field label="Department"><input className="input" name="department" required /></Field>
<Field label="Provider">
<select className="input" name="modelProviderId">
@@ -118,7 +118,6 @@ export function TeamPage({ snapshot, actions }: PageProps) {
className="input"
name="modelId"
list="agent-model-suggestions"
placeholder="Leave blank to inherit provider default"
/>
</Field>
<datalist id="agent-model-suggestions">
@@ -4,7 +4,6 @@ import type { FileObjectRecord } from "../../../../shared/domain";
import { SLASH_COMMANDS } from "./chat-helpers";
export function ChatComposer({
spaceName,
disabled,
files,
attachmentFileId,
@@ -12,7 +11,6 @@ export function ChatComposer({
onSubmit,
composerRef,
}: {
spaceName: string;
disabled: boolean;
files: FileObjectRecord[];
attachmentFileId: string;
@@ -42,7 +40,6 @@ export function ChatComposer({
ref={composerRef}
name="message"
rows={2}
placeholder={spaceName ? `Message #${spaceName}…` : "Select a channel"}
disabled={disabled}
aria-label="Message"
style={{
@@ -77,7 +74,6 @@ export function ChatComposer({
{files.map((file) => <option key={file.id} value={file.id}>{file.name}</option>)}
</select>
<IconButton icon="sparkle" label="Commands" onClick={() => setSlashOpen((open) => !open)} />
<span style={{ fontSize: 11, color: "var(--text-3)", marginLeft: 6 }}>/ to invoke a command</span>
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>⌘↵</span>
<Button tone="primary" size="small" type="submit" disabled={disabled}><Icon name="send" size={12} /> Send</Button>
@@ -3,8 +3,10 @@ import { AvatarStack, Icon, IconButton } from "../../shared/ui";
import type {
ApprovalRecord,
ChatAttachmentRecord,
ChatMentionRecord,
ChatMessageRecord,
ChatSpaceRecord,
ChatSlashInvocationRecord,
ChatThreadRecord,
FileObjectRecord,
} from "../../../../shared/domain";
@@ -20,6 +22,8 @@ export function ChatConversation({
threadsInSpace,
pendingApprovals,
attachments,
mentions,
slashInvocations,
files,
context,
attachmentFileId,
@@ -37,6 +41,8 @@ export function ChatConversation({
threadsInSpace: ChatThreadRecord[];
pendingApprovals: ApprovalRecord[];
attachments: ChatAttachmentRecord[];
mentions: ChatMentionRecord[];
slashInvocations: ChatSlashInvocationRecord[];
files: FileObjectRecord[];
context: MessageContext;
attachmentFileId: string;
@@ -58,6 +64,8 @@ export function ChatConversation({
threadsInSpace={threadsInSpace}
pendingApprovals={pendingApprovals}
attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
files={files}
context={context}
onOpenThread={onOpenThread}
@@ -67,7 +75,6 @@ export function ChatConversation({
)}
</div>
<ChatComposer
spaceName={space?.name ?? ""}
disabled={!space}
files={files}
attachmentFileId={attachmentFileId}
@@ -114,4 +121,3 @@ function ConversationHeader({
</header>
);
}
@@ -2,7 +2,9 @@ import type { ReactNode } from "react";
import { Avatar, Icon, IconButton } from "../../shared/ui";
import type {
ChatAttachmentRecord,
ChatMentionRecord,
ChatMessageRecord,
ChatSlashInvocationRecord,
FileObjectRecord,
} from "../../../../shared/domain";
import { formatTime } from "../types";
@@ -11,6 +13,8 @@ import { messageAttachments, senderName, type MessageContext } from "./chat-help
export function ChatMessageRow({
message,
attachments,
mentions,
slashInvocations,
files,
replyCount,
onOpenThread,
@@ -19,6 +23,8 @@ export function ChatMessageRow({
}: {
message: ChatMessageRecord;
attachments: ChatAttachmentRecord[];
mentions: ChatMentionRecord[];
slashInvocations: ChatSlashInvocationRecord[];
files: FileObjectRecord[];
replyCount: number;
onOpenThread: (() => void) | null;
@@ -28,6 +34,8 @@ export function ChatMessageRow({
const name = senderName(message, context);
const bot = message.senderType === "agent";
const messageFiles = messageAttachments(message.id, attachments, files);
const messageMentions = mentions.filter((mention) => mention.messageId === message.id);
const slashInvocation = slashInvocations.find((invocation) => invocation.messageId === message.id) ?? null;
return (
<div style={{ display: "flex", gap: compact ? 10 : 12, marginBottom: compact ? 12 : 18 }}>
<Avatar name={name} size={compact ? "sm" : ""} bot={bot} />
@@ -36,8 +44,13 @@ export function ChatMessageRow({
<span style={{ fontSize: 13, fontWeight: 540 }}>{name}</span>
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{formatTime(message.createdAt)}</span>
</div>
{slashInvocation && (
<div className="pill mono" style={{ marginBottom: 6, width: "fit-content" }}>
/{slashInvocation.command}
</div>
)}
<div style={{ fontSize: 13, color: "var(--text-1)", lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{renderBody(message.bodyMd)}
{renderBody(message.bodyMd, messageMentions, context)}
</div>
{messageFiles.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
@@ -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(
<span
key={`m-${match.index}`}
key={`m-${mention.messageId}-${mention.offsetStart}`}
style={{ background: "var(--accent-soft)", color: "var(--accent)", padding: "1px 5px", borderRadius: 4, fontWeight: 500 }}
>
@{match[1]}
@{label}
</span>,
);
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 (
<div
@@ -2,7 +2,9 @@ import type { FormEvent } from "react";
import { Button, Icon, IconButton } from "../../shared/ui";
import type {
ChatAttachmentRecord,
ChatMentionRecord,
ChatMessageRecord,
ChatSlashInvocationRecord,
FileObjectRecord,
} from "../../../../shared/domain";
import { formatTime } from "../types";
@@ -13,6 +15,8 @@ export function ChatThreadPanel({
rootMessage,
messages,
attachments,
mentions,
slashInvocations,
files,
context,
onClose,
@@ -21,6 +25,8 @@ export function ChatThreadPanel({
rootMessage: ChatMessageRecord | null;
messages: ChatMessageRecord[];
attachments: ChatAttachmentRecord[];
mentions: ChatMentionRecord[];
slashInvocations: ChatSlashInvocationRecord[];
files: FileObjectRecord[];
context: MessageContext;
onClose: () => void;
@@ -54,6 +60,8 @@ export function ChatThreadPanel({
<ChatMessageRow
message={rootMessage}
attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
files={files}
replyCount={0}
onOpenThread={null}
@@ -68,6 +76,8 @@ export function ChatThreadPanel({
key={message.id}
message={message}
attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
files={files}
replyCount={0}
onOpenThread={null}
@@ -81,7 +91,6 @@ export function ChatThreadPanel({
<textarea
name="message"
rows={2}
placeholder="Reply in thread…"
aria-label="Thread reply"
style={{ width: "100%", border: 0, resize: "none", outline: "none", fontSize: 13, background: "transparent", padding: 0, minHeight: 36 }}
onKeyDown={(event) => {
@@ -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}
@@ -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://"
/>
</Field>
<Field label="Region">
+19
View File
@@ -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[];
+3 -2
View File
@@ -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();
@@ -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 };