ui(chat): separate tool/thinking output and add toggle

- Render assistant reasoning as a distinct block (not merged into message text).\n- Detect tool-like messages reliably and style them separately.\n- Add a "🧠" toggle to hide/show tool + thinking output, persisted in UI settings.
This commit is contained in:
Bradley Priest
2026-01-20 20:16:43 +13:00
parent bee72f1ae0
commit c9d02f0132
11 changed files with 156 additions and 43 deletions

View File

@@ -84,6 +84,11 @@
color: rgba(150, 150, 150, 1); color: rgba(150, 150, 150, 1);
} }
.chat-avatar.tool {
background: rgba(134, 142, 150, 0.2);
color: rgba(134, 142, 150, 1);
}
/* Minimal Bubble Design - dynamic width based on content */ /* Minimal Bubble Design - dynamic width based on content */
.chat-bubble { .chat-bubble {
display: inline-block; display: inline-block;

View File

@@ -12,10 +12,16 @@
} }
.chat-line.assistant, .chat-line.assistant,
.chat-line.other { .chat-line.other,
.chat-line.tool {
justify-content: flex-start; justify-content: flex-start;
} }
.chat-line.tool .chat-bubble {
border-style: dashed;
opacity: 0.95;
}
.chat-msg { .chat-msg {
display: grid; display: grid;
gap: 6px; gap: 6px;

View File

@@ -2,6 +2,22 @@
CHAT TEXT STYLING CHAT TEXT STYLING
============================================= */ ============================================= */
.chat-thinking {
margin-bottom: 10px;
padding: 10px 12px;
border-radius: 10px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.04);
color: var(--muted);
font-size: 12px;
line-height: 1.4;
}
:root[data-theme="light"] .chat-thinking {
border-color: rgba(16, 24, 40, 0.18);
background: rgba(16, 24, 40, 0.03);
}
.chat-text { .chat-text {
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;

View File

@@ -92,6 +92,18 @@ export function renderChatControls(state: AppViewState) {
${refreshIcon} ${refreshIcon}
</button> </button>
<span class="chat-controls__separator">|</span> <span class="chat-controls__separator">|</span>
<button
class="btn btn--sm btn--icon ${state.settings.chatShowThinking ? "active" : ""}"
@click=${() =>
state.applySettings({
...state.settings,
chatShowThinking: !state.settings.chatShowThinking,
})}
aria-pressed=${state.settings.chatShowThinking}
title="Toggle assistant thinking/working output"
>
🧠
</button>
<button <button
class="btn btn--sm btn--icon ${state.settings.chatFocusMode ? "active" : ""}" class="btn btn--sm btn--icon ${state.settings.chatFocusMode ? "active" : ""}"
@click=${() => @click=${() =>

View File

@@ -382,6 +382,7 @@ export function renderApp(state: AppViewState) {
void loadChatHistory(state); void loadChatHistory(state);
}, },
thinkingLevel: state.chatThinkingLevel, thinkingLevel: state.chatThinkingLevel,
showThinking: state.settings.chatShowThinking,
loading: state.chatLoading, loading: state.chatLoading,
sending: state.chatSending, sending: state.chatSending,
messages: state.chatMessages, messages: state.chatMessages,

View File

@@ -106,8 +106,22 @@ export function renderMessageGroup(
function renderAvatar(role: string) { function renderAvatar(role: string) {
const normalized = normalizeRoleForGrouping(role); const normalized = normalizeRoleForGrouping(role);
const initial = normalized === "user" ? "U" : normalized === "assistant" ? "A" : "?"; const initial =
const className = normalized === "user" ? "user" : normalized === "assistant" ? "assistant" : "other"; normalized === "user"
? "U"
: normalized === "assistant"
? "A"
: normalized === "tool"
? "⚙"
: "?";
const className =
normalized === "user"
? "user"
: normalized === "assistant"
? "assistant"
: normalized === "tool"
? "tool"
: "other";
return html`<div class="chat-avatar ${className}">${initial}</div>`; return html`<div class="chat-avatar ${className}">${initial}</div>`;
} }
@@ -132,11 +146,10 @@ function renderGroupedMessage(
const extractedThinking = const extractedThinking =
opts.showReasoning && role === "assistant" ? extractThinking(message) : null; opts.showReasoning && role === "assistant" ? extractThinking(message) : null;
const markdownBase = extractedText?.trim() ? extractedText : null; const markdownBase = extractedText?.trim() ? extractedText : null;
const markdown = extractedThinking const reasoningMarkdown = extractedThinking
? [formatReasoningMarkdown(extractedThinking), markdownBase] ? formatReasoningMarkdown(extractedThinking)
.filter(Boolean) : null;
.join("\n\n") const markdown = markdownBase;
: markdownBase;
const bubbleClasses = [ const bubbleClasses = [
"chat-bubble", "chat-bubble",
@@ -156,6 +169,11 @@ function renderGroupedMessage(
return html` return html`
<div class="${bubbleClasses}"> <div class="${bubbleClasses}">
${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing}
${markdown ${markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>` ? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing} : nothing}

View File

@@ -64,11 +64,10 @@ export function renderMessage(
display?.kind === "json" display?.kind === "json"
? ["```json", display.value, "```"].join("\n") ? ["```json", display.value, "```"].join("\n")
: (display?.value ?? null); : (display?.value ?? null);
const markdown = extractedThinking const reasoningMarkdown = extractedThinking
? [formatReasoningMarkdown(extractedThinking), markdownBase] ? formatReasoningMarkdown(extractedThinking)
.filter(Boolean) : null;
.join("\n\n") const markdown = markdownBase;
: markdownBase;
const timestamp = const timestamp =
typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : ""; typeof m.timestamp === "number" ? new Date(m.timestamp).toLocaleTimeString() : "";
@@ -79,13 +78,17 @@ export function renderMessage(
? "assistant" ? "assistant"
: normalizedRole === "user" : normalizedRole === "user"
? "user" ? "user"
: "other"; : normalizedRole === "tool"
? "tool"
: "other";
const who = const who =
normalizedRole === "assistant" normalizedRole === "assistant"
? "Assistant" ? "Assistant"
: normalizedRole === "user" : normalizedRole === "user"
? "You" ? "You"
: normalizedRole; : normalizedRole === "tool"
? "Working"
: normalizedRole;
const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : ""; const toolCallId = typeof m.toolCallId === "string" ? m.toolCallId : "";
const toolCardBase = const toolCardBase =
@@ -98,6 +101,11 @@ export function renderMessage(
<div class="chat-line ${klass}"> <div class="chat-line ${klass}">
<div class="chat-msg"> <div class="chat-msg">
<div class="chat-bubble ${opts?.streaming ? "streaming" : ""}"> <div class="chat-bubble ${opts?.streaming ? "streaming" : ""}">
${reasoningMarkdown
? html`<div class="chat-thinking">${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
)}</div>`
: nothing}
${markdown ${markdown
? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>` ? html`<div class="chat-text">${unsafeHTML(toSanitizedMarkdownHtml(markdown))}</div>`
: nothing} : nothing}

View File

@@ -103,25 +103,25 @@ describe("message-normalizer", () => {
}); });
describe("normalizeRoleForGrouping", () => { describe("normalizeRoleForGrouping", () => {
it("returns assistant for toolresult", () => { it("returns tool for toolresult", () => {
expect(normalizeRoleForGrouping("toolresult")).toBe("assistant"); expect(normalizeRoleForGrouping("toolresult")).toBe("tool");
expect(normalizeRoleForGrouping("toolResult")).toBe("assistant"); expect(normalizeRoleForGrouping("toolResult")).toBe("tool");
expect(normalizeRoleForGrouping("TOOLRESULT")).toBe("assistant"); expect(normalizeRoleForGrouping("TOOLRESULT")).toBe("tool");
}); });
it("returns assistant for tool_result", () => { it("returns tool for tool_result", () => {
expect(normalizeRoleForGrouping("tool_result")).toBe("assistant"); expect(normalizeRoleForGrouping("tool_result")).toBe("tool");
expect(normalizeRoleForGrouping("TOOL_RESULT")).toBe("assistant"); expect(normalizeRoleForGrouping("TOOL_RESULT")).toBe("tool");
}); });
it("returns assistant for tool", () => { it("returns tool for tool", () => {
expect(normalizeRoleForGrouping("tool")).toBe("assistant"); expect(normalizeRoleForGrouping("tool")).toBe("tool");
expect(normalizeRoleForGrouping("Tool")).toBe("assistant"); expect(normalizeRoleForGrouping("Tool")).toBe("tool");
}); });
it("returns assistant for function", () => { it("returns tool for function", () => {
expect(normalizeRoleForGrouping("function")).toBe("assistant"); expect(normalizeRoleForGrouping("function")).toBe("tool");
expect(normalizeRoleForGrouping("Function")).toBe("assistant"); expect(normalizeRoleForGrouping("Function")).toBe("tool");
}); });
it("preserves user role", () => { it("preserves user role", () => {

View File

@@ -14,8 +14,36 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
const m = message as Record<string, unknown>; const m = message as Record<string, unknown>;
let role = typeof m.role === "string" ? m.role : "unknown"; let role = typeof m.role === "string" ? m.role : "unknown";
// Detect tool result messages by presence of toolCallId or tool_call_id // Detect tool messages by common gateway shapes.
if (typeof m.toolCallId === "string" || typeof m.tool_call_id === "string") { // Some tool events come through as assistant role with tool_* items in the content array.
const hasToolId =
typeof m.toolCallId === "string" || typeof m.tool_call_id === "string";
const contentRaw = m.content;
const contentItems = Array.isArray(contentRaw) ? contentRaw : null;
const hasToolContent =
Array.isArray(contentItems) &&
contentItems.some((item) => {
const x = item as Record<string, unknown>;
const t = String(x.type ?? "").toLowerCase();
return (
t === "toolcall" ||
t === "tool_call" ||
t === "tooluse" ||
t === "tool_use" ||
t === "toolresult" ||
t === "tool_result" ||
t === "tool_call" ||
t === "tool_result" ||
(typeof x.name === "string" && x.arguments != null)
);
});
const hasToolName =
typeof (m as Record<string, unknown>).toolName === "string" ||
typeof (m as Record<string, unknown>).tool_name === "string";
if (hasToolId || hasToolContent || hasToolName) {
role = "toolResult"; role = "toolResult";
} }
@@ -43,19 +71,22 @@ export function normalizeMessage(message: unknown): NormalizedMessage {
/** /**
* Normalize role for grouping purposes. * Normalize role for grouping purposes.
* Tool results should be grouped with assistant messages.
*/ */
export function normalizeRoleForGrouping(role: string): string { export function normalizeRoleForGrouping(role: string): string {
const lower = role.toLowerCase(); const lower = role.toLowerCase();
// All tool-related roles should display as assistant // Keep tool-related roles distinct so the UI can style/toggle them.
if ( if (
lower === "toolresult" || lower === "toolresult" ||
lower === "tool_result" || lower === "tool_result" ||
lower === "tool" || lower === "tool" ||
lower === "function" lower === "function" ||
lower === "toolresult"
) { ) {
return "assistant"; return "tool";
} }
if (lower === "assistant") return "assistant";
if (lower === "user") return "user";
if (lower === "system") return "system";
return role; return role;
} }

View File

@@ -9,6 +9,7 @@ export type UiSettings = {
lastActiveSessionKey: string; lastActiveSessionKey: string;
theme: ThemeMode; theme: ThemeMode;
chatFocusMode: boolean; chatFocusMode: 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 useNewChatLayout: boolean; // Slack-style grouped messages layout
navCollapsed: boolean; // Collapsible sidebar state navCollapsed: boolean; // Collapsible sidebar state
@@ -28,6 +29,7 @@ export function loadSettings(): UiSettings {
lastActiveSessionKey: "main", lastActiveSessionKey: "main",
theme: "system", theme: "system",
chatFocusMode: false, chatFocusMode: false,
chatShowThinking: true,
splitRatio: 0.6, splitRatio: 0.6,
useNewChatLayout: true, // Enabled by default useNewChatLayout: true, // Enabled by default
navCollapsed: false, navCollapsed: false,
@@ -65,6 +67,10 @@ export function loadSettings(): UiSettings {
typeof parsed.chatFocusMode === "boolean" typeof parsed.chatFocusMode === "boolean"
? parsed.chatFocusMode ? parsed.chatFocusMode
: defaults.chatFocusMode, : defaults.chatFocusMode,
chatShowThinking:
typeof parsed.chatShowThinking === "boolean"
? parsed.chatShowThinking
: defaults.chatShowThinking,
splitRatio: splitRatio:
typeof parsed.splitRatio === "number" && typeof parsed.splitRatio === "number" &&
parsed.splitRatio >= 0.4 && parsed.splitRatio >= 0.4 &&

View File

@@ -21,6 +21,7 @@ export type ChatProps = {
sessionKey: string; sessionKey: string;
onSessionKeyChange: (next: string) => void; onSessionKeyChange: (next: string) => void;
thinkingLevel: string | null; thinkingLevel: string | null;
showThinking: boolean;
loading: boolean; loading: boolean;
sending: boolean; sending: boolean;
canAbort?: boolean; canAbort?: boolean;
@@ -69,7 +70,7 @@ export function renderChat(props: ChatProps) {
(row) => row.key === props.sessionKey, (row) => row.key === props.sessionKey,
); );
const reasoningLevel = activeSession?.reasoningLevel ?? "off"; const reasoningLevel = activeSession?.reasoningLevel ?? "off";
const showReasoning = reasoningLevel !== "off"; const showReasoning = props.showThinking && reasoningLevel !== "off";
const composePlaceholder = props.connected const composePlaceholder = props.connected
? "Message (↩ to send, Shift+↩ for line breaks)" ? "Message (↩ to send, Shift+↩ for line breaks)"
@@ -310,18 +311,27 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
}); });
} }
for (let i = historyStart; i < history.length; i++) { for (let i = historyStart; i < history.length; i++) {
const msg = history[i];
const normalized = normalizeMessage(msg);
if (!props.showThinking && normalized.role.toLowerCase() === "toolresult") {
continue;
}
items.push({ items.push({
kind: "message", kind: "message",
key: messageKey(history[i], i), key: messageKey(msg, i),
message: history[i], message: msg,
}); });
} }
for (let i = 0; i < tools.length; i++) { if (props.showThinking) {
items.push({ for (let i = 0; i < tools.length; i++) {
kind: "message", items.push({
key: messageKey(tools[i], i + history.length), kind: "message",
message: tools[i], key: messageKey(tools[i], i + history.length),
}); message: tools[i],
});
}
} }
if (props.stream !== null) { if (props.stream !== null) {