Add board thread interventions

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 17:14:09 -04:00
1 parent 28007b3691
commit 78dbcbe7ce
14 files changed
+990 -370

No files matched your search

@@ -38,6 +38,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
() => snapshot.chatSlashInvocations.filter((invocation) => messageIds.has(invocation.messageId)),
[snapshot.chatSlashInvocations, messageIds],
);
const boardInterventionsAll = useMemo(
() => snapshot.boardInterventions.filter((item) => item.orgId === orgId),
[snapshot.boardInterventions, orgId],
);
const approvalsAll = useMemo(
() => snapshot.approvals.filter((a) => a.orgId === orgId && a.state === "pending"),
[snapshot.approvals, orgId],
@@ -93,6 +97,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
() => (selectedThread ? replayEventsForThread(auditEventsAll, selectedThread, messagesInSpace) : []),
[auditEventsAll, selectedThread, messagesInSpace],
);
const selectedBoardInterventions = useMemo(
() => (selectedThread ? boardInterventionsAll.filter((item) => item.threadId === selectedThread.id) : []),
[boardInterventionsAll, selectedThread],
);
const participants = useMemo(
() => collectParticipants(messagesInSpace, employees, agents),
[messagesInSpace, employees, agents],
@@ -136,6 +144,27 @@ export function ChatPage({ snapshot, actions }: PageProps) {
}));
};
const sendBoardIntervention = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!activeSpace || !selectedThread) return;
const form = new FormData(event.currentTarget);
const action = value(form, "action");
const target = value(form, "target");
const details = value(form, "details");
if (!action || !details) return;
event.currentTarget.reset();
const targetMention = action === "takeover" && target && target !== "board" ? ` @${target}` : "";
void actions.run(() => window.agentCompany.sendMessage({
orgId,
spaceId: activeSpace.id,
threadId: selectedThread.id,
senderType: "board",
senderId: employee.id,
bodyMd: `/board ${action}${targetMention} ${details}`,
attachmentFileIds: [],
}));
};
const createSpace = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
@@ -204,16 +233,19 @@ export function ChatPage({ snapshot, actions }: PageProps) {
{selectedThread && (
<ChatThreadPanel
thread={selectedThread}
rootMessage={messagesInSpace.find((m) => m.id === selectedThread.rootMessageId) ?? null}
messages={threadMessages}
attachments={snapshot.chatAttachments}
mentions={mentionsAll}
slashInvocations={slashInvocationsAll}
boardInterventions={selectedBoardInterventions}
files={files}
auditEvents={threadAuditEvents}
context={{ employees, agents }}
onClose={() => setOpenThreadId(null)}
onSubmit={sendInThread}
onBoardSubmit={sendBoardIntervention}
/>
)}
@@ -1,11 +1,13 @@
import type { FormEvent } from "react";
import { Button, Icon, IconButton } from "../../shared/ui";
import { useState, type FormEvent } from "react";
import { Button, Icon, IconButton, Pill } from "../../shared/ui";
import type {
AuditEventRecord,
BoardInterventionRecord,
ChatAttachmentRecord,
ChatMentionRecord,
ChatMessageRecord,
ChatSlashInvocationRecord,
ChatThreadRecord,
FileObjectRecord,
} from "../../../../shared/domain";
import { formatTime } from "../types";
@@ -13,27 +15,33 @@ import { ChatMessageRow } from "./ChatMessageRow";
import { senderName, type MessageContext } from "./chat-helpers";
export function ChatThreadPanel({
thread,
rootMessage,
messages,
attachments,
mentions,
slashInvocations,
boardInterventions,
files,
auditEvents,
context,
onClose,
onSubmit,
onBoardSubmit,
}: {
thread: ChatThreadRecord;
rootMessage: ChatMessageRecord | null;
messages: ChatMessageRecord[];
attachments: ChatAttachmentRecord[];
mentions: ChatMentionRecord[];
slashInvocations: ChatSlashInvocationRecord[];
boardInterventions: BoardInterventionRecord[];
files: FileObjectRecord[];
auditEvents: AuditEventRecord[];
context: MessageContext;
onClose: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onBoardSubmit: (event: FormEvent<HTMLFormElement>) => void;
}) {
return (
<aside
@@ -49,11 +57,18 @@ export function ChatThreadPanel({
>
<div className="row" style={{ padding: "14px 16px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0 }}>
<div style={{ fontSize: 12.5, fontWeight: 600 }}>Thread</div>
<Pill tone={boardStateTone(thread.boardState)}>{boardStateLabel(thread.boardState)}</Pill>
<div style={{ marginLeft: "auto" }}>
<IconButton icon="x" label="Close thread" onClick={onClose} />
</div>
</div>
<div style={{ padding: 16, overflow: "auto", flex: 1 }}>
<BoardControl
thread={thread}
interventions={boardInterventions}
context={context}
onSubmit={onBoardSubmit}
/>
{rootMessage && (
<>
<div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 10 }}>
@@ -113,6 +128,90 @@ export function ChatThreadPanel({
);
}
function BoardControl({
thread,
interventions,
context,
onSubmit,
}: {
thread: ChatThreadRecord;
interventions: BoardInterventionRecord[];
context: MessageContext;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
}) {
const [action, setAction] = useState("decision");
return (
<section style={{ border: "1px solid var(--border)", borderRadius: 10, padding: 10, background: "var(--panel)", marginBottom: 16 }}>
<div className="row" style={{ marginBottom: 8 }}>
<div className="section-label">Board</div>
<Pill tone={boardStateTone(thread.boardState)}>{boardStateLabel(thread.boardState)}</Pill>
</div>
<form onSubmit={onSubmit} style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<label className="field" style={{ margin: 0 }}>
<span>Action</span>
<select className="input" name="action" value={action} onChange={(event) => setAction(event.target.value)}>
<option value="decision">Decision</option>
<option value="freeze">Freeze</option>
<option value="takeover">Takeover</option>
<option value="rollback">Rollback</option>
<option value="force-approval">Force approval</option>
</select>
</label>
<label className="field" style={{ margin: 0 }}>
<span>Target</span>
<select className="input" name="target" defaultValue="board" disabled={action !== "takeover"}>
<option value="board">Board</option>
{context.employees.map((employee) => (
<option key={employee.id} value={employee.id}>Employee · {employee.displayName}</option>
))}
{context.agents.map((agent) => (
<option key={agent.id} value={agent.id}>Agent · {agent.name}</option>
))}
</select>
</label>
<label className="field" style={{ gridColumn: "1 / -1", margin: 0 }}>
<span>Details</span>
<input className="input" name="details" required aria-label="Board intervention details" />
</label>
<div className="row" style={{ gridColumn: "1 / -1", justifyContent: "flex-end" }}>
<Button tone="primary" size="small" type="submit"><Icon name="shield" size={12} /> Board</Button>
</div>
</form>
{interventions.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 7, marginTop: 10 }}>
{interventions.map((intervention) => (
<div key={intervention.id} style={{ borderTop: "1px solid var(--border-soft)", paddingTop: 7 }}>
<div className="row" style={{ gap: 6, marginBottom: 3 }}>
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{formatTime(intervention.createdAt)}</span>
<Pill tone={intervention.mode === "takeover" ? "violet" : intervention.action === "freeze" ? "red" : undefined}>
{formatBoardAction(intervention.action)}
</Pill>
</div>
<div style={{ fontSize: 12, color: "var(--text-2)", lineHeight: 1.4, wordBreak: "break-word" }}>{intervention.details}</div>
</div>
))}
</div>
)}
</section>
);
}
function boardStateTone(state: string): "red" | "violet" | undefined {
if (state === "frozen") return "red";
if (state === "takeover") return "violet";
return undefined;
}
function boardStateLabel(state: string): string {
if (state === "frozen") return "Frozen";
if (state === "takeover") return "Takeover";
return "Open";
}
function formatBoardAction(action: string): string {
return action.replace(/_/g, " ");
}
function AuditReplay({ events }: { events: AuditEventRecord[] }) {
return (
<section style={{ borderTop: "1px solid var(--border-soft)", marginTop: 18, paddingTop: 14 }}>