diff --git a/CHANGELOG.md b/CHANGELOG.md index 9467dfcf4..b9fcd520e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ui/src/styles/chat.css b/ui/src/styles/chat.css index b23ea3eaa..07d3b644a 100644 --- a/ui/src/styles/chat.css +++ b/ui/src/styles/chat.css @@ -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"; diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index d0e05e508..dea387c45 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -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); } } - diff --git a/ui/src/styles/chat/legacy.css b/ui/src/styles/chat/legacy.css deleted file mode 100644 index 7a802b716..000000000 --- a/ui/src/styles/chat/legacy.css +++ /dev/null @@ -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; -} - diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index 6658d99be..7f7ed7db9 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -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``; - // Icon for grouped view - const groupIcon = html``; // Refresh icon const refreshIcon = html``; const focusIcon = html``; @@ -116,18 +112,6 @@ export function renderChatControls(state: AppViewState) { > ${focusIcon} - `; } diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index cae89498c..4a6342ae0 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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(), diff --git a/ui/src/ui/app-tool-stream.ts b/ui/src/ui/app-tool-stream.ts index 42e5961a4..b94adada2 100644 --- a/ui/src/ui/app-tool-stream.ts +++ b/ui/src/ui/app-tool-stream.ts @@ -31,7 +31,6 @@ type ToolStreamHost = { toolStreamById: Map; toolStreamOrder: string[]; chatToolMessages: Record[]; - toolOutputExpanded: Set; 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 = diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index f6e48cdd4..f0e6a6c51 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -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(); // 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[0], - id, - expanded, - ); - } applySettings(next: UiSettings) { applySettingsInternal( this as unknown as Parameters[0], diff --git a/ui/src/ui/chat-markdown.browser.test.ts b/ui/src/ui/chat-markdown.browser.test.ts index 861f8d0b5..d799d7f7f 100644 --- a/ui/src/ui/chat-markdown.browser.test.ts +++ b/ui/src/ui/chat-markdown.browser.test.ts @@ -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"); }); }); diff --git a/ui/src/ui/chat/copy-as-markdown.ts b/ui/src/ui/chat/copy-as-markdown.ts new file mode 100644 index 000000000..0310a59b8 --- /dev/null +++ b/ui/src/ui/chat/copy-as-markdown.ts @@ -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 { + if (!text) return false; + + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} + +export function renderCopyAsMarkdownButton(markdown: string): TemplateResult { + return html` + + `; +} diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index e84d2b7f1..caa4731a1 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -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`
+ ${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing} ${reasoningMarkdown ? html`
${unsafeHTML( toSanitizedMarkdownHtml(reasoningMarkdown), @@ -181,4 +185,3 @@ function renderGroupedMessage(
`; } - diff --git a/ui/src/ui/chat/legacy-render.ts b/ui/src/ui/chat/legacy-render.ts deleted file mode 100644 index 44ef25cc5..000000000 --- a/ui/src/ui/chat/legacy-render.ts +++ /dev/null @@ -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` -
-
- -
-
- `; -} - -export function renderMessage( - message: unknown, - props?: LegacyToolOutputProps, - opts?: { streaming?: boolean; showReasoning?: boolean }, -) { - const m = message as Record; - 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` -
-
-
- ${reasoningMarkdown - ? html`
${unsafeHTML( - toSanitizedMarkdownHtml(reasoningMarkdown), - )}
` - : nothing} - ${markdown - ? html`
${unsafeHTML(toSanitizedMarkdownHtml(markdown))}
` - : nothing} - ${toolCards.map((card, index) => - renderToolCardLegacy(card, { - id: `${toolCardBase}:${index}`, - expanded: props?.isToolOutputExpanded - ? props.isToolOutputExpanded(`${toolCardBase}:${index}`) - : false, - onToggle: props?.onToolOutputToggle, - }), - )} -
-
- ${who}${timestamp ? html` ยท ${timestamp}` : nothing} -
-
-
- `; -} - diff --git a/ui/src/ui/chat/tool-cards.ts b/ui/src/ui/chat/tool-cards.ts index f725363df..58bace1a2 100644 --- a/ui/src/ui/chat/tool-cards.ts +++ b/ui/src/ui/chat/tool-cards.ts @@ -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` -
-
-
- ${display.emoji} - ${display.label} -
- ${!hasOutput ? html`โœ“` : nothing} -
- ${detail - ? html`
${detail}
` - : nothing} - ${hasOutput - ? html` -
{ - if (!opts?.onToggle) return; - const target = e.currentTarget as HTMLDetailsElement; - opts.onToggle(id, target.open); - }} - > - - ${expanded ? "Hide output" : "Show output"} - - (${card.text?.length ?? 0} chars) - - - ${expanded - ? html`
- ${unsafeHTML(toSanitizedMarkdownHtml(card.text ?? ""))} -
` - : nothing} -
- ` - : nothing} -
- `; -} - export function renderToolCardSidebar( card: ToolCard, onOpenSidebar?: (content: string) => void, @@ -197,4 +141,3 @@ function extractToolText(item: Record): string | undefined { if (typeof item.content === "string") return item.content; return undefined; } - diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 1288895d9..fc410c225 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -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; // 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 diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index f25221db5..4618fdd8b 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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`
@@ -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; })}
- ${useNewLayout && sidebarOpen + ${sidebarOpen ? html` { } } - if (props.useNewChatLayout) return groupMessages(items); - return items; + return groupMessages(items); } function messageKey(message: unknown, index: number): string {