Let authors edit and delete their own chat messages

The editMessage/deleteMessage IPC and their audit events existed but had no UI.
Add an inline editor and delete control on a member's own, non-deleted
messages; show an edited marker and render deleted messages as a muted
placeholder. Thread the handlers from ChatPage through the timeline.
This commit is contained in:
ZacharyZhang-NY committed 2026-05-28 20:38:39 -04:00
1 parent 7c5978d5df
commit b65e059da6
4 files changed
+86 -7

No files matched your search

@@ -207,6 +207,23 @@ export function ChatPage({ snapshot, actions }: PageProps) {
decidedByEmployeeId: employee.id,
}));
const editChatMessage = (messageId: string, bodyMd: string) =>
void actions.run(() => window.agentCompany.editMessage({
orgId,
messageId,
actorType: "employee",
actorId: employee.id,
bodyMd,
}));
const deleteChatMessage = (messageId: string) =>
void actions.run(() => window.agentCompany.deleteMessage({
orgId,
messageId,
actorType: "employee",
actorId: employee.id,
}));
return (
<div className="split-main">
<ChatSidebar
@@ -236,10 +253,13 @@ export function ChatPage({ snapshot, actions }: PageProps) {
files={files}
context={{ employees, agents }}
attachmentFileId={attachmentFileId}
currentEmployeeId={employee.id}
onAttachmentChange={setAttachmentFileId}
onOpenThread={setOpenThreadId}
onApprove={(approvalId) => decideApproval(approvalId, "approved")}
onDecline={(approvalId) => decideApproval(approvalId, "declined")}
onEditMessage={editChatMessage}
onDeleteMessage={deleteChatMessage}
onSubmit={sendInChannel}
composerRef={composerRef}
/>
@@ -27,10 +27,13 @@ export function ChatConversation({
files,
context,
attachmentFileId,
currentEmployeeId,
onAttachmentChange,
onOpenThread,
onApprove,
onDecline,
onEditMessage,
onDeleteMessage,
onSubmit,
composerRef,
}: {
@@ -46,10 +49,13 @@ export function ChatConversation({
files: FileObjectRecord[];
context: MessageContext;
attachmentFileId: string;
currentEmployeeId: string;
onAttachmentChange: (next: string) => void;
onOpenThread: (threadId: string) => void;
onApprove: (approvalId: string) => void;
onDecline: (approvalId: string) => void;
onEditMessage: (messageId: string, bodyMd: string) => void;
onDeleteMessage: (messageId: string) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
}) {
@@ -73,9 +79,12 @@ export function ChatConversation({
slashInvocations={slashInvocations}
files={files}
context={context}
currentEmployeeId={currentEmployeeId}
onOpenThread={onOpenThread}
onApprove={onApprove}
onDecline={onDecline}
onEditMessage={onEditMessage}
onDeleteMessage={onDeleteMessage}
/>
)}
</div>
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Avatar, Icon, IconButton } from "../../shared/ui";
import { useState, type FormEvent, type ReactNode } from "react";
import { Avatar, Button, Icon, IconButton } from "../../shared/ui";
import type {
ChatAttachmentRecord,
ChatMentionRecord,
@@ -20,6 +20,9 @@ export function ChatMessageRow({
onOpenThread,
context,
compact = false,
canModify = false,
onEdit,
onDelete,
}: {
message: ChatMessageRecord;
attachments: ChatAttachmentRecord[];
@@ -30,12 +33,24 @@ export function ChatMessageRow({
onOpenThread: (() => void) | null;
context: MessageContext;
compact?: boolean;
canModify?: boolean;
onEdit?: (bodyMd: string) => void;
onDelete?: () => void;
}) {
const [editing, setEditing] = useState(false);
const name = senderName(message, context);
const bot = message.senderType === "agent";
const messageFiles = messageAttachments(message.id, attachments, files);
const deleted = Boolean(message.deletedAt);
const messageFiles = deleted ? [] : messageAttachments(message.id, attachments, files);
const messageMentions = mentions.filter((mention) => mention.messageId === message.id);
const slashInvocation = slashInvocations.find((invocation) => invocation.messageId === message.id) ?? null;
const slashInvocation = deleted ? null : slashInvocations.find((invocation) => invocation.messageId === message.id) ?? null;
const submitEdit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const next = new FormData(event.currentTarget).get("body");
const value = typeof next === "string" ? next.trim() : "";
if (value && onEdit) onEdit(value);
setEditing(false);
};
return (
<div style={{ display: "flex", gap: compact ? 10 : 12, marginBottom: compact ? 12 : 18 }}>
<Avatar name={name} size={compact ? "sm" : ""} bot={bot} />
@@ -43,15 +58,41 @@ export function ChatMessageRow({
<div className="row" style={{ alignItems: "baseline", gap: 8, marginBottom: 2 }}>
<span style={{ fontSize: 13, fontWeight: 540 }}>{name}</span>
<span className="mono" style={{ fontSize: 11, color: "var(--text-3)" }}>{formatTime(message.createdAt)}</span>
{message.editedAt && !deleted && <span style={{ fontSize: 11, color: "var(--text-3)" }}>(edited)</span>}
{canModify && !editing && (
<span style={{ marginLeft: "auto", display: "flex", gap: 2 }}>
<IconButton icon="more" label="Edit message" size={13} onClick={() => setEditing(true)} />
<IconButton icon="x" label="Delete message" size={13} onClick={onDelete} />
</span>
)}
</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" }}>
{renderBody(message.bodyMd, messageMentions, context)}
</div>
{deleted ? (
<div style={{ fontSize: 13, color: "var(--text-3)", fontStyle: "italic" }}>Message deleted</div>
) : editing ? (
<form onSubmit={submitEdit}>
<textarea
name="body"
defaultValue={message.bodyMd}
autoFocus
aria-label="Edit message"
className="input"
style={{ minHeight: 60, marginBottom: 6 }}
/>
<div className="row" style={{ gap: 6 }}>
<Button tone="primary" size="small" type="submit">Save</Button>
<Button tone="ghost" size="small" onClick={() => setEditing(false)}>Cancel</Button>
</div>
</form>
) : (
<div style={{ fontSize: 13, color: "var(--text-1)", lineHeight: 1.5, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{renderBody(message.bodyMd, messageMentions, context)}
</div>
)}
{messageFiles.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 8, marginTop: 8 }}>
{messageFiles.map((file) => {
@@ -22,9 +22,12 @@ export function ChatTimeline({
slashInvocations,
files,
context,
currentEmployeeId,
onOpenThread,
onApprove,
onDecline,
onEditMessage,
onDeleteMessage,
}: {
rootMessages: ChatMessageRecord[];
messagesInSpace: ChatMessageRecord[];
@@ -35,9 +38,12 @@ export function ChatTimeline({
slashInvocations: ChatSlashInvocationRecord[];
files: FileObjectRecord[];
context: MessageContext;
currentEmployeeId: string;
onOpenThread: (threadId: string) => void;
onApprove: (approvalId: string) => void;
onDecline: (approvalId: string) => void;
onEditMessage: (messageId: string, bodyMd: string) => void;
onDeleteMessage: (messageId: string) => void;
}) {
const elements: ReactNode[] = [];
let lastDay = "";
@@ -62,6 +68,9 @@ export function ChatTimeline({
replyCount={replyCount}
onOpenThread={thread ? () => onOpenThread(thread.id) : null}
context={context}
canModify={message.senderType === "employee" && message.senderId === currentEmployeeId && !message.deletedAt}
onEdit={(bodyMd) => onEditMessage(message.id, bodyMd)}
onDelete={() => onDeleteMessage(message.id)}
/>,
);
});