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"),
|
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">
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
Reference in new issue
Block a user