Q141: reply to a specific message in its thread, and see what each reply answers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a6ab09d6b5
commit
87c297dd45
17 files changed
+377
-57
No files matched your search
@@ -71,6 +71,7 @@ export const platformContracts = {
|
||||
revokeConnector: z.strictObject({ orgId: id, agentId: id, toolkitSlug: z.string() }) satisfies z.ZodType<C.RevokeConnectorInput>,
|
||||
approvalDrifts: z.strictObject({ orgId: id }),
|
||||
runRequests: z.strictObject({ orgId: id, runId: id }),
|
||||
chatThreadHistory: z.strictObject({ orgId: id, threadId: id }),
|
||||
search: z.strictObject({
|
||||
orgId: id, employeeId: id, query: z.string(), entityTypes: z.array(z.enum(SEARCH_ENTITY_TYPES)).optional(), limit: z.number().optional(),
|
||||
}) satisfies z.ZodType<C.SearchInput>,
|
||||
|
||||
@@ -28,6 +28,7 @@ import { SprintService } from "../services/sprint-service";
|
||||
import { ComposioConnectionService } from "../services/composio/composio-connection-service";
|
||||
import { ConnectorGrantsService } from "../services/composio/connector-grants";
|
||||
import { approvalDrifts } from "../services/approval-drift";
|
||||
import { threadHistory } from "../services/chat-history";
|
||||
import { requestsForRun } from "../services/provider-request-store";
|
||||
import { setGoalAuditor } from "../services/goal-audit-inputs";
|
||||
import { IntegrationKeyStore } from "../services/integration-key-store";
|
||||
@@ -413,6 +414,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
});
|
||||
handle("grantConnector", (input) => mutate(() => new ConnectorGrantsService(deps.connection.db).grantFromBoard(input)));
|
||||
handle("runRequests", (input) => ok(requestsForRun(deps.connection.db, input.orgId, input.runId)));
|
||||
handle("chatThreadHistory", (input) => ok(threadHistory(deps.connection.db, input.orgId, input.threadId)));
|
||||
handle("approvalDrifts", (input) => {
|
||||
try {
|
||||
return ok(approvalDrifts(deps.connection.db, input.orgId));
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ThreadHistory } from "../../shared/chat-domain";
|
||||
import { camel } from "./snapshot-service";
|
||||
|
||||
/** Every message of one thread with its attachments, mentions and commands, however far back it started. */
|
||||
export function threadHistory(db: DatabaseSync, orgId: string, threadId: string): ThreadHistory {
|
||||
const thread = db.prepare("select id from chat_threads where id = ? and org_id = ?").get(threadId, orgId);
|
||||
if (!thread) throw new Error("Chat thread was not found.");
|
||||
const rows = <T>(sql: string) => (db.prepare(sql).all(threadId) as Array<Record<string, unknown>>).map((row) => camel(row) as T);
|
||||
return {
|
||||
messages: rows("select * from chat_messages where thread_id = ? order by created_at, rowid"),
|
||||
attachments: rows("select a.* from chat_attachments a join chat_messages m on m.id = a.message_id where m.thread_id = ?"),
|
||||
mentions: rows("select x.* from chat_mentions x join chat_messages m on m.id = x.message_id where m.thread_id = ? order by x.offset_start"),
|
||||
slashInvocations: rows("select s.* from chat_slash_invocations s join chat_messages m on m.id = s.message_id where m.thread_id = ?"),
|
||||
};
|
||||
}
|
||||
@@ -21,7 +21,7 @@ function toBool(value: unknown): boolean {
|
||||
return value === 1 || value === true;
|
||||
}
|
||||
|
||||
function camel(row: Row): Row {
|
||||
export function camel(row: Row): Row {
|
||||
const next: Row = {};
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
next[key.replace(/_([a-z])/g, (_m, ch: string) => ch.toUpperCase())] = value;
|
||||
@@ -97,7 +97,13 @@ export class SnapshotService {
|
||||
chatSpaces: list(this.db, "chat_spaces", "created_at asc"),
|
||||
chatMemberships: list(this.db, "chat_memberships", "space_id asc, principal_type asc, principal_id asc"),
|
||||
chatMessages: recentList<AppSnapshot["chatMessages"][number]>(this.db, "chat_messages", "created_at desc", 500).reverse(),
|
||||
chatThreads: recentList(this.db, "chat_threads", "updated_at desc", 200),
|
||||
// Every thread a loaded message belongs to, plus the most recently active ones, so no shown message loses its thread.
|
||||
chatThreads: all<Row>(this.db.prepare(`
|
||||
select * from chat_threads
|
||||
where id in (select thread_id from (select thread_id from chat_messages order by created_at desc limit 500))
|
||||
or id in (select id from chat_threads order by updated_at desc limit 200)
|
||||
order by updated_at desc
|
||||
`)).map((row) => camel(row) as unknown as AppSnapshot["chatThreads"][number]),
|
||||
chatAttachments: recentList(this.db, "chat_attachments", "message_id desc", 500),
|
||||
chatMentions: recentList(this.db, "chat_mentions", "message_id desc, offset_start asc", 500),
|
||||
chatSlashInvocations: recentList(this.db, "chat_slash_invocations", "created_at desc", 200),
|
||||
|
||||
@@ -88,6 +88,7 @@ const api: DesktopApi = {
|
||||
grantConnector: (input) => invoke(IPC_CHANNELS.grantConnector, input),
|
||||
approvalDrifts: (input) => invoke(IPC_CHANNELS.approvalDrifts, input),
|
||||
runRequests: (input) => invoke(IPC_CHANNELS.runRequests, input),
|
||||
chatThreadHistory: (input) => invoke(IPC_CHANNELS.chatThreadHistory, input),
|
||||
revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input),
|
||||
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
|
||||
search: (input) => invoke(IPC_CHANNELS.search, input),
|
||||
|
||||
@@ -39,6 +39,15 @@ export const zh: Record<string, string> = {
|
||||
"Not sent: {reason}": "未发送:{reason}",
|
||||
"Remove {name}": "移除 {name}",
|
||||
"Not attached: {reason}": "未能附加:{reason}",
|
||||
"Reply in thread": "在话题中回复",
|
||||
"Replying to {name}: {snippet}": "回复 {name}:{snippet}",
|
||||
"Replying in thread: {snippet}": "在话题中回复:{snippet}",
|
||||
"1 reply": "1 条回复",
|
||||
"{count} replies": "{count} 条回复",
|
||||
"Thread": "话题",
|
||||
"Close thread": "关闭话题",
|
||||
"Reply": "回复",
|
||||
"Send reply": "发送回复",
|
||||
"Resume": "恢复",
|
||||
"Missing this run": "本次运行缺少的能力",
|
||||
"Disconnect": "断开连接",
|
||||
|
||||
@@ -149,6 +149,19 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const replyInThread = async (threadId: string, body: string) => {
|
||||
if (!activeSpace) throw new Error("Open a conversation before replying.");
|
||||
await actions.run(() => window.agentCompany.sendMessage({
|
||||
orgId,
|
||||
spaceId: activeSpace.id,
|
||||
threadId,
|
||||
senderType: "employee",
|
||||
senderId: employee.id,
|
||||
bodyMd: body,
|
||||
attachmentFileIds: [],
|
||||
}));
|
||||
};
|
||||
|
||||
const createSpace = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
@@ -235,6 +248,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
space={activeSpace}
|
||||
participants={participants}
|
||||
messagesInSpace={messagesInSpace}
|
||||
threads={snapshot.chatThreads}
|
||||
pendingApprovals={pendingApprovalsInSpace}
|
||||
attachments={snapshot.chatAttachments}
|
||||
mentions={mentionsAll}
|
||||
@@ -257,6 +271,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
onDeleteMessage={deleteChatMessage}
|
||||
onOpenFile={openFile}
|
||||
onSubmit={sendInChannel}
|
||||
onReplyInThread={replyInThread}
|
||||
send={(activeSpaceId ? sends[activeSpaceId] : undefined) ?? IDLE_SEND}
|
||||
composerRef={composerRef}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useLayoutEffect, useRef, useState, type FormEvent, type MutableRefObject } from "react";
|
||||
import { useLayoutEffect, useMemo, useRef, useState, type FormEvent, type MutableRefObject } from "react";
|
||||
import { AvatarStack, Icon, IconButton } from "../../shared/ui";
|
||||
import type {
|
||||
ApprovalRecord,
|
||||
@@ -7,10 +7,13 @@ import type {
|
||||
ChatMessageRecord,
|
||||
ChatSpaceRecord,
|
||||
ChatSlashInvocationRecord,
|
||||
ChatThreadRecord,
|
||||
FileObjectRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { ChatComposer, type Draft, type SendState } from "./ChatComposer";
|
||||
import { ChatTimeline } from "./ChatTimeline";
|
||||
import { ThreadPanel } from "./ThreadPanel";
|
||||
import { buildThreadLinks } from "./thread-links";
|
||||
import type { MessageContext } from "./chat-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
@@ -18,6 +21,7 @@ export function ChatConversation({
|
||||
space,
|
||||
participants,
|
||||
messagesInSpace,
|
||||
threads,
|
||||
pendingApprovals,
|
||||
attachments,
|
||||
mentions,
|
||||
@@ -36,12 +40,14 @@ export function ChatConversation({
|
||||
onDeleteMessage,
|
||||
onOpenFile,
|
||||
onSubmit,
|
||||
onReplyInThread,
|
||||
send,
|
||||
composerRef,
|
||||
}: {
|
||||
space: ChatSpaceRecord | null;
|
||||
participants: string[];
|
||||
messagesInSpace: ChatMessageRecord[];
|
||||
threads: ChatThreadRecord[];
|
||||
pendingApprovals: ApprovalRecord[];
|
||||
attachments: ChatAttachmentRecord[];
|
||||
mentions: ChatMentionRecord[];
|
||||
@@ -60,10 +66,14 @@ export function ChatConversation({
|
||||
onDeleteMessage: (messageId: string) => void;
|
||||
onOpenFile: (fileId: string) => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
onReplyInThread: (threadId: string, body: string) => Promise<void>;
|
||||
send: SendState;
|
||||
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
// The component is keyed by space, so an open thread closes when the conversation changes.
|
||||
const [openThreadId, setOpenThreadId] = useState<string | null>(null);
|
||||
const threadLinks = useMemo(() => buildThreadLinks(messagesInSpace, threads, context), [messagesInSpace, threads, context]);
|
||||
const trimmed = query.trim().toLowerCase();
|
||||
const filtered = trimmed
|
||||
? messagesInSpace.filter((m) => m.bodyMd.toLowerCase().includes(trimmed))
|
||||
@@ -103,58 +113,75 @@ export function ChatConversation({
|
||||
}, [lastMessageId]);
|
||||
|
||||
return (
|
||||
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
|
||||
{space && <ConversationHeader space={space} participants={participants} query={query} onQueryChange={setQuery} />}
|
||||
<div style={{ position: "relative", flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
role="log"
|
||||
aria-label={t("Conversation messages")}
|
||||
style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}
|
||||
>
|
||||
{space && (
|
||||
<ChatTimeline
|
||||
key={space.id}
|
||||
messagesInSpace={filtered}
|
||||
pendingApprovals={pendingApprovals}
|
||||
attachments={attachments}
|
||||
mentions={mentions}
|
||||
slashInvocations={slashInvocations}
|
||||
files={files}
|
||||
context={context}
|
||||
currentEmployeeId={currentEmployeeId}
|
||||
onApprove={onApprove}
|
||||
onDecline={onDecline}
|
||||
onEditMessage={onEditMessage}
|
||||
onDeleteMessage={onDeleteMessage}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
<>
|
||||
<section style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column", background: "var(--panel)" }}>
|
||||
{space && <ConversationHeader space={space} participants={participants} query={query} onQueryChange={setQuery} />}
|
||||
<div style={{ position: "relative", flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
role="log"
|
||||
aria-label={t("Conversation messages")}
|
||||
style={{ flex: 1, overflow: "auto", padding: "20px 24px 8px" }}
|
||||
>
|
||||
{space && (
|
||||
<ChatTimeline
|
||||
key={space.id}
|
||||
messagesInSpace={filtered}
|
||||
pendingApprovals={pendingApprovals}
|
||||
attachments={attachments}
|
||||
mentions={mentions}
|
||||
slashInvocations={slashInvocations}
|
||||
files={files}
|
||||
context={context}
|
||||
currentEmployeeId={currentEmployeeId}
|
||||
threadLinks={threadLinks}
|
||||
onOpenThread={setOpenThreadId}
|
||||
onApprove={onApprove}
|
||||
onDecline={onDecline}
|
||||
onEditMessage={onEditMessage}
|
||||
onDeleteMessage={onDeleteMessage}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!atBottom && (
|
||||
<button type="button" className="jump-latest" onClick={() => jumpToLatest("smooth")}>
|
||||
<span aria-hidden style={{ fontSize: 13, lineHeight: 1 }}>↓</span>
|
||||
{t("Jump to latest")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!atBottom && (
|
||||
<button type="button" className="jump-latest" onClick={() => jumpToLatest("smooth")}>
|
||||
<span aria-hidden style={{ fontSize: 13, lineHeight: 1 }}>↓</span>
|
||||
{t("Jump to latest")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ChatComposer
|
||||
disabled={!space}
|
||||
send={send}
|
||||
files={files}
|
||||
agents={context.agents}
|
||||
employees={context.employees}
|
||||
draft={draft}
|
||||
attachError={attachError}
|
||||
spaceId={space?.id ?? null}
|
||||
onLeave={onDraftLeave}
|
||||
onAttachmentRemove={onAttachmentRemove}
|
||||
onAttach={onAttach}
|
||||
onSubmit={onSubmit}
|
||||
composerRef={composerRef}
|
||||
/>
|
||||
</section>
|
||||
<ChatComposer
|
||||
disabled={!space}
|
||||
send={send}
|
||||
files={files}
|
||||
agents={context.agents}
|
||||
employees={context.employees}
|
||||
draft={draft}
|
||||
attachError={attachError}
|
||||
spaceId={space?.id ?? null}
|
||||
onLeave={onDraftLeave}
|
||||
onAttachmentRemove={onAttachmentRemove}
|
||||
onAttach={onAttach}
|
||||
onSubmit={onSubmit}
|
||||
composerRef={composerRef}
|
||||
/>
|
||||
</section>
|
||||
{openThreadId && space && (
|
||||
<ThreadPanel
|
||||
key={openThreadId}
|
||||
orgId={space.orgId}
|
||||
threadId={openThreadId}
|
||||
refreshKey={messagesInSpace}
|
||||
files={files}
|
||||
context={context}
|
||||
onReply={(body) => onReplyInThread(openThreadId, body)}
|
||||
onClose={() => setOpenThreadId(null)}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
} from "../../../../shared/domain";
|
||||
import { formatTime } from "../types";
|
||||
import { messageAttachments, senderName, type MessageContext } from "./chat-helpers";
|
||||
import type { ThreadLink } from "./thread-links";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
export function ChatMessageRow({
|
||||
@@ -19,6 +20,8 @@ export function ChatMessageRow({
|
||||
files,
|
||||
context,
|
||||
canModify = false,
|
||||
thread,
|
||||
onOpenThread,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onOpenFile,
|
||||
@@ -30,6 +33,9 @@ export function ChatMessageRow({
|
||||
files: FileObjectRecord[];
|
||||
context: MessageContext;
|
||||
canModify?: boolean;
|
||||
/** Where the message sits in its thread; absent inside the thread view itself. */
|
||||
thread?: ThreadLink;
|
||||
onOpenThread?: () => void;
|
||||
onEdit?: (bodyMd: string) => void;
|
||||
onDelete?: () => void;
|
||||
onOpenFile?: (fileId: string) => void;
|
||||
@@ -50,20 +56,33 @@ export function ChatMessageRow({
|
||||
setEditing(false);
|
||||
};
|
||||
return (
|
||||
<div style={{ display: "flex", gap: 12, marginBottom: 18 }}>
|
||||
<div className="chat-message" 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>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>{formatTime(message.createdAt)}</span>
|
||||
{message.editedAt && !deleted && <span style={{ fontSize: 12, color: "var(--text-3)" }}>{t("(edited)")}</span>}
|
||||
{canModify && !editing && (
|
||||
{!editing && (canModify || (onOpenThread && !deleted)) && (
|
||||
<span style={{ marginLeft: "auto", display: "flex", gap: 2 }}>
|
||||
<IconButton icon="edit" label={t("Edit message")} size={16} onClick={() => setEditing(true)} />
|
||||
<IconButton icon="x" label={t("Delete message")} size={16} onClick={onDelete} />
|
||||
{onOpenThread && !deleted && <IconButton icon="chat" label={t("Reply in thread")} size={16} onClick={onOpenThread} />}
|
||||
{canModify && <IconButton icon="edit" label={t("Edit message")} size={16} onClick={() => setEditing(true)} />}
|
||||
{canModify && <IconButton icon="x" label={t("Delete message")} size={16} onClick={onDelete} />}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{thread?.replyTo && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={thread.replyTo.name === null
|
||||
? t("Replying in thread: {snippet}", { snippet: thread.replyTo.snippet })
|
||||
: t("Replying to {name}: {snippet}", { name: thread.replyTo.name, snippet: thread.replyTo.snippet })}
|
||||
onClick={onOpenThread}
|
||||
style={{ display: "block", maxWidth: "100%", margin: "0 0 4px", padding: 0, border: 0, background: "transparent", color: "var(--text-3)", fontSize: 12, textAlign: "left", cursor: "pointer", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
↳ {thread.replyTo.name === null ? thread.replyTo.snippet : `${thread.replyTo.name}: ${thread.replyTo.snippet}`}
|
||||
</button>
|
||||
)}
|
||||
{slashInvocation && (
|
||||
<div className="pill mono" style={{ marginBottom: 6, width: "fit-content" }}>
|
||||
/{slashInvocation.command}
|
||||
@@ -100,6 +119,11 @@ export function ChatMessageRow({
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{thread && thread.replyCount > 0 && (
|
||||
<button type="button" onClick={onOpenThread} style={{ marginTop: 4, padding: 0, border: 0, background: "transparent", color: "var(--accent)", fontSize: 12, cursor: "pointer" }}>
|
||||
{thread.replyCount === 1 ? t("1 reply") : t("{count} replies", { count: String(thread.replyCount) })}
|
||||
</button>
|
||||
)}
|
||||
{readers.length > 0 && (
|
||||
<div style={{ fontSize: 12, color: "var(--text-3)", marginTop: 4 }}>
|
||||
{t("Read by {names}", { names: readers.join(", ") })}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
FileObjectRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { ChatMessageRow } from "./ChatMessageRow";
|
||||
import type { ThreadLink } from "./thread-links";
|
||||
import { formatDay, type MessageContext } from "./chat-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
@@ -37,6 +38,8 @@ export function ChatTimeline({
|
||||
files,
|
||||
context,
|
||||
currentEmployeeId,
|
||||
threadLinks,
|
||||
onOpenThread,
|
||||
onApprove,
|
||||
onDecline,
|
||||
onEditMessage,
|
||||
@@ -51,6 +54,8 @@ export function ChatTimeline({
|
||||
files: FileObjectRecord[];
|
||||
context: MessageContext;
|
||||
currentEmployeeId: string;
|
||||
threadLinks: Map<string, ThreadLink>;
|
||||
onOpenThread: (threadId: string) => void;
|
||||
onApprove: (approvalId: string) => void;
|
||||
onDecline: (approvalId: string) => void;
|
||||
onEditMessage: (messageId: string, bodyMd: string) => void;
|
||||
@@ -83,6 +88,7 @@ export function ChatTimeline({
|
||||
lastDay = day;
|
||||
elements.push(<DayDivider key={`day-${day}`} label={day} />);
|
||||
}
|
||||
const threadId = message.threadId;
|
||||
elements.push(
|
||||
<ChatMessageRow
|
||||
key={message.id}
|
||||
@@ -93,6 +99,8 @@ export function ChatTimeline({
|
||||
files={files}
|
||||
context={context}
|
||||
canModify={message.senderType === "employee" && message.senderId === currentEmployeeId && !message.deletedAt}
|
||||
thread={threadLinks.get(message.id)}
|
||||
onOpenThread={threadId ? () => onOpenThread(threadId) : undefined}
|
||||
onEdit={(bodyMd) => onEditMessage(message.id, bodyMd)}
|
||||
onDelete={() => onDeleteMessage(message.id)}
|
||||
onOpenFile={onOpenFile}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Button, IconButton } from "../../shared/ui";
|
||||
import type { FileObjectRecord } from "../../../../shared/domain";
|
||||
import type { ThreadHistory } from "../../../../shared/chat-domain";
|
||||
import { ChatMessageRow } from "./ChatMessageRow";
|
||||
import { MentionTextarea } from "./MentionTextarea";
|
||||
import type { MessageContext } from "./chat-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
/** One thread beside the conversation: its root and every answer, and a reply box that posts into it. */
|
||||
export function ThreadPanel({
|
||||
orgId,
|
||||
threadId,
|
||||
refreshKey,
|
||||
files,
|
||||
context,
|
||||
onReply,
|
||||
onClose,
|
||||
onOpenFile,
|
||||
}: {
|
||||
orgId: string;
|
||||
threadId: string;
|
||||
/** Changes with every snapshot reload, so new, edited and deleted messages show in the thread. */
|
||||
refreshKey: unknown;
|
||||
files: FileObjectRecord[];
|
||||
context: MessageContext;
|
||||
onReply: (body: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
onOpenFile: (fileId: string) => void;
|
||||
}) {
|
||||
// The snapshot keeps only recent chat, so the thread is loaded whole on its own.
|
||||
const [history, setHistory] = useState<ThreadHistory | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
window.agentCompany.chatThreadHistory({ orgId, threadId }).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.ok) {
|
||||
setHistory(result.data);
|
||||
setLoadError(null);
|
||||
} else setLoadError(result.error.message);
|
||||
}).catch((reason: unknown) => {
|
||||
if (!cancelled) setLoadError(reason instanceof Error ? reason.message : String(reason));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [orgId, threadId, refreshKey]);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const field = form.elements.namedItem("reply") as HTMLTextAreaElement;
|
||||
const body = field.value.trim();
|
||||
if (!body || pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
// The reply stays in the box until it is saved, so a refused one loses nothing.
|
||||
onReply(body).then(() => {
|
||||
field.value = "";
|
||||
setPending(false);
|
||||
}).catch((reason: unknown) => {
|
||||
const message = reason instanceof Error ? reason.message : String(reason);
|
||||
console.error(`[chat] thread reply failed: ${message}`);
|
||||
setError(message);
|
||||
setPending(false);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<aside role="complementary" aria-label={t("Thread")} style={{ width: 360, flexShrink: 0, display: "flex", flexDirection: "column", borderLeft: "1px solid var(--border-soft)", background: "var(--panel)" }}>
|
||||
<header className="row" style={{ height: 52, padding: "0 16px", borderBottom: "1px solid var(--border-soft)", flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t("Thread")}</span>
|
||||
<span style={{ marginLeft: "auto" }}><IconButton icon="x" label={t("Close thread")} onClick={onClose} /></span>
|
||||
</header>
|
||||
<div style={{ flex: 1, overflow: "auto", padding: "16px 16px 8px" }}>
|
||||
{loadError && <div className="form-error" role="alert">{loadError}</div>}
|
||||
{history?.messages.map((message) => (
|
||||
<ChatMessageRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
attachments={history.attachments}
|
||||
mentions={history.mentions}
|
||||
slashInvocations={history.slashInvocations}
|
||||
files={files}
|
||||
context={context}
|
||||
onOpenFile={onOpenFile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={submit} style={{ padding: 12, borderTop: "1px solid var(--border-soft)" }}>
|
||||
<MentionTextarea name="reply" rows={2} disabled={pending} ariaLabel={t("Reply")} agents={context.agents} employees={context.employees} />
|
||||
{error && <div className="form-error" role="alert">{t("Not sent: {reason}", { reason: error })}</div>}
|
||||
<div className="row" style={{ marginTop: 6 }}>
|
||||
<span style={{ marginLeft: "auto" }}><Button tone="primary" size="small" type="submit" disabled={pending}>{t("Send reply")}</Button></span>
|
||||
</div>
|
||||
</form>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ChatMessageRecord, ChatThreadRecord } from "../../../../shared/domain";
|
||||
import { senderName, type MessageContext } from "./chat-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
/** How one message sits in its thread: which message it answers, and how many answers a thread root has. */
|
||||
export interface ThreadLink {
|
||||
threadId: string;
|
||||
/** The root's author and first line; the author is null when the root is older than the loaded chat and only the thread title is known. */
|
||||
replyTo: { name: string | null; snippet: string } | null;
|
||||
replyCount: number;
|
||||
}
|
||||
|
||||
const SNIPPET_CHARS = 80;
|
||||
|
||||
function snippet(message: ChatMessageRecord): string {
|
||||
if (message.deletedAt) return t("Message deleted");
|
||||
const line = message.bodyMd.split(/\r?\n/, 1)[0].trim();
|
||||
return line.length > SNIPPET_CHARS ? `${line.slice(0, SNIPPET_CHARS - 1)}…` : line;
|
||||
}
|
||||
|
||||
/** Thread links for every threaded message in a conversation, keyed by message id. */
|
||||
export function buildThreadLinks(messages: ChatMessageRecord[], threads: ChatThreadRecord[], context: MessageContext): Map<string, ThreadLink> {
|
||||
const rootOf = new Map(threads.map((thread) => [thread.id, thread.rootMessageId]));
|
||||
const titleOf = new Map(threads.map((thread) => [thread.id, thread.title]));
|
||||
const byId = new Map(messages.map((message) => [message.id, message]));
|
||||
const replies = new Map<string, number>();
|
||||
for (const message of messages) {
|
||||
if (message.threadId && rootOf.get(message.threadId) !== message.id) replies.set(message.threadId, (replies.get(message.threadId) ?? 0) + 1);
|
||||
}
|
||||
const links = new Map<string, ThreadLink>();
|
||||
for (const message of messages) {
|
||||
if (!message.threadId) continue;
|
||||
const rootId = rootOf.get(message.threadId);
|
||||
const root = rootId ? byId.get(rootId) : undefined;
|
||||
const isRoot = rootId === message.id;
|
||||
const title = titleOf.get(message.threadId);
|
||||
const replyTo = isRoot ? null : root ? { name: senderName(root, context), snippet: snippet(root) } : title !== undefined ? { name: null, snippet: title } : null;
|
||||
links.set(message.id, {
|
||||
threadId: message.threadId,
|
||||
replyTo,
|
||||
replyCount: isRoot ? replies.get(message.threadId) ?? 0 : 0,
|
||||
});
|
||||
}
|
||||
return links;
|
||||
}
|
||||
@@ -89,3 +89,11 @@ export interface ChatReadStateRecord {
|
||||
lastReadMessageId: string | null;
|
||||
lastReadAt: string;
|
||||
}
|
||||
|
||||
/** One thread in full, loaded on demand because the snapshot holds only recent chat. */
|
||||
export interface ThreadHistory {
|
||||
messages: ChatMessageRecord[];
|
||||
attachments: ChatAttachmentRecord[];
|
||||
mentions: ChatMentionRecord[];
|
||||
slashInvocations: ChatSlashInvocationRecord[];
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ThreadHistory } from "./chat-domain";
|
||||
import type { RunRequestRecord } from "./provider-runtime-domain";
|
||||
import type { ApprovalDrift } from "./approval-pin";
|
||||
import type { DesktopResult } from "./result";
|
||||
@@ -195,6 +196,8 @@ export interface DesktopApi {
|
||||
approvalDrifts(input: { orgId: string }): Promise<DesktopResult<Record<string, ApprovalDrift>>>;
|
||||
/** Every model call a task run made, in order: turns, goal audits and model checks. */
|
||||
runRequests(input: { orgId: string; runId: string }): Promise<DesktopResult<RunRequestRecord[]>>;
|
||||
/** One thread in full, beyond the snapshot's recent window. */
|
||||
chatThreadHistory(input: { orgId: string; threadId: string }): Promise<DesktopResult<ThreadHistory>>;
|
||||
revokeConnector(input: RevokeConnectorInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
search(input: SearchInput): Promise<DesktopResult<SearchResultRecord[]>>;
|
||||
subscribe(callback: (event: RendererEvent) => void): () => void;
|
||||
|
||||
@@ -81,6 +81,7 @@ export const IPC_CHANNELS = {
|
||||
grantConnector: "agentcompany:grant-connector",
|
||||
approvalDrifts: "agentcompany:approval-drifts",
|
||||
runRequests: "agentcompany:run-requests",
|
||||
chatThreadHistory: "agentcompany:chat-thread-history",
|
||||
revokeConnector: "agentcompany:revoke-connector",
|
||||
search: "agentcompany:search",
|
||||
rendererEvent: "agentcompany:renderer-event",
|
||||
|
||||
@@ -118,6 +118,21 @@ test("the pinned CEO conversation is the Messages front door", async () => {
|
||||
await expect(composer).toHaveValue("Draft for the CEO only");
|
||||
await page.locator(".convo-item", { hasText: "general" }).click();
|
||||
await expect(composer).toHaveValue("Draft for general");
|
||||
|
||||
// A reply goes into the thread of the message it answers, and the timeline says what it answers.
|
||||
await page.locator(".convo-item").first().click();
|
||||
const root = chatLog.locator(".chat-message", { hasText: "How are we tracking against the goal?" }).first();
|
||||
await root.getByRole("button", { name: "Reply in thread" }).click();
|
||||
const thread = page.getByRole("complementary", { name: "Thread" });
|
||||
await expect(thread).toContainText("How are we tracking against the goal?");
|
||||
await thread.getByRole("textbox", { name: "Reply" }).fill("Also: which task is late?");
|
||||
await thread.getByRole("button", { name: "Send reply" }).click();
|
||||
await expect(thread.locator(".chat-message", { hasText: "Also: which task is late?" })).toBeVisible();
|
||||
const reply = chatLog.locator(".chat-message", { hasText: "Also: which task is late?" });
|
||||
await expect(reply.getByRole("button", { name: /^Replying to Yang/ })).toContainText("How are we tracking against the goal?");
|
||||
await expect(root).toContainText("1 reply");
|
||||
await thread.getByRole("button", { name: "Close thread" }).click();
|
||||
await expect(thread).toHaveCount(0);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { SnapshotService } from "../../src/main/services/snapshot-service";
|
||||
import { threadHistory } from "../../src/main/services/chat-history";
|
||||
|
||||
describe("a thread's history", () => {
|
||||
test("holds the whole thread even after newer traffic pushed it out of the recent window", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-thread-history-${randomUUID()}.db`));
|
||||
const chat = new ChatService(connection.db);
|
||||
let orgId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = new OrganizationService(connection.db).createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
});
|
||||
const employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
|
||||
const spaceId = (connection.db.prepare("select id from chat_spaces where org_id = ? and name = 'general'").get(orgId) as { id: string }).id;
|
||||
const send = (bodyMd: string, threadId: string | null) => connection.transaction(() => chat.sendMessage({
|
||||
orgId, spaceId, threadId, senderType: "employee", senderId: employeeId, bodyMd, attachmentFileIds: [],
|
||||
}));
|
||||
const rootId = send("Root question", null);
|
||||
const threadId = (connection.db.prepare("select thread_id from chat_messages where id = ?").get(rootId) as { thread_id: string }).thread_id;
|
||||
send("First answer", threadId);
|
||||
for (let index = 0; index < 500; index += 1) send(`Filler ${index}`, null);
|
||||
send("Late answer", threadId);
|
||||
|
||||
const snapshot = new SnapshotService(connection.db).load();
|
||||
expect(snapshot.chatMessages.some((message) => message.id === rootId)).toBe(false);
|
||||
// The thread of every loaded message is in the snapshot, however many threads are newer.
|
||||
const loadedThreads = new Set(snapshot.chatThreads.map((thread) => thread.id));
|
||||
expect(snapshot.chatMessages.filter((message) => message.threadId && !loadedThreads.has(message.threadId))).toEqual([]);
|
||||
expect(threadHistory(connection.db, orgId, threadId).messages.map((message) => message.bodyMd)).toEqual(["Root question", "First answer", "Late answer"]);
|
||||
expect(() => threadHistory(connection.db, orgId, "thread_missing")).toThrow("Chat thread was not found.");
|
||||
connection.close();
|
||||
}, 60_000);
|
||||
});
|
||||
Reference in new issue
Block a user