feat(ui): add copy-as-markdown in chat
Co-authored-by: Bradley Priest <bradleypriest@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
82
ui/src/ui/chat/copy-as-markdown.ts
Normal file
82
ui/src/ui/chat/copy-as-markdown.ts
Normal 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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user