Skip to content

Commit be8a0b3

Browse files
sung17Deathgiver
andauthored
fix(inbox): keep a conversation unread when a bot reply arrives (#1112)
`handleNewMessage` treated every outgoing message as an agent reply, so a flow step, template or comment-automation send broadcast over realtime stamped `agentLastReadAt`/`adminRepliedAt` on every open inbox tab even though nobody had opened the conversation. Mirror the server instead: only `createOutgoing` marks a conversation replied, and it stamps senderType "user" with a senderId or "api" with none. The senderId check also excludes a channel echo, which `received-message` stamps "user" with a null senderId whatever its origin. An incoming message on the open conversation still marks it read, but no longer touches `adminRepliedAt`. Co-authored-by: Deathgiver <anonymous@users.noreply.github.com>
1 parent 9b4e870 commit be8a0b3

2 files changed

Lines changed: 176 additions & 8 deletions

File tree

apps/builder/__tests__/chat-store.test.ts

Lines changed: 145 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ type TestConversation = {
3333
contactId: string
3434
messages: unknown[]
3535
lastActivityAt: Date | null
36-
agentLastReadAt?: Date
36+
agentLastReadAt?: Date | null
37+
adminRepliedAt?: Date | null
3738
}
3839

3940
type TestMessage = {
@@ -42,6 +43,8 @@ type TestMessage = {
4243
conversationId: string
4344
createdAt: Date
4445
messageType: string
46+
senderType?: string
47+
senderId?: string | null
4548
}
4649

4750
const makeConversation = (id: string, lastActivityAt: Date) =>
@@ -62,6 +65,22 @@ const makeMessage = (conversationId: string, createdAt: Date) =>
6265
messageType: "incoming",
6366
}) as TestMessage
6467

68+
const makeOutgoingMessage = (
69+
conversationId: string,
70+
createdAt: Date,
71+
senderType: "user" | "api" | "bot" | "system",
72+
// `received-message` stamps a channel echo senderType "user" with a null
73+
// senderId; `createOutgoing` always carries the acting user's id.
74+
senderId: string | null = senderType === "user" ? "user-1" : null,
75+
) =>
76+
({
77+
...makeMessage(conversationId, createdAt),
78+
id: `msg-${conversationId}-${senderType}`,
79+
messageType: "outgoing",
80+
senderType,
81+
senderId,
82+
}) as TestMessage
83+
6584
const setConversationUrl = (conversationId: string | null) => {
6685
window.history.replaceState(
6786
{},
@@ -391,6 +410,131 @@ describe("chat store conversation updates", () => {
391410
})
392411
})
393412

413+
describe("chat store handleNewMessage read state", () => {
414+
const AGENT_LAST_READ_AT = new Date("2026-01-01T00:00:00Z")
415+
416+
const makeUnreadStore = (activeConversationId: string | null = null) => {
417+
const store = createChatStore()
418+
const conversation = {
419+
...makeConversation("conv-1", new Date("2026-01-01T01:00:00Z")),
420+
agentLastReadAt: AGENT_LAST_READ_AT,
421+
adminRepliedAt: null,
422+
}
423+
store.setState({
424+
conversations: [conversation] as never,
425+
activeConversationId,
426+
})
427+
return store
428+
}
429+
430+
const readStateOf = (store: ReturnType<typeof createChatStore>) => {
431+
const conversation = store
432+
.getState()
433+
.conversations.find((c) => c.id === "conv-1") as TestConversation
434+
return {
435+
agentLastReadAt: conversation.agentLastReadAt,
436+
adminRepliedAt: conversation.adminRepliedAt,
437+
}
438+
}
439+
440+
beforeEach(() => {
441+
vi.clearAllMocks()
442+
setConversationUrl(null)
443+
})
444+
445+
test.each([
446+
"bot",
447+
"system",
448+
] as const)("a %s outgoing message leaves the conversation unread", async (senderType) => {
449+
const store = makeUnreadStore()
450+
451+
await store
452+
.getState()
453+
.handleNewMessage(
454+
makeOutgoingMessage(
455+
"conv-1",
456+
new Date("2026-01-01T02:00:00Z"),
457+
senderType,
458+
) as never,
459+
)
460+
461+
expect(readStateOf(store)).toEqual({
462+
agentLastReadAt: AGENT_LAST_READ_AT,
463+
adminRepliedAt: null,
464+
})
465+
})
466+
467+
test.each([
468+
"user",
469+
"api",
470+
] as const)("a %s outgoing message marks the conversation read and replied", async (senderType) => {
471+
const store = makeUnreadStore()
472+
473+
await store
474+
.getState()
475+
.handleNewMessage(
476+
makeOutgoingMessage(
477+
"conv-1",
478+
new Date("2026-01-01T02:00:00Z"),
479+
senderType,
480+
) as never,
481+
)
482+
483+
const { agentLastReadAt, adminRepliedAt } = readStateOf(store)
484+
expect(agentLastReadAt).not.toEqual(AGENT_LAST_READ_AT)
485+
expect(agentLastReadAt).toEqual(adminRepliedAt)
486+
})
487+
488+
test("a channel echo (senderType user, no senderId) leaves the conversation unread", async () => {
489+
const store = makeUnreadStore()
490+
491+
await store
492+
.getState()
493+
.handleNewMessage(
494+
makeOutgoingMessage(
495+
"conv-1",
496+
new Date("2026-01-01T02:00:00Z"),
497+
"user",
498+
null,
499+
) as never,
500+
)
501+
502+
expect(readStateOf(store)).toEqual({
503+
agentLastReadAt: AGENT_LAST_READ_AT,
504+
adminRepliedAt: null,
505+
})
506+
})
507+
508+
test("an incoming message on the open conversation marks it read without an admin reply", async () => {
509+
const store = makeUnreadStore("conv-1")
510+
511+
await store
512+
.getState()
513+
.handleNewMessage(
514+
makeMessage("conv-1", new Date("2026-01-01T02:00:00Z")) as never,
515+
)
516+
517+
const { agentLastReadAt, adminRepliedAt } = readStateOf(store)
518+
expect(agentLastReadAt).not.toEqual(AGENT_LAST_READ_AT)
519+
expect(adminRepliedAt).toBeNull()
520+
})
521+
522+
test("an incoming message on a background conversation stays unread", async () => {
523+
const store = makeUnreadStore("conv-other")
524+
525+
await store
526+
.getState()
527+
.handleNewMessage(
528+
makeMessage("conv-1", new Date("2026-01-01T02:00:00Z")) as never,
529+
)
530+
531+
expect(readStateOf(store)).toEqual({
532+
agentLastReadAt: AGENT_LAST_READ_AT,
533+
adminRepliedAt: null,
534+
})
535+
})
536+
})
537+
394538
describe("chat store loadMoreMessages", () => {
395539
test("prepends the older page and advances the message cursor", async () => {
396540
const store = createChatStore()

apps/builder/src/features/chat/store/chat-store.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -694,14 +694,38 @@ export const createChatStore = () => {
694694
: {}),
695695
})
696696
}
697-
if (
698-
message.messageType === "outgoing" ||
699-
(message.messageType === "incoming" &&
700-
message.conversationId === activeConversationId)
701-
) {
697+
// Only an outgoing message that `createOutgoing` itself produced clears
698+
// the unread state — a bot/system reply (flow step, template, comment
699+
// automation) must leave it alone. This mirrors the server exactly:
700+
// `createOutgoing` is the only writer that calls `markAgentReplied`,
701+
// and it stamps senderType "user" with a senderId (inbox composer) or
702+
// "api" with none (public API); the worker handlers that send on the
703+
// bot's behalf only bump `lastActivityAt`. Without this guard a flow
704+
// reply broadcast over realtime marked every open inbox tab as read
705+
// even though nobody had opened the conversation.
706+
//
707+
// The senderId check is what excludes a channel echo: `received-message`
708+
// stamps every outgoing echo senderType "user" with a null senderId
709+
// whatever its origin (see its `isEchoOfOwnSend` comment), so a bot send
710+
// whose sourceId dedup missed comes back looking like an agent reply.
711+
// Echoes never persist a read state server-side either, so honouring
712+
// them here would only produce a state that reverts on reload.
713+
const isAgentReply =
714+
message.messageType === "outgoing" &&
715+
((message.senderType === "user" && message.senderId !== null) ||
716+
message.senderType === "api")
717+
// An incoming message only counts as read while the agent has that
718+
// conversation open — and it is never an admin reply, so it must not
719+
// touch `adminRepliedAt` (that drives the "no admin reply" filter).
720+
const isReadWhileConversationOpen =
721+
message.messageType === "incoming" &&
722+
message.conversationId === activeConversationId
723+
724+
if (isAgentReply || isReadWhileConversationOpen) {
725+
const readAt = new Date()
702726
updateConversation(message.conversationId, {
703-
agentLastReadAt: new Date(),
704-
adminRepliedAt: new Date(),
727+
agentLastReadAt: readAt,
728+
...(isAgentReply ? { adminRepliedAt: readAt } : {}),
705729
})
706730
}
707731

0 commit comments

Comments
 (0)