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:
1 parent
3246ea5f98
commit
62e663ed90
4 files changed
+73
-71
No files matched your search
@@ -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
|
||||||
|
|||||||
+35
-42
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user