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:
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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user