Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down
1 change: 1 addition & 0 deletions app/src/components/channels/channel-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
103 changes: 103 additions & 0 deletions app/src/components/channels/chat-transcript.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,24 @@ 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,
useState,
} 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,
Expand All @@ -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,
Expand All @@ -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<Readonly<Message>>;
Expand Down Expand Up @@ -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<Record<string, number>> = {};
const EMPTY_REACTIONS: readonly MessageReaction[] = [];

/**
* Split a person's message into the skill they invoked and the rest of what they typed.
Expand Down Expand Up @@ -612,6 +627,8 @@ function MessageActions({
onQuote,
onEdit,
onRetry,
reactions,
onReact,
time,
}: {
align: "start" | "end";
Expand All @@ -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 =
Expand All @@ -633,6 +654,13 @@ function MessageActions({
{time !== undefined ? (
<span className="mr-1 text-[11px]">{formatTime(time)}</span>
) : null}
{onReact ? (
<MessageReactionPicker
align={align}
onToggle={onReact}
reactions={reactions}
/>
) : null}
<button
aria-label={copied ? "Copied" : "Copy message"}
className={button}
Expand Down Expand Up @@ -733,8 +761,10 @@ function attachmentsKey(
type TranscriptMessageProps = {
commandNames?: string;
delay: number;
messageId: string;
role: "user" | "assistant";
text: string;
reactions: readonly MessageReaction[];
attachments?: readonly VisibleAttachment[] | undefined;
/** First seen by this browser; undefined renders no time at all. */
time?: number | undefined;
Expand All @@ -745,6 +775,13 @@ type TranscriptMessageProps = {
onEdit?: ((text: string) => 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;
};

/**
Expand All @@ -768,15 +805,18 @@ const TranscriptMessage = memo(
function TranscriptMessage({
commandNames = "",
delay,
messageId,
role,
text,
reactions,
attachments,
time,
searchTint,
settling,
onQuote,
onEdit,
onRetry,
onToggleReaction,
}: TranscriptMessageProps) {
const isUser = role === "user";
const align = isUser ? "end" : "start";
Expand Down Expand Up @@ -878,6 +918,15 @@ const TranscriptMessage = memo(
{attachments && attachments.length > 0 ? (
<MessageAttachments attachments={attachments} />
) : null}
{onToggleReaction ? (
<MessageReactionChips
align={align}
onToggle={(emoji, active) =>
onToggleReaction(messageId, emoji, active)
}
reactions={reactions}
/>
) : null}
</Arriving>
<MessageFooter>
<MessageActions
Expand All @@ -886,7 +935,14 @@ const TranscriptMessage = memo(
onCopy={handleCopy}
onEdit={isUser && onEdit ? () => 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}
/>
Expand All @@ -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 &&
Expand All @@ -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.
*
Expand Down Expand Up @@ -1378,6 +1447,7 @@ function blockMessageId(block: TranscriptBlock): string {

export function ChatTranscript({
busy = false,
channelId,
commandNames = "",
messages,
onRemoveQueued,
Expand Down Expand Up @@ -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),
Comment thread
Clondin marked this conversation as resolved.
);
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<string, MessageReaction[]>();
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
Expand Down Expand Up @@ -1529,6 +1622,11 @@ export function ChatTranscript({
{historyNotice}
</p>
) : null}
{reactionMutation.error ? (
<p className="sr-only" role="alert">
{reactionMutation.error.message}
</p>
) : null}
{restoring && items.length === 0 ? <TranscriptSkeleton /> : null}
{!restoring && items.length === 0 && !busy && queued.length === 0
? emptyState
Expand Down Expand Up @@ -1615,15 +1713,20 @@ 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
}
settling={
item.role === "assistant" && item.id === settlingId
}
role={item.role}
reactions={
reactionsByMessage.get(item.id) ?? EMPTY_REACTIONS
}
searchTint={
item.id === activeSearchMessageId
? "active"
Expand Down
Loading
Loading