Skip to content

Commit 51ee9b4

Browse files
idoubiclaude
andcommitted
feat(web): respect newlines + render split-marker bubbles in chat-screen
- Pull in remark-breaks so single newlines in markdown become <br> in the rendered bubble. Without it, IM-style replies (single \n separators between thoughts) collapsed to one paragraph in the web UI and looked off vs how the IM channels render them. - Render the on-the-wire <|split|> SplitMessageMarker into separate bubbles client-side so web matches the IM dispatcher's behavior. The marker constant is colocated in chat-screen.tsx with a doc comment pointing at internal/channels/base.go for the source of truth. - Admin /chats page: add loading + refresh affordances (Loader2 + RefreshCw), small UX polish on the listing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8c16740 commit 51ee9b4

4 files changed

Lines changed: 110 additions & 22 deletions

File tree

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"react": "19.2.3",
2020
"react-dom": "19.2.3",
2121
"react-markdown": "^10.1.0",
22+
"remark-breaks": "^4.0.0",
2223
"remark-gfm": "^4.0.1",
2324
"shadcn": "^4.1.0",
2425
"tailwind-merge": "^3.5.0",

web/pnpm-lock.yaml

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/src/app/admin/chats/page.tsx

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"use client";
22

3-
import { useEffect, useMemo, useState } from "react";
3+
import { useCallback, useEffect, useMemo, useState } from "react";
44
import {
55
MessagesSquare,
66
ChevronLeft,
77
ChevronRight,
88
Bot,
99
User as UserIcon,
1010
ExternalLink,
11+
Loader2,
12+
RefreshCw,
1113
} from "lucide-react";
1214
import { Button } from "@/components/ui/button";
1315
import { Card, CardContent } from "@/components/ui/card";
@@ -27,26 +29,34 @@ const PAGE_SIZE = 30;
2729
export default function AdminChatsPage() {
2830
const [sessions, setSessions] = useState<AdminChatSessionEntry[]>([]);
2931
const [error, setError] = useState("");
32+
const [loading, setLoading] = useState(true);
33+
const [refreshing, setRefreshing] = useState(false);
3034
const [page, setPage] = useState(1);
3135

32-
useEffect(() => {
33-
let aborted = false;
34-
(async () => {
35-
try {
36-
const list = await adminListChats();
37-
if (aborted) return;
38-
setSessions(list);
39-
setError("");
40-
} catch (e) {
41-
if (aborted) return;
42-
setError(e instanceof Error ? e.message : "Failed to load chats");
43-
}
44-
})();
45-
return () => {
46-
aborted = true;
47-
};
36+
// load is shared by the initial mount effect and the refresh button.
37+
// The mount path passes initial=true so it owns the full-page spinner
38+
// (sessions still empty); manual refreshes use the smaller in-button
39+
// spinner instead so the existing rows stay visible while the fetch
40+
// is in flight.
41+
const load = useCallback(async (initial: boolean) => {
42+
if (initial) setLoading(true);
43+
else setRefreshing(true);
44+
try {
45+
const list = await adminListChats();
46+
setSessions(list);
47+
setError("");
48+
} catch (e) {
49+
setError(e instanceof Error ? e.message : "Failed to load chats");
50+
} finally {
51+
if (initial) setLoading(false);
52+
else setRefreshing(false);
53+
}
4854
}, []);
4955

56+
useEffect(() => {
57+
void load(true);
58+
}, [load]);
59+
5060
// Newest first — backend doesn't guarantee order across (user, agent)
5161
// pairs because it concatenates per-agent lists.
5262
const sorted = useMemo(
@@ -62,13 +72,23 @@ export default function AdminChatsPage() {
6272

6373
return (
6474
<div className="p-6 space-y-6 max-w-5xl mx-auto">
65-
<div className="flex items-center justify-between">
75+
<div className="flex items-center justify-between gap-4">
6676
<div>
6777
<h2 className="text-2xl font-semibold tracking-tight">Chats</h2>
6878
<p className="text-sm text-muted-foreground mt-1">
6979
All conversations across every agent on the platform.
7080
</p>
7181
</div>
82+
<Button
83+
variant="outline"
84+
size="sm"
85+
onClick={() => void load(false)}
86+
disabled={loading || refreshing}
87+
title="Refresh chats"
88+
>
89+
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? "animate-spin" : ""}`} />
90+
Refresh
91+
</Button>
7292
</div>
7393

7494
{error && (
@@ -79,7 +99,14 @@ export default function AdminChatsPage() {
7999
</Card>
80100
)}
81101

82-
{sorted.length === 0 ? (
102+
{loading ? (
103+
<div className="rounded-lg border border-border bg-card">
104+
<div className="flex flex-col items-center justify-center py-16">
105+
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
106+
<p className="mt-3 text-xs text-muted-foreground/60">Loading chats…</p>
107+
</div>
108+
</div>
109+
) : sorted.length === 0 ? (
83110
<div className="rounded-lg border border-border bg-card">
84111
<div className="flex flex-col items-center justify-center py-16">
85112
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary/10 mb-4">

web/src/components/chat-screen.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Bot, Send, Copy, Check, Pencil, Wrench, ChevronDown, ChevronRight, Down
1010
import Link from "next/link";
1111
import ReactMarkdown, { defaultUrlTransform } from "react-markdown";
1212
import remarkGfm from "remark-gfm";
13+
import remarkBreaks from "remark-breaks";
1314
import { ExternalAnchor } from "@/components/markdown-link";
1415

1516
// react-markdown's default urlTransform strips any protocol not in the
@@ -116,7 +117,7 @@ function renderContentWithDataImages(
116117
);
117118
}
118119
return (
119-
<ReactMarkdown key={i} remarkPlugins={[remarkGfm]} urlTransform={urlTransformFn} components={{ a: ExternalAnchor }}>
120+
<ReactMarkdown key={i} remarkPlugins={[remarkGfm, remarkBreaks]} urlTransform={urlTransformFn} components={{ a: ExternalAnchor }}>
120121
{p.text}
121122
</ReactMarkdown>
122123
);
@@ -212,6 +213,23 @@ const CHAT_PROSE_CLASS =
212213
"prose-th:py-1 prose-th:px-2 prose-td:py-1 prose-td:px-2 " +
213214
"prose-hr:my-3";
214215

216+
// Wire token the agent emits to request a multi-bubble reply — must
217+
// match channels.SplitMessageMarker in internal/channels/base.go. On
218+
// IM channels the dispatcher (manager.dispatchOutbound) splits the
219+
// outbound text on this marker into separate platform messages; the
220+
// web UI renders one bubble per split chunk so the experience matches.
221+
const SPLIT_MARKER = "<|split|>";
222+
223+
// splitOnMarker breaks `s` on SPLIT_MARKER, trims each chunk, and
224+
// drops the empty ones. Used at render time so a streamed assistant
225+
// reply containing the marker becomes multiple bubbles without any
226+
// upstream content-event rewriting.
227+
function splitOnMarker(s: string): string[] {
228+
if (!s.includes(SPLIT_MARKER)) return [s];
229+
const parts = s.split(SPLIT_MARKER).map((p) => p.trim()).filter((p) => p.length > 0);
230+
return parts.length > 0 ? parts : [s];
231+
}
232+
215233
// Single-segment identity filenames that route to the agent's home dir
216234
// (not the workspace) — exclude from the "Your files" panel.
217235
const SYSTEM_FILES = new Set([
@@ -2030,6 +2048,28 @@ export function ChatScreen() {
20302048
}
20312049
continue;
20322050
}
2051+
// Agent bubbles may carry the `<|split|>` marker the
2052+
// LLM emits for multi-bubble output (mirrors IM channel
2053+
// behavior). Expand into one bubble per chunk so the
2054+
// marker never surfaces as literal text. Attach files /
2055+
// metadata only to the last chunk to match the IM
2056+
// dispatcher's "attach to last chunk" rule.
2057+
if (msg.role === "agent" && msg.content.includes(SPLIT_MARKER)) {
2058+
const parts = splitOnMarker(msg.content);
2059+
parts.forEach((part, idx) => {
2060+
const isLast = idx === parts.length - 1;
2061+
elements.push(
2062+
renderRegularBubble({
2063+
...msg,
2064+
id: `${msg.id}-s${idx}`,
2065+
content: part,
2066+
files: isLast ? msg.files : undefined,
2067+
metadata: isLast ? msg.metadata : undefined,
2068+
}),
2069+
);
2070+
});
2071+
continue;
2072+
}
20332073
elements.push(renderRegularBubble(msg));
20342074
}
20352075
return elements;
@@ -2123,7 +2163,7 @@ export function ChatScreen() {
21232163
(attachedImages.get(msg.id)?.length ?? 0) > 0,
21242164
makeUrlTransform(selectedAgent, sessionId),
21252165
) ?? (
2126-
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={makeUrlTransform(selectedAgent, sessionId)} components={{ a: ExternalAnchor }}>
2166+
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} urlTransform={makeUrlTransform(selectedAgent, sessionId)} components={{ a: ExternalAnchor }}>
21272167
{msg.content}
21282168
</ReactMarkdown>
21292169
)}
@@ -2676,7 +2716,7 @@ function ToolCallGroup({ msg, surfacedSrcs, agentId, sessionId, nested = false,
26762716
<div className="bg-muted rounded-2xl rounded-bl-md px-4 py-2.5">
26772717
<div className={CHAT_PROSE_CLASS}>
26782718
{renderContentWithDataImages(msg.content, surfacedSrcs, false, makeUrlTransform(agentId, sessionId)) ?? (
2679-
<ReactMarkdown remarkPlugins={[remarkGfm]} urlTransform={makeUrlTransform(agentId, sessionId)} components={{ a: ExternalAnchor }}>
2719+
<ReactMarkdown remarkPlugins={[remarkGfm, remarkBreaks]} urlTransform={makeUrlTransform(agentId, sessionId)} components={{ a: ExternalAnchor }}>
26802720
{msg.content}
26812721
</ReactMarkdown>
26822722
)}

0 commit comments

Comments
 (0)