Open threads in place - the duplicate side panel is gone

The right-hand thread panel repeated exactly what the flat timeline already
showed. Opening a thread now focuses the center pane itself: back control,
board state, the root message with its replies, board intervention
controls, and a thread reply composer - one conversation surface, never
two. The release contract also catches up with the icon shipping inside
the package, a pin the icon commit should have updated.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-07 00:35:42 -04:00
1 parent 3246ea5f98
commit 62e663ed90
4 files changed
+73 -71

No files matched your search

+28 -28
View File
@@ -3,7 +3,7 @@ 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 { ChatThreadView } from "./chat/ChatThreadView";
import { import {
buildConversations, buildConversations,
collectParticipants, collectParticipants,
@@ -266,33 +266,8 @@ export function ChatPage({ snapshot, actions }: PageProps) {
onCreateSpace={() => setCreateOpen(true)} onCreateSpace={() => setCreateOpen(true)}
/> />
<ChatConversation {selectedThread ? (
space={activeSpace} <ChatThreadView
participants={participants}
messagesInSpace={messagesInSpace}
threadsInSpace={threadsInSpace}
pendingApprovals={pendingApprovalsInSpace}
attachments={snapshot.chatAttachments}
mentions={mentionsAll}
slashInvocations={slashInvocationsAll}
files={files}
context={{ employees, agents }}
attachmentFileId={attachmentFileId}
currentEmployeeId={employee.id}
onAttachmentChange={setAttachmentFileId}
onAttach={attachFile}
onOpenThread={openThread}
onApprove={(approvalId) => decideApproval(approvalId, "approved")}
onDecline={(approvalId) => decideApproval(approvalId, "declined")}
onEditMessage={editChatMessage}
onDeleteMessage={deleteChatMessage}
onOpenFile={openFile}
onSubmit={sendInChannel}
composerRef={composerRef}
/>
{selectedThread && (
<ChatThreadPanel
thread={selectedThread} thread={selectedThread}
rootMessage={messagesAll.find((m) => m.id === selectedThread.rootMessageId) ?? null} rootMessage={messagesAll.find((m) => m.id === selectedThread.rootMessageId) ?? null}
messages={threadMessages} messages={threadMessages}
@@ -311,6 +286,31 @@ export function ChatPage({ snapshot, actions }: PageProps) {
onBoardSubmit={sendBoardIntervention} onBoardSubmit={sendBoardIntervention}
onOpenFile={openFile} onOpenFile={openFile}
/> />
) : (
<ChatConversation
space={activeSpace}
participants={participants}
messagesInSpace={messagesInSpace}
threadsInSpace={threadsInSpace}
pendingApprovals={pendingApprovalsInSpace}
attachments={snapshot.chatAttachments}
mentions={mentionsAll}
slashInvocations={slashInvocationsAll}
files={files}
context={{ employees, agents }}
attachmentFileId={attachmentFileId}
currentEmployeeId={employee.id}
onAttachmentChange={setAttachmentFileId}
onAttach={attachFile}
onOpenThread={openThread}
onApprove={(approvalId) => decideApproval(approvalId, "approved")}
onDecline={(approvalId) => decideApproval(approvalId, "declined")}
onEditMessage={editChatMessage}
onDeleteMessage={deleteChatMessage}
onOpenFile={openFile}
onSubmit={sendInChannel}
composerRef={composerRef}
/>
)} )}
<Dialog <Dialog
@@ -12,9 +12,14 @@ import type {
import { formatTime } from "../types"; import { formatTime } from "../types";
import { ChatMessageRow } from "./ChatMessageRow"; import { ChatMessageRow } from "./ChatMessageRow";
import { MentionTextarea } from "./MentionTextarea"; import { MentionTextarea } from "./MentionTextarea";
import { senderName, type MessageContext } from "./chat-helpers"; import type { MessageContext } from "./chat-helpers";
export function ChatThreadPanel({ /**
* 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, thread,
rootMessage, rootMessage,
messages, messages,
@@ -53,25 +58,13 @@ export function ChatThreadPanel({
}) { }) {
const attachment = files.find((file) => file.id === attachmentFileId) ?? null; const attachment = files.find((file) => file.id === attachmentFileId) ?? null;
return ( return (
<aside <section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
style={{ <header className="row" style={{ height: 52, padding: "0 16px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0, gap: 10 }}>
width: 320, <IconButton icon="chevronLeft" label="Close thread" onClick={onClose} />
flexShrink: 0, <span style={{ fontSize: 14, fontWeight: 600 }}>Thread</span>
borderLeft: "1px solid var(--border-soft)",
background: "var(--bg)",
display: "flex",
flexDirection: "column",
minHeight: 0,
}}
>
<div className="row" style={{ padding: "14px 16px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0 }}>
<div style={{ fontSize: 12, fontWeight: 600 }}>Thread</div>
<Pill tone={boardStateTone(thread.boardState)}>{boardStateLabel(thread.boardState)}</Pill> <Pill tone={boardStateTone(thread.boardState)}>{boardStateLabel(thread.boardState)}</Pill>
<div style={{ marginLeft: "auto" }}> </header>
<IconButton icon="x" label="Close thread" onClick={onClose} /> <div style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}>
</div>
</div>
<div style={{ padding: 16, overflow: "auto", flex: 1 }}>
<BoardControl <BoardControl
thread={thread} thread={thread}
interventions={boardInterventions} interventions={boardInterventions}
@@ -80,25 +73,26 @@ export function ChatThreadPanel({
onSubmit={onBoardSubmit} onSubmit={onBoardSubmit}
/> />
{rootMessage && ( {rootMessage && (
<> <ChatMessageRow
<div style={{ fontSize: 12, color: "var(--text-3)", marginBottom: 10 }}> message={rootMessage}
{formatTime(rootMessage.createdAt)} · {senderName(rootMessage, context)} attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
files={files}
replyCount={0}
onOpenThread={null}
context={context}
onOpenFile={onOpenFile}
/>
)}
{messages.length > 0 && (
<div className="row" style={{ gap: 12, margin: "4px 0 16px" }}>
<div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
<div style={{ fontSize: 12, color: "var(--text-3)", fontWeight: 540 }}>
{messages.length} {messages.length === 1 ? "reply" : "replies"}
</div> </div>
<div style={{ border: "1px solid var(--border)", borderRadius: 10, padding: 12, background: "var(--panel)", marginBottom: 16 }}> <div style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
<ChatMessageRow </div>
message={rootMessage}
attachments={attachments}
mentions={mentions}
slashInvocations={slashInvocations}
files={files}
replyCount={0}
onOpenThread={null}
context={context}
onOpenFile={onOpenFile}
compact
/>
</div>
</>
)} )}
{messages.map((message) => ( {messages.map((message) => (
<ChatMessageRow <ChatMessageRow
@@ -112,11 +106,10 @@ export function ChatThreadPanel({
onOpenThread={null} onOpenThread={null}
context={context} context={context}
onOpenFile={onOpenFile} onOpenFile={onOpenFile}
compact
/> />
))} ))}
</div> </div>
<form onSubmit={onSubmit} style={{ padding: 12, flexShrink: 0 }}> <form onSubmit={onSubmit} style={{ padding: "0 24px 16px", flexShrink: 0 }}>
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 10, background: "var(--panel)", padding: "8px 10px" }}> <div style={{ border: "1px solid var(--border-strong)", borderRadius: 10, background: "var(--panel)", padding: "8px 10px" }}>
<MentionTextarea <MentionTextarea
name="message" name="message"
@@ -148,7 +141,7 @@ export function ChatThreadPanel({
</div> </div>
</div> </div>
</form> </form>
</aside> </section>
); );
} }
@@ -168,7 +161,7 @@ function BoardControl({
const [action, setAction] = useState("decision"); const [action, setAction] = useState("decision");
if (!canIntervene && interventions.length === 0) return null; if (!canIntervene && interventions.length === 0) return null;
return ( return (
<section style={{ border: "1px solid var(--border)", borderRadius: 10, padding: 10, background: "var(--panel)", marginBottom: 16 }}> <section style={{ border: "1px solid var(--border)", borderRadius: 10, padding: 10, background: "var(--panel)", marginBottom: 16, maxWidth: 480 }}>
<div className="row" style={{ marginBottom: 8 }}> <div className="row" style={{ marginBottom: 8 }}>
<div className="section-label">Board</div> <div className="section-label">Board</div>
<Pill tone={boardStateTone(thread.boardState)}>{boardStateLabel(thread.boardState)}</Pill> <Pill tone={boardStateTone(thread.boardState)}>{boardStateLabel(thread.boardState)}</Pill>
@@ -64,8 +64,12 @@ describe("desktop release contract", () => {
expect(topLevelValue(config, "asar")).toBe("true"); expect(topLevelValue(config, "asar")).toBe("true");
expect(directories).toContain("output: release"); expect(directories).toContain("output: release");
expect(directories).toContain("buildResources: resources"); expect(directories).toContain("buildResources: resources");
expect(topLevelList(config, "files")).toEqual(["out/**/*", "package.json"]); // resources/icon.png ships inside the package so windows can load it at
// runtime on Windows and Linux.
expect(topLevelList(config, "files")).toEqual(["out/**/*", "package.json", "resources/icon.png"]);
expect(existsSync(join(projectRoot, "resources", "icon.png"))).toBe(true); expect(existsSync(join(projectRoot, "resources", "icon.png"))).toBe(true);
expect(existsSync(join(projectRoot, "resources", "icon.ico"))).toBe(true);
expect(existsSync(join(projectRoot, "resources", "icon-mac.png"))).toBe(true);
expect(existsSync(join(projectRoot, "resources", "entitlements.mac.plist"))).toBe(true); expect(existsSync(join(projectRoot, "resources", "entitlements.mac.plist"))).toBe(true);
}); });
+5
View File
@@ -101,8 +101,13 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await page.getByRole("button", { name: "Send" }).click(); await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("@Board").first()).toBeVisible(); await expect(page.getByText("@Board").first()).toBeVisible();
await expect(page.getByText("/escalate").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 page.getByRole("button", { name: "Open thread" }).first().click();
await expect(page.getByRole("textbox", { name: "Thread reply" })).toBeVisible(); 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();
await expect(page.getByRole("textbox", { name: "Message" })).toBeVisible();
await page.getByRole("button", { name: "Approvals" }).click(); await page.getByRole("button", { name: "Approvals" }).click();
await expect(page.getByText("Escalate release").first()).toBeVisible(); await expect(page.getByText("Escalate release").first()).toBeVisible();