Expose chat command metadata in snapshots
This commit is contained in:
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">
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user