From 8b2f94a715558b79e4c96abf43a0de73a7a87f26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 30 Jun 2026 22:56:58 -0400 Subject: [PATCH] Show all messages flat in channel and fix V8 OOM crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AgentCompany-New/src/main/app/ipc-handlers.ts | 8 +- .../src/main/services/snapshot-service.ts | 18 ++- .../src/renderer/src/pages/ChatPage.tsx | 80 +--------- .../src/pages/chat/ChatConversation.tsx | 129 +++++----------- .../renderer/src/pages/chat/ChatTimeline.tsx | 146 +++--------------- 5 files changed, 80 insertions(+), 301 deletions(-) diff --git a/AgentCompany-New/src/main/app/ipc-handlers.ts b/AgentCompany-New/src/main/app/ipc-handlers.ts index e5121f06..de0c645c 100644 --- a/AgentCompany-New/src/main/app/ipc-handlers.ts +++ b/AgentCompany-New/src/main/app/ipc-handlers.ts @@ -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); diff --git a/AgentCompany-New/src/main/services/snapshot-service.ts b/AgentCompany-New/src/main/services/snapshot-service.ts index 94997cbc..ba06c5a5 100644 --- a/AgentCompany-New/src/main/services/snapshot-service.ts +++ b/AgentCompany-New/src/main/services/snapshot-service.ts @@ -29,6 +29,10 @@ function list(db: DatabaseSync, table: string, orderBy: string): T[] { return all(db.prepare(`select * from ${table} order by ${orderBy}`)).map((row) => camel(row) as T); } +function recentList(db: DatabaseSync, table: string, orderBy: string, limit: number): T[] { + return all(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(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(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"), diff --git a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx index 24e3d308..aadacfd5 100644 --- a/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/ChatPage.tsx @@ -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(null); - const [openThreadId, setOpenThreadId] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [attachmentFileId, setAttachmentFileId] = useState(""); - const [threadAttachmentFileId, setThreadAttachmentFileId] = useState(""); const composerRef = useRef(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) => { event.preventDefault(); if (!activeSpace) return; @@ -140,46 +119,6 @@ export function ChatPage({ snapshot, actions }: PageProps) { })); }; - const sendInThread = (event: FormEvent) => { - 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) => { - 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) => { 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} /> void; onSubmit: (event: FormEvent) => void; composerRef: MutableRefObject; - 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 (
- {space && !showThread && } - {space && showThread && } - + {space && }
- {space && !showThread && ( + {space && ( )} - {space && showThread && ( - 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} - /> - )}
{ - setSearchOpen((open) => { if (open) onQueryChange(""); return !open; }); + setSearchOpen((open) => { + if (open) onQueryChange(""); + return !open; + }); }; return (
{space.name} - + {space.topic}
{searchOpen && ( - onQueryChange(e.target.value)} - onKeyDown={(e) => { if (e.key === "Escape") toggleSearch(); }} style={{ width: 200, height: 28, padding: "0 10px" }} /> + onQueryChange(event.target.value)} + onKeyDown={(event) => { if (event.key === "Escape") toggleSearch(); }} + style={{ width: 200, height: 28, padding: "0 10px" }} + /> )} {!searchOpen && participants.length > 0 && } @@ -164,52 +164,3 @@ function ChannelHeader({ space, participants, query, onQueryChange }: {
); } - -function ThreadHeader({ thread, onBack }: { thread: ChatThreadRecord; onBack: () => void }) { - return ( -
- - - {thread.title} - -
- ); -} - -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) => ( - onEditMessage(message.id, bodyMd)} - onDelete={() => onDeleteMessage(message.id)} - onOpenFile={onOpenFile} - /> - ))} - - ); -} diff --git a/AgentCompany-New/src/renderer/src/pages/chat/ChatTimeline.tsx b/AgentCompany-New/src/renderer/src/pages/chat/ChatTimeline.tsx index 56c5a666..b6036cba 100644 --- a/AgentCompany-New/src/renderer/src/pages/chat/ChatTimeline.tsx +++ b/AgentCompany-New/src/renderer/src/pages/chat/ChatTimeline.tsx @@ -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(); } - 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( - 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( - onApprove(approval.id)} - onDecline={() => onDecline(approval.id)} - />, + 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 ( - - ); -} - -function threadParticipants(replies: ChatMessageRecord[], context: MessageContext): string[] { - const seen = new Set(); - 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 ( -
+
{label}
@@ -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 ( -
+
Approval needed