diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 370fec9c660..2e0853ed079 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 8b92c051fc1..2726d7041f6 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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 */ diff --git a/ui/src/styles/layout.css b/ui/src/styles/layout.css index 6e19806bb32..ac87e1b106c 100644 --- a/ui/src/styles/layout.css +++ b/ui/src/styles/layout.css @@ -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; +} diff --git a/ui/src/styles/layout.mobile.css b/ui/src/styles/layout.mobile.css index 036e6a7c588..cb5818190bd 100644 --- a/ui/src/styles/layout.mobile.css +++ b/ui/src/styles/layout.mobile.css @@ -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; diff --git a/ui/src/ui/app-render.helpers.ts b/ui/src/ui/app-render.helpers.ts index a7ecb15c370..77ba247a26d 100644 --- a/ui/src/ui/app-render.helpers.ts +++ b/ui/src/ui/app-render.helpers.ts @@ -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` + + + + `; const refreshIcon = html` ${icons.brain} + + `; + const focusIcon = html` + + + + + + + + `; + + return html` +
+ +
{ + e.stopPropagation(); + }}> +
+ +
+ + + +
+
+
+
+ `; +} + function switchChatSession(state: AppViewState, nextSessionKey: string) { state.sessionKey = nextSessionKey; state.chatMessage = ""; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 643edfca521..328f2cb6e33 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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) { ${t("common.search")} ⌘K -
${renderTopbarThemeModeToggle(state)}
+
+ ${isChat ? renderChatMobileToggle(state) : nothing} + ${renderTopbarThemeModeToggle(state)} +
@@ -1346,6 +1351,7 @@ export function renderApp(state: AppViewState) { }, thinkingLevel: state.chatThinkingLevel, showThinking, + showToolCalls, loading: state.chatLoading, sending: state.chatSending, compactionStatus: state.compactionStatus, diff --git a/ui/src/ui/app-settings.test.ts b/ui/src/ui/app-settings.test.ts index e259031d76e..aecc1f5bbcb 100644 --- a/ui/src/ui/app-settings.test.ts +++ b/ui/src/ui/app-settings.test.ts @@ -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, diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 6b584be512b..5b7549c8d64 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -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; @@ -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; } diff --git a/ui/src/ui/storage.node.test.ts b/ui/src/ui/storage.node.test.ts index b3fc09f079d..64ce3aec95c 100644 --- a/ui/src/ui/storage.node.test.ts +++ b/ui/src/ui/storage.node.test.ts @@ -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, diff --git a/ui/src/ui/storage.ts b/ui/src/ui/storage.ts index 4a46b8d0703..02e826b3a1d 100644 --- a/ui/src/ui/storage.ts +++ b/ui/src/ui/storage.ts @@ -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 (240–400px) @@ -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, diff --git a/ui/src/ui/views/chat.browser.test.ts b/ui/src/ui/views/chat.browser.test.ts index be2b5ab277e..fa7947a328a 100644 --- a/ui/src/ui/views/chat.browser.test.ts +++ b/ui/src/ui/views/chat.browser.test.ts @@ -9,6 +9,7 @@ function createProps(overrides: Partial = {}): ChatProps { onSessionKeyChange: () => undefined, thinkingLevel: null, showThinking: false, + showToolCalls: true, loading: false, sending: false, canAbort: false, diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 22c141c3919..860727c1927 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -123,6 +123,7 @@ function createProps(overrides: Partial = {}): ChatProps { onSessionKeyChange: () => undefined, thinkingLevel: null, showThinking: false, + showToolCalls: true, loading: false, sending: false, canAbort: false, diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index 1d0b877d042..88a712706f0 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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 { 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 { 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),