From 0a40c55b3c48b59e6f57ae6c4bcccdaddcba6be2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Apr 2026 01:19:23 -0400 Subject: [PATCH] fix(thread-panel): wire the scoped reply composer to real participants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The thread-panel composer was pinned to `channel="direct"` with no `directParticipantIds` and no `replyTo`. Clicking reply in a human review thread sent a message with no recipient and the send button stayed disabled — a silent dead-end for the entire Co-Founder review UX. The fix exposes the thread's participant list to the renderer: - `listThreadParticipantsSchema` and the matching IPC channel (`agent-company:list-thread-participants`) return the rows from `conversation_participants` for a given thread. - `threadIntegration.onTaskCreated` / `onApprovalCreated` already enrol the owning agent and the virtual user on thread creation, so the list is populated without any backfill. - `ThreadPanel` loads participants alongside events and forwards the agent ids to `MessageComposer.directParticipantIds`. The composer's existing `directRecipientId` memo picks the first non-board agent, so replies now target the correct agent — or surface the "no recipient" notice when the thread has none. The Board/user member is deliberately excluded from the list the composer consumes: the composer wants an agent to route to. All other wiring (wait-state precision, threadId) stays untouched. --- src/main/ipc-thread-handlers.ts | 11 +++++++++++ src/preload/index.ts | 1 + src/renderer/components/communication/ThreadPanel.tsx | 10 +++++++++- src/shared/contracts/channels.ts | 1 + src/shared/contracts/desktop-api/threads.ts | 5 +++-- src/shared/contracts/schemas-threads.ts | 5 +++++ 6 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/main/ipc-thread-handlers.ts b/src/main/ipc-thread-handlers.ts index 32fa6780..bc47e247 100644 --- a/src/main/ipc-thread-handlers.ts +++ b/src/main/ipc-thread-handlers.ts @@ -1,6 +1,7 @@ import { IPC_CHANNELS, listThreadEventsSchema, + listThreadParticipantsSchema, listThreadWaitStatesSchema, threadGetterSchema, } from "@shared/contracts"; @@ -35,4 +36,14 @@ export function registerThreadHandlers({ registerHandle, db, ensureCompanyExists const waitStates = db.listThreadWaitStates(input.companyId, { resolvedOnly: input.resolvedOnly }); return ok(waitStates); }); + + registerHandle(IPC_CHANNELS.listThreadParticipants, async (_event, payload) => { + const input = listThreadParticipantsSchema.parse(payload); + const companyError = ensureCompanyExists(input.companyId); + if (companyError) return companyError; + const thread = db.getThread(input.threadId, input.companyId); + if (!thread) return fail("THREAD_NOT_FOUND", "Thread not found"); + const participants = db.listParticipants(input.threadId); + return ok(participants); + }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index 1fc46a2b..757d95f5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -109,6 +109,7 @@ const api: DesktopApi = { getThread: (input) => ipcRenderer.invoke(IPC_CHANNELS.getThread, input), listThreadEvents: (input) => ipcRenderer.invoke(IPC_CHANNELS.listThreadEvents, input), listThreadWaitStates: (input) => ipcRenderer.invoke(IPC_CHANNELS.listThreadWaitStates, input), + listThreadParticipants: (input) => ipcRenderer.invoke(IPC_CHANNELS.listThreadParticipants, input), subscribe: (callback: (event: DesktopEvent) => void) => { const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload); ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener); diff --git a/src/renderer/components/communication/ThreadPanel.tsx b/src/renderer/components/communication/ThreadPanel.tsx index 7afa75e2..8e0eb08f 100644 --- a/src/renderer/components/communication/ThreadPanel.tsx +++ b/src/renderer/components/communication/ThreadPanel.tsx @@ -9,7 +9,7 @@ import { useState, useEffect, useCallback, useRef } from "react" import { X, ChatCircleDots, ArrowBendUpLeft } from "../ui/icons" import { motion } from "framer-motion" -import type { AgentRecord, ConversationEventRecord, ThreadWaitStateRecord, ConversationThreadRecord, DocumentRecord } from "@shared/types" +import type { AgentRecord, ConversationEventRecord, ConversationParticipantRecord, ThreadWaitStateRecord, ConversationThreadRecord, DocumentRecord } from "@shared/types" import WaitStateIndicator, { type WaitStateInfo } from "./WaitStateIndicator" import { MessageComposer } from "./MessageComposer" import { formatMessageTime } from "../../lib/formatters" @@ -62,6 +62,7 @@ export default function ThreadPanel({ threadId, companyId, agents, documents, on const [thread, setThread] = useState(null) const [events, setEvents] = useState([]) const [waitStates, setWaitStates] = useState([]) + const [participants, setParticipants] = useState([]) const [replyWaitStateId, setReplyWaitStateId] = useState(null) const scrollRef = useRef(null) @@ -76,6 +77,9 @@ export default function ThreadPanel({ threadId, companyId, agents, documents, on if (wsResult.ok) { setWaitStates(wsResult.data.filter((ws) => ws.threadId === threadId && !ws.resolvedAt)) } + + const participantsResult = await window.agentCompany.listThreadParticipants({ threadId, companyId }) + if (participantsResult.ok) setParticipants(participantsResult.data) }, [threadId, companyId]) useEffect(() => { void loadThread() }, [loadThread]) @@ -96,6 +100,9 @@ export default function ThreadPanel({ threadId, companyId, agents, documents, on }, [loadThread]) const activeReplyWaitState = waitStates.find((waitState) => waitState.waitingFor !== "user_approval") ?? null + const agentParticipantIds = participants + .filter((p) => p.participantType === "agent") + .map((p) => p.participantId) return ( setReplyWaitStateId(null)} diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index ca0fac82..5c6b4563 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -92,6 +92,7 @@ export const IPC_CHANNELS = { getThread: "agent-company:get-thread", listThreadEvents: "agent-company:list-thread-events", listThreadWaitStates: "agent-company:list-thread-wait-states", + listThreadParticipants: "agent-company:list-thread-participants", windowMinimize: "window:minimize", windowMaximize: "window:maximize", windowClose: "window:close", diff --git a/src/shared/contracts/desktop-api/threads.ts b/src/shared/contracts/desktop-api/threads.ts index 39fe5fb8..ef37bb03 100644 --- a/src/shared/contracts/desktop-api/threads.ts +++ b/src/shared/contracts/desktop-api/threads.ts @@ -1,10 +1,11 @@ import type { z } from "zod"; -import type { ConversationEventRecord, ConversationThreadRecord, ThreadWaitStateRecord } from "../../types"; -import type { listThreadEventsSchema, listThreadWaitStatesSchema, threadGetterSchema } from "../schemas-threads"; +import type { ConversationEventRecord, ConversationParticipantRecord, ConversationThreadRecord, ThreadWaitStateRecord } from "../../types"; +import type { listThreadEventsSchema, listThreadParticipantsSchema, listThreadWaitStatesSchema, threadGetterSchema } from "../schemas-threads"; import type { DesktopResult } from "./common"; export type ThreadsApi = { getThread: (input: z.infer) => Promise>; listThreadEvents: (input: z.infer) => Promise>; listThreadWaitStates: (input: z.infer) => Promise>; + listThreadParticipants: (input: z.infer) => Promise>; }; diff --git a/src/shared/contracts/schemas-threads.ts b/src/shared/contracts/schemas-threads.ts index 8478cbe6..0fa66c03 100644 --- a/src/shared/contracts/schemas-threads.ts +++ b/src/shared/contracts/schemas-threads.ts @@ -16,3 +16,8 @@ export const listThreadWaitStatesSchema = z.object({ companyId: z.string().uuid(), resolvedOnly: z.boolean().default(false), }); + +export const listThreadParticipantsSchema = z.object({ + companyId: z.string().uuid(), + threadId: z.string().uuid(), +});