From 700410b98f7f91dc6fb8f75ef56dd99dc1a1678b Mon Sep 17 00:00:00 2001
From: Cheskie Londinsky
Date: Mon, 24 Aug 2026 13:11:27 -0400
Subject: [PATCH 1/2] feat(chat): adopt OpenMaus conversation UX
---
THIRD_PARTY_NOTICES.md | 8 +-
app/src/components/channels/channel-chat.tsx | 1 +
.../components/channels/chat-transcript.tsx | 103 +++++++++++++
.../channels/conversation-switcher.tsx | 137 ++++++++++++++++++
.../components/channels/conversation-view.tsx | 4 +
.../channels/deployment-preview-chat.tsx | 66 ++++-----
.../components/channels/message-reactions.tsx | 108 ++++++++++++++
app/src/components/tasks/task-run-status.tsx | 113 +++++++++------
.../lib/channels/conversation-groups.test.ts | 45 ++++++
app/src/lib/channels/conversation-groups.ts | 17 +++
app/src/lib/channels/reactions.test.ts | 41 ++++++
app/src/lib/channels/reactions.ts | 55 ++++++-
app/src/lib/deployment-preview.ts | 45 ++++++
.../_authed/_app/channel/$channelId.tsx | 13 +-
design-qa.md | 67 +++++++++
15 files changed, 733 insertions(+), 90 deletions(-)
create mode 100644 app/src/components/channels/conversation-switcher.tsx
create mode 100644 app/src/components/channels/message-reactions.tsx
create mode 100644 app/src/lib/channels/conversation-groups.test.ts
create mode 100644 app/src/lib/channels/conversation-groups.ts
create mode 100644 app/src/lib/channels/reactions.test.ts
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 75d94f5..6db6d1e 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -3,8 +3,9 @@
## OpenMausBot UI components
Portions of Kayco OpenBot's user interface are adapted from
-[OpenMausBot](https://github.com/milind-soni/OpenMausBot) commit
-`ca3131444bbb2b125e8d90593be930efb2f19854`.
+[OpenMausBot](https://github.com/milind-soni/OpenMausBot) commits
+`ca3131444bbb2b125e8d90593be930efb2f19854` and
+`df32587d0fb9229b021086b22c9fd711116598a5`.
Copyright 2026 Milind Soni and OpenMausBot contributors.
@@ -15,6 +16,9 @@ Adapted surfaces:
- `app/src/components/app-sidebar/channel.tsx`: contact-style conversation row and revealed actions.
- `app/src/components/channels/codex-status.tsx`: compact model trigger and grouped model menu.
+- `app/src/components/channels/conversation-switcher.tsx`: current-conversation trigger and related-conversation menu.
+- `app/src/components/channels/message-reactions.tsx`: hover reaction picker and persistent reaction counts.
+- `app/src/components/tasks/task-run-status.tsx`: compact expandable execution timeline.
- `app/src/components/gallery/decisions.tsx`: lettered option card and free-text answer.
- `app/src/routes/_authed/admin/plugins.tsx`: searchable two-column connected-app catalogue.
diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx
index e28fdac..a6549d9 100644
--- a/app/src/components/channels/channel-chat.tsx
+++ b/app/src/components/channels/channel-chat.tsx
@@ -610,6 +610,7 @@ export function ChannelChat({
// Follow the whole turn, including the pre-run wait and the idle gaps between frontend
// tool runs. The wire-level flag alone makes the progress line blink out while work remains.
busy={agent.isRunning || turnsInFlight > 0}
+ channelId={channel.id}
// The `/` menu exposes only skills granted to this Bot.
commands={skillCommands}
// Readiness is handled by `say`; deletion is the only disabled-chat state.
diff --git a/app/src/components/channels/chat-transcript.tsx b/app/src/components/channels/chat-transcript.tsx
index e8c5a63..05700cf 100644
--- a/app/src/components/channels/chat-transcript.tsx
+++ b/app/src/components/channels/chat-transcript.tsx
@@ -15,11 +15,13 @@ import {
IconQuote,
IconRefresh,
} from "@tabler/icons-react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import Avatar from "boring-avatars";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
memo,
type ReactNode,
+ useCallback,
useEffect,
useMemo,
useRef,
@@ -27,6 +29,10 @@ import {
} from "react";
import { Streamdown } from "streamdown";
import { LiquidThinkingOrb } from "@/components/channels/liquid-thinking-orb";
+import {
+ MessageReactionChips,
+ MessageReactionPicker,
+} from "@/components/channels/message-reactions";
import {
describeTurnReceipt,
thinkingStatusText,
@@ -48,6 +54,12 @@ import {
useMessageScroller,
} from "@/components/ui/message-scroller";
import { Skeleton } from "@/components/ui/skeleton";
+import {
+ type MessageReaction,
+ type MessageReactionEmoji,
+ messageReactionsQueryOptions,
+ setMessageReactionMutationOptions,
+} from "@/lib/channels/reactions";
import {
markdownComponents,
markdownControls,
@@ -70,6 +82,8 @@ import { ToolLine } from "./tool-line";
type ChatTranscriptProps = {
busy?: boolean;
+ /** Durable channel identity used to read and write reactions. */
+ channelId?: string;
/** Comma-separated `/` command names, used to tell a skill chip from a leading slash. */
commandNames?: string;
messages: ReadonlyArray>;
@@ -116,6 +130,7 @@ type ChatTranscriptProps = {
/** One shared empty array, so a screen without a queue does not hand down a new one per render. */
const EMPTY_QUEUE: readonly QueuedMessage[] = [];
const EMPTY_TIMES: Readonly> = {};
+const EMPTY_REACTIONS: readonly MessageReaction[] = [];
/**
* Split a person's message into the skill they invoked and the rest of what they typed.
@@ -612,6 +627,8 @@ function MessageActions({
onQuote,
onEdit,
onRetry,
+ reactions,
+ onReact,
time,
}: {
align: "start" | "end";
@@ -620,6 +637,10 @@ function MessageActions({
onQuote?: (() => void) | undefined;
onEdit?: (() => void) | undefined;
onRetry?: (() => void) | undefined;
+ reactions: readonly MessageReaction[];
+ onReact?:
+ | ((emoji: MessageReactionEmoji, active: boolean) => void)
+ | undefined;
time?: number | undefined;
}) {
const button =
@@ -633,6 +654,13 @@ function MessageActions({
{time !== undefined ? (
{formatTime(time)}
) : null}
+ {onReact ? (
+
+ ) : null}
void) | undefined;
/** Present only on the newest answer: retrying anything older would rewrite history. */
onRetry?: (() => void) | undefined;
+ onToggleReaction?:
+ | ((
+ messageId: string,
+ emoji: MessageReactionEmoji,
+ active: boolean,
+ ) => void)
+ | undefined;
};
/**
@@ -768,8 +805,10 @@ const TranscriptMessage = memo(
function TranscriptMessage({
commandNames = "",
delay,
+ messageId,
role,
text,
+ reactions,
attachments,
time,
searchTint,
@@ -777,6 +816,7 @@ const TranscriptMessage = memo(
onQuote,
onEdit,
onRetry,
+ onToggleReaction,
}: TranscriptMessageProps) {
const isUser = role === "user";
const align = isUser ? "end" : "start";
@@ -878,6 +918,15 @@ const TranscriptMessage = memo(
{attachments && attachments.length > 0 ? (
) : null}
+ {onToggleReaction ? (
+
+ onToggleReaction(messageId, emoji, active)
+ }
+ reactions={reactions}
+ />
+ ) : null}
onEdit(text) : undefined}
onQuote={onQuote ? () => onQuote(text, role) : undefined}
+ onReact={
+ onToggleReaction
+ ? (emoji, active) =>
+ onToggleReaction(messageId, emoji, active)
+ : undefined
+ }
onRetry={!isUser && onRetry ? onRetry : undefined}
+ reactions={reactions}
// Only user rows carry their time here; a coworker's turn shows it in the header.
time={isUser ? time : undefined}
/>
@@ -897,6 +953,7 @@ const TranscriptMessage = memo(
},
(prev, next) =>
prev.role === next.role &&
+ prev.messageId === next.messageId &&
prev.text === next.text &&
prev.delay === next.delay &&
prev.commandNames === next.commandNames &&
@@ -906,9 +963,21 @@ const TranscriptMessage = memo(
prev.onQuote === next.onQuote &&
prev.onEdit === next.onEdit &&
prev.onRetry === next.onRetry &&
+ prev.onToggleReaction === next.onToggleReaction &&
+ reactionsKey(prev.reactions) === reactionsKey(next.reactions) &&
attachmentsKey(prev.attachments) === attachmentsKey(next.attachments),
);
+function reactionsKey(reactions: readonly MessageReaction[]): string {
+ return reactions
+ .map((reaction) =>
+ [reaction.emoji, reaction.count, reaction.mine ? "mine" : "other"].join(
+ ":",
+ ),
+ )
+ .join(",");
+}
+
/**
* The readable summary Codex exposes while it works.
*
@@ -1378,6 +1447,7 @@ function blockMessageId(block: TranscriptBlock): string {
export function ChatTranscript({
busy = false,
+ channelId,
commandNames = "",
messages,
onRemoveQueued,
@@ -1407,6 +1477,29 @@ export function ChatTranscript({
*/
const items = toVisibleChatItems(messages);
const blocks = toBlocks(items);
+ const reactionMessageIds = items.flatMap((item) =>
+ item.kind === "text" ? [item.id] : [],
+ );
+ const queryClient = useQueryClient();
+ const reactions = useQuery(
+ messageReactionsQueryOptions(channelId, reactionMessageIds),
+ );
+ const reactionMutation = useMutation(
+ setMessageReactionMutationOptions(queryClient, channelId ?? "none"),
+ );
+ const toggleReaction = useCallback(
+ (messageId: string, emoji: MessageReactionEmoji, active: boolean) => {
+ if (!channelId) return;
+ reactionMutation.mutate({ messageId, emoji, active });
+ },
+ [channelId, reactionMutation.mutate],
+ );
+ const reactionsByMessage = new Map();
+ for (const reaction of reactions.data ?? []) {
+ const existing = reactionsByMessage.get(reaction.messageId);
+ if (existing) existing.push(reaction);
+ else reactionsByMessage.set(reaction.messageId, [reaction]);
+ }
/*
* ONLY WHILE THERE IS NOTHING ELSE TO LOOK AT. Streamed text and a running tool already show
@@ -1529,6 +1622,11 @@ export function ChatTranscript({
{historyNotice}
) : null}
+ {reactionMutation.error ? (
+
+ {reactionMutation.error.message}
+
+ ) : null}
{restoring && items.length === 0 ? : null}
{!restoring && items.length === 0 && !busy && queued.length === 0
? emptyState
@@ -1615,8 +1713,10 @@ export function ChatTranscript({
attachments={item.attachments}
commandNames={commandNames}
delay={delay}
+ messageId={item.id}
onEdit={onEdit}
onQuote={onQuote}
+ onToggleReaction={channelId ? toggleReaction : undefined}
onRetry={
item.id === latestAssistantId ? onRetryLatest : undefined
}
@@ -1624,6 +1724,9 @@ export function ChatTranscript({
item.role === "assistant" && item.id === settlingId
}
role={item.role}
+ reactions={
+ reactionsByMessage.get(item.id) ?? EMPTY_REACTIONS
+ }
searchTint={
item.id === activeSearchMessageId
? "active"
diff --git a/app/src/components/channels/conversation-switcher.tsx b/app/src/components/channels/conversation-switcher.tsx
new file mode 100644
index 0000000..8bfebb4
--- /dev/null
+++ b/app/src/components/channels/conversation-switcher.tsx
@@ -0,0 +1,137 @@
+import {
+ IconCheck,
+ IconChevronDown,
+ IconMessageCircle,
+ IconMessagePlus,
+} from "@tabler/icons-react";
+import { useQuery } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { channelsForParticipants } from "@/lib/channels/conversation-groups";
+import {
+ type AgentChannel,
+ channelListQueryOptions,
+} from "@/lib/channels/queries";
+
+/*
+ * The compact current-task trigger and sibling-task menu are adapted from OpenMausBot's
+ * src/components/TaskPicker.tsx at df32587d0fb9229b021086b22c9fd711116598a5.
+ * Copyright 2026 Milind Soni and OpenMausBot contributors, Apache-2.0.
+ * Modified for Kayco's durable coworker conversations and TanStack Router.
+ */
+
+export function ConversationSwitcher({
+ channel,
+ subtitle,
+}: {
+ channel: AgentChannel | undefined;
+ subtitle?: string;
+}) {
+ const channels = useQuery(channelListQueryOptions());
+ const related = channelsForParticipants(
+ channels.data,
+ channel?.agentIds ?? [],
+ );
+ const count = Math.max(related.length, channel ? 1 : 0);
+ const groupLabel =
+ (channel?.agentIds.length ?? 0) > 1
+ ? "Workroom conversations"
+ : "Conversations with this coworker";
+
+ if (!channel) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ }
+ >
+
+ {channel.name}
+
+
+ {count}
+
+
+
+
+
+ {groupLabel}
+ {related.map((conversation) => {
+ const selected = conversation.id === channel.id;
+ return (
+
+ }
+ >
+
+
+
+ {conversation.name}
+
+
+ {conversation.lastMessage || "No messages yet"}
+
+
+ {selected ? (
+
+ ) : null}
+
+ );
+ })}
+
+
+
+ }
+ >
+
+ New conversation with{" "}
+ {channel.agentIds.length > 1 ? "this team" : "this coworker"}
+
+
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+ );
+}
diff --git a/app/src/components/channels/conversation-view.tsx b/app/src/components/channels/conversation-view.tsx
index 991d18d..b5b3fa6 100644
--- a/app/src/components/channels/conversation-view.tsx
+++ b/app/src/components/channels/conversation-view.tsx
@@ -35,6 +35,7 @@ import { useTabActivityStatus } from "@/lib/tab-status";
export function ConversationView({
messages,
+ channelId,
busy = false,
notice,
activity,
@@ -59,6 +60,8 @@ export function ConversationView({
onRetryLatest,
}: {
messages: readonly Message[];
+ /** Durable conversation identity. Enables server-backed reactions when present. */
+ channelId?: string;
busy?: boolean;
/** Shown above the composer. An error, or why this conversation is read-only. */
notice?: ReactNode;
@@ -336,6 +339,7 @@ export function ConversationView({
agentId={agentId}
assistantName={assistantName}
busy={busy}
+ channelId={channelId}
commandNames={(commands ?? [])
.map((command) => command.name)
.join(",")}
diff --git a/app/src/components/channels/deployment-preview-chat.tsx b/app/src/components/channels/deployment-preview-chat.tsx
index 92fea82..d31cb90 100644
--- a/app/src/components/channels/deployment-preview-chat.tsx
+++ b/app/src/components/channels/deployment-preview-chat.tsx
@@ -1,36 +1,47 @@
-import { useEffect, useRef } from "react";
+import type { Message } from "@ag-ui/core";
+import { ChatTranscript } from "@/components/channels/chat-transcript";
import { TaskRunStatus } from "@/components/tasks/task-run-status";
import type { AgentChannel } from "@/lib/channels/queries";
-type PreviewMessage = {
- id: string;
- role: "user" | "assistant";
- text: string;
-};
-
-const previewTranscripts: Record = {
+const previewTranscripts: Record = {
"preview-policy-review": [
{
id: "policy-question",
role: "user",
- text: "What is the approval path for a new external software vendor?",
+ content: "What is the approval path for a new external software vendor?",
},
{
id: "policy-answer",
role: "assistant",
- text: "I found the relevant policy. Start with the business owner and Security review, then route contracts through Legal and Procurement. Finance approval is also required when the annual commitment exceeds the team threshold.",
+ content:
+ "I found the relevant policy. Start with the business owner and Security review, then route contracts through Legal and Procurement. Finance approval is also required when the annual commitment exceeds the team threshold.",
},
],
"preview-weekly-brief": [
{
id: "brief-question",
role: "user",
- text: "Turn this week's project notes into a concise leadership update.",
+ content:
+ "Turn this week's project notes into a concise leadership update.",
},
{
id: "brief-answer",
role: "assistant",
- text: "Your draft is ready for review. It leads with decisions made, separates current risks from open questions, and closes with the three actions needed next week.",
+ content:
+ "Your draft is ready for review. It leads with decisions made, separates current risks from open questions, and closes with the three actions needed next week.",
+ },
+ ],
+ "preview-vendor-follow-up": [
+ {
+ id: "vendor-follow-up-question",
+ role: "user",
+ content: "What information is still missing from the vendor packet?",
+ },
+ {
+ id: "vendor-follow-up-answer",
+ role: "assistant",
+ content:
+ "The security questionnaire and data-retention schedule are still missing. I also flagged the renewal clause because it needs Procurement review before approval.",
},
],
};
@@ -38,34 +49,15 @@ const previewTranscripts: Record = {
/** A read-only conversation that shows the channel design without starting a CopilotKit runtime. */
export function DeploymentPreviewChat({ channel }: { channel: AgentChannel }) {
const messages = previewTranscripts[channel.id] ?? [];
- const transcript = useRef(null);
-
- useEffect(() => {
- const frame = transcript.current;
- if (frame) frame.scrollTop = frame.scrollHeight;
- }, []);
return (
-
-
- {messages.map((message) => (
-
- {message.text}
-
- ))}
+
+
diff --git a/app/src/components/channels/message-reactions.tsx b/app/src/components/channels/message-reactions.tsx
new file mode 100644
index 0000000..32941c8
--- /dev/null
+++ b/app/src/components/channels/message-reactions.tsx
@@ -0,0 +1,108 @@
+import { IconMoodPlus } from "@tabler/icons-react";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+ MESSAGE_REACTION_EMOJIS,
+ type MessageReaction,
+ type MessageReactionEmoji,
+} from "@/lib/channels/reactions";
+
+/*
+ * The hover reaction picker and persistent count chips are adapted from OpenMausBot's
+ * src/components/Reactions.tsx at df32587d0fb9229b021086b22c9fd711116598a5.
+ * Copyright 2026 Milind Soni and OpenMausBot contributors, Apache-2.0.
+ * Modified for Kayco's server-owned reaction aggregates and accessible menu controls.
+ */
+
+const REACTION_LABEL: Record
= {
+ "π": "thumbs up",
+ "β€οΈ": "heart",
+ "π": "celebrate",
+ "π": "eyes",
+ "β
": "done",
+};
+
+type ToggleReaction = (emoji: MessageReactionEmoji, active: boolean) => void;
+
+export function MessageReactionPicker({
+ align,
+ reactions,
+ onToggle,
+}: {
+ align: "start" | "end";
+ reactions: readonly MessageReaction[];
+ onToggle: ToggleReaction;
+}) {
+ return (
+
+
+ }
+ >
+
+
+
+ {MESSAGE_REACTION_EMOJIS.map((emoji) => {
+ const active = reactions.some(
+ (reaction) => reaction.emoji === emoji && reaction.mine,
+ );
+ return (
+ onToggle(emoji, !active)}
+ >
+ {emoji}
+
+ );
+ })}
+
+
+ );
+}
+
+export function MessageReactionChips({
+ align,
+ reactions,
+ onToggle,
+}: {
+ align: "start" | "end";
+ reactions: readonly MessageReaction[];
+ onToggle: ToggleReaction;
+}) {
+ if (reactions.length === 0) return null;
+ return (
+
+ {reactions.map((reaction) => (
+ onToggle(reaction.emoji, !reaction.mine)}
+ type="button"
+ >
+ {reaction.emoji}
+ {reaction.count}
+
+ ))}
+
+ );
+}
diff --git a/app/src/components/tasks/task-run-status.tsx b/app/src/components/tasks/task-run-status.tsx
index cc179f3..fb9b77f 100644
--- a/app/src/components/tasks/task-run-status.tsx
+++ b/app/src/components/tasks/task-run-status.tsx
@@ -100,57 +100,79 @@ function RunCard({
() => visibleSteps(events.data ?? [], run),
[events.data, run],
);
+ const latestStep = steps.at(-1);
+
+ if (compact) {
+ return (
+
+
+
+
+
+
+
+ {STATUS_LABEL[run.status]}
+
+
+ {durationLabel(run)}
+
+
+
+
+ {onRetry &&
+ (run.status === "failed" || run.status === "cancelled") ? (
+
onRetry(run.id)}
+ size="sm"
+ variant="outline"
+ >
+
+ Retry
+
+ ) : null}
+
+
+ );
+ }
return (
-
-
+ {/*
+ * The collapsed execution strip and expanded event history are adapted from OpenMausBot's
+ * src/components/ChatView.tsx at df32587d0fb9229b021086b22c9fd711116598a5.
+ * Copyright 2026 Milind Soni and OpenMausBot contributors, Apache-2.0.
+ * Kayco keeps approval decisions outside the disclosure so required action is never hidden.
+ */}
+
+
-
-
-
- {STATUS_LABEL[run.status]}
-
-
- {durationLabel(run)}
-
+
+
+ Execution timeline
{run.attempt > 1 ? (
-
+
Attempt {run.attempt} of {run.maxAttempts}
) : null}
-
- {!compact ? (
-
- {run.title}
-
- ) : null}
-
-
- {onRetry && (run.status === "failed" || run.status === "cancelled") ? (
-
onRetry(run.id)}
- size="sm"
- variant="outline"
- >
-
- Retry
-
- ) : null}
-
-
- {!compact ? (
- <>
+
+
+ {latestStep?.label ?? STATUS_LABEL[run.status]} Β· {run.title}
+
+
+
+ {durationLabel(run)}
+
+
+
+
{steps.length > 0 ?
: null}
- {pending ?
: null}
{!pending && completedApprovals?.length ? (
) : null}
@@ -172,8 +194,17 @@ function RunCard({
) : null}
- >
+
+
+ {onRetry && (run.status === "failed" || run.status === "cancelled") ? (
+
+ onRetry(run.id)} size="sm" variant="outline">
+
+ Retry
+
+
) : null}
+ {pending ? : null}
);
}
diff --git a/app/src/lib/channels/conversation-groups.test.ts b/app/src/lib/channels/conversation-groups.test.ts
new file mode 100644
index 0000000..862af1a
--- /dev/null
+++ b/app/src/lib/channels/conversation-groups.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, test } from "bun:test";
+import { channelsForParticipants } from "./conversation-groups";
+import type { ChannelSummary } from "./queries";
+
+function channel(id: string, agentIds: string[]): ChannelSummary {
+ return {
+ id,
+ name: id,
+ agentIds,
+ threadId: id,
+ active: true,
+ lastMessage: null,
+ lastMessageAt: null,
+ lastMessageAgentId: null,
+ createdAt: "2026-08-24T12:00:00.000Z",
+ };
+}
+
+describe("conversation groups", () => {
+ test("finds every conversation with the exact same coworkers", () => {
+ const channels = [
+ channel("one", ["knowledge"]),
+ channel("two", ["knowledge"]),
+ channel("different", ["general-assistant"]),
+ ];
+
+ expect(
+ channelsForParticipants(channels, ["knowledge"]).map(({ id }) => id),
+ ).toEqual(["one", "two"]);
+ });
+
+ test("treats workroom participant order as irrelevant without matching subsets", () => {
+ const channels = [
+ channel("same", ["research", "writer"]),
+ channel("reversed", ["writer", "research"]),
+ channel("subset", ["research"]),
+ ];
+
+ expect(
+ channelsForParticipants(channels, ["research", "writer"]).map(
+ ({ id }) => id,
+ ),
+ ).toEqual(["same", "reversed"]);
+ });
+});
diff --git a/app/src/lib/channels/conversation-groups.ts b/app/src/lib/channels/conversation-groups.ts
new file mode 100644
index 0000000..87384bd
--- /dev/null
+++ b/app/src/lib/channels/conversation-groups.ts
@@ -0,0 +1,17 @@
+import type { ChannelSummary } from "./queries";
+
+function participantKey(participantIds: readonly string[]): string {
+ return [...new Set(participantIds)].sort().join("\u0000");
+}
+
+/** Conversations belong together only when they have the exact same set of coworkers. */
+export function channelsForParticipants(
+ channels: readonly ChannelSummary[] | undefined,
+ participantIds: readonly string[],
+): ChannelSummary[] {
+ if (!channels || participantIds.length === 0) return [];
+ const expected = participantKey(participantIds);
+ return channels.filter(
+ (channel) => participantKey(channel.agentIds) === expected,
+ );
+}
diff --git a/app/src/lib/channels/reactions.test.ts b/app/src/lib/channels/reactions.test.ts
new file mode 100644
index 0000000..a500c74
--- /dev/null
+++ b/app/src/lib/channels/reactions.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, test } from "bun:test";
+import { applyReactionChange, type MessageReaction } from "./reactions";
+
+const liked: MessageReaction = {
+ messageId: "message-1",
+ emoji: "π",
+ count: 2,
+ mine: false,
+};
+
+describe("optimistic message reactions", () => {
+ test("adds the current person's reaction to an existing count", () => {
+ expect(
+ applyReactionChange([liked], {
+ messageId: "message-1",
+ emoji: "π",
+ active: true,
+ }),
+ ).toEqual([{ ...liked, count: 3, mine: true }]);
+ });
+
+ test("removes an empty aggregate when the current person unreacts", () => {
+ expect(
+ applyReactionChange([{ ...liked, count: 1, mine: true }], {
+ messageId: "message-1",
+ emoji: "π",
+ active: false,
+ }),
+ ).toEqual([]);
+ });
+
+ test("leaves unrelated messages and already-satisfied states unchanged", () => {
+ expect(
+ applyReactionChange([liked], {
+ messageId: "message-2",
+ emoji: "π",
+ active: false,
+ }),
+ ).toEqual([liked]);
+ });
+});
diff --git a/app/src/lib/channels/reactions.ts b/app/src/lib/channels/reactions.ts
index cdae399..2ebec81 100644
--- a/app/src/lib/channels/reactions.ts
+++ b/app/src/lib/channels/reactions.ts
@@ -1,7 +1,7 @@
import {
mutationOptions,
- queryOptions,
type QueryClient,
+ queryOptions,
} from "@tanstack/react-query";
export const MESSAGE_REACTION_EMOJIS = ["π", "β€οΈ", "π", "π", "β
"] as const;
@@ -20,6 +20,41 @@ const reactionKeys = {
[...reactionKeys.channel(channelId), [...messageIds]] as const,
};
+/** Apply one person's desired reaction state to an aggregate returned by the server. */
+export function applyReactionChange(
+ reactions: readonly MessageReaction[],
+ input: {
+ messageId: string;
+ emoji: MessageReactionEmoji;
+ active: boolean;
+ },
+): MessageReaction[] {
+ const existing = reactions.find(
+ (reaction) =>
+ reaction.messageId === input.messageId && reaction.emoji === input.emoji,
+ );
+ if (!existing) {
+ return input.active
+ ? [
+ ...reactions,
+ {
+ messageId: input.messageId,
+ emoji: input.emoji,
+ count: 1,
+ mine: true,
+ },
+ ]
+ : [...reactions];
+ }
+ if (existing.mine === input.active) return [...reactions];
+
+ const count = Math.max(0, existing.count + (input.active ? 1 : -1));
+ return reactions.flatMap((reaction) => {
+ if (reaction !== existing) return [reaction];
+ return count === 0 ? [] : [{ ...reaction, count, mine: input.active }];
+ });
+}
+
export function messageReactionsQueryOptions(
channelId: string | undefined,
messageIds: readonly string[],
@@ -66,7 +101,23 @@ export function setMessageReactionMutationOptions(
);
if (!response.ok) throw new Error("Could not update that reaction.");
},
- onSuccess: () =>
+ onMutate: async (input) => {
+ const queryKey = reactionKeys.channel(channelId);
+ await queryClient.cancelQueries({ queryKey });
+ const previous = queryClient.getQueriesData({
+ queryKey,
+ });
+ queryClient.setQueriesData({ queryKey }, (current) =>
+ current ? applyReactionChange(current, input) : current,
+ );
+ return { previous };
+ },
+ onError: (_error, _input, context) => {
+ for (const [queryKey, reactions] of context?.previous ?? []) {
+ queryClient.setQueryData(queryKey, reactions);
+ }
+ },
+ onSettled: () =>
queryClient.invalidateQueries({
queryKey: reactionKeys.channel(channelId),
}),
diff --git a/app/src/lib/deployment-preview.ts b/app/src/lib/deployment-preview.ts
index 3ac9fc2..b9646c6 100644
--- a/app/src/lib/deployment-preview.ts
+++ b/app/src/lib/deployment-preview.ts
@@ -1,6 +1,11 @@
import type { AgentProfile } from "./agents/queries";
import type { AuthenticatedUser } from "./auth/queries";
import type { ChannelSummary } from "./channels/queries";
+import {
+ applyReactionChange,
+ type MessageReaction,
+ type MessageReactionEmoji,
+} from "./channels/reactions";
import type { CodexPreferences } from "./codex/queries";
import type { PluginsPage } from "./plugins/queries";
import type { ApprovalRequest, TaskRun, TaskRunEvent } from "./runs/queries";
@@ -85,6 +90,17 @@ const previewChannels: ChannelSummary[] = [
lastMessageAgentId: "knowledge",
createdAt: "2026-08-19T15:10:00.000Z",
},
+ {
+ id: "preview-vendor-follow-up",
+ name: "Vendor follow-up",
+ agentIds: ["knowledge"],
+ threadId: "preview-vendor-follow-up",
+ active: true,
+ lastMessage: "The security questionnaire is still missing.",
+ lastMessageAt: "2026-08-20T16:10:00.000Z",
+ lastMessageAgentId: "knowledge",
+ createdAt: "2026-08-20T15:45:00.000Z",
+ },
{
id: "preview-weekly-brief",
name: "Weekly brief",
@@ -212,6 +228,14 @@ const previewRunEvents: TaskRunEvent[] = [
];
let previewApprovalStatus: ApprovalRequest["status"] = "pending";
+let previewReactions: MessageReaction[] = [
+ {
+ messageId: "policy-answer",
+ emoji: "π",
+ count: 2,
+ mine: false,
+ },
+];
let previewCodexPreferences: CodexPreferences = {
model: "gpt-5.2-codex",
effort: "high",
@@ -298,6 +322,27 @@ export function installDeploymentPreviewApi() {
if (method === "GET" && path === "/api/channels") {
return json({ channels: previewChannels });
}
+ if (method === "POST" && path.endsWith("/reactions/query")) {
+ const raw = typeof init?.body === "string" ? init.body : "";
+ const { messageIds = [] } = JSON.parse(raw || "{}") as {
+ messageIds?: string[];
+ };
+ return json({
+ reactions: previewReactions.filter((reaction) =>
+ messageIds.includes(reaction.messageId),
+ ),
+ });
+ }
+ if (method === "PUT" && path.endsWith("/reactions")) {
+ const raw = typeof init?.body === "string" ? init.body : "";
+ const input = JSON.parse(raw || "{}") as {
+ messageId: string;
+ emoji: MessageReactionEmoji;
+ active: boolean;
+ };
+ previewReactions = applyReactionChange(previewReactions, input);
+ return json({ ok: true });
+ }
if (method === "GET" && path.startsWith("/api/channels/")) {
const id = decodeURIComponent(path.slice("/api/channels/".length));
const channel = previewChannels.find((candidate) => candidate.id === id);
diff --git a/app/src/routes/_authed/_app/channel/$channelId.tsx b/app/src/routes/_authed/_app/channel/$channelId.tsx
index ff76edb..d4d761c 100644
--- a/app/src/routes/_authed/_app/channel/$channelId.tsx
+++ b/app/src/routes/_authed/_app/channel/$channelId.tsx
@@ -14,6 +14,7 @@ import { AgentProfile } from "@/components/agents/agent-profile";
import { ChannelAvatar } from "@/components/channels/avatar";
import { ChannelChat } from "@/components/channels/channel-chat";
import { CodexChannelStatus } from "@/components/channels/codex-status";
+import { ConversationSwitcher } from "@/components/channels/conversation-switcher";
import { DeploymentPreviewChat } from "@/components/channels/deployment-preview-chat";
import { ComputerView } from "@/components/computer/computer-view";
import { useNeedsYou } from "@/components/computer/needs-you";
@@ -237,14 +238,10 @@ function RouteComponent() {
ease: EASE_OUT,
}}
>
-
- {channel.data?.name ?? "Channel"}
-
- {activeProfile?.title ? (
-
- {activeProfile.title}
-
- ) : null}
+
{(channel.data?.agentIds.length ?? 0) > 1 ? (
Date: Mon, 24 Aug 2026 13:21:54 -0400
Subject: [PATCH 2/2] fix(chat): handle reaction and coworker edge cases
---
.../channels/conversation-switcher.tsx | 32 +++++++------
app/src/lib/channels/reactions.test.ts | 26 +++++++++-
app/src/lib/channels/reactions.ts | 48 ++++++++++++++-----
3 files changed, 80 insertions(+), 26 deletions(-)
diff --git a/app/src/components/channels/conversation-switcher.tsx b/app/src/components/channels/conversation-switcher.tsx
index 8bfebb4..2d31d8a 100644
--- a/app/src/components/channels/conversation-switcher.tsx
+++ b/app/src/components/channels/conversation-switcher.tsx
@@ -111,20 +111,24 @@ export function ConversationSwitcher({
);
})}
-
-
- }
- >
-
- New conversation with{" "}
- {channel.agentIds.length > 1 ? "this team" : "this coworker"}
-
+ {channel.active ? (
+ <>
+
+
+ }
+ >
+
+ New conversation with{" "}
+ {channel.agentIds.length > 1 ? "this team" : "this coworker"}
+
+ >
+ ) : null}
{subtitle ? (
diff --git a/app/src/lib/channels/reactions.test.ts b/app/src/lib/channels/reactions.test.ts
index a500c74..c1f239d 100644
--- a/app/src/lib/channels/reactions.test.ts
+++ b/app/src/lib/channels/reactions.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test";
-import { applyReactionChange, type MessageReaction } from "./reactions";
+import {
+ applyReactionChange,
+ type MessageReaction,
+ messageReactionIdBatches,
+} from "./reactions";
const liked: MessageReaction = {
messageId: "message-1",
@@ -39,3 +43,23 @@ describe("optimistic message reactions", () => {
).toEqual([liked]);
});
});
+
+describe("message reaction query batches", () => {
+ test("keeps every message while respecting the server's 200-id limit", () => {
+ const messageIds = Array.from(
+ { length: 401 },
+ (_, index) => `message-${index}`,
+ );
+
+ const batches = messageReactionIdBatches(messageIds);
+
+ expect(batches.map((batch) => batch.length)).toEqual([200, 200, 1]);
+ expect(batches.flat()).toEqual(messageIds);
+ });
+
+ test("does not request duplicate message ids", () => {
+ expect(messageReactionIdBatches(["one", "two", "one"])).toEqual([
+ ["one", "two"],
+ ]);
+ });
+});
diff --git a/app/src/lib/channels/reactions.ts b/app/src/lib/channels/reactions.ts
index 2ebec81..c321898 100644
--- a/app/src/lib/channels/reactions.ts
+++ b/app/src/lib/channels/reactions.ts
@@ -20,6 +20,24 @@ const reactionKeys = {
[...reactionKeys.channel(channelId), [...messageIds]] as const,
};
+const MAX_REACTION_MESSAGE_IDS = 200;
+
+/** Split transcript lookups to match the server's per-request reaction limit. */
+export function messageReactionIdBatches(
+ messageIds: readonly string[],
+): string[][] {
+ const uniqueIds = [...new Set(messageIds)];
+ const batches: string[][] = [];
+ for (
+ let index = 0;
+ index < uniqueIds.length;
+ index += MAX_REACTION_MESSAGE_IDS
+ ) {
+ batches.push(uniqueIds.slice(index, index + MAX_REACTION_MESSAGE_IDS));
+ }
+ return batches;
+}
+
/** Apply one person's desired reaction state to an aggregate returned by the server. */
export function applyReactionChange(
reactions: readonly MessageReaction[],
@@ -63,18 +81,26 @@ export function messageReactionsQueryOptions(
queryKey: reactionKeys.messages(channelId ?? "none", messageIds),
enabled: Boolean(channelId) && messageIds.length > 0,
queryFn: async (): Promise => {
- const response = await fetch(
- `/api/channels/${encodeURIComponent(channelId ?? "")}/reactions/query`,
- {
- method: "POST",
- credentials: "include",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ messageIds }),
- },
+ const batches = messageReactionIdBatches(messageIds);
+ const results = await Promise.all(
+ batches.map(async (batch) => {
+ const response = await fetch(
+ `/api/channels/${encodeURIComponent(channelId ?? "")}/reactions/query`,
+ {
+ method: "POST",
+ credentials: "include",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ messageIds: batch }),
+ },
+ );
+ if (!response.ok) {
+ throw new Error("Could not load message reactions.");
+ }
+ return ((await response.json()) as { reactions: MessageReaction[] })
+ .reactions;
+ }),
);
- if (!response.ok) throw new Error("Could not load message reactions.");
- return ((await response.json()) as { reactions: MessageReaction[] })
- .reactions;
+ return results.flat();
},
staleTime: 15_000,
});