Show all messages flat in channel and fix V8 OOM crash

Chat: removed all thread abstraction. Every message in a channel now
renders inline chronologically as a flat list — no compact rows, no
thread panel, no thread navigation. Just messages, like Slack.

OOM fix: snapshot now limits fast-growing tables (chat_messages: 500,
task_runs: 200, provider_requests: 100) instead of loading unbounded
rows. This prevents the renderer V8 heap from growing past 3.5GB and
crashing. The notifyDataChanged callback is also wrapped in try/catch
to prevent errors when the renderer frame is disposed during agent runs.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-30 22:56:58 -04:00
1 parent 016ae25901
commit 8b2f94a715
5 files changed
+80 -301

No files matched your search

@@ -44,9 +44,11 @@ export function registerIpcHandlers(deps: IpcDeps): void {
const providerOAuth = new ProviderOAuthService();
const skills = new SkillService(deps.connection.db, deps.artifactRoot);
const notifyDataChanged = () => {
if (!deps.mainWindow.isDestroyed()) {
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
}
try {
if (!deps.mainWindow.isDestroyed() && !deps.mainWindow.webContents.isDestroyed()) {
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
}
} catch { /* renderer may have been disposed during agent run */ }
};
const taskExecution = new TaskExecutionService(deps.connection.db, providers, skills, notifyDataChanged);
const social = new SocialService(deps.connection.db);
@@ -29,6 +29,10 @@ function list<T>(db: DatabaseSync, table: string, orderBy: string): T[] {
return all<Row>(db.prepare(`select * from ${table} order by ${orderBy}`)).map((row) => camel(row) as T);
}
function recentList<T>(db: DatabaseSync, table: string, orderBy: string, limit: number): T[] {
return all<Row>(db.prepare(`select * from ${table} order by ${orderBy} limit ${limit}`)).map((row) => camel(row) as T);
}
export class SnapshotService {
constructor(private readonly db: DatabaseSync) {}
@@ -90,15 +94,15 @@ export class SnapshotService {
workspaces,
goals: list(this.db, "goals", "created_at asc"),
projects: list(this.db, "projects", "created_at asc"),
taskRuns: list(this.db, "task_runs", "created_at asc"),
taskRuns: recentList<AppSnapshot["taskRuns"][number]>(this.db, "task_runs", "created_at desc", 200).reverse(),
approvals: list(this.db, "approvals", "created_at desc"),
chatSpaces: list(this.db, "chat_spaces", "created_at asc"),
chatMemberships: list(this.db, "chat_memberships", "space_id asc, principal_type asc, principal_id asc"),
chatMessages: list(this.db, "chat_messages", "created_at asc"),
chatThreads: list(this.db, "chat_threads", "updated_at desc"),
chatAttachments: list(this.db, "chat_attachments", "message_id asc"),
chatMentions: list(this.db, "chat_mentions", "message_id asc, offset_start asc"),
chatSlashInvocations: list(this.db, "chat_slash_invocations", "created_at asc"),
chatMessages: recentList<AppSnapshot["chatMessages"][number]>(this.db, "chat_messages", "created_at desc", 500).reverse(),
chatThreads: recentList(this.db, "chat_threads", "updated_at desc", 200),
chatAttachments: recentList(this.db, "chat_attachments", "message_id desc", 500),
chatMentions: recentList(this.db, "chat_mentions", "message_id desc, offset_start asc", 500),
chatSlashInvocations: recentList(this.db, "chat_slash_invocations", "created_at desc", 200),
boardInterventions: list(this.db, "board_interventions", "created_at asc"),
chatPresence: list(this.db, "chat_presence", "last_seen_at desc"),
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
@@ -110,7 +114,7 @@ export class SnapshotService {
providerAuthProfiles,
providerAuthSessions,
providerCatalog: PROVIDER_CATALOG,
providerRequests: list(this.db, "provider_requests", "created_at desc"),
providerRequests: recentList(this.db, "provider_requests", "created_at desc", 100),
providerRateLimits,
providerRateLimitObservations,
skillDefinitions: list(this.db, "skill_definitions", "created_at asc"),
@@ -3,7 +3,6 @@ import { Field } from "../shared/ui";
import { Dialog } from "../shared/Dialog";
import { ChatSidebar } from "./chat/ChatSidebar";
import { ChatConversation } from "./chat/ChatConversation";
import { ChatThreadPanel } from "./chat/ChatThreadPanel";
import {
collectParticipants,
filterApprovalsForSpace,
@@ -61,10 +60,8 @@ export function ChatPage({ snapshot, actions }: PageProps) {
);
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [attachmentFileId, setAttachmentFileId] = useState<string>("");
const [threadAttachmentFileId, setThreadAttachmentFileId] = useState<string>("");
const composerRef = useRef<HTMLTextAreaElement | null>(null);
const firstAvailableSpaceId = channels[0]?.id ?? directs[0]?.id ?? null;
@@ -97,30 +94,12 @@ export function ChatPage({ snapshot, actions }: PageProps) {
[threadsAll, activeSpace],
);
const rootMessages = messagesInSpace.filter((message) => isRootMessage(message, threadsInSpace));
const selectedThread = threadsInSpace.find((t) => t.id === openThreadId) ?? null;
const threadMessages = selectedThread
? messagesInSpace.filter((m) => m.threadId === selectedThread.id && m.id !== selectedThread.rootMessageId)
: [];
const selectedBoardInterventions = useMemo(
() => (selectedThread ? boardInterventionsAll.filter((item) => item.threadId === selectedThread.id) : []),
[boardInterventionsAll, selectedThread],
);
const participants = useMemo(
() => collectParticipants(messagesInSpace, employees, agents),
[messagesInSpace, employees, agents],
);
const pendingApprovalsInSpace = filterApprovalsForSpace(approvalsAll, activeSpace);
const openThread = (threadId: string) => {
setOpenThreadId(threadId);
setThreadAttachmentFileId("");
};
const closeThread = () => {
setOpenThreadId(null);
setThreadAttachmentFileId("");
};
const sendInChannel = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!activeSpace) return;
@@ -140,46 +119,6 @@ export function ChatPage({ snapshot, actions }: PageProps) {
}));
};
const sendInThread = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!activeSpace || !selectedThread) return;
const form = new FormData(event.currentTarget);
const body = value(form, "message");
if (!body) return;
event.currentTarget.reset();
setThreadAttachmentFileId("");
void actions.run(() => window.agentCompany.sendMessage({
orgId,
spaceId: activeSpace.id,
threadId: selectedThread.id,
senderType: "employee",
senderId: employee.id,
bodyMd: body,
attachmentFileIds: threadAttachmentFileId ? [threadAttachmentFileId] : [],
}));
};
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);
@@ -197,7 +136,6 @@ export function ChatPage({ snapshot, actions }: PageProps) {
const created = result.chatSpaces.find((space) => space.orgId === orgId && !existingSpaceIds.has(space.id));
if (!created) throw new Error("Created chat space is missing from snapshot.");
setSelectedSpaceId(created.id);
setOpenThreadId(null);
setCreateOpen(false);
});
};
@@ -231,10 +169,6 @@ export function ChatPage({ snapshot, actions }: PageProps) {
importAttachment(setAttachmentFileId);
};
const attachThreadFile = () => {
importAttachment(setThreadAttachmentFileId);
};
const importAttachment = (onSelect: (fileId: string) => void) => {
void window.agentCompany.importFile({ orgId }).then((result) => {
if (!result.ok) throw new Error(result.error.message);
@@ -259,7 +193,6 @@ export function ChatPage({ snapshot, actions }: PageProps) {
activeSpaceId={activeSpaceId}
onSelect={(spaceId) => {
setSelectedSpaceId(spaceId);
closeThread();
}}
onCreateSpace={() => setCreateOpen(true)}
/>
@@ -276,21 +209,18 @@ export function ChatPage({ snapshot, actions }: PageProps) {
slashInvocations={slashInvocationsAll}
files={files}
context={{ employees, agents }}
attachmentFileId={selectedThread ? threadAttachmentFileId : attachmentFileId}
attachmentFileId={attachmentFileId}
currentEmployeeId={employee.id}
onAttachmentChange={selectedThread ? setThreadAttachmentFileId : setAttachmentFileId}
onAttach={selectedThread ? attachThreadFile : attachFile}
onOpenThread={openThread}
onAttachmentChange={setAttachmentFileId}
onAttach={attachFile}
onOpenThread={() => {}}
onApprove={(approvalId) => decideApproval(approvalId, "approved")}
onDecline={(approvalId) => decideApproval(approvalId, "declined")}
onEditMessage={editChatMessage}
onDeleteMessage={deleteChatMessage}
onOpenFile={openFile}
onSubmit={selectedThread ? sendInThread : sendInChannel}
onSubmit={sendInChannel}
composerRef={composerRef}
activeThread={selectedThread}
threadMessages={threadMessages}
onCloseThread={closeThread}
/>
<Dialog
@@ -12,7 +12,6 @@ import type {
} from "../../../../shared/domain";
import { ChatComposer } from "./ChatComposer";
import { ChatTimeline } from "./ChatTimeline";
import { ChatMessageRow } from "./ChatMessageRow";
import type { MessageContext } from "./chat-helpers";
export function ChatConversation({
@@ -39,9 +38,6 @@ export function ChatConversation({
onOpenFile,
onSubmit,
composerRef,
activeThread,
threadMessages,
onCloseThread,
}: {
space: ChatSpaceRecord | null;
participants: string[];
@@ -66,28 +62,20 @@ export function ChatConversation({
onOpenFile: (fileId: string) => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
activeThread: ChatThreadRecord | null;
threadMessages: ChatMessageRecord[];
onCloseThread: () => void;
}) {
const [query, setQuery] = useState("");
const trimmed = query.trim().toLowerCase();
const visibleRoots = trimmed
? rootMessages.filter((message) => message.bodyMd.toLowerCase().includes(trimmed))
: rootMessages;
const showThread = activeThread !== null;
const filtered = trimmed
? messagesInSpace.filter((m) => m.bodyMd.toLowerCase().includes(trimmed))
: messagesInSpace;
return (
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
{space && !showThread && <ChannelHeader space={space} participants={participants} query={query} onQueryChange={setQuery} />}
{space && showThread && <ThreadHeader thread={activeThread} onBack={onCloseThread} />}
{space && <ConversationHeader space={space} participants={participants} query={query} onQueryChange={setQuery} />}
<div style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}>
{space && !showThread && (
{space && (
<ChatTimeline
rootMessages={visibleRoots}
messagesInSpace={messagesInSpace}
rootMessages={[]}
messagesInSpace={filtered}
threadsInSpace={threadsInSpace}
pendingApprovals={pendingApprovals}
attachments={attachments}
@@ -104,22 +92,6 @@ export function ChatConversation({
onOpenFile={onOpenFile}
/>
)}
{space && showThread && (
<ThreadView
thread={activeThread}
rootMessage={messagesInSpace.find((m) => m.id === activeThread.rootMessageId) ?? null}
messages={threadMessages}
attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
files={files}
context={context}
currentEmployeeId={currentEmployeeId}
onEditMessage={onEditMessage}
onDeleteMessage={onDeleteMessage}
onOpenFile={onOpenFile}
/>
)}
</div>
<ChatComposer
disabled={!space}
@@ -136,7 +108,12 @@ export function ChatConversation({
);
}
function ChannelHeader({ space, participants, query, onQueryChange }: {
function ConversationHeader({
space,
participants,
query,
onQueryChange,
}: {
space: ChatSpaceRecord;
participants: string[];
query: string;
@@ -144,19 +121,42 @@ function ChannelHeader({ space, participants, query, onQueryChange }: {
}) {
const [searchOpen, setSearchOpen] = useState(false);
const toggleSearch = () => {
setSearchOpen((open) => { if (open) onQueryChange(""); return !open; });
setSearchOpen((open) => {
if (open) onQueryChange("");
return !open;
});
};
return (
<header className="row" style={{ height: 52, padding: "0 24px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0 }}>
<Icon name={space.visibility === "restricted" ? "lock" : "hash"} size={15} color="var(--text-3)" />
<span style={{ fontSize: 14, fontWeight: 600 }}>{space.name}</span>
<span style={{ fontSize: 12, color: "var(--text-3)", borderLeft: "1px solid var(--border)", paddingLeft: 10, marginLeft: 4, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, minWidth: 0 }}>
<span
style={{
fontSize: 12,
color: "var(--text-3)",
borderLeft: "1px solid var(--border)",
paddingLeft: 10,
marginLeft: 4,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
flex: 1,
minWidth: 0,
}}
>
{space.topic}
</span>
<div style={{ marginLeft: "auto", display: "flex", gap: 8, alignItems: "center" }}>
{searchOpen && (
<input className="input" autoFocus aria-label="Search messages" value={query} onChange={(e) => onQueryChange(e.target.value)}
onKeyDown={(e) => { if (e.key === "Escape") toggleSearch(); }} style={{ width: 200, height: 28, padding: "0 10px" }} />
<input
className="input"
autoFocus
aria-label="Search messages"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => { if (event.key === "Escape") toggleSearch(); }}
style={{ width: 200, height: 28, padding: "0 10px" }}
/>
)}
{!searchOpen && participants.length > 0 && <AvatarStack names={participants.slice(0, 5)} bot />}
<IconButton icon="search" label="Search" onClick={toggleSearch} />
@@ -164,52 +164,3 @@ function ChannelHeader({ space, participants, query, onQueryChange }: {
</header>
);
}
function ThreadHeader({ thread, onBack }: { thread: ChatThreadRecord; onBack: () => void }) {
return (
<header className="row" style={{ height: 52, padding: "0 16px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0, gap: 8 }}>
<IconButton icon="arrowUpRight" label="Back to channel" onClick={onBack} />
<span style={{ fontSize: 14, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{thread.title}
</span>
</header>
);
}
function ThreadView({ thread, rootMessage, messages, attachments, mentions, slashInvocations, files, context, currentEmployeeId, onEditMessage, onDeleteMessage, onOpenFile }: {
thread: ChatThreadRecord;
rootMessage: ChatMessageRecord | null;
messages: ChatMessageRecord[];
attachments: ChatAttachmentRecord[];
mentions: ChatMentionRecord[];
slashInvocations: ChatSlashInvocationRecord[];
files: FileObjectRecord[];
context: MessageContext;
currentEmployeeId: string;
onEditMessage: (messageId: string, bodyMd: string) => void;
onDeleteMessage: (messageId: string) => void;
onOpenFile: (fileId: string) => void;
}) {
const allMessages = rootMessage ? [rootMessage, ...messages] : messages;
return (
<>
{allMessages.map((message) => (
<ChatMessageRow
key={message.id}
message={message}
attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
files={files}
replyCount={0}
onOpenThread={null}
context={context}
canModify={message.senderType === "employee" && message.senderId === currentEmployeeId && !message.deletedAt}
onEdit={(bodyMd) => onEditMessage(message.id, bodyMd)}
onDelete={() => onDeleteMessage(message.id)}
onOpenFile={onOpenFile}
/>
))}
</>
);
}
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Avatar, Button, Icon, Pill } from "../../shared/ui";
import { Button, Icon, Pill } from "../../shared/ui";
import type {
ApprovalRecord,
ChatAttachmentRecord,
@@ -9,7 +9,8 @@ import type {
ChatThreadRecord,
FileObjectRecord,
} from "../../../../shared/domain";
import { formatDay, senderName, type MessageContext } from "./chat-helpers";
import { ChatMessageRow } from "./ChatMessageRow";
import { formatDay, type MessageContext } from "./chat-helpers";
export function ChatTimeline({
rootMessages,
@@ -48,142 +49,41 @@ export function ChatTimeline({
}) {
const elements: ReactNode[] = [];
let lastDay = "";
for (const message of rootMessages) {
for (const message of messagesInSpace) {
const day = formatDay(message.createdAt);
if (day !== lastDay) {
lastDay = day;
elements.push(<DayDivider key={`day-${day}`} label={day} />);
}
const thread = threadsInSpace.find((t) => t.rootMessageId === message.id) ?? null;
const replies = thread
? messagesInSpace.filter((m) => m.threadId === thread.id && m.id !== thread.rootMessageId)
: [];
const lastReply = replies.length > 0 ? replies[replies.length - 1] : null;
const participantNames = threadParticipants(replies, context);
elements.push(
<ThreadRow
<ChatMessageRow
key={message.id}
message={message}
thread={thread}
replyCount={replies.length}
lastReply={lastReply}
participantNames={participantNames}
attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
files={files}
replyCount={0}
onOpenThread={null}
context={context}
onOpen={thread ? () => onOpenThread(thread.id) : null}
canModify={message.senderType === "employee" && message.senderId === currentEmployeeId && !message.deletedAt}
onEdit={(bodyMd) => onEditMessage(message.id, bodyMd)}
onDelete={() => onDeleteMessage(message.id)}
onOpenFile={onOpenFile}
/>,
);
}
for (const approval of pendingApprovals) {
elements.push(
<ApprovalInline
key={`approval-${approval.id}`}
approval={approval}
onApprove={() => onApprove(approval.id)}
onDecline={() => onDecline(approval.id)}
/>,
<ApprovalInline key={`approval-${approval.id}`} approval={approval} onApprove={() => onApprove(approval.id)} onDecline={() => onDecline(approval.id)} />,
);
}
return <>{elements}</>;
}
function ThreadRow({
message,
thread,
replyCount,
lastReply,
participantNames,
context,
onOpen,
}: {
message: ChatMessageRecord;
thread: ChatThreadRecord | null;
replyCount: number;
lastReply: ChatMessageRecord | null;
participantNames: string[];
context: MessageContext;
onOpen: (() => void) | null;
}) {
const name = senderName(message, context);
const bot = message.senderType === "agent";
const preview = message.bodyMd.replace(/\n+/g, " ").slice(0, 120);
const time = formatTime(message.createdAt);
return (
<button
type="button"
onClick={onOpen ?? undefined}
style={{
display: "flex",
alignItems: "flex-start",
gap: 10,
width: "100%",
padding: "10px 12px",
border: 0,
borderRadius: 8,
background: "transparent",
cursor: onOpen ? "pointer" : "default",
textAlign: "left",
transition: "background 0.1s",
}}
onMouseEnter={(e) => { if (onOpen) e.currentTarget.style.background = "var(--hover)"; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }}
>
<Avatar name={name} size="sm" bot={bot} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 6, marginBottom: 2 }}>
<span style={{ fontSize: 13, fontWeight: 560 }}>{name}</span>
<span style={{ fontSize: 11, color: "var(--text-4)" }}>{time}</span>
</div>
<div style={{
fontSize: 13,
color: "var(--text-2)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
lineHeight: 1.4,
}}>
{preview}
</div>
{replyCount > 0 && (
<div style={{ display: "flex", alignItems: "center", gap: 6, marginTop: 4 }}>
{participantNames.slice(0, 3).map((n) => (
<Avatar key={n} name={n} size="sm" bot />
))}
<span style={{ fontSize: 11, color: "var(--accent)", fontWeight: 500 }}>
{replyCount} {replyCount === 1 ? "reply" : "replies"}
</span>
{lastReply && (
<span style={{ fontSize: 11, color: "var(--text-4)" }}>
{formatTime(lastReply.createdAt)}
</span>
)}
</div>
)}
</div>
</button>
);
}
function threadParticipants(replies: ChatMessageRecord[], context: MessageContext): string[] {
const seen = new Set<string>();
const names: string[] = [];
for (const r of replies) {
const key = `${r.senderType}:${r.senderId}`;
if (seen.has(key)) continue;
seen.add(key);
names.push(senderName(r, context));
}
return names;
}
function formatTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
function DayDivider({ label }: { label: string }) {
return (
<div className="row" style={{ gap: 12, margin: "8px 0 12px" }}>
<div className="row" style={{ gap: 12, margin: "8px 0 16px" }}>
<div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
<div style={{ fontSize: 12, color: "var(--text-3)", fontWeight: 540 }}>{label}</div>
<div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
@@ -191,17 +91,9 @@ function DayDivider({ label }: { label: string }) {
);
}
function ApprovalInline({
approval,
onApprove,
onDecline,
}: {
approval: ApprovalRecord;
onApprove: () => void;
onDecline: () => void;
}) {
function ApprovalInline({ approval, onApprove, onDecline }: { approval: ApprovalRecord; onApprove: () => void; onDecline: () => void }) {
return (
<div style={{ margin: "8px 0 12px 12px", maxWidth: 480, border: "1px solid var(--border-strong)", borderRadius: 12, background: "var(--panel)", padding: 14 }}>
<div style={{ margin: "14px 0 18px 44px", maxWidth: 480, border: "1px solid var(--border-strong)", borderRadius: 12, background: "var(--panel)", padding: 14 }}>
<div className="row" style={{ marginBottom: 8 }}>
<Icon name="approve" size={14} color="var(--amber)" />
<span style={{ fontSize: 12, color: "var(--amber)", fontWeight: 540 }}>Approval needed</span>