Remove threads from Messages: every conversation is flat
All messages already render chronologically in the channel pane, so the thread affordance only duplicated them behind a second view. The reply chip, the in-place thread pane, and the thread composer are gone; ChatThreadView is deleted and the thread props are removed from the conversation, timeline, and message-row chain. Message writes are untouched.
This commit is contained in:
1 parent
d19573187c
commit
0ec13e3e3a
6 files changed
+28
-432
No files matched your search
@@ -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<string | null>(null);
|
||||
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [attachmentFileId, setAttachmentFileId] = useState<string>("");
|
||||
const [threadAttachmentFileId, setThreadAttachmentFileId] = useState<string>("");
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(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<HTMLFormElement>) => {
|
||||
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<HTMLFormElement>) => {
|
||||
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<HTMLFormElement>) => {
|
||||
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 ? (
|
||||
<ChatThreadView
|
||||
thread={selectedThread}
|
||||
rootMessage={messagesAll.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={() => importAttachment(setThreadAttachmentFileId)}
|
||||
onSubmit={sendInThread}
|
||||
onBoardSubmit={sendBoardIntervention}
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
<ChatConversation
|
||||
space={activeSpace}
|
||||
participants={participants}
|
||||
messagesInSpace={messagesInSpace}
|
||||
pendingApprovals={pendingApprovalsInSpace}
|
||||
attachments={snapshot.chatAttachments}
|
||||
mentions={mentionsAll}
|
||||
slashInvocations={slashInvocationsAll}
|
||||
files={files}
|
||||
context={{ employees, agents }}
|
||||
attachmentFileId={attachmentFileId}
|
||||
currentEmployeeId={employee.id}
|
||||
onAttachmentChange={setAttachmentFileId}
|
||||
onAttach={attachFile}
|
||||
onApprove={(approvalId) => decideApproval(approvalId, "approved")}
|
||||
onDecline={(approvalId) => decideApproval(approvalId, "declined")}
|
||||
onEditMessage={editChatMessage}
|
||||
onDeleteMessage={deleteChatMessage}
|
||||
onOpenFile={openFile}
|
||||
onSubmit={sendInChannel}
|
||||
composerRef={composerRef}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
title="New space"
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
ChatMessageRecord,
|
||||
ChatSpaceRecord,
|
||||
ChatSlashInvocationRecord,
|
||||
ChatThreadRecord,
|
||||
FileObjectRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { ChatComposer } from "./ChatComposer";
|
||||
@@ -18,7 +17,6 @@ export function ChatConversation({
|
||||
space,
|
||||
participants,
|
||||
messagesInSpace,
|
||||
threadsInSpace,
|
||||
pendingApprovals,
|
||||
attachments,
|
||||
mentions,
|
||||
@@ -29,7 +27,6 @@ export function ChatConversation({
|
||||
currentEmployeeId,
|
||||
onAttachmentChange,
|
||||
onAttach,
|
||||
onOpenThread,
|
||||
onApprove,
|
||||
onDecline,
|
||||
onEditMessage,
|
||||
@@ -41,7 +38,6 @@ export function ChatConversation({
|
||||
space: ChatSpaceRecord | null;
|
||||
participants: string[];
|
||||
messagesInSpace: ChatMessageRecord[];
|
||||
threadsInSpace: ChatThreadRecord[];
|
||||
pendingApprovals: ApprovalRecord[];
|
||||
attachments: ChatAttachmentRecord[];
|
||||
mentions: ChatMentionRecord[];
|
||||
@@ -52,7 +48,6 @@ export function ChatConversation({
|
||||
currentEmployeeId: string;
|
||||
onAttachmentChange: (next: string) => 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({
|
||||
<ChatTimeline
|
||||
key={space.id}
|
||||
messagesInSpace={filtered}
|
||||
threadsInSpace={threadsInSpace}
|
||||
pendingApprovals={pendingApprovals}
|
||||
attachments={attachments}
|
||||
mentions={mentions}
|
||||
@@ -82,7 +76,6 @@ export function ChatConversation({
|
||||
files={files}
|
||||
context={context}
|
||||
currentEmployeeId={currentEmployeeId}
|
||||
onOpenThread={onOpenThread}
|
||||
onApprove={onApprove}
|
||||
onDecline={onDecline}
|
||||
onEditMessage={onEditMessage}
|
||||
|
||||
@@ -16,10 +16,7 @@ export function ChatMessageRow({
|
||||
mentions,
|
||||
slashInvocations,
|
||||
files,
|
||||
replyCount,
|
||||
onOpenThread,
|
||||
context,
|
||||
compact = false,
|
||||
canModify = false,
|
||||
onEdit,
|
||||
onDelete,
|
||||
@@ -30,10 +27,7 @@ export function ChatMessageRow({
|
||||
mentions: ChatMentionRecord[];
|
||||
slashInvocations: ChatSlashInvocationRecord[];
|
||||
files: FileObjectRecord[];
|
||||
replyCount: number;
|
||||
onOpenThread: (() => void) | null;
|
||||
context: MessageContext;
|
||||
compact?: boolean;
|
||||
canModify?: boolean;
|
||||
onEdit?: (bodyMd: string) => void;
|
||||
onDelete?: () => void;
|
||||
@@ -54,8 +48,8 @@ export function ChatMessageRow({
|
||||
setEditing(false);
|
||||
};
|
||||
return (
|
||||
<div style={{ display: "flex", gap: compact ? 10 : 12, marginBottom: compact ? 12 : 18 }}>
|
||||
<Avatar name={name} size={compact ? "sm" : ""} bot={bot} />
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 18 }}>
|
||||
<Avatar name={name} bot={bot} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="row" style={{ alignItems: "baseline", gap: 8, marginBottom: 2 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 540 }}>{name}</span>
|
||||
@@ -104,28 +98,6 @@ export function ChatMessageRow({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{onOpenThread && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open thread"
|
||||
onClick={onOpenThread}
|
||||
style={{
|
||||
marginTop: 8,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
fontSize: 11.5,
|
||||
color: "var(--accent)",
|
||||
cursor: "pointer",
|
||||
padding: "3px 8px",
|
||||
borderRadius: 6,
|
||||
background: "var(--accent-soft)",
|
||||
border: 0,
|
||||
}}
|
||||
>
|
||||
<Icon name="chat" size={14} /> {replyCount > 0 ? `${replyCount} ${replyCount === 1 ? "reply" : "replies"}` : "Thread"} →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<HTMLFormElement>) => void;
|
||||
onBoardSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onOpenFile: (fileId: string) => void;
|
||||
}) {
|
||||
const attachment = files.find((file) => file.id === attachmentFileId) ?? null;
|
||||
return (
|
||||
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
|
||||
<header className="row" style={{ height: 52, padding: "0 16px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0, gap: 10 }}>
|
||||
<IconButton icon="chevronLeft" label="Close thread" onClick={onClose} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>Thread</span>
|
||||
<Pill tone={boardStateTone(thread.boardState)}>{boardStateLabel(thread.boardState)}</Pill>
|
||||
</header>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}>
|
||||
<BoardControl
|
||||
thread={thread}
|
||||
interventions={boardInterventions}
|
||||
context={context}
|
||||
canIntervene={canIntervene}
|
||||
onSubmit={onBoardSubmit}
|
||||
/>
|
||||
{rootMessage && (
|
||||
<ChatMessageRow
|
||||
message={rootMessage}
|
||||
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 style={{ flex: 1, height: 1, background: "var(--border-soft)" }} />
|
||||
</div>
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<ChatMessageRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
attachments={attachments}
|
||||
mentions={mentions}
|
||||
slashInvocations={slashInvocations}
|
||||
files={files}
|
||||
replyCount={0}
|
||||
onOpenThread={null}
|
||||
context={context}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<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" }}>
|
||||
<MentionTextarea
|
||||
name="message"
|
||||
rows={2}
|
||||
ariaLabel="Thread reply"
|
||||
agents={context.agents}
|
||||
employees={context.employees}
|
||||
style={{ minHeight: 36 }}
|
||||
/>
|
||||
<div className="row" style={{ paddingTop: 6, gap: 4 }}>
|
||||
<IconButton icon="attach" label="Attach file" onClick={onAttach} />
|
||||
{attachment && (
|
||||
<span className="pill" style={{ maxWidth: 150 }}>
|
||||
<Icon name="file" size={14} />
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{attachment.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove thread attachment"
|
||||
onClick={() => onAttachmentChange("")}
|
||||
style={{ display: "inline-flex", border: 0, background: "transparent", padding: 0, color: "var(--text-3)", cursor: "pointer" }}
|
||||
>
|
||||
<Icon name="x" size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<Button tone="primary" size="small" type="submit"><Icon name="send" size={12} /> Reply</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function BoardControl({
|
||||
thread,
|
||||
interventions,
|
||||
context,
|
||||
canIntervene,
|
||||
onSubmit,
|
||||
}: {
|
||||
thread: ChatThreadRecord;
|
||||
interventions: BoardInterventionRecord[];
|
||||
context: MessageContext;
|
||||
canIntervene: boolean;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
}) {
|
||||
const [action, setAction] = useState("decision");
|
||||
if (!canIntervene && interventions.length === 0) return null;
|
||||
return (
|
||||
<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="section-label">Board</div>
|
||||
<Pill tone={boardStateTone(thread.boardState)}>{boardStateLabel(thread.boardState)}</Pill>
|
||||
</div>
|
||||
{canIntervene && (
|
||||
<form onSubmit={onSubmit} style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
||||
<label className="field" style={{ margin: 0 }}>
|
||||
<span>Action</span>
|
||||
<select className="input" name="action" value={action} onChange={(event) => setAction(event.target.value)}>
|
||||
<option value="decision">Decision</option>
|
||||
<option value="freeze">Freeze</option>
|
||||
<option value="takeover">Takeover</option>
|
||||
<option value="rollback">Rollback</option>
|
||||
<option value="force-approval">Force approval</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field" style={{ margin: 0 }}>
|
||||
<span>Target</span>
|
||||
<select className="input" name="target" defaultValue="board" disabled={action !== "takeover"}>
|
||||
<option value="board">Board</option>
|
||||
{context.employees.map((employee) => (
|
||||
<option key={employee.id} value={employee.id}>Employee · {employee.displayName}</option>
|
||||
))}
|
||||
{context.agents.map((agent) => (
|
||||
<option key={agent.id} value={agent.id}>Agent · {agent.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field" style={{ gridColumn: "1 / -1", margin: 0 }}>
|
||||
<span>Details</span>
|
||||
<input className="input" name="details" required aria-label="Board intervention details" />
|
||||
</label>
|
||||
<div className="row" style={{ gridColumn: "1 / -1", justifyContent: "flex-end" }}>
|
||||
<Button tone="primary" size="small" type="submit"><Icon name="shield" size={12} /> Board</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{interventions.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 7, marginTop: 10 }}>
|
||||
{interventions.map((intervention) => (
|
||||
<div key={intervention.id} style={{ borderTop: "1px solid var(--border-soft)", paddingTop: 7 }}>
|
||||
<div className="row" style={{ gap: 6, marginBottom: 3 }}>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>{formatTime(intervention.createdAt)}</span>
|
||||
<Pill tone={intervention.mode === "takeover" ? "violet" : intervention.action === "freeze" ? "red" : undefined}>
|
||||
{formatBoardAction(intervention.action)}
|
||||
</Pill>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: "var(--text-2)", lineHeight: 1.4, wordBreak: "break-word" }}>{intervention.details}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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, " ");
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
ChatMentionRecord,
|
||||
ChatMessageRecord,
|
||||
ChatSlashInvocationRecord,
|
||||
ChatThreadRecord,
|
||||
FileObjectRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { ChatMessageRow } from "./ChatMessageRow";
|
||||
@@ -30,7 +29,6 @@ function groupBy<T>(items: T[], keyOf: (item: T) => string): Map<string, T[]> {
|
||||
|
||||
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<string, ChatThreadRecord>();
|
||||
for (const thread of threadsInSpace) {
|
||||
if (thread.rootMessageId) threadByRoot.set(thread.rootMessageId, thread);
|
||||
}
|
||||
const replyCountByThread = new Map<string, number>();
|
||||
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(<DayDivider key={`day-${day}`} label={day} />);
|
||||
}
|
||||
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(
|
||||
<ChatMessageRow
|
||||
key={message.id}
|
||||
@@ -108,8 +90,6 @@ export function ChatTimeline({
|
||||
mentions={mentionsByMessage.get(message.id) ?? EMPTY_MENTIONS}
|
||||
slashInvocations={invocationsByMessage.get(message.id) ?? EMPTY_INVOCATIONS}
|
||||
files={files}
|
||||
replyCount={replyCount}
|
||||
onOpenThread={thread ? () => onOpenThread(thread.id) : null}
|
||||
context={context}
|
||||
canModify={message.senderType === "employee" && message.senderId === currentEmployeeId && !message.deletedAt}
|
||||
onEdit={(bodyMd) => onEditMessage(message.id, bodyMd)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user