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} + ))} + + ); +} 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") ? ( + + ) : 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") ? ( - - ) : 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") ? ( +
+ +
) : 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 ? (