Open threads in-place in center pane and fix office crash

Chat: clicking a thread row now replaces the center pane content with
the thread's full messages (back button to return to thread list).
Removed the right-side thread panel entirely — threads navigate in
place like Slack, not as a side panel overlay.

Office: reduced frame rate to 24fps, skip rendering when canvas has
zero dimensions, schedule next frame AFTER draw (not before) to
prevent runaway frame buildup that caused the white screen crash.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-30 22:49:41 -04:00
1 parent 82e28111e9
commit 016ae25901
3 files changed
+102 -69

No files matched your search

@@ -276,42 +276,23 @@ export function ChatPage({ snapshot, actions }: PageProps) {
slashInvocations={slashInvocationsAll}
files={files}
context={{ employees, agents }}
attachmentFileId={attachmentFileId}
attachmentFileId={selectedThread ? threadAttachmentFileId : attachmentFileId}
currentEmployeeId={employee.id}
onAttachmentChange={setAttachmentFileId}
onAttach={attachFile}
onAttachmentChange={selectedThread ? setThreadAttachmentFileId : setAttachmentFileId}
onAttach={selectedThread ? attachThreadFile : attachFile}
onOpenThread={openThread}
onApprove={(approvalId) => decideApproval(approvalId, "approved")}
onDecline={(approvalId) => decideApproval(approvalId, "declined")}
onEditMessage={editChatMessage}
onDeleteMessage={deleteChatMessage}
onOpenFile={openFile}
onSubmit={sendInChannel}
onSubmit={selectedThread ? sendInThread : sendInChannel}
composerRef={composerRef}
activeThread={selectedThread}
threadMessages={threadMessages}
onCloseThread={closeThread}
/>
{selectedThread && (
<ChatThreadPanel
thread={selectedThread}
rootMessage={messagesInSpace.find((m) => m.id === selectedThread.rootMessageId) ?? null}
messages={threadMessages}
attachments={snapshot.chatAttachments}
mentions={mentionsAll}
slashInvocations={slashInvocationsAll}
boardInterventions={selectedBoardInterventions}
files={files}
context={{ employees, agents }}
canIntervene={canBoardIntervene}
attachmentFileId={threadAttachmentFileId}
onClose={closeThread}
onAttachmentChange={setThreadAttachmentFileId}
onAttach={attachThreadFile}
onSubmit={sendInThread}
onBoardSubmit={sendBoardIntervention}
onOpenFile={openFile}
/>
)}
<Dialog
title="New space"
open={createOpen}
@@ -59,23 +59,25 @@ export function OfficePage({ snapshot }: PageProps) {
if (!ctx) return;
let running = true;
const FPS_INTERVAL = 1000 / 30;
const FPS_INTERVAL = 1000 / 24;
const tick = (now: number) => {
if (!running) return;
animRef.current = requestAnimationFrame(tick);
const elapsed = now - lastFrameRef.current;
if (elapsed < FPS_INTERVAL) return;
lastFrameRef.current = now - (elapsed % FPS_INTERVAL);
if (elapsed < FPS_INTERVAL) {
animRef.current = requestAnimationFrame(tick);
return;
}
lastFrameRef.current = now;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
canvas.width = w * dpr;
canvas.height = h * dpr;
}
if (w === 0 || h === 0) { animRef.current = requestAnimationFrame(tick); return; }
const pw = Math.round(w * dpr);
const ph = Math.round(h * dpr);
if (canvas.width !== pw || canvas.height !== ph) { canvas.width = pw; canvas.height = ph; }
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
for (const agent of agentsRef.current) {
@@ -90,6 +92,7 @@ export function OfficePage({ snapshot }: PageProps) {
}
drawScene(ctx, w, h, agentsRef.current, now);
animRef.current = requestAnimationFrame(tick);
};
animRef.current = requestAnimationFrame(tick);
@@ -12,6 +12,7 @@ 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({
@@ -38,6 +39,9 @@ export function ChatConversation({
onOpenFile,
onSubmit,
composerRef,
activeThread,
threadMessages,
onCloseThread,
}: {
space: ChatSpaceRecord | null;
participants: string[];
@@ -62,17 +66,25 @@ 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;
return (
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
{space && <ConversationHeader space={space} participants={participants} query={query} onQueryChange={setQuery} />}
{space && !showThread && <ChannelHeader 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" }}>
{space && (
{space && !showThread && (
<ChatTimeline
rootMessages={visibleRoots}
messagesInSpace={messagesInSpace}
@@ -92,6 +104,22 @@ 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}
@@ -108,12 +136,7 @@ export function ChatConversation({
);
}
function ConversationHeader({
space,
participants,
query,
onQueryChange,
}: {
function ChannelHeader({ space, participants, query, onQueryChange }: {
space: ChatSpaceRecord;
participants: string[];
query: string;
@@ -121,42 +144,19 @@ function ConversationHeader({
}) {
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={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => { if (event.key === "Escape") toggleSearch(); }}
style={{ width: 200, height: 28, padding: "0 10px" }}
/>
<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" }} />
)}
{!searchOpen && participants.length > 0 && <AvatarStack names={participants.slice(0, 5)} bot />}
<IconButton icon="search" label="Search" onClick={toggleSearch} />
@@ -164,3 +164,52 @@ function ConversationHeader({
</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}
/>
))}
</>
);
}