From f8e642cfdbfd04e998725ca52dd42820a2feef50 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 09:05:06 +0000 Subject: [PATCH 01/22] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20extract=20IMAG?= =?UTF-8?q?E=5FTOOL=5FEXCESS=5FCOUNT=5FSETUP=5FHINT=20constant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both image_generate and image_edit duplicated the same "Adjust Settings > Experiments > Image Tools or request fewer images." setup hint literal when the requested image count exceeded the configured maxImagesPerCall. Hoist it next to the existing IMAGE_TOOL_PROVIDER_SETUP_HINT in imageArtifacts.ts so the guidance has a single source of truth, matching the pattern established by the prior auto-cleanup PR. --- src/node/services/tools/imageArtifacts.ts | 6 ++++++ src/node/services/tools/image_edit.ts | 3 ++- src/node/services/tools/image_generate.ts | 3 ++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/node/services/tools/imageArtifacts.ts b/src/node/services/tools/imageArtifacts.ts index e712091f83..4492c64dc1 100644 --- a/src/node/services/tools/imageArtifacts.ts +++ b/src/node/services/tools/imageArtifacts.ts @@ -23,6 +23,12 @@ const THUMBNAIL_MEDIA_TYPE = "image/webp"; export const IMAGE_TOOL_PROVIDER_SETUP_HINT = "Check OpenAI provider credentials, billing, rate limits, and content policy."; +// Setup hint surfaced when a single image tool call requests more images than +// the configured `maxImagesPerCall` allows. Shared across `image_generate` and +// `image_edit` so the guidance stays consistent. +export const IMAGE_TOOL_EXCESS_COUNT_SETUP_HINT = + "Adjust Settings > Experiments > Image Tools or request fewer images."; + type ImageModelOperation = "generation" | "editing"; type ImageToolName = "image_generate" | "image_edit"; diff --git a/src/node/services/tools/image_edit.ts b/src/node/services/tools/image_edit.ts index cf1f750d48..cfc994f70c 100644 --- a/src/node/services/tools/image_edit.ts +++ b/src/node/services/tools/image_edit.ts @@ -19,6 +19,7 @@ import { getImageDimensions, getImageDimensionsFromMetadata, getImageOutputDir, + IMAGE_TOOL_EXCESS_COUNT_SETUP_HINT, IMAGE_TOOL_PROVIDER_SETUP_HINT, processImageArtifacts, reportImageToolUsage, @@ -68,7 +69,7 @@ export const createImageEditTool: ToolFactory = (config) => { return { success: false, error: `Requested ${requestedCount} edited images, but Image Tools is configured for a maximum of ${runtime.maxImagesPerCall}.`, - setupHint: "Adjust Settings > Experiments > Image Tools or request fewer images.", + setupHint: IMAGE_TOOL_EXCESS_COUNT_SETUP_HINT, }; } diff --git a/src/node/services/tools/image_generate.ts b/src/node/services/tools/image_generate.ts index 2cc00bd497..3dd20a5152 100644 --- a/src/node/services/tools/image_generate.ts +++ b/src/node/services/tools/image_generate.ts @@ -12,6 +12,7 @@ import { buildOpenAIImageProviderOptions, formatImageModelError, getImageOutputDir, + IMAGE_TOOL_EXCESS_COUNT_SETUP_HINT, IMAGE_TOOL_PROVIDER_SETUP_HINT, processImageArtifacts, reportImageToolUsage, @@ -48,7 +49,7 @@ export const createImageGenerateTool: ToolFactory = (config) => { return { success: false, error: `Requested ${requestedCount} images, but Image Tools is configured for a maximum of ${runtime.maxImagesPerCall}.`, - setupHint: "Adjust Settings > Experiments > Image Tools or request fewer images.", + setupHint: IMAGE_TOOL_EXCESS_COUNT_SETUP_HINT, }; } From 9b9895e7b0905d583611077fcf95e4bd14aec9fc Mon Sep 17 00:00:00 2001 From: ammar-agent Date: Sat, 16 May 2026 00:26:10 +0000 Subject: [PATCH 02/22] =?UTF-8?q?=F0=9F=A4=96=20refactor:=20hoist=20mermai?= =?UTF-8?q?d=20sanitizer=20URL=20tables=20to=20module=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move `urlAttributes`/`blockedSchemes` out of `sanitizeMermaidSvg` and into module-level constants (`SANITIZER_URL_ATTRIBUTES`, `SANITIZER_BLOCKED_URL_SCHEMES`). They are pure lookup data with no per-call dependency, so allocating them on every render of every Mermaid diagram was wasted work — these now allocate once per module load. Pure cleanup: identical contents, identical lookup semantics, no behavior change. The full Mermaid.test.tsx suite (including the recent sanitizer regression tests) still passes. --- _Generated with `mux` • Model: `anthropic:claude-opus-4-7` • Thinking: `xhigh` • Cost: `$`_ --- src/browser/features/Messages/Mermaid.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/browser/features/Messages/Mermaid.tsx b/src/browser/features/Messages/Mermaid.tsx index 5409f639b1..885dd543e5 100644 --- a/src/browser/features/Messages/Mermaid.tsx +++ b/src/browser/features/Messages/Mermaid.tsx @@ -8,6 +8,11 @@ import { usePersistedState } from "@/browser/hooks/usePersistedState"; const MIN_HEIGHT = 300; const MAX_HEIGHT = 1200; +// Sanitizer lookup tables. Hoisted to module scope so they aren't reallocated on +// every sanitizeMermaidSvg() call (which can run per render for large SVGs). +const SANITIZER_URL_ATTRIBUTES = new Set(["href", "xlink:href", "src", "action", "formaction"]); +const SANITIZER_BLOCKED_URL_SCHEMES = ["javascript:", "vbscript:", "data:text/html"]; + // Initialize mermaid mermaid.initialize({ startOnLoad: false, @@ -162,9 +167,6 @@ export function sanitizeMermaidSvg(svg: string): string | null { node.remove(); }); - const urlAttributes = new Set(["href", "xlink:href", "src", "action", "formaction"]); - const blockedSchemes = ["javascript:", "vbscript:", "data:text/html"]; - const elementsToScan: Element[] = [svgRoot, ...Array.from(svgRoot.querySelectorAll("*"))]; elementsToScan.forEach((element) => { for (const attribute of Array.from(element.attributes)) { @@ -177,8 +179,8 @@ export function sanitizeMermaidSvg(svg: string): string | null { } if ( - urlAttributes.has(attributeName) && - blockedSchemes.some((scheme) => canonicalUrlValue.startsWith(scheme)) + SANITIZER_URL_ATTRIBUTES.has(attributeName) && + SANITIZER_BLOCKED_URL_SCHEMES.some((scheme) => canonicalUrlValue.startsWith(scheme)) ) { element.removeAttribute(attribute.name); } From 9715ab238f6aa787a2d560df6aefc1dffb196bf9 Mon Sep 17 00:00:00 2001 From: ammar-agent Date: Sat, 16 May 2026 20:19:00 +0000 Subject: [PATCH 03/22] refactor: extract tool-part phase lookup to module-level constant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `summarizeToolPart` carried the AI SDK v5 `state` → lifecycle phase mapping inline as a nested ternary plus a multi-line comment listing the states. The ternary obscured the actual mapping (output-available and output-redacted both fold to "done") and made adding new states a multi-line edit. Hoist the mapping to a `TOOL_PART_PHASE_BY_STATE` record at module scope, keyed on the SDK state strings and typed as the literal phase union. The summarizer now does a single table lookup. States not in the table (e.g. `input-streaming`) still yield `null` and the bare `[tool ]` form, matching prior behavior exactly. Behavior-preserving: same input strings, same output bytes for every `state` value the prior code handled, and the same `null` fallthrough for everything else. --- src/node/services/agentStatusService.ts | 29 ++++++++++++++----------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/node/services/agentStatusService.ts b/src/node/services/agentStatusService.ts index 45e9afce95..7282caf48f 100644 --- a/src/node/services/agentStatusService.ts +++ b/src/node/services/agentStatusService.ts @@ -513,6 +513,21 @@ function extractMessageText(message: MuxMessage): string { .join("\n"); } +/** + * Lifecycle phase the status model sees for each AI SDK v5 tool-part `state`. + * This is the single highest-signal datum for distinguishing "Deploying + * service" (in flight) from "Deployed service" (finished) — without it the + * prompt was forced to guess from prose alone. States not listed here + * (e.g. `input-streaming`) intentionally yield no phase suffix so the + * model isn't asked to interpret transient pre-call states. + */ +const TOOL_PART_PHASE_BY_STATE: Record = { + "input-available": "running", + "output-available": "done", + // Output returned but body stripped (still "done" for our purposes). + "output-redacted": "done", +}; + function summarizeToolPart(part: unknown): string | null { if (typeof part !== "object" || part === null) return null; const record = part as { type?: unknown; toolName?: unknown; state?: unknown }; @@ -526,20 +541,8 @@ function summarizeToolPart(part: unknown): string | null { ? type.slice(5) : null; if (!toolName) return null; - // The lifecycle phase is the single highest-signal datum the status model - // needs to distinguish "Deploying service" (in flight) from "Deployed - // service" (finished). AI SDK v5 tool parts carry a `state` field: - // - "input-available" → call sent, no result yet (running) - // - "output-available" → result returned (done) - // - "output-redacted" → result returned but body stripped (still done) - // Without this marker the prompt was forced to guess from prose alone. const state = typeof record.state === "string" ? record.state : null; - const phase = - state === "output-available" || state === "output-redacted" - ? "done" - : state === "input-available" - ? "running" - : null; + const phase = state !== null ? (TOOL_PART_PHASE_BY_STATE[state] ?? null) : null; return phase ? `[tool ${toolName} ${phase}]` : `[tool ${toolName}]`; } From fe79575f5d8bb4f1e20aba6ba01f0c8aba189fcf Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 00:27:25 +0000 Subject: [PATCH 04/22] refactor: extract createSlashCommandExperimentResolver helper Three slash-command discovery surfaces (suggestions in ChatInput, ghost hints in ChatInput, and CommandPalette) duplicated the same inline lambda: (experimentId) => resolveSlashCommandExperimentValue(experimentId, { goals: goalsExperimentEnabled, workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, }) Add createSlashCommandExperimentResolver(snapshot) next to its sibling resolver so each callsite only describes the experiment snapshot it observes. The new helper returns the exact same closure each callsite previously built inline, so behavior is byte-identical. --- .../components/CommandPalette/CommandPalette.tsx | 9 ++++----- src/browser/features/ChatInput/index.tsx | 16 +++++++--------- .../utils/slashCommands/experimentVisibility.ts | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/browser/components/CommandPalette/CommandPalette.tsx b/src/browser/components/CommandPalette/CommandPalette.tsx index a4c260af4e..c727232ade 100644 --- a/src/browser/components/CommandPalette/CommandPalette.tsx +++ b/src/browser/components/CommandPalette/CommandPalette.tsx @@ -13,7 +13,7 @@ import { matchesKeybind, } from "@/browser/utils/ui/keybinds"; import { stopKeyboardPropagation } from "@/browser/utils/events"; -import { resolveSlashCommandExperimentValue } from "@/browser/utils/slashCommands/experimentVisibility"; +import { createSlashCommandExperimentResolver } from "@/browser/utils/slashCommands/experimentVisibility"; import { getSlashCommandSuggestions } from "@/browser/utils/slashCommands/suggestions"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; @@ -290,10 +290,9 @@ export const CommandPalette: React.FC = ({ getSlashContext const suggestions = getSlashCommandSuggestions(q, { agentSkills, variant: ctx.workspaceId ? "workspace" : "creation", - isExperimentEnabled: (experimentId) => - resolveSlashCommandExperimentValue(experimentId, { - workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, - }), + isExperimentEnabled: createSlashCommandExperimentResolver({ + workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, + }), }); const section = "Slash Commands"; const groups: PaletteGroup[] = [ diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index d1772bae2e..f7c909865e 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -85,7 +85,7 @@ import { getSlashCommandSuggestions, type SlashSuggestion, } from "@/browser/utils/slashCommands/suggestions"; -import { resolveSlashCommandExperimentValue } from "@/browser/utils/slashCommands/experimentVisibility"; +import { createSlashCommandExperimentResolver } from "@/browser/utils/slashCommands/experimentVisibility"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/browser/components/Tooltip/Tooltip"; import { AgentModePicker } from "@/browser/components/AgentModePicker/AgentModePicker"; import { ContextUsageIndicatorButton } from "@/browser/components/ContextUsageIndicatorButton/ContextUsageIndicatorButton"; @@ -1427,10 +1427,9 @@ const ChatInputInner: React.FC = (props) => { const suggestions = getSlashCommandSuggestions(input, { agentSkills: agentSkillDescriptors, variant, - isExperimentEnabled: (experimentId) => - resolveSlashCommandExperimentValue(experimentId, { - workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, - }), + isExperimentEnabled: createSlashCommandExperimentResolver({ + workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, + }), }); setCommandSuggestions((prev) => replaceSuggestions(prev, suggestions)); setShowCommandSuggestions(suggestions.length > 0); @@ -1440,10 +1439,9 @@ const ChatInputInner: React.FC = (props) => { // Show only when suggestions are hidden and the input is exactly "/command " with no args yet. const commandGhostHint = getCommandGhostHint(input, showCommandSuggestions, { variant, - isExperimentEnabled: (experimentId) => - resolveSlashCommandExperimentValue(experimentId, { - workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, - }), + isExperimentEnabled: createSlashCommandExperimentResolver({ + workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled, + }), }); // Load agent skills for suggestions diff --git a/src/browser/utils/slashCommands/experimentVisibility.ts b/src/browser/utils/slashCommands/experimentVisibility.ts index 1313946fd9..b1b9ff5321 100644 --- a/src/browser/utils/slashCommands/experimentVisibility.ts +++ b/src/browser/utils/slashCommands/experimentVisibility.ts @@ -15,3 +15,17 @@ export function resolveSlashCommandExperimentValue( return undefined; } } + +/** + * Build the `isExperimentEnabled` predicate consumed by slash-command + * discovery surfaces (suggestions, ghost hints, command palette). Each + * surface previously inlined the same `(experimentId) => + * resolveSlashCommandExperimentValue(experimentId, snapshot)` lambda; this + * helper keeps the resolver wiring in one place so callsites only describe + * the snapshot they observe. + */ +export function createSlashCommandExperimentResolver( + snapshot: SlashCommandExperimentSnapshot +): (experimentId: ExperimentId) => boolean | undefined { + return (experimentId) => resolveSlashCommandExperimentValue(experimentId, snapshot); +} From e170542fd902cf9752e99d6e927ec5201544b8ac Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Sun, 17 May 2026 16:24:03 +0000 Subject: [PATCH 05/22] refactor: dedupe Runtime type import in cli/run.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SSH-provisioning commit (#3302) added a second `import type { Runtime } from "../node/runtime/Runtime"` line right below an existing `import type { InitLogger, WorkspaceInitResult }` from the same module. Fold the new symbol into the existing import — byte-identical semantics, one fewer line of boilerplate, no behavior change. --- src/cli/run.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cli/run.ts b/src/cli/run.ts index e4371d0354..e206df56f2 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -69,10 +69,9 @@ import assert from "../common/utils/assert"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { log, type LogLevel } from "../node/services/log"; import chalk from "chalk"; -import type { InitLogger, WorkspaceInitResult } from "../node/runtime/Runtime"; +import type { InitLogger, Runtime, WorkspaceInitResult } from "../node/runtime/Runtime"; import { DockerRuntime } from "../node/runtime/DockerRuntime"; import { createRuntime, runFullInit } from "../node/runtime/runtimeFactory"; -import type { Runtime } from "../node/runtime/Runtime"; import { execSync } from "child_process"; import { getParseOptions } from "./argv"; import { EXPERIMENT_IDS } from "../common/constants/experiments"; From 0ad9b1be73c4b33dc00a4aa74c0be6571a6e2cb7 Mon Sep 17 00:00:00 2001 From: ammar-agent Date: Mon, 18 May 2026 00:30:36 +0000 Subject: [PATCH 06/22] refactor: dedupe built-in skill name list in agentSkills test Extract a shared `BUILT_IN_SKILL_NAMES` constant for the two test assertions in `agentSkillsService.test.ts` that previously enumerated the same five built-in skill names inline. The two existing call sites differ only in their project-skill prefix; spreading the shared constant at the end of each list preserves the exact sort order and assertion semantics. Byte-equivalent to the previous inline arrays; the only effect is that the next new built-in skill needs to land in one place instead of two. --- .../agentSkills/agentSkillsService.test.ts | 35 ++++++++++--------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/src/node/services/agentSkills/agentSkillsService.test.ts b/src/node/services/agentSkills/agentSkillsService.test.ts index a70fa56ec1..94cf8b0200 100644 --- a/src/node/services/agentSkills/agentSkillsService.test.ts +++ b/src/node/services/agentSkills/agentSkillsService.test.ts @@ -201,6 +201,22 @@ class RemotePathMappedRuntime extends RemoteRuntime { } } +/** + * Built-in skill names exposed by `discoverAgentSkills` / + * `discoverAgentSkillsDiagnostics` in this repo. Centralized so adding (or + * removing) a built-in only requires updating one list instead of every + * assertion that enumerates the full set. `orchestrate` is unadvertised but + * still discoverable, so it appears here for the same reason it appears in + * the discovery output. + */ +const BUILT_IN_SKILL_NAMES = [ + "imagegen", + "init", + "mux-diagram", + "mux-docs", + "orchestrate", +] as const; + describe("agentSkillsService", () => { test("getDefaultAgentSkillsRoots derives global root from runtime mux home", () => { class MuxHomeRuntime extends LocalRuntime { @@ -249,15 +265,7 @@ describe("agentSkillsService", () => { // Should include project/global skills plus built-in skills // Note: deep-review skill is a project skill in the Mux repo, not a built-in - expect(skills.map((s) => s.name)).toEqual([ - "bar", - "foo", - "imagegen", - "init", - "mux-diagram", - "mux-docs", - "orchestrate", - ]); + expect(skills.map((s) => s.name)).toEqual(["bar", "foo", ...BUILT_IN_SKILL_NAMES]); const foo = skills.find((s) => s.name === "foo"); expect(foo).toBeDefined(); @@ -657,14 +665,7 @@ describe("agentSkillsService", () => { const diagnostics = await discoverAgentSkillsDiagnostics(runtime, project.path, { roots }); - expect(diagnostics.skills.map((s) => s.name)).toEqual([ - "foo", - "imagegen", - "init", - "mux-diagram", - "mux-docs", - "orchestrate", - ]); + expect(diagnostics.skills.map((s) => s.name)).toEqual(["foo", ...BUILT_IN_SKILL_NAMES]); const invalidNames = diagnostics.invalidSkills.map((issue) => issue.directoryName).sort(); expect(invalidNames).toEqual( From cc6063da03f01a7d8bb3abe5c63df52f32b3606b Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 13:13:06 +0000 Subject: [PATCH 07/22] refactor: extract AdvisorSwitch component in TasksSection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tooltip + Switch + Reset Button block for the per-agent advisor toggle was duplicated between renderAgentDefaults and renderUnknownAgentDefaults. The two call sites differ only in the agent id variable and which setter/resetter to invoke, so wrap the shared markup in a small AdvisorSwitch component. Same aria-label, same tooltip text via getAdvisorSwitchState, same conditional Reset semantics — pure cleanup with no behavior change. --- .../Settings/Sections/TasksSection.tsx | 97 +++++++++---------- 1 file changed, 47 insertions(+), 50 deletions(-) diff --git a/src/browser/features/Settings/Sections/TasksSection.tsx b/src/browser/features/Settings/Sections/TasksSection.tsx index f35ad23f34..01fa4267f0 100644 --- a/src/browser/features/Settings/Sections/TasksSection.tsx +++ b/src/browser/features/Settings/Sections/TasksSection.tsx @@ -291,6 +291,41 @@ function getAdvisorSwitchState( return { checked, title }; } +// Renders the per-agent advisor toggle (Tooltip + Switch + optional Reset button). +// The same block previously lived inline in renderAgentDefaults and +// renderUnknownAgentDefaults; extracting it keeps the two call sites byte-for-byte +// identical (same aria-label, same tooltip text, same reset semantics). +function AdvisorSwitch(props: { + agentId: string; + advisorEnabledOverride: boolean | undefined; + onChange: (checked: boolean) => void; + onReset: () => void; +}): React.ReactNode { + const state = getAdvisorSwitchState(props.agentId, props.advisorEnabledOverride); + return ( + <> + + +
+
Advisor
+ +
+
+ {state.title} +
+ {props.advisorEnabledOverride !== undefined ? ( + + ) : null} + + ); +} + function areTaskSettingsEqual(a: TaskSettings, b: TaskSettings): boolean { return ( a.maxParallelAgentTasks === b.maxParallelAgentTasks && @@ -881,7 +916,6 @@ export function TasksSection() { const writesSubagentAiDefaults = agent.subagentRunnable && !agent.uiSelectable; const enabledOverride = entry?.enabled; const advisorEnabledOverride = entry?.advisorEnabled; - const advisorSwitchState = getAdvisorSwitchState(agent.id, advisorEnabledOverride); const enablementLocked = agent.id === "exec" || agent.id === "plan" || agent.id === "compact" || agent.id === "mux"; @@ -1001,30 +1035,12 @@ export function TasksSection() { {advisorToolEnabled ? (
- - -
-
Advisor
- setAgentAdvisorEnabled(agent.id, checked)} - aria-label={`Toggle ${agent.id} advisor`} - /> -
-
- {advisorSwitchState.title} -
- {advisorEnabledOverride !== undefined ? ( - - ) : null} + setAgentAdvisorEnabled(agent.id, checked)} + onReset={() => resetAgentAdvisorEnabled(agent.id)} + />
) : null} @@ -1106,7 +1122,6 @@ export function TasksSection() { const modelValue = entry?.modelString ?? INHERIT; const thinkingValue = entry?.thinkingLevel ?? INHERIT; const advisorEnabledOverride = entry?.advisorEnabled; - const advisorSwitchState = getAdvisorSwitchState(agentId, advisorEnabledOverride); const effectiveModel = modelValue !== INHERIT ? modelValue : inheritedEffectiveModel; return ( @@ -1121,30 +1136,12 @@ export function TasksSection() { {advisorToolEnabled ? (
- - -
-
Advisor
- setAgentAdvisorEnabled(agentId, checked)} - aria-label={`Toggle ${agentId} advisor`} - /> -
-
- {advisorSwitchState.title} -
- {advisorEnabledOverride !== undefined ? ( - - ) : null} + setAgentAdvisorEnabled(agentId, checked)} + onReset={() => resetAgentAdvisorEnabled(agentId)} + />
) : null} From 7f0c1959761ec404cdac1e0525abd34b4c496e02 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 17:01:05 +0000 Subject: [PATCH 08/22] refactor: share TEXT_OUTPUT_DELTA_FIELDS constant Hoist the ["text", "delta", "textDelta"] field priority used to extract AI SDK v5 text-delta payloads into a shared TEXT_OUTPUT_DELTA_FIELDS constant in src/common/utils/ai/streamChunks.ts and reuse it from streamManager.ts (fullStream text-delta handling) and tools/advisor.ts (advisor streamText onChunk handling). Byte-equivalent semantics; only the wiring detail (that the same fallback list applies to both callsites) moves into the shared module. --- src/common/utils/ai/streamChunks.ts | 8 ++++++++ src/node/services/streamManager.ts | 8 ++------ src/node/services/tools/advisor.ts | 4 ++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/common/utils/ai/streamChunks.ts b/src/common/utils/ai/streamChunks.ts index 3648e8506c..eb4c8b9dc7 100644 --- a/src/common/utils/ai/streamChunks.ts +++ b/src/common/utils/ai/streamChunks.ts @@ -1,3 +1,11 @@ +/** + * Field priority for AI SDK v5 text-delta payloads emitted by `fullStream` + * parts and `streamText()` `onChunk` callbacks. Providers/SDKs normalize the + * delta into one of these keys; `text` is preferred when present and the + * older `delta` / `textDelta` aliases are fallbacks. + */ +export const TEXT_OUTPUT_DELTA_FIELDS = ["text", "delta", "textDelta"] as const; + export function extractChunkDeltaText( chunk: Record, fieldPriority: readonly string[] diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index aca475533b..4531e2b4e9 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -64,7 +64,7 @@ import { runLanguageModelCleanup } from "./languageModelCleanup"; import { shellQuote } from "@/common/utils/shell"; import { classify429Capacity } from "@/common/utils/errors/classify429Capacity"; import { normalizeLiteralRequiredToolPattern } from "@/common/utils/agentTools"; -import { extractChunkDeltaText } from "@/common/utils/ai/streamChunks"; +import { extractChunkDeltaText, TEXT_OUTPUT_DELTA_FIELDS } from "@/common/utils/ai/streamChunks"; // Disable noisy AI SDK warning logging. globalThis.AI_SDK_LOG_WARNINGS = false; @@ -1837,11 +1837,7 @@ export class StreamManager extends EventEmitter { // Providers/SDKs may stream text deltas under different keys. const textDeltaPart = part as Record; - const deltaText = extractChunkDeltaText(textDeltaPart, [ - "text", - "delta", - "textDelta", - ]); + const deltaText = extractChunkDeltaText(textDeltaPart, TEXT_OUTPUT_DELTA_FIELDS); if (deltaText.length === 0) { if ( diff --git a/src/node/services/tools/advisor.ts b/src/node/services/tools/advisor.ts index 83557ba44f..283e10b8c5 100644 --- a/src/node/services/tools/advisor.ts +++ b/src/node/services/tools/advisor.ts @@ -10,7 +10,7 @@ import { import type { ModelMessage } from "@/common/types/message"; import { THINKING_LEVEL_OFF, coerceThinkingLevel } from "@/common/types/thinking"; import { buildProviderOptions } from "@/common/utils/ai/providerOptions"; -import { extractChunkDeltaText } from "@/common/utils/ai/streamChunks"; +import { extractChunkDeltaText, TEXT_OUTPUT_DELTA_FIELDS } from "@/common/utils/ai/streamChunks"; import { getErrorMessage } from "@/common/utils/errors"; import { sanitizeErrorMessageForDisplay } from "@/common/utils/providerOutputSanitization"; import type { AdvisorOutputEvent, AdvisorPhaseEvent } from "@/common/types/stream"; @@ -102,7 +102,7 @@ function getAdvisorTextDelta(chunk: unknown): string | undefined { return undefined; } - const text = extractChunkDeltaText(record, ["text", "delta", "textDelta"]); + const text = extractChunkDeltaText(record, TEXT_OUTPUT_DELTA_FIELDS); return text.length > 0 ? text : undefined; } From 424daa0b58e178c1aa2d9457968c7749fdfcefda Mon Sep 17 00:00:00 2001 From: mux-agent Date: Mon, 18 May 2026 20:38:39 +0000 Subject: [PATCH 09/22] refactor: extract StatTileHeader from goal-tab budget/turns tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BudgetTile and TurnsTile each render the same dt-label + Edit-button header row. Lift the duplicated markup into a small file-scoped `StatTileHeader` helper so the styling lives in one place and the tile bodies focus on their own numeric content. Behavior, aria labels, and class names are unchanged — `GoalTab.test.tsx` (23 tests, including the inline budget/turn-cap editors that locate the openers via `getByLabelText("Edit goal …")`) still passes. --- src/browser/features/RightSidebar/GoalTab.tsx | 71 ++++++++++++------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/src/browser/features/RightSidebar/GoalTab.tsx b/src/browser/features/RightSidebar/GoalTab.tsx index e33cb9b9fc..4d71cf80c0 100644 --- a/src/browser/features/RightSidebar/GoalTab.tsx +++ b/src/browser/features/RightSidebar/GoalTab.tsx @@ -792,6 +792,39 @@ export function GoalTab(props: GoalTabProps) { ); } +interface StatTileHeaderProps { + /** Tile label rendered as a `
` (the parent `
` lives in the active-goal section). */ + label: string; + canEdit: boolean; + /** Aria label for the inline "Edit" button — distinguishes the budget vs turn-cap openers in tests + a11y. */ + editAriaLabel: string; + onEdit: (event: React.MouseEvent) => void; +} + +/** + * Shared header row for the active-goal stat tiles (Budget, Turns): + * a `
` label on the left and an optional inline "Edit" button on + * the right. Kept scoped to this file because the styling is intentionally + * local to the dashboard tile look and not reused elsewhere. + */ +function StatTileHeader(props: StatTileHeaderProps) { + return ( +
+
{props.label}
+ {props.canEdit && ( + + )} + + ); +} + interface BudgetTileProps { costCents: number; budgetCents: number | null; @@ -853,19 +886,12 @@ function BudgetTile(props: BudgetTileProps) { return (
-
-
Budget
- {canEdit && ( - - )} -
+
{formatGoalCents(costCents)} @@ -939,19 +965,12 @@ function TurnsTile(props: TurnsTileProps) { return (
-
-
Turns
- {canEdit && ( - - )} -
+
{hasCap ? `${turnsUsed} / ${turnCap}` : String(turnsUsed)} From b4d2a2e8e09e3b0c08471e68e245f6756182c811 Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 09:11:54 +0000 Subject: [PATCH 10/22] refactor: hoist GoalInterventionPolicy to common/orpc/types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three callsites (ChatInput/types.ts, messageQueue.ts, agentSession.ts) each privately redeclared the same NonNullable alias after #3319 added the field. Centralizing the type next to SendMessageOptions itself removes the duplication and ensures future sub-typing changes only need to land in one place. Pure relocation — identical type semantics at every callsite. --- src/browser/features/ChatInput/types.ts | 5 ++++- src/common/orpc/types.ts | 7 +++++++ src/node/services/agentSession.ts | 3 +-- src/node/services/messageQueue.ts | 7 ++++--- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/browser/features/ChatInput/types.ts b/src/browser/features/ChatInput/types.ts index 96b9a13040..713f67a335 100644 --- a/src/browser/features/ChatInput/types.ts +++ b/src/browser/features/ChatInput/types.ts @@ -4,7 +4,10 @@ import type { Review } from "@/common/types/review"; import type { EditingMessageState, PendingUserMessage } from "@/browser/utils/chatEditing"; import type { SendMessageOptions } from "@/common/orpc/types"; -export type GoalInterventionPolicy = NonNullable; +// Re-export so `ChatInput/types` (the existing barrel for ChatInput-local +// types) stays the single import surface for this feature, while the +// canonical declaration lives next to `SendMessageOptions` itself. +export type { GoalInterventionPolicy } from "@/common/orpc/types"; export type QueueDispatchMode = NonNullable; export interface ChatInputAPI { diff --git a/src/common/orpc/types.ts b/src/common/orpc/types.ts index d2166ca52b..a077e2f8c3 100644 --- a/src/common/orpc/types.ts +++ b/src/common/orpc/types.ts @@ -28,6 +28,13 @@ import type { export type BranchListResult = z.infer; export type SendMessageOptions = z.infer; +/** + * Canonical sub-types derived from {@link SendMessageOptions}. Kept in this + * shared module so the browser ChatInput, the node MessageQueue, and the + * AgentSession dispatcher all reach for the same alias instead of each + * privately re-deriving `NonNullable`. + */ +export type GoalInterventionPolicy = NonNullable; // Provider types (single source of truth - derived from schemas) export type AWSCredentialStatus = z.infer; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8e36ccfde0..2e799a5235 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -19,6 +19,7 @@ import { computePriorHistoryFingerprint } from "@/common/orpc/onChatCursorFinger import type { WorkspaceChatMessage, SendMessageOptions, + GoalInterventionPolicy, FilePart, DeleteMessage, OnChatMode, @@ -177,8 +178,6 @@ interface CompactionRequestMetadata { }; } -type GoalInterventionPolicy = NonNullable; - interface AutoRetryResumeRequest { // Same-session auto-retry must preserve the full normalized request because // ACP correlation/delegation lives in transient send options that are diff --git a/src/node/services/messageQueue.ts b/src/node/services/messageQueue.ts index b9c9c30a44..725f1192e4 100644 --- a/src/node/services/messageQueue.ts +++ b/src/node/services/messageQueue.ts @@ -1,4 +1,4 @@ -import type { FilePart, SendMessageOptions } from "@/common/orpc/types"; +import type { FilePart, GoalInterventionPolicy, SendMessageOptions } from "@/common/orpc/types"; import type { ReviewNoteData } from "@/common/types/review"; // Type guard for compaction request metadata (for display text) @@ -42,9 +42,10 @@ function hasReviews(meta: unknown): meta is MetadataWithReviews { return Array.isArray(obj.reviews); } -type GoalInterventionPolicy = NonNullable; - // Derive from the Zod schema (SendMessageOptions) to stay in sync automatically. +// `GoalInterventionPolicy` lives in `@/common/orpc/types` next to +// `SendMessageOptions` so the ChatInput + AgentSession callsites share the +// same alias instead of each privately re-deriving it. type QueueDispatchMode = NonNullable; /** From 3aef8f1be5e8625278908f40928fcea89fb9e85b Mon Sep 17 00:00:00 2001 From: ammar-agent Date: Tue, 19 May 2026 17:09:52 +0000 Subject: [PATCH 11/22] refactor: convert synthesizeSilentContinuationSummary to free function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The silent-continuation summary helper added in #3326 was declared as a private method on `AgentSession` but only depends on its input `parts` argument — it never touches `this`. Convert it to a module-level free function alongside the existing free helpers (`stripGoalInterventionPolicy`, `coerceGoalSyntheticMessageKind`, `extractAgentSkillRefs`, etc.) so the no-`this` contract is obvious to readers and the file's helper style stays consistent. The companion `maybeAutoCompleteGoalFromSilentContinuation` method stays on the class because it accesses `this.workspaceGoalService`, `this.workspaceId`, and `log` indirectly through instance context. Byte-for-byte identical logic at the only callsite. Pure cleanup, no behavior change. --- src/node/services/agentSession.ts | 52 ++++++++++++++++++------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 2e799a5235..d0bf6e2ff8 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -218,6 +218,35 @@ function coerceGoalSyntheticMessageKind(value: unknown): GoalSyntheticMessageKin return undefined; } +/** + * Last non-empty text part, trimmed and length-capped; falls back to a constant. + * + * Pure helper used by the silent-continuation auto-complete hook to synthesize + * a completion summary when a `goal_continuation` turn ends with text only and + * no `complete_goal` tool call. Kept as a free function (not a method) because + * it depends only on its input — matches the existing helper style above and + * makes the no-`this` contract obvious to readers. + */ +function synthesizeSilentContinuationSummary(parts: StreamEndEvent["parts"]): string { + for (let index = parts.length - 1; index >= 0; index -= 1) { + const part = parts[index]; + if (part.type !== "text") { + continue; + } + const trimmed = part.text.trim(); + if (trimmed.length === 0) { + continue; + } + if (trimmed.length <= SILENT_CONTINUATION_COMPLETION_SUMMARY_MAX_LENGTH) { + return trimmed; + } + // Reserve one character for the ellipsis so the persisted summary + // stays under the configured cap. + return `${trimmed.slice(0, SILENT_CONTINUATION_COMPLETION_SUMMARY_MAX_LENGTH - 1)}…`; + } + return SILENT_CONTINUATION_COMPLETION_SUMMARY_FALLBACK; +} + const MAX_CONSECUTIVE_AGENT_SWITCHES = 3; const SAFE_AGENT_SWITCH_FALLBACK_CANDIDATES = ["exec", "plan"] as const; @@ -4943,7 +4972,7 @@ export class AgentSession { if (payload.parts.some((part) => part.type === "dynamic-tool")) { return; } - const summary = this.synthesizeSilentContinuationSummary(payload.parts); + const summary = synthesizeSilentContinuationSummary(payload.parts); try { await this.workspaceGoalService.completeGoalFromSilentContinuation({ workspaceId: this.workspaceId, @@ -4961,27 +4990,6 @@ export class AgentSession { } } - /** Last non-empty text part, trimmed and length-capped; falls back to a constant. */ - private synthesizeSilentContinuationSummary(parts: StreamEndEvent["parts"]): string { - for (let index = parts.length - 1; index >= 0; index -= 1) { - const part = parts[index]; - if (part.type !== "text") { - continue; - } - const trimmed = part.text.trim(); - if (trimmed.length === 0) { - continue; - } - if (trimmed.length <= SILENT_CONTINUATION_COMPLETION_SUMMARY_MAX_LENGTH) { - return trimmed; - } - // Reserve one character for the ellipsis so the persisted summary - // stays under the configured cap. - return `${trimmed.slice(0, SILENT_CONTINUATION_COMPLETION_SUMMARY_MAX_LENGTH - 1)}…`; - } - return SILENT_CONTINUATION_COMPLETION_SUMMARY_FALLBACK; - } - /** Extract a successful switch_agent tool result from stream-end parts (latest wins). */ private extractSwitchAgentResult(payload: StreamEndEvent): SwitchAgentResult | undefined { for (let index = payload.parts.length - 1; index >= 0; index -= 1) { From d78702dd7c5a5931bce677988e77b6023e9ba69d Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 20:36:37 +0000 Subject: [PATCH 12/22] refactor: use loop index for review_pane dedup seed map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seed loop in applyReviewPaneUpdate built a key→index map for the existing base list by calling base.indexOf(h) on every iteration — O(n²) when the loop already had the index in hand. Switch to `base.entries()` so the position the map stores is unambiguously the slot we just visited. Behavior-identical for unique references (the only realistic case), and now correct-by-construction even if a future refactor ever introduced duplicate references in `base`. --- src/node/services/tools/review_pane.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/node/services/tools/review_pane.ts b/src/node/services/tools/review_pane.ts index b1c172d91e..11457a109d 100644 --- a/src/node/services/tools/review_pane.ts +++ b/src/node/services/tools/review_pane.ts @@ -109,9 +109,14 @@ export function applyReviewPaneUpdate( // Dedup by formatted path:range key, preferring the latest comment when // the same region is flagged twice (typical when an agent calls `add` // and then re-flags a refined comment). + // + // The seed loop uses the iteration index directly (instead of + // `base.indexOf(h)`) so seeding is O(n) and the position the map stores + // is unambiguously the slot we just visited — `indexOf` would have been + // O(n²) and only worked correctly when every entry was a unique reference. const seen = new Map(); - for (const h of base) { - seen.set(formatAssistedFilter(h), base.indexOf(h)); + for (const [i, h] of base.entries()) { + seen.set(formatAssistedFilter(h), i); } for (const h of parsed) { const key = formatAssistedFilter(h); From 1c9d930ef12b425aa52743e14e5e4c6c56eeb95a Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 09:15:12 +0000 Subject: [PATCH 13/22] refactor: drop unused __resetAdditionalSystemContextFocusForTests helper This test-only export was added speculatively in #3262 (Instructions tab) but no test ever imports it; ripgrep across src/ and tests/ finds zero callers. Removing the unused export lets pendingFocusGeneration become `const` since the reset helper was the only reason it was `let`. The Map is still mutated in place via .set(), and getter/setter exports are unchanged, so observable runtime behavior is byte-identical. --- src/browser/utils/additionalSystemContextStore.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/browser/utils/additionalSystemContextStore.ts b/src/browser/utils/additionalSystemContextStore.ts index 500b580fc0..00c7602841 100644 --- a/src/browser/utils/additionalSystemContextStore.ts +++ b/src/browser/utils/additionalSystemContextStore.ts @@ -167,7 +167,7 @@ export function queueAdditionalSystemContextSave( * soon as it mounts, and the focus request remains visible because we replay * it with a small generation counter. */ -let pendingFocusGeneration = new Map(); +const pendingFocusGeneration = new Map(); export function requestAdditionalSystemContextFocus(workspaceId: string): void { const next = (pendingFocusGeneration.get(workspaceId) ?? 0) + 1; @@ -200,11 +200,6 @@ export function getAdditionalSystemContextFocusGeneration(workspaceId: string): return pendingFocusGeneration.get(workspaceId) ?? 0; } -/** Test-only: reset focus generation counters between scenarios. */ -export function __resetAdditionalSystemContextFocusForTests(): void { - pendingFocusGeneration = new Map(); -} - export function useAdditionalSystemContextHydrated(workspaceId: string): boolean { return useSyncExternalStore( (callback) => subscribeAdditionalSystemContext(workspaceId, callback), From 86f6ad6826e91270f7d5fc8f7fe879051e6a959c Mon Sep 17 00:00:00 2001 From: "mux-bot[bot]" <264182336+mux-bot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 17:04:56 +0000 Subject: [PATCH 14/22] refactor: extract CLI_DISABLED_TOOL_NAMES to dedupe mux run toolPolicy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five toolPolicy entries differed only by tool name and all shared `action: "disable" as const`. Hoist the names into a local `CLI_DISABLED_TOOL_NAMES` tuple and produce the policy array via `.map(name => ({ regex_match: name, action: "disable" as const }))`. Picked from the `ask_user_question` disable that landed in #3336 — that commit added a fifth identical-shape entry, so the table form is now strictly tidier than five copy-pasted object literals. Adding the next disabled CLI tool drops to one string instead of duplicating the shape. Byte-equivalent toolPolicy contents (same five entries, same order, same action), so no behavior change. Generated with `mux` --- src/cli/run.ts | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/cli/run.ts b/src/cli/run.ts index e206df56f2..b861cda88f 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -754,6 +754,19 @@ async function main(): Promise { const experiments = buildExperimentsObject(opts.experiment); + // Tools disabled for `mux run`: either no-op or require UI interaction in headless mode. + // - ask_user_question: waits for a desktop/mobile answer UI; headless `mux run` cannot answer it + // - status_set: backend no-op, status indicator only visible in desktop UI + // - todo_write/todo_read: TODO list only visible in desktop UI + // - notify: sends OS notifications via Electron, silently swallowed in CLI + const CLI_DISABLED_TOOL_NAMES = [ + "ask_user_question", + "status_set", + "todo_write", + "todo_read", + "notify", + ] as const; + const buildSendOptions = (cliMode: CLIMode): SendMessageOptions => ({ model, thinkingLevel, @@ -763,18 +776,10 @@ async function main(): Promise { ...(opts.use1m && { anthropic: { use1MContext: true } }), ...(opts.serviceTier != null && { openai: { serviceTier: opts.serviceTier } }), }, - // Disable UI-only tools that either no-op or require UI interaction in CLI mode: - // - ask_user_question: waits for a desktop/mobile answer UI; headless `mux run` cannot answer it - // - status_set: backend no-op, status indicator only visible in desktop UI - // - todo_write/todo_read: TODO list only visible in desktop UI - // - notify: sends OS notifications via Electron, silently swallowed in CLI - toolPolicy: [ - { regex_match: "ask_user_question", action: "disable" as const }, - { regex_match: "status_set", action: "disable" as const }, - { regex_match: "todo_write", action: "disable" as const }, - { regex_match: "todo_read", action: "disable" as const }, - { regex_match: "notify", action: "disable" as const }, - ], + toolPolicy: CLI_DISABLED_TOOL_NAMES.map((name) => ({ + regex_match: name, + action: "disable" as const, + })), // Plan agent instructions are handled by the backend (has access to plan file path) }); From 214377b0a0a118c9df679e050361458bc6785e87 Mon Sep 17 00:00:00 2001 From: Mux Auto-Cleanup Date: Wed, 20 May 2026 20:45:53 +0000 Subject: [PATCH 15/22] refactor: extract section-pill className helper in AskUserQuestionToolCall The two pill renderers in the ask_user_question executing UI (one per question plus the Summary pill) inlined the same nine-class Tailwind string and the same three-way selected/complete/neutral branch logic. Extract a module-level `getSectionPillClassName(isSelected, isComplete)` helper so both callsites share one declaration; the next visual tweak to those pills now lives in one place instead of two. Identical class strings at both callsites, so the rendered output is byte-equivalent and the AskUserQuestionToolCall.test.tsx suite (4/4 pass) continues to find the question pills by their unchanged role and labels. --- .../Tools/AskUserQuestionToolCall.tsx | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/src/browser/features/Tools/AskUserQuestionToolCall.tsx b/src/browser/features/Tools/AskUserQuestionToolCall.tsx index d948712d6c..114b15e065 100644 --- a/src/browser/features/Tools/AskUserQuestionToolCall.tsx +++ b/src/browser/features/Tools/AskUserQuestionToolCall.tsx @@ -179,6 +179,23 @@ function getDescriptionsForLabels(question: AskUserQuestionQuestion, labels: str .filter((d): d is string => d !== undefined); } +/** + * Shared class string for the section-selector pills in the executing UI + * (one pill per question plus the Summary pill). Both pills paint with the + * same three visual states: selected (accent), complete (success-tinted), + * or pending (neutral). + */ +function getSectionPillClassName(isSelected: boolean, isComplete: boolean): string { + return cn( + "inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-[11px] font-medium transition-colors", + isSelected + ? "border-accent bg-accent text-accent-foreground shadow-sm" + : isComplete + ? "border-success/40 bg-success/10 text-success hover:bg-success/20" + : "border-white/10 bg-white/5 text-secondary hover:border-white/20 hover:text-foreground" + ); +} + /** Auto-resizing textarea for "Other" text input. */ function AutoResizeTextarea(props: { value: string; @@ -592,14 +609,7 @@ export function AskUserQuestionToolCall(props: {