feat(ui): add copy-as-markdown in chat

Co-authored-by: Bradley Priest <bradleypriest@users.noreply.github.com>
This commit is contained in:
Peter Steinberger
2026-01-21 02:36:17 +00:00
parent 5bd55037e4
commit 03916ed10e
15 changed files with 177 additions and 324 deletions

View File

@@ -20,6 +20,7 @@ Docs: https://docs.clawd.bot
- Security: warn when <=300B models run without sandboxing and with web tools enabled.
- Skills: add download installs with OS-filtered install options; add local sherpa-onnx-tts skill.
- Docs: clarify WhatsApp voice notes and Windows WSL portproxy LAN access notes.
- UI: add copy-as-markdown with error feedback and drop legacy list view. (#1345) — thanks @bradleypriest.
### Fixes
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.

View File

@@ -1,5 +1,4 @@
@import "./chat/layout.css";
@import "./chat/legacy.css";
@import "./chat/text.css";
@import "./chat/grouped.css";
@import "./chat/tool-cards.css";

View File

@@ -91,6 +91,7 @@
/* Minimal Bubble Design - dynamic width based on content */
.chat-bubble {
position: relative;
display: inline-block;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.12);
@@ -102,6 +103,77 @@
word-wrap: break-word;
}
.chat-bubble.has-copy {
padding-right: 36px;
}
.chat-copy-btn {
position: absolute;
top: 6px;
right: 8px;
border: 1px solid var(--border);
background: rgba(0, 0, 0, 0.22);
color: var(--muted);
border-radius: 8px;
padding: 4px 6px;
font-size: 14px;
line-height: 1;
cursor: pointer;
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease-out, background 120ms ease-out;
}
.chat-copy-btn__icon {
display: inline-block;
width: 1em;
text-align: center;
}
.chat-bubble:hover .chat-copy-btn {
opacity: 1;
pointer-events: auto;
}
.chat-copy-btn:hover {
background: rgba(0, 0, 0, 0.3);
}
.chat-copy-btn[data-copying="1"] {
opacity: 0;
pointer-events: none;
}
.chat-copy-btn[data-error="1"] {
opacity: 1;
pointer-events: auto;
border-color: rgba(255, 69, 58, 0.8);
background: rgba(255, 69, 58, 0.18);
color: rgba(255, 69, 58, 1);
}
.chat-copy-btn[data-copied="1"] {
opacity: 1;
pointer-events: auto;
border-color: rgba(52, 199, 183, 0.8);
background: rgba(52, 199, 183, 0.18);
color: rgba(52, 199, 183, 1);
}
.chat-copy-btn:focus-visible {
opacity: 1;
pointer-events: auto;
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (hover: none) {
.chat-copy-btn {
opacity: 1;
pointer-events: auto;
}
}
.chat-bubble:hover {
background: rgba(0, 0, 0, 0.18);
}
@@ -145,4 +217,3 @@
transform: translateY(0);
}
}

View File

@@ -1,43 +0,0 @@
/* =============================================
LEGACY CHAT LINE LAYOUT (non-grouped)
============================================= */
.chat-line {
display: flex;
margin-bottom: 12px;
}
.chat-line.user {
justify-content: flex-end;
}
.chat-line.assistant,
.chat-line.other,
.chat-line.tool {
justify-content: flex-start;
}
.chat-line.tool .chat-bubble {
border-style: dashed;
opacity: 0.95;
}
.chat-msg {
display: grid;
gap: 6px;
max-width: min(900px, 95%);
}
.chat-line.user .chat-msg {
justify-items: end;
}
.chat-stamp {
font-size: 11px;
color: var(--muted);
}
.chat-line.user .chat-stamp {
text-align: right;
}

View File

@@ -39,10 +39,6 @@ export function renderTab(state: AppViewState, tab: Tab) {
export function renderChatControls(state: AppViewState) {
const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult);
// Icon for list view (legacy)
const listIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>`;
// Icon for grouped view
const groupIcon = html`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"></rect><rect x="14" y="3" width="7" height="7"></rect><rect x="14" y="14" width="7" height="7"></rect><rect x="3" y="14" width="7" height="7"></rect></svg>`;
// Refresh icon
const refreshIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"></path><path d="M21 3v5h-5"></path></svg>`;
const focusIcon = html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7V4h3"></path><path d="M20 7V4h-3"></path><path d="M4 17v3h3"></path><path d="M20 17v3h-3"></path><circle cx="12" cy="12" r="3"></circle></svg>`;
@@ -116,18 +112,6 @@ export function renderChatControls(state: AppViewState) {
>
${focusIcon}
</button>
<button
class="btn btn--sm btn--icon ${state.settings.useNewChatLayout ? "active" : ""}"
@click=${() =>
state.applySettings({
...state.settings,
useNewChatLayout: !state.settings.useNewChatLayout,
})}
aria-pressed=${state.settings.useNewChatLayout}
title="${state.settings.useNewChatLayout ? "Switch to list view" : "Switch to grouped view"}"
>
${state.settings.useNewChatLayout ? groupIcon : listIcon}
</button>
</div>
`;
}

View File

@@ -429,11 +429,7 @@ export function renderApp(state: AppViewState) {
disabledReason: chatDisabledReason,
error: state.lastError,
sessions: state.sessionsResult,
isToolOutputExpanded: (id) => state.toolOutputExpanded.has(id),
onToolOutputToggle: (id, expanded) =>
state.toggleToolOutput(id, expanded),
focusMode: state.settings.chatFocusMode,
useNewChatLayout: state.settings.useNewChatLayout,
onRefresh: () => {
state.resetToolStream();
return loadChatHistory(state);
@@ -443,11 +439,6 @@ export function renderApp(state: AppViewState) {
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
}),
onToggleLayout: () =>
state.applySettings({
...state.settings,
useNewChatLayout: !state.settings.useNewChatLayout,
}),
onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next),
onSend: () => state.handleSendChat(),

View File

@@ -31,7 +31,6 @@ type ToolStreamHost = {
toolStreamById: Map<string, ToolStreamEntry>;
toolStreamOrder: string[];
chatToolMessages: Record<string, unknown>[];
toolOutputExpanded: Set<string>;
toolStreamSyncTimer: number | null;
};
@@ -136,20 +135,9 @@ export function resetToolStream(host: ToolStreamHost) {
host.toolStreamById.clear();
host.toolStreamOrder = [];
host.chatToolMessages = [];
host.toolOutputExpanded = new Set();
flushToolStreamSync(host);
}
export function toggleToolOutput(host: ToolStreamHost, id: string, expanded: boolean) {
const next = new Set(host.toolOutputExpanded);
if (expanded) {
next.add(id);
} else {
next.delete(id);
}
host.toolOutputExpanded = next;
}
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
if (!payload || payload.stream !== "tool") return;
const sessionKey =

View File

@@ -33,7 +33,6 @@ import type { DevicePairingList } from "./controllers/devices";
import type { ExecApprovalRequest } from "./controllers/exec-approval";
import {
resetToolStream as resetToolStreamInternal,
toggleToolOutput as toggleToolOutputInternal,
type ToolStreamEntry,
} from "./app-tool-stream";
import {
@@ -109,7 +108,6 @@ export class ClawdbotApp extends LitElement {
@state() chatRunId: string | null = null;
@state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = [];
@state() toolOutputExpanded = new Set<string>();
// Sidebar state for tool output viewing
@state() sidebarOpen = false;
@state() sidebarContent: string | null = null;
@@ -301,13 +299,6 @@ export class ClawdbotApp extends LitElement {
);
}
toggleToolOutput(id: string, expanded: boolean) {
toggleToolOutputInternal(
this as unknown as Parameters<typeof toggleToolOutputInternal>[0],
id,
expanded,
);
}
applySettings(next: UiSettings) {
applySettingsInternal(
this as unknown as Parameters<typeof applySettingsInternal>[0],

View File

@@ -28,12 +28,7 @@ afterEach(() => {
});
describe("chat markdown rendering", () => {
it("renders markdown inside tool result cards", async () => {
localStorage.setItem(
"clawdbot.control.settings.v1",
JSON.stringify({ useNewChatLayout: false }),
);
it("renders markdown inside tool output sidebar", async () => {
const app = mountApp("/chat");
await app.updateComplete;
@@ -48,12 +43,16 @@ describe("chat markdown rendering", () => {
timestamp,
},
];
// Expand the tool output card so its markdown is rendered into the DOM.
app.toolOutputExpanded = new Set([`${timestamp}:1`]);
await app.updateComplete;
const strong = app.querySelector(".chat-tool-card__output strong");
const toolCard = app.querySelector(".chat-tool-card") as HTMLElement | null;
expect(toolCard).not.toBeNull();
toolCard?.click();
await app.updateComplete;
const strong = app.querySelector(".sidebar-markdown strong");
expect(strong?.textContent).toBe("world");
});
});

View File

@@ -0,0 +1,82 @@
import { html, type TemplateResult } from "lit";
const COPIED_FOR_MS = 1500;
const ERROR_FOR_MS = 2000;
const COPY_LABEL = "Copy as markdown";
const COPIED_LABEL = "Copied";
const ERROR_LABEL = "Copy failed";
const COPY_ICON = "📋";
const COPIED_ICON = "✓";
const ERROR_ICON = "!";
async function copyTextToClipboard(text: string): Promise<boolean> {
if (!text) return false;
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
export function renderCopyAsMarkdownButton(markdown: string): TemplateResult {
return html`
<button
class="chat-copy-btn"
type="button"
title=${COPY_LABEL}
aria-label=${COPY_LABEL}
@click=${async (e: Event) => {
const btn = e.currentTarget as HTMLButtonElement | null;
const icon = btn?.querySelector(
".chat-copy-btn__icon",
) as HTMLElement | null;
if (!btn || btn.dataset.copying === "1") return;
btn.dataset.copying = "1";
btn.setAttribute("aria-busy", "true");
btn.disabled = true;
const copied = await copyTextToClipboard(markdown);
if (!btn.isConnected) return;
delete btn.dataset.copying;
btn.removeAttribute("aria-busy");
btn.disabled = false;
if (!copied) {
btn.dataset.error = "1";
btn.title = ERROR_LABEL;
btn.setAttribute("aria-label", ERROR_LABEL);
if (icon) icon.textContent = ERROR_ICON;
window.setTimeout(() => {
if (!btn.isConnected) return;
delete btn.dataset.error;
btn.title = COPY_LABEL;
btn.setAttribute("aria-label", COPY_LABEL);
if (icon) icon.textContent = COPY_ICON;
}, ERROR_FOR_MS);
return;
}
btn.dataset.copied = "1";
btn.title = COPIED_LABEL;
btn.setAttribute("aria-label", COPIED_LABEL);
if (icon) icon.textContent = COPIED_ICON;
window.setTimeout(() => {
if (!btn.isConnected) return;
delete btn.dataset.copied;
btn.title = COPY_LABEL;
btn.setAttribute("aria-label", COPY_LABEL);
if (icon) icon.textContent = COPY_ICON;
}, COPIED_FOR_MS);
}}
>
<span class="chat-copy-btn__icon" aria-hidden="true">${COPY_ICON}</span>
</button>
`;
}

View File

@@ -3,6 +3,7 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { toSanitizedMarkdownHtml } from "../markdown";
import type { MessageGroup } from "../types/chat-types";
import { renderCopyAsMarkdownButton } from "./copy-as-markdown";
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer";
import {
extractText,
@@ -150,9 +151,11 @@ function renderGroupedMessage(
? formatReasoningMarkdown(extractedThinking)
: null;
const markdown = markdownBase;
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
const bubbleClasses = [
"chat-bubble",
canCopyMarkdown ? "has-copy" : "",
opts.isStreaming ? "streaming" : "",
"fade-in",
]
@@ -169,6 +172,7 @@ function renderGroupedMessage(
return html`
<div class="${bubbleClasses}">
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
@@ -181,4 +185,3 @@ function renderGroupedMessage(
</div>
`;
}

View File

@@ -1,129 +0,0 @@
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { toSanitizedMarkdownHtml } from "../markdown";
import {
isToolResultMessage,
normalizeRoleForGrouping,
} from "./message-normalizer";
import {
extractText,
extractThinking,
formatReasoningMarkdown,
} from "./message-extract";
import { extractToolCards, renderToolCardLegacy } from "./tool-cards";
export type LegacyToolOutputProps = {
isToolOutputExpanded?: (id: string) => boolean;
onToolOutputToggle?: (id: string, expanded: boolean) => void;
};
export function renderReadingIndicator() {
return html`
<div class="chat-line assistant">
<div class="chat-msg">
<div class="chat-bubble chat-reading-indicator" aria-hidden="true">
<span class="chat-reading-indicator__dots">
<span></span><span></span><span></span>
</span>
</div>
</div>
</div>
`;
}
export function renderMessage(
message: unknown,
props?: LegacyToolOutputProps,
opts?: { streaming?: boolean; showReasoning?: boolean },
) {
const m = message as Record<string, unknown>;
const role = typeof m.role === "string" ? m.role : "unknown";
const toolCards = extractToolCards(message);
const hasToolCards = toolCards.length > 0;
const isToolResult =
isToolResultMessage(message) ||
typeof m.toolCallId === "string" ||
typeof m.tool_call_id === "string";
const extractedText = extractText(message);
const extractedThinking =
opts?.showReasoning && role === "assistant" ? extractThinking(message) : null;
const contentText = typeof m.content === "string" ? m.content : null;
const fallback = hasToolCards ? null : JSON.stringify(message, null, 2);
const display =
!isToolResult && extractedText?.trim()
? { kind: "text" as const, value: extractedText }
: !isToolResult && contentText?.trim()
? { kind: "text" as const, value: contentText }
: !isToolResult && fallback
? { kind: "json" as const, value: fallback }
: null;
const markdownBase =
display?.kind === "json"
? ["```json", display.value, "```"].join("\n")
: (display?.value ?? null);
const reasoningMarkdown = extractedThinking
? formatReasoningMarkdown(extractedThinking)
: null;
const markdown = markdownBase;
const timestamp =
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
const normalizedRole = normalizeRoleForGrouping(role);
const klass =
normalizedRole === "assistant"
? "assistant"
: normalizedRole === "user"
? "user"
: normalizedRole === "tool"
? "tool"
: "other";
const who =
normalizedRole === "assistant"
? "Assistant"
: normalizedRole === "user"
? "You"
: normalizedRole === "tool"
? "Working"
: normalizedRole;
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
const toolCardBase =
toolCallId ||
(typeof m.id === "string" ? m.id : "") ||
(typeof m.messageId === "string" ? m.messageId : "") ||
(typeof m.timestamp === "number" ? String(m.timestamp) : "tool-card");
return html`
<div class="chat-line ${klass}">
<div class="chat-msg">
<div class="chat-bubble ${opts?.streaming ? "streaming" : ""}">
${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing}
${markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing}
${toolCards.map((card, index) =>
renderToolCardLegacy(card, {
id: `${toolCardBase}:${index}`,
expanded: props?.isToolOutputExpanded
? props.isToolOutputExpanded(`${toolCardBase}:${index}`)
: false,
onToggle: props?.onToolOutputToggle,
}),
)}
</div>
<div class="chat-stamp mono">
${who}${timestamp ? html` · ${timestamp}` : nothing}
</div>
</div>
</div>
`;
}

View File

@@ -1,7 +1,5 @@
import { html, nothing } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { toSanitizedMarkdownHtml } from "../markdown";
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
import type { ToolCard } from "../types/chat-types";
import { TOOL_INLINE_THRESHOLD } from "./constants";
@@ -54,60 +52,6 @@ export function extractToolCards(message: unknown): ToolCard[] {
return cards;
}
export function renderToolCardLegacy(
card: ToolCard,
opts?: {
id: string;
expanded: boolean;
onToggle?: (id: string, expanded: boolean) => void;
},
) {
const display = resolveToolDisplay({ name: card.name, args: card.args });
const detail = formatToolDetail(display);
const hasOutput = typeof card.text === "string" && card.text.length > 0;
const expanded = opts?.expanded ?? false;
const id = opts?.id ?? `${card.name}-${Math.random()}`;
return html`
<div class="chat-tool-card">
<div class="chat-tool-card__header">
<div class="chat-tool-card__title">
<span class="chat-tool-card__icon">${display.emoji}</span>
<span>${display.label}</span>
</div>
${!hasOutput ? html`<span class="chat-tool-card__status">✓</span>` : nothing}
</div>
${detail
? html`<div class="chat-tool-card__detail">${detail}</div>`
: nothing}
${hasOutput
? html`
<details
class="chat-tool-card__details"
?open=${expanded}
@toggle=${(e: Event) => {
if (!opts?.onToggle) return;
const target = e.currentTarget as HTMLDetailsElement;
opts.onToggle(id, target.open);
}}
>
<summary class="chat-tool-card__summary">
${expanded ? "Hide output" : "Show output"}
<span class="chat-tool-card__summary-meta">
(${card.text?.length ?? 0} chars)
</span>
</summary>
${expanded
? html`<div class="chat-tool-card__output chat-text">
${unsafeHTML(toSanitizedMarkdownHtml(card.text ?? ""))}
</div>`
: nothing}
</details>
`
: nothing}
</div>
`;
}
export function renderToolCardSidebar(
card: ToolCard,
onOpenSidebar?: (content: string) => void,
@@ -197,4 +141,3 @@ function extractToolText(item: Record<string, unknown>): string | undefined {
if (typeof item.content === "string") return item.content;
return undefined;
}

View File

@@ -11,7 +11,6 @@ export type UiSettings = {
chatFocusMode: boolean;
chatShowThinking: boolean;
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
useNewChatLayout: boolean; // Slack-style grouped messages layout
navCollapsed: boolean; // Collapsible sidebar state
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
};
@@ -31,7 +30,6 @@ export function loadSettings(): UiSettings {
chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6,
useNewChatLayout: true, // Enabled by default
navCollapsed: false,
navGroupsCollapsed: {},
};
@@ -77,10 +75,6 @@ export function loadSettings(): UiSettings {
parsed.splitRatio <= 0.7
? parsed.splitRatio
: defaults.splitRatio,
useNewChatLayout:
typeof parsed.useNewChatLayout === "boolean"
? parsed.useNewChatLayout
: defaults.useNewChatLayout,
navCollapsed:
typeof parsed.navCollapsed === "boolean"
? parsed.navCollapsed

View File

@@ -8,7 +8,6 @@ import {
normalizeRoleForGrouping,
} from "../chat/message-normalizer";
import { extractText } from "../chat/message-extract";
import { renderMessage, renderReadingIndicator } from "../chat/legacy-render";
import {
renderMessageGroup,
renderReadingIndicatorGroup,
@@ -36,14 +35,9 @@ export type ChatProps = {
disabledReason: string | null;
error: string | null;
sessions: SessionsListResult | null;
// Legacy tool output expand/collapse (used when useNewChatLayout is false)
isToolOutputExpanded: (id: string) => boolean;
onToolOutputToggle: (id: string, expanded: boolean) => void;
// Focus mode
focusMode: boolean;
// Feature flag for new Slack-style layout with sidebar
useNewChatLayout?: boolean;
// Sidebar state (used when useNewChatLayout is true)
// Sidebar state
sidebarOpen?: boolean;
sidebarContent?: string | null;
sidebarError?: string | null;
@@ -51,7 +45,6 @@ export type ChatProps = {
// Event handlers
onRefresh: () => void;
onToggleFocusMode: () => void;
onToggleLayout?: () => void;
onDraftChange: (next: string) => void;
onSend: () => void;
onAbort?: () => void;
@@ -78,7 +71,6 @@ export function renderChat(props: ChatProps) {
const splitRatio = props.splitRatio ?? 0.6;
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
const useNewLayout = props.useNewChatLayout ?? false;
return html`
<section class="card chat">
@@ -122,27 +114,15 @@ export function renderChat(props: ChatProps) {
: nothing}
${repeat(buildChatItems(props), (item) => item.key, (item) => {
if (item.kind === "reading-indicator") {
return useNewLayout
? renderReadingIndicatorGroup()
: renderReadingIndicator();
return renderReadingIndicatorGroup();
}
if (item.kind === "stream") {
return useNewLayout
? renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
)
: renderMessage(
{
role: "assistant",
content: [{ type: "text", text: item.text }],
timestamp: item.startedAt,
},
props,
{ streaming: true, showReasoning },
);
return renderStreamingGroup(
item.text,
item.startedAt,
props.onOpenSidebar,
);
}
if (item.kind === "group") {
@@ -152,12 +132,12 @@ export function renderChat(props: ChatProps) {
});
}
return renderMessage(item.message, props, { showReasoning });
return nothing;
})}
</div>
</div>
${useNewLayout && sidebarOpen
${sidebarOpen
? html`
<resizable-divider
.splitRatio=${splitRatio}
@@ -348,8 +328,7 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
}
}
if (props.useNewChatLayout) return groupMessages(items);
return items;
return groupMessages(items);
}
function messageKey(message: unknown, index: number): string {