diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index c764e025..c0d36f15 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -3,13 +3,11 @@ import { Field } from "../shared/ui"; import { Dialog } from "../shared/Dialog"; import { ChatSidebar } from "./chat/ChatSidebar"; import { ChatConversation } from "./chat/ChatConversation"; -import { ChatThreadView } from "./chat/ChatThreadView"; import { buildConversations, collectParticipants, filterApprovalsForSpace, } from "./chat/chat-helpers"; -import { ROLE_KEY } from "../../../shared/domain"; import type { PageProps } from "./types"; import { activeOrgId, requireActiveEmployee } from "./types"; @@ -28,7 +26,6 @@ export function ChatPage({ snapshot, actions }: PageProps) { const agents = useMemo(() => snapshot.agents.filter((item) => item.orgId === orgId), [snapshot.agents, orgId]); const files = useMemo(() => snapshot.files.filter((item) => item.orgId === orgId), [snapshot.files, orgId]); const messagesAll = useMemo(() => snapshot.chatMessages.filter((m) => m.orgId === orgId), [snapshot.chatMessages, orgId]); - const threadsAll = useMemo(() => snapshot.chatThreads.filter((t) => t.orgId === orgId), [snapshot.chatThreads, orgId]); const messageIds = useMemo(() => new Set(messagesAll.map((message) => message.id)), [messagesAll]); const mentionsAll = useMemo( () => snapshot.chatMentions.filter((mention) => messageIds.has(mention.messageId)), @@ -38,10 +35,6 @@ export function ChatPage({ snapshot, actions }: PageProps) { () => snapshot.chatSlashInvocations.filter((invocation) => messageIds.has(invocation.messageId)), [snapshot.chatSlashInvocations, messageIds], ); - const boardInterventionsAll = useMemo( - () => snapshot.boardInterventions.filter((item) => item.orgId === orgId), - [snapshot.boardInterventions, orgId], - ); const approvalsAll = useMemo( () => snapshot.approvals.filter((a) => a.orgId === orgId && a.state === "pending"), [snapshot.approvals, orgId], @@ -58,23 +51,9 @@ export function ChatPage({ snapshot, actions }: PageProps) { }), [spaces, snapshot.chatMemberships, messagesAll, snapshot.chatReadStates, employees, agents, employee.id], ); - const canBoardIntervene = useMemo( - () => - snapshot.roleGrants.some( - (grant) => - grant.orgId === orgId && - grant.principalType === "employee" && - grant.principalId === employee.id && - (grant.roleKey === ROLE_KEY.APPROVAL_DECIDER || grant.roleKey === ROLE_KEY.ORG_ADMIN), - ), - [snapshot.roleGrants, orgId, employee.id], - ); - 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 = conversations[0]?.space.id ?? null; @@ -102,74 +81,12 @@ export function ChatPage({ snapshot, actions }: PageProps) { () => (activeSpace ? messagesAll.filter((m) => m.spaceId === activeSpace.id) : []), [messagesAll, activeSpace], ); - const threadsInSpace = useMemo( - () => (activeSpace ? threadsAll.filter((t) => t.spaceId === activeSpace.id) : []), - [threadsAll, activeSpace], - ); - const selectedThread = threadsInSpace.find((thread) => thread.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 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 sendInChannel = (event: FormEvent) => { event.preventDefault(); if (!activeSpace) return; @@ -206,7 +123,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); - closeThread(); setCreateOpen(false); }); }; @@ -259,59 +175,32 @@ export function ChatPage({ snapshot, actions }: PageProps) { conversations={conversations} presence={snapshot.chatPresence} activeSpaceId={activeSpaceId} - onSelect={(spaceId) => { - setSelectedSpaceId(spaceId); - closeThread(); - }} + onSelect={setSelectedSpaceId} onCreateSpace={() => setCreateOpen(true)} /> - {selectedThread ? ( - 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={() => importAttachment(setThreadAttachmentFileId)} - onSubmit={sendInThread} - onBoardSubmit={sendBoardIntervention} - onOpenFile={openFile} - /> - ) : ( - decideApproval(approvalId, "approved")} - onDecline={(approvalId) => decideApproval(approvalId, "declined")} - onEditMessage={editChatMessage} - onDeleteMessage={deleteChatMessage} - onOpenFile={openFile} - onSubmit={sendInChannel} - composerRef={composerRef} - /> - )} + decideApproval(approvalId, "approved")} + onDecline={(approvalId) => decideApproval(approvalId, "declined")} + onEditMessage={editChatMessage} + onDeleteMessage={deleteChatMessage} + onOpenFile={openFile} + onSubmit={sendInChannel} + composerRef={composerRef} + /> void; onAttach: () => void; - onOpenThread: (threadId: string) => void; onApprove: (approvalId: string) => void; onDecline: (approvalId: string) => void; onEditMessage: (messageId: string, bodyMd: string) => void; @@ -74,7 +69,6 @@ export function ChatConversation({ void) | null; context: MessageContext; - compact?: boolean; canModify?: boolean; onEdit?: (bodyMd: string) => void; onDelete?: () => void; @@ -54,8 +48,8 @@ export function ChatMessageRow({ setEditing(false); }; return ( -
- +
+
{name} @@ -104,28 +98,6 @@ export function ChatMessageRow({ })}
)} - {onOpenThread && ( - - )}
); diff --git a/src/renderer/src/pages/chat/ChatThreadView.tsx b/src/renderer/src/pages/chat/ChatThreadView.tsx deleted file mode 100644 index a8bb9c2f..00000000 --- a/src/renderer/src/pages/chat/ChatThreadView.tsx +++ /dev/null @@ -1,235 +0,0 @@ -import { useState, type FormEvent } from "react"; -import { Button, Icon, IconButton, Pill } from "../../shared/ui"; -import type { - BoardInterventionRecord, - ChatAttachmentRecord, - ChatMentionRecord, - ChatMessageRecord, - ChatSlashInvocationRecord, - ChatThreadRecord, - FileObjectRecord, -} from "../../../../shared/domain"; -import { formatTime } from "../types"; -import { ChatMessageRow } from "./ChatMessageRow"; -import { MentionTextarea } from "./MentionTextarea"; -import type { MessageContext } from "./chat-helpers"; - -/** - * Thread focus, in place: the center pane narrows to one thread — root, - * replies, board controls, and a reply composer — and the back control - * returns to the conversation. No side panel duplicating the timeline. - */ -export function ChatThreadView({ - thread, - rootMessage, - messages, - attachments, - mentions, - slashInvocations, - boardInterventions, - files, - context, - canIntervene, - attachmentFileId, - onClose, - onAttachmentChange, - onAttach, - onSubmit, - onBoardSubmit, - onOpenFile, -}: { - thread: ChatThreadRecord; - rootMessage: ChatMessageRecord | null; - messages: ChatMessageRecord[]; - attachments: ChatAttachmentRecord[]; - mentions: ChatMentionRecord[]; - slashInvocations: ChatSlashInvocationRecord[]; - boardInterventions: BoardInterventionRecord[]; - files: FileObjectRecord[]; - context: MessageContext; - canIntervene: boolean; - attachmentFileId: string; - onClose: () => void; - onAttachmentChange: (next: string) => void; - onAttach: () => void; - onSubmit: (event: FormEvent) => void; - onBoardSubmit: (event: FormEvent) => void; - onOpenFile: (fileId: string) => void; -}) { - const attachment = files.find((file) => file.id === attachmentFileId) ?? null; - return ( -
-
- - Thread - {boardStateLabel(thread.boardState)} -
-
- - {rootMessage && ( - - )} - {messages.length > 0 && ( -
-
-
- {messages.length} {messages.length === 1 ? "reply" : "replies"} -
-
-
- )} - {messages.map((message) => ( - - ))} -
-
-
- -
- - {attachment && ( - - - {attachment.name} - - - )} -
- -
-
-
-
-
- ); -} - -function BoardControl({ - thread, - interventions, - context, - canIntervene, - onSubmit, -}: { - thread: ChatThreadRecord; - interventions: BoardInterventionRecord[]; - context: MessageContext; - canIntervene: boolean; - onSubmit: (event: FormEvent) => void; -}) { - const [action, setAction] = useState("decision"); - if (!canIntervene && interventions.length === 0) return null; - return ( -
-
-
Board
- {boardStateLabel(thread.boardState)} -
- {canIntervene && ( -
- - - -
- -
-
- )} - {interventions.length > 0 && ( -
- {interventions.map((intervention) => ( -
-
- {formatTime(intervention.createdAt)} - - {formatBoardAction(intervention.action)} - -
-
{intervention.details}
-
- ))} -
- )} -
- ); -} - -function boardStateTone(state: string): "red" | "violet" | undefined { - if (state === "frozen") return "red"; - if (state === "takeover") return "violet"; - return undefined; -} - -function boardStateLabel(state: string): string { - if (state === "frozen") return "Frozen"; - if (state === "takeover") return "Takeover"; - return "Open"; -} - -function formatBoardAction(action: string): string { - return action.replace(/_/g, " "); -} diff --git a/src/renderer/src/pages/chat/ChatTimeline.tsx b/src/renderer/src/pages/chat/ChatTimeline.tsx index 58a57740..f11d539d 100644 --- a/src/renderer/src/pages/chat/ChatTimeline.tsx +++ b/src/renderer/src/pages/chat/ChatTimeline.tsx @@ -6,7 +6,6 @@ import type { ChatMentionRecord, ChatMessageRecord, ChatSlashInvocationRecord, - ChatThreadRecord, FileObjectRecord, } from "../../../../shared/domain"; import { ChatMessageRow } from "./ChatMessageRow"; @@ -30,7 +29,6 @@ function groupBy(items: T[], keyOf: (item: T) => string): Map { export function ChatTimeline({ messagesInSpace, - threadsInSpace, pendingApprovals, attachments, mentions, @@ -38,7 +36,6 @@ export function ChatTimeline({ files, context, currentEmployeeId, - onOpenThread, onApprove, onDecline, onEditMessage, @@ -46,7 +43,6 @@ export function ChatTimeline({ onOpenFile, }: { messagesInSpace: ChatMessageRecord[]; - threadsInSpace: ChatThreadRecord[]; pendingApprovals: ApprovalRecord[]; attachments: ChatAttachmentRecord[]; mentions: ChatMentionRecord[]; @@ -54,7 +50,6 @@ export function ChatTimeline({ files: FileObjectRecord[]; context: MessageContext; currentEmployeeId: string; - onOpenThread: (threadId: string) => void; onApprove: (approvalId: string) => void; onDecline: (approvalId: string) => void; onEditMessage: (messageId: string, bodyMd: string) => void; @@ -66,15 +61,6 @@ export function ChatTimeline({ const attachmentsByMessage = groupBy(attachments, (item) => item.messageId); const mentionsByMessage = groupBy(mentions, (item) => item.messageId); const invocationsByMessage = groupBy(slashInvocations, (item) => item.messageId); - const threadByRoot = new Map(); - for (const thread of threadsInSpace) { - if (thread.rootMessageId) threadByRoot.set(thread.rootMessageId, thread); - } - const replyCountByThread = new Map(); - for (const message of messagesInSpace) { - if (!message.threadId) continue; - replyCountByThread.set(message.threadId, (replyCountByThread.get(message.threadId) ?? 0) + 1); - } const hiddenCount = Math.max(0, messagesInSpace.length - visibleCount); const visible = hiddenCount > 0 ? messagesInSpace.slice(hiddenCount) : messagesInSpace; @@ -96,10 +82,6 @@ export function ChatTimeline({ lastDay = day; elements.push(); } - const thread = threadByRoot.get(message.id) ?? null; - const replyCount = thread - ? Math.max(0, (replyCountByThread.get(thread.id) ?? 0) - (thread.rootMessageId && message.threadId === thread.id ? 1 : 0)) - : 0; elements.push( onOpenThread(thread.id) : null} context={context} canModify={message.senderType === "employee" && message.senderId === currentEmployeeId && !message.deletedAt} onEdit={(bodyMd) => onEditMessage(message.id, bodyMd)} diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index ea896a10..83680782 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -106,12 +106,9 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByRole("button", { name: "Send" }).click(); await expect(page.getByText("@Board").first()).toBeVisible(); await expect(page.getByText("/escalate").first()).toBeVisible(); - // Threads open in place: the center pane becomes the thread and the - // channel composer is gone until you go back. - await page.getByRole("button", { name: "Open thread" }).first().click(); - await expect(page.getByRole("textbox", { name: "Thread reply" })).toBeVisible(); - await expect(page.getByRole("textbox", { name: "Message" })).not.toBeVisible(); - await page.getByRole("button", { name: "Close thread" }).click(); + // Conversations are flat: every message lives in the channel pane and no + // thread affordance exists. + await expect(page.getByRole("button", { name: "Open thread" })).toHaveCount(0); await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible(); await page.getByRole("button", { name: "Approvals" }).click(); await expect(page.getByText("Escalate release").first()).toBeVisible();