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:
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 providerOAuth = new ProviderOAuthService();
|
||||||
const skills = new SkillService(deps.connection.db, deps.artifactRoot);
|
const skills = new SkillService(deps.connection.db, deps.artifactRoot);
|
||||||
const notifyDataChanged = () => {
|
const notifyDataChanged = () => {
|
||||||
if (!deps.mainWindow.isDestroyed()) {
|
try {
|
||||||
deps.mainWindow.webContents.send(IPC_CHANNELS.rendererEvent, { type: "snapshot-changed" });
|
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 taskExecution = new TaskExecutionService(deps.connection.db, providers, skills, notifyDataChanged);
|
||||||
const social = new SocialService(deps.connection.db);
|
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);
|
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 {
|
export class SnapshotService {
|
||||||
constructor(private readonly db: DatabaseSync) {}
|
constructor(private readonly db: DatabaseSync) {}
|
||||||
|
|
||||||
@@ -90,15 +94,15 @@ export class SnapshotService {
|
|||||||
workspaces,
|
workspaces,
|
||||||
goals: list(this.db, "goals", "created_at asc"),
|
goals: list(this.db, "goals", "created_at asc"),
|
||||||
projects: list(this.db, "projects", "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"),
|
approvals: list(this.db, "approvals", "created_at desc"),
|
||||||
chatSpaces: list(this.db, "chat_spaces", "created_at asc"),
|
chatSpaces: list(this.db, "chat_spaces", "created_at asc"),
|
||||||
chatMemberships: list(this.db, "chat_memberships", "space_id asc, principal_type asc, principal_id 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"),
|
chatMessages: recentList<AppSnapshot["chatMessages"][number]>(this.db, "chat_messages", "created_at desc", 500).reverse(),
|
||||||
chatThreads: list(this.db, "chat_threads", "updated_at desc"),
|
chatThreads: recentList(this.db, "chat_threads", "updated_at desc", 200),
|
||||||
chatAttachments: list(this.db, "chat_attachments", "message_id asc"),
|
chatAttachments: recentList(this.db, "chat_attachments", "message_id desc", 500),
|
||||||
chatMentions: list(this.db, "chat_mentions", "message_id asc, offset_start asc"),
|
chatMentions: recentList(this.db, "chat_mentions", "message_id desc, offset_start asc", 500),
|
||||||
chatSlashInvocations: list(this.db, "chat_slash_invocations", "created_at asc"),
|
chatSlashInvocations: recentList(this.db, "chat_slash_invocations", "created_at desc", 200),
|
||||||
boardInterventions: list(this.db, "board_interventions", "created_at asc"),
|
boardInterventions: list(this.db, "board_interventions", "created_at asc"),
|
||||||
chatPresence: list(this.db, "chat_presence", "last_seen_at desc"),
|
chatPresence: list(this.db, "chat_presence", "last_seen_at desc"),
|
||||||
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
|
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
|
||||||
@@ -110,7 +114,7 @@ export class SnapshotService {
|
|||||||
providerAuthProfiles,
|
providerAuthProfiles,
|
||||||
providerAuthSessions,
|
providerAuthSessions,
|
||||||
providerCatalog: PROVIDER_CATALOG,
|
providerCatalog: PROVIDER_CATALOG,
|
||||||
providerRequests: list(this.db, "provider_requests", "created_at desc"),
|
providerRequests: recentList(this.db, "provider_requests", "created_at desc", 100),
|
||||||
providerRateLimits,
|
providerRateLimits,
|
||||||
providerRateLimitObservations,
|
providerRateLimitObservations,
|
||||||
skillDefinitions: list(this.db, "skill_definitions", "created_at asc"),
|
skillDefinitions: list(this.db, "skill_definitions", "created_at asc"),
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Field } from "../shared/ui";
|
|||||||
import { Dialog } from "../shared/Dialog";
|
import { Dialog } from "../shared/Dialog";
|
||||||
import { ChatSidebar } from "./chat/ChatSidebar";
|
import { ChatSidebar } from "./chat/ChatSidebar";
|
||||||
import { ChatConversation } from "./chat/ChatConversation";
|
import { ChatConversation } from "./chat/ChatConversation";
|
||||||
import { ChatThreadPanel } from "./chat/ChatThreadPanel";
|
|
||||||
import {
|
import {
|
||||||
collectParticipants,
|
collectParticipants,
|
||||||
filterApprovalsForSpace,
|
filterApprovalsForSpace,
|
||||||
@@ -61,10 +60,8 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
|
const [selectedSpaceId, setSelectedSpaceId] = useState<string | null>(null);
|
||||||
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [attachmentFileId, setAttachmentFileId] = useState<string>("");
|
const [attachmentFileId, setAttachmentFileId] = useState<string>("");
|
||||||
const [threadAttachmentFileId, setThreadAttachmentFileId] = useState<string>("");
|
|
||||||
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
const composerRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
const firstAvailableSpaceId = channels[0]?.id ?? directs[0]?.id ?? null;
|
const firstAvailableSpaceId = channels[0]?.id ?? directs[0]?.id ?? null;
|
||||||
@@ -97,30 +94,12 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
[threadsAll, activeSpace],
|
[threadsAll, activeSpace],
|
||||||
);
|
);
|
||||||
const rootMessages = messagesInSpace.filter((message) => isRootMessage(message, threadsInSpace));
|
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(
|
const participants = useMemo(
|
||||||
() => collectParticipants(messagesInSpace, employees, agents),
|
() => collectParticipants(messagesInSpace, employees, agents),
|
||||||
[messagesInSpace, employees, agents],
|
[messagesInSpace, employees, agents],
|
||||||
);
|
);
|
||||||
const pendingApprovalsInSpace = filterApprovalsForSpace(approvalsAll, activeSpace);
|
const pendingApprovalsInSpace = filterApprovalsForSpace(approvalsAll, activeSpace);
|
||||||
|
|
||||||
const openThread = (threadId: string) => {
|
|
||||||
setOpenThreadId(threadId);
|
|
||||||
setThreadAttachmentFileId("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeThread = () => {
|
|
||||||
setOpenThreadId(null);
|
|
||||||
setThreadAttachmentFileId("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendInChannel = (event: FormEvent<HTMLFormElement>) => {
|
const sendInChannel = (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!activeSpace) return;
|
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>) => {
|
const createSpace = (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = new FormData(event.currentTarget);
|
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));
|
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.");
|
if (!created) throw new Error("Created chat space is missing from snapshot.");
|
||||||
setSelectedSpaceId(created.id);
|
setSelectedSpaceId(created.id);
|
||||||
setOpenThreadId(null);
|
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -231,10 +169,6 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
importAttachment(setAttachmentFileId);
|
importAttachment(setAttachmentFileId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const attachThreadFile = () => {
|
|
||||||
importAttachment(setThreadAttachmentFileId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const importAttachment = (onSelect: (fileId: string) => void) => {
|
const importAttachment = (onSelect: (fileId: string) => void) => {
|
||||||
void window.agentCompany.importFile({ orgId }).then((result) => {
|
void window.agentCompany.importFile({ orgId }).then((result) => {
|
||||||
if (!result.ok) throw new Error(result.error.message);
|
if (!result.ok) throw new Error(result.error.message);
|
||||||
@@ -259,7 +193,6 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
activeSpaceId={activeSpaceId}
|
activeSpaceId={activeSpaceId}
|
||||||
onSelect={(spaceId) => {
|
onSelect={(spaceId) => {
|
||||||
setSelectedSpaceId(spaceId);
|
setSelectedSpaceId(spaceId);
|
||||||
closeThread();
|
|
||||||
}}
|
}}
|
||||||
onCreateSpace={() => setCreateOpen(true)}
|
onCreateSpace={() => setCreateOpen(true)}
|
||||||
/>
|
/>
|
||||||
@@ -276,21 +209,18 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
|||||||
slashInvocations={slashInvocationsAll}
|
slashInvocations={slashInvocationsAll}
|
||||||
files={files}
|
files={files}
|
||||||
context={{ employees, agents }}
|
context={{ employees, agents }}
|
||||||
attachmentFileId={selectedThread ? threadAttachmentFileId : attachmentFileId}
|
attachmentFileId={attachmentFileId}
|
||||||
currentEmployeeId={employee.id}
|
currentEmployeeId={employee.id}
|
||||||
onAttachmentChange={selectedThread ? setThreadAttachmentFileId : setAttachmentFileId}
|
onAttachmentChange={setAttachmentFileId}
|
||||||
onAttach={selectedThread ? attachThreadFile : attachFile}
|
onAttach={attachFile}
|
||||||
onOpenThread={openThread}
|
onOpenThread={() => {}}
|
||||||
onApprove={(approvalId) => decideApproval(approvalId, "approved")}
|
onApprove={(approvalId) => decideApproval(approvalId, "approved")}
|
||||||
onDecline={(approvalId) => decideApproval(approvalId, "declined")}
|
onDecline={(approvalId) => decideApproval(approvalId, "declined")}
|
||||||
onEditMessage={editChatMessage}
|
onEditMessage={editChatMessage}
|
||||||
onDeleteMessage={deleteChatMessage}
|
onDeleteMessage={deleteChatMessage}
|
||||||
onOpenFile={openFile}
|
onOpenFile={openFile}
|
||||||
onSubmit={selectedThread ? sendInThread : sendInChannel}
|
onSubmit={sendInChannel}
|
||||||
composerRef={composerRef}
|
composerRef={composerRef}
|
||||||
activeThread={selectedThread}
|
|
||||||
threadMessages={threadMessages}
|
|
||||||
onCloseThread={closeThread}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type {
|
|||||||
} from "../../../../shared/domain";
|
} from "../../../../shared/domain";
|
||||||
import { ChatComposer } from "./ChatComposer";
|
import { ChatComposer } from "./ChatComposer";
|
||||||
import { ChatTimeline } from "./ChatTimeline";
|
import { ChatTimeline } from "./ChatTimeline";
|
||||||
import { ChatMessageRow } from "./ChatMessageRow";
|
|
||||||
import type { MessageContext } from "./chat-helpers";
|
import type { MessageContext } from "./chat-helpers";
|
||||||
|
|
||||||
export function ChatConversation({
|
export function ChatConversation({
|
||||||
@@ -39,9 +38,6 @@ export function ChatConversation({
|
|||||||
onOpenFile,
|
onOpenFile,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
composerRef,
|
composerRef,
|
||||||
activeThread,
|
|
||||||
threadMessages,
|
|
||||||
onCloseThread,
|
|
||||||
}: {
|
}: {
|
||||||
space: ChatSpaceRecord | null;
|
space: ChatSpaceRecord | null;
|
||||||
participants: string[];
|
participants: string[];
|
||||||
@@ -66,28 +62,20 @@ export function ChatConversation({
|
|||||||
onOpenFile: (fileId: string) => void;
|
onOpenFile: (fileId: string) => void;
|
||||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||||
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
|
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
|
||||||
activeThread: ChatThreadRecord | null;
|
|
||||||
threadMessages: ChatMessageRecord[];
|
|
||||||
onCloseThread: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const trimmed = query.trim().toLowerCase();
|
const trimmed = query.trim().toLowerCase();
|
||||||
const visibleRoots = trimmed
|
const filtered = trimmed
|
||||||
? rootMessages.filter((message) => message.bodyMd.toLowerCase().includes(trimmed))
|
? messagesInSpace.filter((m) => m.bodyMd.toLowerCase().includes(trimmed))
|
||||||
: rootMessages;
|
: messagesInSpace;
|
||||||
|
|
||||||
const showThread = activeThread !== null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
|
<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 && <ConversationHeader space={space} participants={participants} query={query} onQueryChange={setQuery} />}
|
||||||
{space && showThread && <ThreadHeader thread={activeThread} onBack={onCloseThread} />}
|
|
||||||
|
|
||||||
<div style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}>
|
<div style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}>
|
||||||
{space && !showThread && (
|
{space && (
|
||||||
<ChatTimeline
|
<ChatTimeline
|
||||||
rootMessages={visibleRoots}
|
rootMessages={[]}
|
||||||
messagesInSpace={messagesInSpace}
|
messagesInSpace={filtered}
|
||||||
threadsInSpace={threadsInSpace}
|
threadsInSpace={threadsInSpace}
|
||||||
pendingApprovals={pendingApprovals}
|
pendingApprovals={pendingApprovals}
|
||||||
attachments={attachments}
|
attachments={attachments}
|
||||||
@@ -104,22 +92,6 @@ export function ChatConversation({
|
|||||||
onOpenFile={onOpenFile}
|
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>
|
</div>
|
||||||
<ChatComposer
|
<ChatComposer
|
||||||
disabled={!space}
|
disabled={!space}
|
||||||
@@ -136,7 +108,12 @@ export function ChatConversation({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChannelHeader({ space, participants, query, onQueryChange }: {
|
function ConversationHeader({
|
||||||
|
space,
|
||||||
|
participants,
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
}: {
|
||||||
space: ChatSpaceRecord;
|
space: ChatSpaceRecord;
|
||||||
participants: string[];
|
participants: string[];
|
||||||
query: string;
|
query: string;
|
||||||
@@ -144,19 +121,42 @@ function ChannelHeader({ space, participants, query, onQueryChange }: {
|
|||||||
}) {
|
}) {
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const toggleSearch = () => {
|
const toggleSearch = () => {
|
||||||
setSearchOpen((open) => { if (open) onQueryChange(""); return !open; });
|
setSearchOpen((open) => {
|
||||||
|
if (open) onQueryChange("");
|
||||||
|
return !open;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<header className="row" style={{ height: 52, padding: "0 24px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0 }}>
|
<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)" />
|
<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: 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}
|
{space.topic}
|
||||||
</span>
|
</span>
|
||||||
<div style={{ marginLeft: "auto", display: "flex", gap: 8, alignItems: "center" }}>
|
<div style={{ marginLeft: "auto", display: "flex", gap: 8, alignItems: "center" }}>
|
||||||
{searchOpen && (
|
{searchOpen && (
|
||||||
<input className="input" autoFocus aria-label="Search messages" value={query} onChange={(e) => onQueryChange(e.target.value)}
|
<input
|
||||||
onKeyDown={(e) => { if (e.key === "Escape") toggleSearch(); }} style={{ width: 200, height: 28, padding: "0 10px" }} />
|
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 />}
|
{!searchOpen && participants.length > 0 && <AvatarStack names={participants.slice(0, 5)} bot />}
|
||||||
<IconButton icon="search" label="Search" onClick={toggleSearch} />
|
<IconButton icon="search" label="Search" onClick={toggleSearch} />
|
||||||
@@ -164,52 +164,3 @@ function ChannelHeader({ space, participants, query, onQueryChange }: {
|
|||||||
</header>
|
</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 type { ReactNode } from "react";
|
||||||
import { Avatar, Button, Icon, Pill } from "../../shared/ui";
|
import { Button, Icon, Pill } from "../../shared/ui";
|
||||||
import type {
|
import type {
|
||||||
ApprovalRecord,
|
ApprovalRecord,
|
||||||
ChatAttachmentRecord,
|
ChatAttachmentRecord,
|
||||||
@@ -9,7 +9,8 @@ import type {
|
|||||||
ChatThreadRecord,
|
ChatThreadRecord,
|
||||||
FileObjectRecord,
|
FileObjectRecord,
|
||||||
} from "../../../../shared/domain";
|
} 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({
|
export function ChatTimeline({
|
||||||
rootMessages,
|
rootMessages,
|
||||||
@@ -48,142 +49,41 @@ export function ChatTimeline({
|
|||||||
}) {
|
}) {
|
||||||
const elements: ReactNode[] = [];
|
const elements: ReactNode[] = [];
|
||||||
let lastDay = "";
|
let lastDay = "";
|
||||||
for (const message of rootMessages) {
|
for (const message of messagesInSpace) {
|
||||||
const day = formatDay(message.createdAt);
|
const day = formatDay(message.createdAt);
|
||||||
if (day !== lastDay) {
|
if (day !== lastDay) {
|
||||||
lastDay = day;
|
lastDay = day;
|
||||||
elements.push(<DayDivider key={`day-${day}`} label={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(
|
elements.push(
|
||||||
<ThreadRow
|
<ChatMessageRow
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
thread={thread}
|
attachments={attachments}
|
||||||
replyCount={replies.length}
|
mentions={mentions}
|
||||||
lastReply={lastReply}
|
slashInvocations={slashInvocations}
|
||||||
participantNames={participantNames}
|
files={files}
|
||||||
|
replyCount={0}
|
||||||
|
onOpenThread={null}
|
||||||
context={context}
|
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) {
|
for (const approval of pendingApprovals) {
|
||||||
elements.push(
|
elements.push(
|
||||||
<ApprovalInline
|
<ApprovalInline key={`approval-${approval.id}`} approval={approval} onApprove={() => onApprove(approval.id)} onDecline={() => onDecline(approval.id)} />,
|
||||||
key={`approval-${approval.id}`}
|
|
||||||
approval={approval}
|
|
||||||
onApprove={() => onApprove(approval.id)}
|
|
||||||
onDecline={() => onDecline(approval.id)}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <>{elements}</>;
|
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 }) {
|
function DayDivider({ label }: { label: string }) {
|
||||||
return (
|
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={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
|
||||||
<div style={{ fontSize: 12, color: "var(--text-3)", fontWeight: 540 }}>{label}</div>
|
<div style={{ fontSize: 12, color: "var(--text-3)", fontWeight: 540 }}>{label}</div>
|
||||||
<div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
|
<div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
|
||||||
@@ -191,17 +91,9 @@ function DayDivider({ label }: { label: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ApprovalInline({
|
function ApprovalInline({ approval, onApprove, onDecline }: { approval: ApprovalRecord; onApprove: () => void; onDecline: () => void }) {
|
||||||
approval,
|
|
||||||
onApprove,
|
|
||||||
onDecline,
|
|
||||||
}: {
|
|
||||||
approval: ApprovalRecord;
|
|
||||||
onApprove: () => void;
|
|
||||||
onDecline: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
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 }}>
|
<div className="row" style={{ marginBottom: 8 }}>
|
||||||
<Icon name="approve" size={14} color="var(--amber)" />
|
<Icon name="approve" size={14} color="var(--amber)" />
|
||||||
<span style={{ fontSize: 12, color: "var(--amber)", fontWeight: 540 }}>Approval needed</span>
|
<span style={{ fontSize: 12, color: "var(--amber)", fontWeight: 540 }}>Approval needed</span>
|
||||||
|
|||||||
Reference in new issue
Block a user