Add thread scoped audit replay
This commit is contained in:
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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user