fix(thread-panel): wire the scoped reply composer to real participants

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.
This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 01:19:23 -04:00
1 parent e53ac36176
commit 0a40c55b3c
6 files changed
+30 -3

No files matched your search

@@ -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<ConversationThreadRecord | null>(null)
const [events, setEvents] = useState<ConversationEventRecord[]>([])
const [waitStates, setWaitStates] = useState<ThreadWaitStateRecord[]>([])
const [participants, setParticipants] = useState<ConversationParticipantRecord[]>([])
const [replyWaitStateId, setReplyWaitStateId] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(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 (
<motion.div
@@ -157,6 +164,7 @@ export default function ThreadPanel({ threadId, companyId, agents, documents, on
companyId={companyId}
channel="direct"
channelTargetId={null}
directParticipantIds={agentParticipantIds}
replyTo={null}
onSent={handleSent}
onCancelReply={() => setReplyWaitStateId(null)}