feat(webchat): add toggle to hide tool calls and thinking blocks (#20317) thanks @nmccready

Merged via maintainer override after review.\n\nRed required checks are unrelated to this PR; local inspection found no blocker in the diff.
This commit is contained in:
nmccready 2026-03-14 20:03:04 -04:00 committed by GitHub
parent e5a42c0bec
commit f4aff83c51
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 307 additions and 13 deletions

View File

@ -161,6 +161,7 @@ export const en: TranslationMap = {
disconnected: "Disconnected from gateway.",
refreshTitle: "Refresh chat data",
thinkingToggle: "Toggle assistant thinking/working output",
toolCallsToggle: "Toggle tool calls and tool results",
focusToggle: "Toggle focus mode (hide sidebar + page header)",
hideCronSessions: "Hide cron sessions",
showCronSessions: "Show cron sessions",

View File

@ -990,3 +990,6 @@
background: var(--panel-strong);
border-color: var(--accent);
}
/* Mobile dropdown toggle — hidden on desktop */
/* Mobile gear toggle + dropdown are hidden by default in layout.css */

View File

@ -1030,3 +1030,16 @@
grid-template-columns: 1fr;
}
}
/* Mobile chat controls — hidden on desktop, shown in layout.mobile.css */
.chat-mobile-controls-wrapper {
display: none;
}
.chat-controls-mobile-toggle {
display: none;
}
.chat-controls-dropdown {
display: none;
}

View File

@ -316,23 +316,77 @@
display: none;
}
/* Hide the entire content-header on mobile chat — controls are in mobile gear menu */
.content--chat .content-header {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
display: none;
}
.content--chat {
gap: 2px;
}
.content--chat .content-header > div:first-child,
.content--chat .page-meta,
.content--chat .chat-controls {
/* Show the mobile gear toggle (lives in topbar now) */
.chat-mobile-controls-wrapper {
display: flex;
position: relative;
}
.chat-mobile-controls-wrapper .chat-controls-mobile-toggle {
display: flex;
}
/* The dropdown panel — anchored below the gear in topbar */
.chat-mobile-controls-wrapper .chat-controls-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
z-index: 100;
background: var(--card, #161b22);
border: 1px solid var(--border, #30363d);
border-radius: 10px;
padding: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
flex-direction: column;
gap: 4px;
min-width: 220px;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown.open {
display: flex;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session {
min-width: unset;
max-width: unset;
width: 100%;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__session select {
width: 100%;
font-size: 14px;
padding: 10px 12px;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .chat-controls__thinking {
display: flex;
flex-direction: row;
gap: 6px;
padding: 4px 0;
justify-content: center;
}
.chat-mobile-controls-wrapper .chat-controls-dropdown .btn--icon {
min-width: 44px;
height: 44px;
}
.content {
padding: 4px 4px 16px;
gap: 12px;

View File

@ -173,7 +173,24 @@ export function renderChatControls(state: AppViewState) {
const disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
const toolCallsIcon = 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="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
></path>
</svg>
`;
const refreshIcon = html`
<svg
width="18"
@ -252,6 +269,23 @@ export function renderChatControls(state: AppViewState) {
>
${icons.brain}
</button>
<button
class="btn btn--sm btn--icon ${showToolCalls ? "active" : ""}"
?disabled=${disableThinkingToggle}
@click=${() => {
if (disableThinkingToggle) {
return;
}
state.applySettings({
...state.settings,
chatShowToolCalls: !state.settings.chatShowToolCalls,
});
}}
aria-pressed=${showToolCalls}
title=${disableThinkingToggle ? t("chat.onboardingDisabled") : t("chat.toolCallsToggle")}
>
${toolCallsIcon}
</button>
<button
class="btn btn--sm btn--icon ${focusActive ? "active" : ""}"
?disabled=${disableFocusToggle}
@ -289,6 +323,163 @@ export function renderChatControls(state: AppViewState) {
`;
}
/**
* Mobile-only gear toggle + dropdown for chat controls.
* Rendered in the topbar so it doesn't consume content-header space.
* Hidden on desktop via CSS.
*/
export function renderChatMobileToggle(state: AppViewState) {
const sessionGroups = resolveSessionOptionGroups(state, state.sessionKey, state.sessionsResult);
const disableThinkingToggle = state.onboarding;
const disableFocusToggle = state.onboarding;
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls;
const focusActive = state.onboarding ? true : state.settings.chatFocusMode;
const toolCallsIcon = 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="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"
></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>
`;
return html`
<div class="chat-mobile-controls-wrapper">
<button
class="btn btn--sm btn--icon chat-controls-mobile-toggle"
@click=${(e: Event) => {
e.stopPropagation();
const btn = e.currentTarget as HTMLElement;
const dropdown = btn.nextElementSibling as HTMLElement;
if (dropdown) {
const isOpen = dropdown.classList.toggle("open");
if (isOpen) {
const close = () => {
dropdown.classList.remove("open");
document.removeEventListener("click", close);
};
setTimeout(() => document.addEventListener("click", close, { once: true }), 0);
}
}
}}
title="Chat settings"
aria-label="Chat settings"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</button>
<div class="chat-controls-dropdown" @click=${(e: Event) => {
e.stopPropagation();
}}>
<div class="chat-controls">
<label class="field chat-controls__session">
<select
.value=${state.sessionKey}
@change=${(e: Event) => {
const next = (e.target as HTMLSelectElement).value;
switchChatSession(state, next);
}}
>
${sessionGroups.map(
(group) => html`
<optgroup label=${group.label}>
${group.options.map(
(opt) => html`
<option value=${opt.key} title=${opt.title}>
${opt.label}
</option>
`,
)}
</optgroup>
`,
)}
</select>
</label>
<div class="chat-controls__thinking">
<button
class="btn btn--sm btn--icon ${showThinking ? "active" : ""}"
?disabled=${disableThinkingToggle}
@click=${() => {
if (!disableThinkingToggle) {
state.applySettings({
...state.settings,
chatShowThinking: !state.settings.chatShowThinking,
});
}
}}
aria-pressed=${showThinking}
title=${t("chat.thinkingToggle")}
>
${icons.brain}
</button>
<button
class="btn btn--sm btn--icon ${showToolCalls ? "active" : ""}"
?disabled=${disableThinkingToggle}
@click=${() => {
if (!disableThinkingToggle) {
state.applySettings({
...state.settings,
chatShowToolCalls: !state.settings.chatShowToolCalls,
});
}
}}
aria-pressed=${showToolCalls}
title=${t("chat.toolCallsToggle")}
>
${toolCallsIcon}
</button>
<button
class="btn btn--sm btn--icon ${focusActive ? "active" : ""}"
?disabled=${disableFocusToggle}
@click=${() => {
if (!disableFocusToggle) {
state.applySettings({
...state.settings,
chatFocusMode: !state.settings.chatFocusMode,
});
}
}}
aria-pressed=${focusActive}
title=${t("chat.focusToggle")}
>
${focusIcon}
</button>
</div>
</div>
</div>
</div>
`;
}
function switchChatSession(state: AppViewState, nextSessionKey: string) {
state.sessionKey = nextSessionKey;
state.chatMessage = "";

View File

@ -8,6 +8,7 @@ import { refreshChatAvatar } from "./app-chat.ts";
import { renderUsageTab } from "./app-render-usage-tab.ts";
import {
renderChatControls,
renderChatMobileToggle,
renderChatSessionSelect,
renderTab,
renderSidebarConnectionStatus,
@ -307,6 +308,7 @@ export function renderApp(state: AppViewState) {
const navDrawerOpen = Boolean(state.navDrawerOpen && !chatFocus && !state.onboarding);
const navCollapsed = Boolean(state.settings.navCollapsed && !navDrawerOpen);
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls;
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
const configValue =
@ -438,7 +440,10 @@ export function renderApp(state: AppViewState) {
<span class="topbar-search__label">${t("common.search")}</span>
<kbd class="topbar-search__kbd">K</kbd>
</button>
<div class="topbar-status">${renderTopbarThemeModeToggle(state)}</div>
<div class="topbar-status">
${isChat ? renderChatMobileToggle(state) : nothing}
${renderTopbarThemeModeToggle(state)}
</div>
</div>
</div>
</header>
@ -1346,6 +1351,7 @@ export function renderApp(state: AppViewState) {
},
thinkingLevel: state.chatThinkingLevel,
showThinking,
showToolCalls,
loading: state.chatLoading,
sending: state.chatSending,
compactionStatus: state.compactionStatus,

View File

@ -38,6 +38,7 @@ type SettingsHost = {
themeMode: ThemeMode;
chatFocusMode: boolean;
chatShowThinking: boolean;
chatShowToolCalls: boolean;
splitRatio: number;
navCollapsed: boolean;
navWidth: number;
@ -95,6 +96,7 @@ const createHost = (tab: Tab): SettingsHost => ({
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,

View File

@ -114,6 +114,7 @@ export function renderMessageGroup(
opts: {
onOpenSidebar?: (content: string) => void;
showReasoning: boolean;
showToolCalls?: boolean;
assistantName?: string;
assistantAvatar?: string | null;
basePath?: string;
@ -165,6 +166,7 @@ export function renderMessageGroup(
{
isStreaming: group.isStreaming && index === group.messages.length - 1,
showReasoning: opts.showReasoning,
showToolCalls: opts.showToolCalls ?? true,
},
opts.onOpenSidebar,
),
@ -619,7 +621,7 @@ function jsonSummaryLabel(parsed: unknown): string {
function renderGroupedMessage(
message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean },
opts: { isStreaming: boolean; showReasoning: boolean; showToolCalls?: boolean },
onOpenSidebar?: (content: string) => void,
) {
const m = message as Record<string, unknown>;
@ -632,7 +634,7 @@ function renderGroupedMessage(
typeof m.toolCallId === "string" ||
typeof m.tool_call_id === "string";
const toolCards = extractToolCards(message);
const toolCards = (opts.showToolCalls ?? true) ? extractToolCards(message) : [];
const hasToolCards = toolCards.length > 0;
const images = extractImages(message);
const hasImages = images.length > 0;
@ -656,7 +658,9 @@ function renderGroupedMessage(
return renderCollapsedToolCards(toolCards, onOpenSidebar);
}
if (!markdown && !hasToolCards && !hasImages) {
// Suppress empty bubbles when tool cards are the only content and toggle is off
const visibleToolCards = hasToolCards && (opts.showToolCalls ?? true);
if (!markdown && !visibleToolCards && !hasImages) {
return nothing;
}

View File

@ -132,6 +132,7 @@ describe("loadSettings default gateway URL derivation", () => {
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
@ -157,6 +158,7 @@ describe("loadSettings default gateway URL derivation", () => {
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
@ -186,6 +188,7 @@ describe("loadSettings default gateway URL derivation", () => {
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
@ -202,6 +205,7 @@ describe("loadSettings default gateway URL derivation", () => {
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
@ -232,6 +236,7 @@ describe("loadSettings default gateway URL derivation", () => {
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
@ -250,6 +255,7 @@ describe("loadSettings default gateway URL derivation", () => {
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
@ -275,6 +281,7 @@ describe("loadSettings default gateway URL derivation", () => {
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
@ -289,6 +296,7 @@ describe("loadSettings default gateway URL derivation", () => {
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
@ -316,6 +324,7 @@ describe("loadSettings default gateway URL derivation", () => {
themeMode: "light",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 320,

View File

@ -17,6 +17,7 @@ export type UiSettings = {
themeMode: ThemeMode;
chatFocusMode: boolean;
chatShowThinking: boolean;
chatShowToolCalls: boolean;
splitRatio: number; // Sidebar split ratio (0.4 to 0.7, default 0.6)
navCollapsed: boolean; // Collapsible sidebar state
navWidth: number; // Sidebar width when expanded (240400px)
@ -131,6 +132,7 @@ export function loadSettings(): UiSettings {
themeMode: "system",
chatFocusMode: false,
chatShowThinking: true,
chatShowToolCalls: true,
splitRatio: 0.6,
navCollapsed: false,
navWidth: 220,
@ -173,6 +175,10 @@ export function loadSettings(): UiSettings {
typeof parsed.chatShowThinking === "boolean"
? parsed.chatShowThinking
: defaults.chatShowThinking,
chatShowToolCalls:
typeof parsed.chatShowToolCalls === "boolean"
? parsed.chatShowToolCalls
: defaults.chatShowToolCalls,
splitRatio:
typeof parsed.splitRatio === "number" &&
parsed.splitRatio >= 0.4 &&
@ -214,6 +220,7 @@ function persistSettings(next: UiSettings) {
themeMode: next.themeMode,
chatFocusMode: next.chatFocusMode,
chatShowThinking: next.chatShowThinking,
chatShowToolCalls: next.chatShowToolCalls,
splitRatio: next.splitRatio,
navCollapsed: next.navCollapsed,
navWidth: next.navWidth,

View File

@ -9,6 +9,7 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
onSessionKeyChange: () => undefined,
thinkingLevel: null,
showThinking: false,
showToolCalls: true,
loading: false,
sending: false,
canAbort: false,

View File

@ -123,6 +123,7 @@ function createProps(overrides: Partial<ChatProps> = {}): ChatProps {
onSessionKeyChange: () => undefined,
thinkingLevel: null,
showThinking: false,
showToolCalls: true,
loading: false,
sending: false,
canAbort: false,

View File

@ -56,6 +56,7 @@ export type ChatProps = {
onSessionKeyChange: (next: string) => void;
thinkingLevel: string | null;
showThinking: boolean;
showToolCalls: boolean;
loading: boolean;
sending: boolean;
canAbort?: boolean;
@ -932,6 +933,7 @@ export function renderChat(props: ChatProps) {
return renderMessageGroup(item, {
onOpenSidebar: props.onOpenSidebar,
showReasoning,
showToolCalls: props.showToolCalls,
assistantName: props.assistantName,
assistantAvatar: assistantIdentity.avatar,
basePath: props.basePath,
@ -1409,7 +1411,7 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
continue;
}
if (!props.showThinking && normalized.role.toLowerCase() === "toolresult") {
if (!props.showToolCalls && normalized.role.toLowerCase() === "toolresult") {
continue;
}
@ -1438,7 +1440,7 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
startedAt: segments[i].ts,
});
}
if (i < tools.length) {
if (i < tools.length && props.showToolCalls) {
items.push({
kind: "message",
key: messageKey(tools[i], i + history.length),