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:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 23:01:57 -04:00
1 parent a6ab09d6b5
commit 87c297dd45
17 files changed
+377 -57

No files matched your search

+1
View File
@@ -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>,
+2
View File
@@ -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));
+16
View File
@@ -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 = ?"),
};
}
+8 -2
View File
@@ -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),
+1
View File
@@ -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),
+9
View File
@@ -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": "断开连接",
+15
View File
@@ -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}
/>
)}
</>
);
}
+28 -4
View File
@@ -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}
+100
View File
@@ -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;
}
+8
View File
@@ -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[];
}
+3
View File
@@ -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;
+1
View File
@@ -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",
+15
View File
@@ -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();
}
+39
View File
@@ -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);
});