From 87c297dd453a5f677881afe6349eb2176ee60fe3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 23:01:57 -0400 Subject: [PATCH] Q141: reply to a specific message in its thread, and see what each reply answers Co-Authored-By: Claude Opus 5.5 --- src/main/app/ipc-contracts/platform.ts | 1 + src/main/app/ipc-handlers.ts | 2 + src/main/services/chat-history.ts | 16 +++ src/main/services/snapshot-service.ts | 10 +- src/preload/index.ts | 1 + src/renderer/src/i18n/zh.ts | 9 ++ src/renderer/src/pages/ChatPage.tsx | 15 ++ .../src/pages/chat/ChatConversation.tsx | 129 +++++++++++------- .../src/pages/chat/ChatMessageRow.tsx | 32 ++++- src/renderer/src/pages/chat/ChatTimeline.tsx | 8 ++ src/renderer/src/pages/chat/ThreadPanel.tsx | 100 ++++++++++++++ src/renderer/src/pages/chat/thread-links.ts | 45 ++++++ src/shared/chat-domain.ts | 8 ++ src/shared/desktop-api.ts | 3 + src/shared/ipc.ts | 1 + tests/e2e/ceo-chat.spec.ts | 15 ++ tests/unit/chat-thread-history.test.ts | 39 ++++++ 17 files changed, 377 insertions(+), 57 deletions(-) create mode 100644 src/main/services/chat-history.ts create mode 100644 src/renderer/src/pages/chat/ThreadPanel.tsx create mode 100644 src/renderer/src/pages/chat/thread-links.ts create mode 100644 tests/unit/chat-thread-history.test.ts diff --git a/src/main/app/ipc-contracts/platform.ts b/src/main/app/ipc-contracts/platform.ts index fd39d098..71f9fab7 100644 --- a/src/main/app/ipc-contracts/platform.ts +++ b/src/main/app/ipc-contracts/platform.ts @@ -71,6 +71,7 @@ export const platformContracts = { revokeConnector: z.strictObject({ orgId: id, agentId: id, toolkitSlug: z.string() }) satisfies z.ZodType, 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, diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index c728e29f..b21b34b3 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -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)); diff --git a/src/main/services/chat-history.ts b/src/main/services/chat-history.ts new file mode 100644 index 00000000..1c837d2b --- /dev/null +++ b/src/main/services/chat-history.ts @@ -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 = (sql: string) => (db.prepare(sql).all(threadId) as Array>).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 = ?"), + }; +} diff --git a/src/main/services/snapshot-service.ts b/src/main/services/snapshot-service.ts index f2a45df4..1733f3ed 100644 --- a/src/main/services/snapshot-service.ts +++ b/src/main/services/snapshot-service.ts @@ -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(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(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), diff --git a/src/preload/index.ts b/src/preload/index.ts index 43d4d1fb..2ab72691 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index d1bf8a91..ed034879 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -39,6 +39,15 @@ export const zh: Record = { "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": "断开连接", diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index f7cd4fac..df292752 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -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) => { 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} /> diff --git a/src/renderer/src/pages/chat/ChatConversation.tsx b/src/renderer/src/pages/chat/ChatConversation.tsx index a19f4f79..8ff84478 100644 --- a/src/renderer/src/pages/chat/ChatConversation.tsx +++ b/src/renderer/src/pages/chat/ChatConversation.tsx @@ -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) => void; + onReplyInThread: (threadId: string, body: string) => Promise; send: SendState; composerRef: MutableRefObject; }) { const [query, setQuery] = useState(""); + // The component is keyed by space, so an open thread closes when the conversation changes. + const [openThreadId, setOpenThreadId] = useState(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 ( -
- {space && } -
-
- {space && ( - + <> +
+ {space && } +
+
+ {space && ( + + )} +
+ {!atBottom && ( + )}
- {!atBottom && ( - - )} -
- -
+ + + {openThreadId && space && ( + onReplyInThread(openThreadId, body)} + onClose={() => setOpenThreadId(null)} + onOpenFile={onOpenFile} + /> + )} + ); } diff --git a/src/renderer/src/pages/chat/ChatMessageRow.tsx b/src/renderer/src/pages/chat/ChatMessageRow.tsx index 50681bae..388f30bb 100644 --- a/src/renderer/src/pages/chat/ChatMessageRow.tsx +++ b/src/renderer/src/pages/chat/ChatMessageRow.tsx @@ -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 ( -
+
{name} {formatTime(message.createdAt)} {message.editedAt && !deleted && {t("(edited)")}} - {canModify && !editing && ( + {!editing && (canModify || (onOpenThread && !deleted)) && ( - setEditing(true)} /> - + {onOpenThread && !deleted && } + {canModify && setEditing(true)} />} + {canModify && } )}
+ {thread?.replyTo && ( + + )} {slashInvocation && (
/{slashInvocation.command} @@ -100,6 +119,11 @@ export function ChatMessageRow({ })}
)} + {thread && thread.replyCount > 0 && ( + + )} {readers.length > 0 && (
{t("Read by {names}", { names: readers.join(", ") })} diff --git a/src/renderer/src/pages/chat/ChatTimeline.tsx b/src/renderer/src/pages/chat/ChatTimeline.tsx index a361970e..09745907 100644 --- a/src/renderer/src/pages/chat/ChatTimeline.tsx +++ b/src/renderer/src/pages/chat/ChatTimeline.tsx @@ -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; + 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(); } + const threadId = message.threadId; elements.push( onOpenThread(threadId) : undefined} onEdit={(bodyMd) => onEditMessage(message.id, bodyMd)} onDelete={() => onDeleteMessage(message.id)} onOpenFile={onOpenFile} diff --git a/src/renderer/src/pages/chat/ThreadPanel.tsx b/src/renderer/src/pages/chat/ThreadPanel.tsx new file mode 100644 index 00000000..1419a4ad --- /dev/null +++ b/src/renderer/src/pages/chat/ThreadPanel.tsx @@ -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; + 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(null); + const [loadError, setLoadError] = useState(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(null); + const submit = (event: FormEvent) => { + 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 ( + + ); +} diff --git a/src/renderer/src/pages/chat/thread-links.ts b/src/renderer/src/pages/chat/thread-links.ts new file mode 100644 index 00000000..169b49a9 --- /dev/null +++ b/src/renderer/src/pages/chat/thread-links.ts @@ -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 { + 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(); + 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(); + 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; +} diff --git a/src/shared/chat-domain.ts b/src/shared/chat-domain.ts index f0847011..297f517c 100644 --- a/src/shared/chat-domain.ts +++ b/src/shared/chat-domain.ts @@ -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[]; +} diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index 8964b39b..33b8f0e9 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -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>>; /** Every model call a task run made, in order: turns, goal audits and model checks. */ runRequests(input: { orgId: string; runId: string }): Promise>; + /** One thread in full, beyond the snapshot's recent window. */ + chatThreadHistory(input: { orgId: string; threadId: string }): Promise>; revokeConnector(input: RevokeConnectorInput): Promise>; search(input: SearchInput): Promise>; subscribe(callback: (event: RendererEvent) => void): () => void; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 8b3b93ff..b00eb91c 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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", diff --git a/tests/e2e/ceo-chat.spec.ts b/tests/e2e/ceo-chat.spec.ts index c835f324..f0141ea4 100644 --- a/tests/e2e/ceo-chat.spec.ts +++ b/tests/e2e/ceo-chat.spec.ts @@ -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(); } diff --git a/tests/unit/chat-thread-history.test.ts b/tests/unit/chat-thread-history.test.ts new file mode 100644 index 00000000..ee8b791b --- /dev/null +++ b/tests/unit/chat-thread-history.test.ts @@ -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); +});