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"), chatMessages: list(this.db, "chat_messages", "created_at asc"),
chatThreads: list(this.db, "chat_threads", "updated_at desc"), chatThreads: list(this.db, "chat_threads", "updated_at desc"),
chatAttachments: list(this.db, "chat_attachments", "message_id asc"), 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"), chatPresence: list(this.db, "chat_presence", "last_seen_at desc"),
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"), chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
files, files,
@@ -129,8 +129,7 @@ export function Onboarding({
</div> </div>
<h1 className="page-h1" style={{ fontSize: 34, marginTop: 32, letterSpacing: "-0.02em" }}>Start your AI company.</h1> <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 }}> <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 Stand up the workspace, bind the provider, and queue the first operating loop.
from your provider, hairline UI, zero placeholder noise.
</p> </p>
<div className="onboarding-steps"> <div className="onboarding-steps">
{STEPS.map((entry) => ( {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 /> <textarea className="input" value={draft.companyDescription} onChange={(event) => onChange({ companyDescription: event.target.value })} required />
</Field> </Field>
<Field label="Workspace path (optional)"> <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> </Field>
<Actions onBack={onBack} onNext={onNext} /> <Actions onBack={onBack} onNext={onNext} />
</div> </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 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 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 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( const approvalsAll = useMemo(
() => snapshot.approvals.filter((a) => a.orgId === orgId && a.state === "pending"), () => snapshot.approvals.filter((a) => a.orgId === orgId && a.state === "pending"),
[snapshot.approvals, orgId], [snapshot.approvals, orgId],
@@ -163,6 +172,8 @@ export function ChatPage({ snapshot, actions }: PageProps) {
threadsInSpace={threadsInSpace} threadsInSpace={threadsInSpace}
pendingApprovals={pendingApprovalsInSpace} pendingApprovals={pendingApprovalsInSpace}
attachments={snapshot.chatAttachments} attachments={snapshot.chatAttachments}
mentions={mentionsAll}
slashInvocations={slashInvocationsAll}
files={files} files={files}
context={{ employees, agents }} context={{ employees, agents }}
attachmentFileId={attachmentFileId} attachmentFileId={attachmentFileId}
@@ -179,6 +190,8 @@ export function ChatPage({ snapshot, actions }: PageProps) {
rootMessage={messagesInSpace.find((m) => m.id === selectedThread.rootMessageId) ?? null} rootMessage={messagesInSpace.find((m) => m.id === selectedThread.rootMessageId) ?? null}
messages={threadMessages} messages={threadMessages}
attachments={snapshot.chatAttachments} attachments={snapshot.chatAttachments}
mentions={mentionsAll}
slashInvocations={slashInvocationsAll}
files={files} files={files}
context={{ employees, agents }} context={{ employees, agents }}
onClose={() => setOpenThreadId(null)} 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}> <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="Name"><input className="input" name="name" required /></Field>
<Field label="Role"><input className="input" name="role" 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="Department"><input className="input" name="department" required /></Field>
<Field label="Provider"> <Field label="Provider">
<select className="input" name="modelProviderId"> <select className="input" name="modelProviderId">
@@ -118,7 +118,6 @@ export function TeamPage({ snapshot, actions }: PageProps) {
className="input" className="input"
name="modelId" name="modelId"
list="agent-model-suggestions" list="agent-model-suggestions"
placeholder="Leave blank to inherit provider default"
/> />
</Field> </Field>
<datalist id="agent-model-suggestions"> <datalist id="agent-model-suggestions">
@@ -4,7 +4,6 @@ import type { FileObjectRecord } from "../../../../shared/domain";
import { SLASH_COMMANDS } from "./chat-helpers"; import { SLASH_COMMANDS } from "./chat-helpers";
export function ChatComposer({ export function ChatComposer({
spaceName,
disabled, disabled,
files, files,
attachmentFileId, attachmentFileId,
@@ -12,7 +11,6 @@ export function ChatComposer({
onSubmit, onSubmit,
composerRef, composerRef,
}: { }: {
spaceName: string;
disabled: boolean; disabled: boolean;
files: FileObjectRecord[]; files: FileObjectRecord[];
attachmentFileId: string; attachmentFileId: string;
@@ -42,7 +40,6 @@ export function ChatComposer({
ref={composerRef} ref={composerRef}
name="message" name="message"
rows={2} rows={2}
placeholder={spaceName ? `Message #${spaceName}…` : "Select a channel"}
disabled={disabled} disabled={disabled}
aria-label="Message" aria-label="Message"
style={{ style={{
@@ -77,7 +74,6 @@ export function ChatComposer({
{files.map((file) => <option key={file.id} value={file.id}>{file.name}</option>)} {files.map((file) => <option key={file.id} value={file.id}>{file.name}</option>)}
</select> </select>
<IconButton icon="sparkle" label="Commands" onClick={() => setSlashOpen((open) => !open)} /> <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 }}> <div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>⌘↵</span> <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> <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 { import type {
ApprovalRecord, ApprovalRecord,
ChatAttachmentRecord, ChatAttachmentRecord,
ChatMentionRecord,
ChatMessageRecord, ChatMessageRecord,
ChatSpaceRecord, ChatSpaceRecord,
ChatSlashInvocationRecord,
ChatThreadRecord, ChatThreadRecord,
FileObjectRecord, FileObjectRecord,
} from "../../../../shared/domain"; } from "../../../../shared/domain";
@@ -20,6 +22,8 @@ export function ChatConversation({
threadsInSpace, threadsInSpace,
pendingApprovals, pendingApprovals,
attachments, attachments,
mentions,
slashInvocations,
files, files,
context, context,
attachmentFileId, attachmentFileId,
@@ -37,6 +41,8 @@ export function ChatConversation({
threadsInSpace: ChatThreadRecord[]; threadsInSpace: ChatThreadRecord[];
pendingApprovals: ApprovalRecord[]; pendingApprovals: ApprovalRecord[];
attachments: ChatAttachmentRecord[]; attachments: ChatAttachmentRecord[];
mentions: ChatMentionRecord[];
slashInvocations: ChatSlashInvocationRecord[];
files: FileObjectRecord[]; files: FileObjectRecord[];
context: MessageContext; context: MessageContext;
attachmentFileId: string; attachmentFileId: string;
@@ -58,6 +64,8 @@ export function ChatConversation({
threadsInSpace={threadsInSpace} threadsInSpace={threadsInSpace}
pendingApprovals={pendingApprovals} pendingApprovals={pendingApprovals}
attachments={attachments} attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
files={files} files={files}
context={context} context={context}
onOpenThread={onOpenThread} onOpenThread={onOpenThread}
@@ -67,7 +75,6 @@ export function ChatConversation({
)} )}
</div> </div>
<ChatComposer <ChatComposer
spaceName={space?.name ?? ""}
disabled={!space} disabled={!space}
files={files} files={files}
attachmentFileId={attachmentFileId} attachmentFileId={attachmentFileId}
@@ -114,4 +121,3 @@ function ConversationHeader({
</header> </header>
); );
} }
@@ -2,7 +2,9 @@ import type { ReactNode } from "react";
import { Avatar, Icon, IconButton } from "../../shared/ui"; import { Avatar, Icon, IconButton } from "../../shared/ui";
import type { import type {
ChatAttachmentRecord, ChatAttachmentRecord,
ChatMentionRecord,
ChatMessageRecord, ChatMessageRecord,
ChatSlashInvocationRecord,
FileObjectRecord, FileObjectRecord,
} from "../../../../shared/domain"; } from "../../../../shared/domain";
import { formatTime } from "../types"; import { formatTime } from "../types";
@@ -11,6 +13,8 @@ import { messageAttachments, senderName, type MessageContext } from "./chat-help
export function ChatMessageRow({ export function ChatMessageRow({
message, message,
attachments, attachments,
mentions,
slashInvocations,
files, files,
replyCount, replyCount,
onOpenThread, onOpenThread,
@@ -19,6 +23,8 @@ export function ChatMessageRow({
}: { }: {
message: ChatMessageRecord; message: ChatMessageRecord;
attachments: ChatAttachmentRecord[]; attachments: ChatAttachmentRecord[];
mentions: ChatMentionRecord[];
slashInvocations: ChatSlashInvocationRecord[];
files: FileObjectRecord[]; files: FileObjectRecord[];
replyCount: number; replyCount: number;
onOpenThread: (() => void) | null; onOpenThread: (() => void) | null;
@@ -28,6 +34,8 @@ export function ChatMessageRow({
const name = senderName(message, context); const name = senderName(message, context);
const bot = message.senderType === "agent"; const bot = message.senderType === "agent";
const messageFiles = messageAttachments(message.id, attachments, files); 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 ( return (
<div style={{ display: "flex", gap: compact ? 10 : 12, marginBottom: compact ? 12 : 18 }}> <div style={{ display: "flex", gap: compact ? 10 : 12, marginBottom: compact ? 12 : 18 }}>
<Avatar name={name} size={compact ? "sm" : ""} bot={bot} /> <Avatar name={name} size={compact ? "sm" : ""} bot={bot} />
@@ -36,8 +44,13 @@ export function ChatMessageRow({
<span style={{ fontSize: 13, fontWeight: 540 }}>{name}</span> <span style={{ fontSize: 13, fontWeight: 540 }}>{name}</span>
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{formatTime(message.createdAt)}</span> <span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{formatTime(message.createdAt)}</span>
</div> </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" }}> <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> </div>
{messageFiles.length > 0 && ( {messageFiles.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}> <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 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 lastIndex = 0;
let match; for (const mention of ordered) {
while ((match = regex.exec(body)) !== null) { if (mention.offsetStart < lastIndex) throw new Error("Message mention offsets overlap.");
if (match.index > lastIndex) parts.push(body.slice(lastIndex, match.index)); 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( parts.push(
<span <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 }} style={{ background: "var(--accent-soft)", color: "var(--accent)", padding: "1px 5px", borderRadius: 4, fontWeight: 500 }}
> >
@{match[1]} @{label}
</span>, </span>,
); );
lastIndex = match.index + match[0].length; lastIndex = mention.offsetEnd;
} }
if (lastIndex < body.length) parts.push(body.slice(lastIndex)); if (lastIndex < body.length) parts.push(body.slice(lastIndex));
return parts; 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 }) { function AttachmentCard({ file }: { file: FileObjectRecord }) {
return ( return (
<div <div
@@ -2,7 +2,9 @@ import type { FormEvent } from "react";
import { Button, Icon, IconButton } from "../../shared/ui"; import { Button, Icon, IconButton } from "../../shared/ui";
import type { import type {
ChatAttachmentRecord, ChatAttachmentRecord,
ChatMentionRecord,
ChatMessageRecord, ChatMessageRecord,
ChatSlashInvocationRecord,
FileObjectRecord, FileObjectRecord,
} from "../../../../shared/domain"; } from "../../../../shared/domain";
import { formatTime } from "../types"; import { formatTime } from "../types";
@@ -13,6 +15,8 @@ export function ChatThreadPanel({
rootMessage, rootMessage,
messages, messages,
attachments, attachments,
mentions,
slashInvocations,
files, files,
context, context,
onClose, onClose,
@@ -21,6 +25,8 @@ export function ChatThreadPanel({
rootMessage: ChatMessageRecord | null; rootMessage: ChatMessageRecord | null;
messages: ChatMessageRecord[]; messages: ChatMessageRecord[];
attachments: ChatAttachmentRecord[]; attachments: ChatAttachmentRecord[];
mentions: ChatMentionRecord[];
slashInvocations: ChatSlashInvocationRecord[];
files: FileObjectRecord[]; files: FileObjectRecord[];
context: MessageContext; context: MessageContext;
onClose: () => void; onClose: () => void;
@@ -54,6 +60,8 @@ export function ChatThreadPanel({
<ChatMessageRow <ChatMessageRow
message={rootMessage} message={rootMessage}
attachments={attachments} attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
files={files} files={files}
replyCount={0} replyCount={0}
onOpenThread={null} onOpenThread={null}
@@ -68,6 +76,8 @@ export function ChatThreadPanel({
key={message.id} key={message.id}
message={message} message={message}
attachments={attachments} attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
files={files} files={files}
replyCount={0} replyCount={0}
onOpenThread={null} onOpenThread={null}
@@ -81,7 +91,6 @@ export function ChatThreadPanel({
<textarea <textarea
name="message" name="message"
rows={2} rows={2}
placeholder="Reply in thread…"
aria-label="Thread reply" aria-label="Thread reply"
style={{ width: "100%", border: 0, resize: "none", outline: "none", fontSize: 13, background: "transparent", padding: 0, minHeight: 36 }} style={{ width: "100%", border: 0, resize: "none", outline: "none", fontSize: 13, background: "transparent", padding: 0, minHeight: 36 }}
onKeyDown={(event) => { onKeyDown={(event) => {
@@ -3,7 +3,9 @@ import { Button, Icon, Pill } from "../../shared/ui";
import type { import type {
ApprovalRecord, ApprovalRecord,
ChatAttachmentRecord, ChatAttachmentRecord,
ChatMentionRecord,
ChatMessageRecord, ChatMessageRecord,
ChatSlashInvocationRecord,
ChatThreadRecord, ChatThreadRecord,
FileObjectRecord, FileObjectRecord,
} from "../../../../shared/domain"; } from "../../../../shared/domain";
@@ -16,6 +18,8 @@ export function ChatTimeline({
threadsInSpace, threadsInSpace,
pendingApprovals, pendingApprovals,
attachments, attachments,
mentions,
slashInvocations,
files, files,
context, context,
onOpenThread, onOpenThread,
@@ -27,6 +31,8 @@ export function ChatTimeline({
threadsInSpace: ChatThreadRecord[]; threadsInSpace: ChatThreadRecord[];
pendingApprovals: ApprovalRecord[]; pendingApprovals: ApprovalRecord[];
attachments: ChatAttachmentRecord[]; attachments: ChatAttachmentRecord[];
mentions: ChatMentionRecord[];
slashInvocations: ChatSlashInvocationRecord[];
files: FileObjectRecord[]; files: FileObjectRecord[];
context: MessageContext; context: MessageContext;
onOpenThread: (threadId: string) => void; onOpenThread: (threadId: string) => void;
@@ -50,6 +56,8 @@ export function ChatTimeline({
key={message.id} key={message.id}
message={message} message={message}
attachments={attachments} attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
files={files} files={files}
replyCount={replyCount} replyCount={replyCount}
onOpenThread={thread ? () => onOpenThread(thread.id) : null} onOpenThread={thread ? () => onOpenThread(thread.id) : null}
@@ -179,7 +179,6 @@ function CloudFieldsForm({ entry, provider }: { entry: ProviderCatalogEntry; pro
name="cloudBaseUrl" name="cloudBaseUrl"
value={state.baseUrl} value={state.baseUrl}
onChange={(event) => update({ baseUrl: event.target.value })} onChange={(event) => update({ baseUrl: event.target.value })}
placeholder="https://"
/> />
</Field> </Field>
<Field label="Region"> <Field label="Region">
+19
View File
@@ -216,6 +216,23 @@ export interface ChatAttachmentRecord {
previewKey: string | null; 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 { export interface ChatPresenceRecord {
principalType: string; principalType: string;
principalId: string; principalId: string;
@@ -426,6 +443,8 @@ export interface AppSnapshot {
chatMessages: ChatMessageRecord[]; chatMessages: ChatMessageRecord[];
chatThreads: ChatThreadRecord[]; chatThreads: ChatThreadRecord[];
chatAttachments: ChatAttachmentRecord[]; chatAttachments: ChatAttachmentRecord[];
chatMentions: ChatMentionRecord[];
chatSlashInvocations: ChatSlashInvocationRecord[];
chatPresence: ChatPresenceRecord[]; chatPresence: ChatPresenceRecord[];
chatReadStates: ChatReadStateRecord[]; chatReadStates: ChatReadStateRecord[];
files: FileObjectRecord[]; 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.getByLabel("Topic").fill("Release coordination");
await page.locator(".modal").getByRole("button", { name: "Create" }).click(); await page.locator(".modal").getByRole("button", { name: "Create" }).click();
await expect(page.getByText("release").first()).toBeVisible(); 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 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: "Social" }).click();
await page.getByRole("button", { name: "Connect account" }).click(); await page.getByRole("button", { name: "Connect account" }).click();
@@ -235,6 +235,8 @@ describe("domain services", () => {
expect(state.chatMessages).toHaveLength(1); expect(state.chatMessages).toHaveLength(1);
expect(state.chatThreads).toHaveLength(1); expect(state.chatThreads).toHaveLength(1);
expect(state.chatAttachments).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); expect(state.chatPresence).toHaveLength(1);
const slash = connection.db.prepare("select command from chat_slash_invocations").get() as { command: string }; 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 }; const mention = connection.db.prepare("select target_type, target_id from chat_mentions").get() as { target_type: string; target_id: string };