Add thread scoped audit replay

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 15:52:02 -04:00
1 parent d24cf6b289
commit ff6fcf879d
5 files changed
+79 -2

No files matched your search

@@ -8,6 +8,7 @@ import {
collectParticipants,
filterApprovalsForSpace,
isRootMessage,
replayEventsForThread,
} from "./chat/chat-helpers";
import type { PageProps } from "./types";
import { activeOrgId, requireActiveEmployee } from "./types";
@@ -41,6 +42,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
() => snapshot.approvals.filter((a) => a.orgId === orgId && a.state === "pending"),
[snapshot.approvals, orgId],
);
const auditEventsAll = useMemo(
() => snapshot.auditEvents.filter((event) => event.orgId === orgId),
[snapshot.auditEvents, orgId],
);
const channels = useMemo(() => spaces.filter((space) => space.spaceType !== "direct"), [spaces]);
const directs = useMemo(() => spaces.filter((space) => space.spaceType === "direct"), [spaces]);
@@ -84,6 +89,10 @@ export function ChatPage({ snapshot, actions }: PageProps) {
const threadMessages = selectedThread
? messagesInSpace.filter((m) => m.threadId === selectedThread.id && m.id !== selectedThread.rootMessageId)
: [];
const threadAuditEvents = useMemo(
() => (selectedThread ? replayEventsForThread(auditEventsAll, selectedThread, messagesInSpace) : []),
[auditEventsAll, selectedThread, messagesInSpace],
);
const participants = useMemo(
() => collectParticipants(messagesInSpace, employees, agents),
[messagesInSpace, employees, agents],
@@ -201,6 +210,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
mentions={mentionsAll}
slashInvocations={slashInvocationsAll}
files={files}
auditEvents={threadAuditEvents}
context={{ employees, agents }}
onClose={() => setOpenThreadId(null)}
onSubmit={sendInThread}
@@ -57,9 +57,10 @@ export function ChatMessageRow({
{messageFiles.map((file) => <AttachmentCard key={file.id} file={file} />)}
</div>
)}
{replyCount > 0 && onOpenThread && (
{onOpenThread && (
<button
type="button"
aria-label="Open thread"
onClick={onOpenThread}
style={{
marginTop: 8,
@@ -75,7 +76,7 @@ export function ChatMessageRow({
border: 0,
}}
>
<Icon name="chat" size={11} /> {replyCount} {replyCount === 1 ? "reply" : "replies"} →
<Icon name="chat" size={11} /> {replyCount > 0 ? `${replyCount} ${replyCount === 1 ? "reply" : "replies"}` : "Thread"} →
</button>
)}
</div>
@@ -1,6 +1,7 @@
import type { FormEvent } from "react";
import { Button, Icon, IconButton } from "../../shared/ui";
import type {
AuditEventRecord,
ChatAttachmentRecord,
ChatMentionRecord,
ChatMessageRecord,
@@ -18,6 +19,7 @@ export function ChatThreadPanel({
mentions,
slashInvocations,
files,
auditEvents,
context,
onClose,
onSubmit,
@@ -28,6 +30,7 @@ export function ChatThreadPanel({
mentions: ChatMentionRecord[];
slashInvocations: ChatSlashInvocationRecord[];
files: FileObjectRecord[];
auditEvents: AuditEventRecord[];
context: MessageContext;
onClose: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
@@ -85,6 +88,7 @@ export function ChatThreadPanel({
compact
/>
))}
{auditEvents.length > 0 && <AuditReplay events={auditEvents} />}
</div>
<form onSubmit={onSubmit} style={{ padding: 12, flexShrink: 0 }}>
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 10, background: "var(--panel)", padding: "8px 10px" }}>
@@ -108,3 +112,33 @@ export function ChatThreadPanel({
</aside>
);
}
function AuditReplay({ events }: { events: AuditEventRecord[] }) {
return (
<section style={{ borderTop: "1px solid var(--border-soft)", marginTop: 18, paddingTop: 14 }}>
<div className="section-label" style={{ marginBottom: 10 }}>Audit</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{events.map((event) => (
<div
key={event.id}
className="row"
style={{
alignItems: "baseline",
gap: 8,
border: "1px solid var(--border)",
borderRadius: 8,
padding: "8px 10px",
background: "var(--panel)",
minWidth: 0,
}}
>
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)", flexShrink: 0 }}>{formatTime(event.createdAt)}</span>
<span style={{ fontSize: 12, color: "var(--text-1)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{event.entityType}.{event.action}
</span>
</div>
))}
</div>
</section>
);
}
@@ -1,6 +1,7 @@
import type {
AgentRecord,
ApprovalRecord,
AuditEventRecord,
ChatAttachmentRecord,
ChatMessageRecord,
ChatSpaceRecord,
@@ -80,6 +81,33 @@ export function countMessagesIn(spaceId: string, messages: ChatMessageRecord[]):
return messages.filter((message) => message.spaceId === spaceId).length;
}
export function replayEventsForThread(
events: AuditEventRecord[],
thread: ChatThreadRecord,
messages: ChatMessageRecord[],
): AuditEventRecord[] {
const messageIds = new Set(
messages
.filter((message) => message.threadId === thread.id || message.id === thread.rootMessageId)
.map((message) => message.id),
);
return events
.filter((event) => {
if (event.entityType === "chat_message" && messageIds.has(event.entityId)) return true;
const payload = auditPayload(event);
return payload.threadId === thread.id || payload.sourceThreadId === thread.id;
})
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}
function auditPayload(event: AuditEventRecord): Record<string, unknown> {
const parsed = JSON.parse(event.payloadJson) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Audit event payload is invalid.");
}
return parsed as Record<string, unknown>;
}
export function formatDay(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "—";
@@ -61,6 +61,10 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("@Board").first()).toBeVisible();
await expect(page.getByText("/escalate").first()).toBeVisible();
await page.getByRole("button", { name: "Open thread" }).first().click();
await expect(page.getByText("Audit")).toBeVisible();
await expect(page.getByText("chat_message.sent").first()).toBeVisible();
await expect(page.getByText("approval.requested").first()).toBeVisible();
await page.getByRole("button", { name: "Approvals" }).click();
await expect(page.getByText("Escalate release").first()).toBeVisible();