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:
1 parent
e53ac36176
commit
0a40c55b3c
6 files changed
+30
-3
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
IPC_CHANNELS,
|
IPC_CHANNELS,
|
||||||
listThreadEventsSchema,
|
listThreadEventsSchema,
|
||||||
|
listThreadParticipantsSchema,
|
||||||
listThreadWaitStatesSchema,
|
listThreadWaitStatesSchema,
|
||||||
threadGetterSchema,
|
threadGetterSchema,
|
||||||
} from "@shared/contracts";
|
} from "@shared/contracts";
|
||||||
@@ -35,4 +36,14 @@ export function registerThreadHandlers({ registerHandle, db, ensureCompanyExists
|
|||||||
const waitStates = db.listThreadWaitStates(input.companyId, { resolvedOnly: input.resolvedOnly });
|
const waitStates = db.listThreadWaitStates(input.companyId, { resolvedOnly: input.resolvedOnly });
|
||||||
return ok(waitStates);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -109,6 +109,7 @@ const api: DesktopApi = {
|
|||||||
getThread: (input) => ipcRenderer.invoke(IPC_CHANNELS.getThread, input),
|
getThread: (input) => ipcRenderer.invoke(IPC_CHANNELS.getThread, input),
|
||||||
listThreadEvents: (input) => ipcRenderer.invoke(IPC_CHANNELS.listThreadEvents, input),
|
listThreadEvents: (input) => ipcRenderer.invoke(IPC_CHANNELS.listThreadEvents, input),
|
||||||
listThreadWaitStates: (input) => ipcRenderer.invoke(IPC_CHANNELS.listThreadWaitStates, input),
|
listThreadWaitStates: (input) => ipcRenderer.invoke(IPC_CHANNELS.listThreadWaitStates, input),
|
||||||
|
listThreadParticipants: (input) => ipcRenderer.invoke(IPC_CHANNELS.listThreadParticipants, input),
|
||||||
subscribe: (callback: (event: DesktopEvent) => void) => {
|
subscribe: (callback: (event: DesktopEvent) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload);
|
const listener = (_event: Electron.IpcRendererEvent, payload: DesktopEvent) => callback(payload);
|
||||||
ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener);
|
ipcRenderer.on(IPC_CHANNELS.rendererEvent, listener);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react"
|
import { useState, useEffect, useCallback, useRef } from "react"
|
||||||
import { X, ChatCircleDots, ArrowBendUpLeft } from "../ui/icons"
|
import { X, ChatCircleDots, ArrowBendUpLeft } from "../ui/icons"
|
||||||
import { motion } from "framer-motion"
|
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 WaitStateIndicator, { type WaitStateInfo } from "./WaitStateIndicator"
|
||||||
import { MessageComposer } from "./MessageComposer"
|
import { MessageComposer } from "./MessageComposer"
|
||||||
import { formatMessageTime } from "../../lib/formatters"
|
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 [thread, setThread] = useState<ConversationThreadRecord | null>(null)
|
||||||
const [events, setEvents] = useState<ConversationEventRecord[]>([])
|
const [events, setEvents] = useState<ConversationEventRecord[]>([])
|
||||||
const [waitStates, setWaitStates] = useState<ThreadWaitStateRecord[]>([])
|
const [waitStates, setWaitStates] = useState<ThreadWaitStateRecord[]>([])
|
||||||
|
const [participants, setParticipants] = useState<ConversationParticipantRecord[]>([])
|
||||||
const [replyWaitStateId, setReplyWaitStateId] = useState<string | null>(null)
|
const [replyWaitStateId, setReplyWaitStateId] = useState<string | null>(null)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
@@ -76,6 +77,9 @@ export default function ThreadPanel({ threadId, companyId, agents, documents, on
|
|||||||
if (wsResult.ok) {
|
if (wsResult.ok) {
|
||||||
setWaitStates(wsResult.data.filter((ws) => ws.threadId === threadId && !ws.resolvedAt))
|
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])
|
}, [threadId, companyId])
|
||||||
|
|
||||||
useEffect(() => { void loadThread() }, [loadThread])
|
useEffect(() => { void loadThread() }, [loadThread])
|
||||||
@@ -96,6 +100,9 @@ export default function ThreadPanel({ threadId, companyId, agents, documents, on
|
|||||||
}, [loadThread])
|
}, [loadThread])
|
||||||
|
|
||||||
const activeReplyWaitState = waitStates.find((waitState) => waitState.waitingFor !== "user_approval") ?? null
|
const activeReplyWaitState = waitStates.find((waitState) => waitState.waitingFor !== "user_approval") ?? null
|
||||||
|
const agentParticipantIds = participants
|
||||||
|
.filter((p) => p.participantType === "agent")
|
||||||
|
.map((p) => p.participantId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -157,6 +164,7 @@ export default function ThreadPanel({ threadId, companyId, agents, documents, on
|
|||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
channel="direct"
|
channel="direct"
|
||||||
channelTargetId={null}
|
channelTargetId={null}
|
||||||
|
directParticipantIds={agentParticipantIds}
|
||||||
replyTo={null}
|
replyTo={null}
|
||||||
onSent={handleSent}
|
onSent={handleSent}
|
||||||
onCancelReply={() => setReplyWaitStateId(null)}
|
onCancelReply={() => setReplyWaitStateId(null)}
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export const IPC_CHANNELS = {
|
|||||||
getThread: "agent-company:get-thread",
|
getThread: "agent-company:get-thread",
|
||||||
listThreadEvents: "agent-company:list-thread-events",
|
listThreadEvents: "agent-company:list-thread-events",
|
||||||
listThreadWaitStates: "agent-company:list-thread-wait-states",
|
listThreadWaitStates: "agent-company:list-thread-wait-states",
|
||||||
|
listThreadParticipants: "agent-company:list-thread-participants",
|
||||||
windowMinimize: "window:minimize",
|
windowMinimize: "window:minimize",
|
||||||
windowMaximize: "window:maximize",
|
windowMaximize: "window:maximize",
|
||||||
windowClose: "window:close",
|
windowClose: "window:close",
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import type { ConversationEventRecord, ConversationThreadRecord, ThreadWaitStateRecord } from "../../types";
|
import type { ConversationEventRecord, ConversationParticipantRecord, ConversationThreadRecord, ThreadWaitStateRecord } from "../../types";
|
||||||
import type { listThreadEventsSchema, listThreadWaitStatesSchema, threadGetterSchema } from "../schemas-threads";
|
import type { listThreadEventsSchema, listThreadParticipantsSchema, listThreadWaitStatesSchema, threadGetterSchema } from "../schemas-threads";
|
||||||
import type { DesktopResult } from "./common";
|
import type { DesktopResult } from "./common";
|
||||||
|
|
||||||
export type ThreadsApi = {
|
export type ThreadsApi = {
|
||||||
getThread: (input: z.infer<typeof threadGetterSchema>) => Promise<DesktopResult<ConversationThreadRecord>>;
|
getThread: (input: z.infer<typeof threadGetterSchema>) => Promise<DesktopResult<ConversationThreadRecord>>;
|
||||||
listThreadEvents: (input: z.infer<typeof listThreadEventsSchema>) => Promise<DesktopResult<ConversationEventRecord[]>>;
|
listThreadEvents: (input: z.infer<typeof listThreadEventsSchema>) => Promise<DesktopResult<ConversationEventRecord[]>>;
|
||||||
listThreadWaitStates: (input: z.infer<typeof listThreadWaitStatesSchema>) => Promise<DesktopResult<ThreadWaitStateRecord[]>>;
|
listThreadWaitStates: (input: z.infer<typeof listThreadWaitStatesSchema>) => Promise<DesktopResult<ThreadWaitStateRecord[]>>;
|
||||||
|
listThreadParticipants: (input: z.infer<typeof listThreadParticipantsSchema>) => Promise<DesktopResult<ConversationParticipantRecord[]>>;
|
||||||
};
|
};
|
||||||
@@ -16,3 +16,8 @@ export const listThreadWaitStatesSchema = z.object({
|
|||||||
companyId: z.string().uuid(),
|
companyId: z.string().uuid(),
|
||||||
resolvedOnly: z.boolean().default(false),
|
resolvedOnly: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const listThreadParticipantsSchema = z.object({
|
||||||
|
companyId: z.string().uuid(),
|
||||||
|
threadId: z.string().uuid(),
|
||||||
|
});
|
||||||
Reference in new issue
Block a user