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.
|
- 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.
|
- 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.
|
- 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
|
### Fixes
|
||||||
- Discovery: shorten Bonjour DNS-SD service type to `_clawdbot-gw._tcp` and update discovery clients/docs.
|
- 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.
|
- Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241) — thanks @gnarco.
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
@import "./chat/layout.css";
|
@import "./chat/layout.css";
|
||||||
@import "./chat/legacy.css";
|
|
||||||
@import "./chat/text.css";
|
@import "./chat/text.css";
|
||||||
@import "./chat/grouped.css";
|
@import "./chat/grouped.css";
|
||||||
@import "./chat/tool-cards.css";
|
@import "./chat/tool-cards.css";
|
||||||
|
|||||||
@@ -91,6 +91,7 @@
|
|||||||
|
|
||||||
/* Minimal Bubble Design - dynamic width based on content */
|
/* Minimal Bubble Design - dynamic width based on content */
|
||||||
.chat-bubble {
|
.chat-bubble {
|
||||||
|
position: relative;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: rgba(0, 0, 0, 0.12);
|
background: rgba(0, 0, 0, 0.12);
|
||||||
@@ -102,6 +103,77 @@
|
|||||||
word-wrap: break-word;
|
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 {
|
.chat-bubble:hover {
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
@@ -145,4 +217,3 @@
|
|||||||
transform: translateY(0);
|
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) {
|
export function renderChatControls(state: AppViewState) {
|
||||||
const sessionOptions = resolveSessionOptions(state.sessionKey, state.sessionsResult);
|
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
|
// 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 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>`;
|
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}
|
${focusIcon}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -429,11 +429,7 @@ export function renderApp(state: AppViewState) {
|
|||||||
disabledReason: chatDisabledReason,
|
disabledReason: chatDisabledReason,
|
||||||
error: state.lastError,
|
error: state.lastError,
|
||||||
sessions: state.sessionsResult,
|
sessions: state.sessionsResult,
|
||||||
isToolOutputExpanded: (id) => state.toolOutputExpanded.has(id),
|
|
||||||
onToolOutputToggle: (id, expanded) =>
|
|
||||||
state.toggleToolOutput(id, expanded),
|
|
||||||
focusMode: state.settings.chatFocusMode,
|
focusMode: state.settings.chatFocusMode,
|
||||||
useNewChatLayout: state.settings.useNewChatLayout,
|
|
||||||
onRefresh: () => {
|
onRefresh: () => {
|
||||||
state.resetToolStream();
|
state.resetToolStream();
|
||||||
return loadChatHistory(state);
|
return loadChatHistory(state);
|
||||||
@@ -443,11 +439,6 @@ export function renderApp(state: AppViewState) {
|
|||||||
...state.settings,
|
...state.settings,
|
||||||
chatFocusMode: !state.settings.chatFocusMode,
|
chatFocusMode: !state.settings.chatFocusMode,
|
||||||
}),
|
}),
|
||||||
onToggleLayout: () =>
|
|
||||||
state.applySettings({
|
|
||||||
...state.settings,
|
|
||||||
useNewChatLayout: !state.settings.useNewChatLayout,
|
|
||||||
}),
|
|
||||||
onChatScroll: (event) => state.handleChatScroll(event),
|
onChatScroll: (event) => state.handleChatScroll(event),
|
||||||
onDraftChange: (next) => (state.chatMessage = next),
|
onDraftChange: (next) => (state.chatMessage = next),
|
||||||
onSend: () => state.handleSendChat(),
|
onSend: () => state.handleSendChat(),
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ type ToolStreamHost = {
|
|||||||
toolStreamById: Map<string, ToolStreamEntry>;
|
toolStreamById: Map<string, ToolStreamEntry>;
|
||||||
toolStreamOrder: string[];
|
toolStreamOrder: string[];
|
||||||
chatToolMessages: Record<string, unknown>[];
|
chatToolMessages: Record<string, unknown>[];
|
||||||
toolOutputExpanded: Set<string>;
|
|
||||||
toolStreamSyncTimer: number | null;
|
toolStreamSyncTimer: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,20 +135,9 @@ export function resetToolStream(host: ToolStreamHost) {
|
|||||||
host.toolStreamById.clear();
|
host.toolStreamById.clear();
|
||||||
host.toolStreamOrder = [];
|
host.toolStreamOrder = [];
|
||||||
host.chatToolMessages = [];
|
host.chatToolMessages = [];
|
||||||
host.toolOutputExpanded = new Set();
|
|
||||||
flushToolStreamSync(host);
|
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) {
|
export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPayload) {
|
||||||
if (!payload || payload.stream !== "tool") return;
|
if (!payload || payload.stream !== "tool") return;
|
||||||
const sessionKey =
|
const sessionKey =
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ import type { DevicePairingList } from "./controllers/devices";
|
|||||||
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
import type { ExecApprovalRequest } from "./controllers/exec-approval";
|
||||||
import {
|
import {
|
||||||
resetToolStream as resetToolStreamInternal,
|
resetToolStream as resetToolStreamInternal,
|
||||||
toggleToolOutput as toggleToolOutputInternal,
|
|
||||||
type ToolStreamEntry,
|
type ToolStreamEntry,
|
||||||
} from "./app-tool-stream";
|
} from "./app-tool-stream";
|
||||||
import {
|
import {
|
||||||
@@ -109,7 +108,6 @@ export class ClawdbotApp extends LitElement {
|
|||||||
@state() chatRunId: string | null = null;
|
@state() chatRunId: string | null = null;
|
||||||
@state() chatThinkingLevel: string | null = null;
|
@state() chatThinkingLevel: string | null = null;
|
||||||
@state() chatQueue: ChatQueueItem[] = [];
|
@state() chatQueue: ChatQueueItem[] = [];
|
||||||
@state() toolOutputExpanded = new Set<string>();
|
|
||||||
// Sidebar state for tool output viewing
|
// Sidebar state for tool output viewing
|
||||||
@state() sidebarOpen = false;
|
@state() sidebarOpen = false;
|
||||||
@state() sidebarContent: string | null = null;
|
@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) {
|
applySettings(next: UiSettings) {
|
||||||
applySettingsInternal(
|
applySettingsInternal(
|
||||||
this as unknown as Parameters<typeof applySettingsInternal>[0],
|
this as unknown as Parameters<typeof applySettingsInternal>[0],
|
||||||
|
|||||||
@@ -28,12 +28,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("chat markdown rendering", () => {
|
describe("chat markdown rendering", () => {
|
||||||
it("renders markdown inside tool result cards", async () => {
|
it("renders markdown inside tool output sidebar", async () => {
|
||||||
localStorage.setItem(
|
|
||||||
"clawdbot.control.settings.v1",
|
|
||||||
JSON.stringify({ useNewChatLayout: false }),
|
|
||||||
);
|
|
||||||
|
|
||||||
const app = mountApp("/chat");
|
const app = mountApp("/chat");
|
||||||
await app.updateComplete;
|
await app.updateComplete;
|
||||||
|
|
||||||
@@ -48,12 +43,16 @@ describe("chat markdown rendering", () => {
|
|||||||
timestamp,
|
timestamp,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
// Expand the tool output card so its markdown is rendered into the DOM.
|
|
||||||
app.toolOutputExpanded = new Set([`${timestamp}:1`]);
|
|
||||||
|
|
||||||
await app.updateComplete;
|
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");
|
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 { toSanitizedMarkdownHtml } from "../markdown";
|
||||||
import type { MessageGroup } from "../types/chat-types";
|
import type { MessageGroup } from "../types/chat-types";
|
||||||
|
import { renderCopyAsMarkdownButton } from "./copy-as-markdown";
|
||||||
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer";
|
import { isToolResultMessage, normalizeRoleForGrouping } from "./message-normalizer";
|
||||||
import {
|
import {
|
||||||
extractText,
|
extractText,
|
||||||
@@ -150,9 +151,11 @@ function renderGroupedMessage(
|
|||||||
? formatReasoningMarkdown(extractedThinking)
|
? formatReasoningMarkdown(extractedThinking)
|
||||||
: null;
|
: null;
|
||||||
const markdown = markdownBase;
|
const markdown = markdownBase;
|
||||||
|
const canCopyMarkdown = role === "assistant" && Boolean(markdown?.trim());
|
||||||
|
|
||||||
const bubbleClasses = [
|
const bubbleClasses = [
|
||||||
"chat-bubble",
|
"chat-bubble",
|
||||||
|
canCopyMarkdown ? "has-copy" : "",
|
||||||
opts.isStreaming ? "streaming" : "",
|
opts.isStreaming ? "streaming" : "",
|
||||||
"fade-in",
|
"fade-in",
|
||||||
]
|
]
|
||||||
@@ -169,6 +172,7 @@ function renderGroupedMessage(
|
|||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="${bubbleClasses}">
|
<div class="${bubbleClasses}">
|
||||||
|
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
|
||||||
${reasoningMarkdown
|
${reasoningMarkdown
|
||||||
? html`<div class="chat-thinking">${unsafeHTML(
|
? html`<div class="chat-thinking">${unsafeHTML(
|
||||||
toSanitizedMarkdownHtml(reasoningMarkdown),
|
toSanitizedMarkdownHtml(reasoningMarkdown),
|
||||||
@@ -181,4 +185,3 @@ function renderGroupedMessage(
|
|||||||
</div>
|
</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 { html, nothing } from "lit";
|
||||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
|
||||||
|
|
||||||
import { toSanitizedMarkdownHtml } from "../markdown";
|
|
||||||
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
|
import { formatToolDetail, resolveToolDisplay } from "../tool-display";
|
||||||
import type { ToolCard } from "../types/chat-types";
|
import type { ToolCard } from "../types/chat-types";
|
||||||
import { TOOL_INLINE_THRESHOLD } from "./constants";
|
import { TOOL_INLINE_THRESHOLD } from "./constants";
|
||||||
@@ -54,60 +52,6 @@ export function extractToolCards(message: unknown): ToolCard[] {
|
|||||||
return cards;
|
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(
|
export function renderToolCardSidebar(
|
||||||
card: ToolCard,
|
card: ToolCard,
|
||||||
onOpenSidebar?: (content: string) => void,
|
onOpenSidebar?: (content: string) => void,
|
||||||
@@ -197,4 +141,3 @@ function extractToolText(item: Record<string, unknown>): string | undefined {
|
|||||||
if (typeof item.content === "string") return item.content;
|
if (typeof item.content === "string") return item.content;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ export type UiSettings = {
|
|||||||
chatFocusMode: boolean;
|
chatFocusMode: boolean;
|
||||||
chatShowThinking: boolean;
|
chatShowThinking: boolean;
|
||||||
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
|
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
|
navCollapsed: boolean; // Collapsible sidebar state
|
||||||
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
navGroupsCollapsed: Record<string, boolean>; // Which nav groups are collapsed
|
||||||
};
|
};
|
||||||
@@ -31,7 +30,6 @@ export function loadSettings(): UiSettings {
|
|||||||
chatFocusMode: false,
|
chatFocusMode: false,
|
||||||
chatShowThinking: true,
|
chatShowThinking: true,
|
||||||
splitRatio: 0.6,
|
splitRatio: 0.6,
|
||||||
useNewChatLayout: true, // Enabled by default
|
|
||||||
navCollapsed: false,
|
navCollapsed: false,
|
||||||
navGroupsCollapsed: {},
|
navGroupsCollapsed: {},
|
||||||
};
|
};
|
||||||
@@ -77,10 +75,6 @@ export function loadSettings(): UiSettings {
|
|||||||
parsed.splitRatio <= 0.7
|
parsed.splitRatio <= 0.7
|
||||||
? parsed.splitRatio
|
? parsed.splitRatio
|
||||||
: defaults.splitRatio,
|
: defaults.splitRatio,
|
||||||
useNewChatLayout:
|
|
||||||
typeof parsed.useNewChatLayout === "boolean"
|
|
||||||
? parsed.useNewChatLayout
|
|
||||||
: defaults.useNewChatLayout,
|
|
||||||
navCollapsed:
|
navCollapsed:
|
||||||
typeof parsed.navCollapsed === "boolean"
|
typeof parsed.navCollapsed === "boolean"
|
||||||
? parsed.navCollapsed
|
? parsed.navCollapsed
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
normalizeRoleForGrouping,
|
normalizeRoleForGrouping,
|
||||||
} from "../chat/message-normalizer";
|
} from "../chat/message-normalizer";
|
||||||
import { extractText } from "../chat/message-extract";
|
import { extractText } from "../chat/message-extract";
|
||||||
import { renderMessage, renderReadingIndicator } from "../chat/legacy-render";
|
|
||||||
import {
|
import {
|
||||||
renderMessageGroup,
|
renderMessageGroup,
|
||||||
renderReadingIndicatorGroup,
|
renderReadingIndicatorGroup,
|
||||||
@@ -36,14 +35,9 @@ export type ChatProps = {
|
|||||||
disabledReason: string | null;
|
disabledReason: string | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
sessions: SessionsListResult | 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
|
// Focus mode
|
||||||
focusMode: boolean;
|
focusMode: boolean;
|
||||||
// Feature flag for new Slack-style layout with sidebar
|
// Sidebar state
|
||||||
useNewChatLayout?: boolean;
|
|
||||||
// Sidebar state (used when useNewChatLayout is true)
|
|
||||||
sidebarOpen?: boolean;
|
sidebarOpen?: boolean;
|
||||||
sidebarContent?: string | null;
|
sidebarContent?: string | null;
|
||||||
sidebarError?: string | null;
|
sidebarError?: string | null;
|
||||||
@@ -51,7 +45,6 @@ export type ChatProps = {
|
|||||||
// Event handlers
|
// Event handlers
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onToggleFocusMode: () => void;
|
onToggleFocusMode: () => void;
|
||||||
onToggleLayout?: () => void;
|
|
||||||
onDraftChange: (next: string) => void;
|
onDraftChange: (next: string) => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
onAbort?: () => void;
|
onAbort?: () => void;
|
||||||
@@ -78,7 +71,6 @@ export function renderChat(props: ChatProps) {
|
|||||||
|
|
||||||
const splitRatio = props.splitRatio ?? 0.6;
|
const splitRatio = props.splitRatio ?? 0.6;
|
||||||
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
|
const sidebarOpen = Boolean(props.sidebarOpen && props.onCloseSidebar);
|
||||||
const useNewLayout = props.useNewChatLayout ?? false;
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<section class="card chat">
|
<section class="card chat">
|
||||||
@@ -122,27 +114,15 @@ export function renderChat(props: ChatProps) {
|
|||||||
: nothing}
|
: nothing}
|
||||||
${repeat(buildChatItems(props), (item) => item.key, (item) => {
|
${repeat(buildChatItems(props), (item) => item.key, (item) => {
|
||||||
if (item.kind === "reading-indicator") {
|
if (item.kind === "reading-indicator") {
|
||||||
return useNewLayout
|
return renderReadingIndicatorGroup();
|
||||||
? renderReadingIndicatorGroup()
|
|
||||||
: renderReadingIndicator();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.kind === "stream") {
|
if (item.kind === "stream") {
|
||||||
return useNewLayout
|
return renderStreamingGroup(
|
||||||
? renderStreamingGroup(
|
item.text,
|
||||||
item.text,
|
item.startedAt,
|
||||||
item.startedAt,
|
props.onOpenSidebar,
|
||||||
props.onOpenSidebar,
|
);
|
||||||
)
|
|
||||||
: renderMessage(
|
|
||||||
{
|
|
||||||
role: "assistant",
|
|
||||||
content: [{ type: "text", text: item.text }],
|
|
||||||
timestamp: item.startedAt,
|
|
||||||
},
|
|
||||||
props,
|
|
||||||
{ streaming: true, showReasoning },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.kind === "group") {
|
if (item.kind === "group") {
|
||||||
@@ -152,12 +132,12 @@ export function renderChat(props: ChatProps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return renderMessage(item.message, props, { showReasoning });
|
return nothing;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${useNewLayout && sidebarOpen
|
${sidebarOpen
|
||||||
? html`
|
? html`
|
||||||
<resizable-divider
|
<resizable-divider
|
||||||
.splitRatio=${splitRatio}
|
.splitRatio=${splitRatio}
|
||||||
@@ -348,8 +328,7 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.useNewChatLayout) return groupMessages(items);
|
return groupMessages(items);
|
||||||
return items;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function messageKey(message: unknown, index: number): string {
|
function messageKey(message: unknown, index: number): string {
|
||||||
|
|||||||
Reference in New Issue
Block a user