Add board thread interventions
This commit is contained in:
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 }}>
|
||||
|
||||
Reference in new issue
Block a user