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/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/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); } diff --git a/src/browser/features/RightSidebar/CodeReview/ReviewPanel.assistedStats.test.ts b/src/browser/features/RightSidebar/CodeReview/ReviewPanel.assistedStats.test.ts index 8e079bc458..f897ab1bee 100644 --- a/src/browser/features/RightSidebar/CodeReview/ReviewPanel.assistedStats.test.ts +++ b/src/browser/features/RightSidebar/CodeReview/ReviewPanel.assistedStats.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; import type { AssistedReviewHunk, DiffHunk } from "@/common/types/review"; +import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { - buildReviewDiffPathFilter, buildReviewDiffPathFilterSpecs, countUnreadAssistedHunks, getEffectiveReviewFrontendFilters, @@ -10,6 +10,27 @@ import { getNextDismissedAssistedKeys, } from "./ReviewPanel"; +// Test-only single-result wrapper around `buildReviewDiffPathFilterSpecs`. +// Production callers go through the multi-spec form directly to handle the +// multi-project case; these tests only exercise the first spec's pathFilter, +// so the wrapper lives here instead of polluting ReviewPanel's exported API. +function buildReviewDiffPathFilter(params: { + isImmersive: boolean; + assistedOnly: boolean; + assistedHunks: readonly AssistedReviewHunk[]; + selectedFilePath: string | null; + selectedDiffPath: string; + workspaceMetadata: Pick | null | undefined; + repoRootProjectPath: string | null | undefined; +}): string { + return ( + buildReviewDiffPathFilterSpecs({ + ...params, + projectPath: params.repoRootProjectPath ?? "", + })[0]?.pathFilter ?? "" + ); +} + function hunk(overrides: Partial): DiffHunk { return { id: overrides.id ?? "h1", diff --git a/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx index 5a6733b1a0..b9fa9f77cc 100644 --- a/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ReviewPanel.tsx @@ -32,7 +32,11 @@ import React, { useRef, useSyncExternalStore, } from "react"; -import { findAssistedMatch, formatAssistedFilter } from "@/common/utils/review/assistedReview"; +import { + filterDismissedAssistedHunks, + findAssistedMatch, + formatAssistedFilter, +} from "@/common/utils/review/assistedReview"; import { createPortal } from "react-dom"; import { HunkViewer } from "./HunkViewer"; import { InlineReviewNote, type ReviewActionCallbacks } from "../../Shared/InlineReviewNote"; @@ -529,23 +533,6 @@ export function buildReviewDiffPathFilterSpecs(params: { })); } -export function buildReviewDiffPathFilter(params: { - isImmersive: boolean; - assistedOnly: boolean; - assistedHunks: readonly AssistedReviewHunk[]; - selectedFilePath: string | null; - selectedDiffPath: string; - workspaceMetadata: Pick | null | undefined; - repoRootProjectPath: string | null | undefined; -}): string { - return ( - buildReviewDiffPathFilterSpecs({ - ...params, - projectPath: params.repoRootProjectPath ?? "", - })[0]?.pathFilter ?? "" - ); -} - export function getEffectiveReviewIncludeUncommitted(params: { assistedOnly: boolean; includeUncommitted: boolean; @@ -639,11 +626,10 @@ export const ReviewAssistedStatsReporter: React.FC { - if (dismissedAssistedKeys.length === 0) return rawAssistedHunks; - const dismissed = new Set(dismissedAssistedKeys); - return rawAssistedHunks.filter((entry) => !dismissed.has(formatAssistedFilter(entry))); - }, [rawAssistedHunks, dismissedAssistedKeys]); + const assistedHunks = useMemo( + () => filterDismissedAssistedHunks(rawAssistedHunks, dismissedAssistedKeys), + [rawAssistedHunks, dismissedAssistedKeys] + ); // Self-heal the dismissed-pin list whenever the agent's set changes: // drop any dismissed key that is no longer present in the agent's pins @@ -1091,19 +1077,12 @@ export const ReviewPanel: React.FC = ({ [], { listener: true } ); - const dismissedAssistedKeySet = useMemo( - () => new Set(dismissedAssistedKeys), - [dismissedAssistedKeys] - ); - // Effective assisted set after applying user dismissals. Memoized so all // downstream maps depend on a stable reference when nothing changes. - const assistedHunks = useMemo(() => { - if (dismissedAssistedKeySet.size === 0) return rawAssistedHunks; - return rawAssistedHunks.filter( - (entry) => !dismissedAssistedKeySet.has(formatAssistedFilter(entry)) - ); - }, [rawAssistedHunks, dismissedAssistedKeySet]); + const assistedHunks = useMemo( + () => filterDismissedAssistedHunks(rawAssistedHunks, dismissedAssistedKeys), + [rawAssistedHunks, dismissedAssistedKeys] + ); // The self-healing prune of stale dismissed keys lives in // `ReviewAssistedStatsReporter` (always mounted) so it runs even when the diff --git a/src/browser/features/RightSidebar/GoalTab.tsx b/src/browser/features/RightSidebar/GoalTab.tsx index e33cb9b9fc..3ce6a8f145 100644 --- a/src/browser/features/RightSidebar/GoalTab.tsx +++ b/src/browser/features/RightSidebar/GoalTab.tsx @@ -90,6 +90,16 @@ interface GoalTabProps { const parseBudgetInput = parseGoalBudgetInputCents; const parseTurnCapInput = parseGoalTurnCapInput; +// Shared className for the neutral lifecycle action buttons in the active-goal +// header (Pause / Mark complete / Reopen). All three render with the same +// bordered surface-secondary chip styling so they read as visual peers; the +// accent-colored Archive and success-tinted Resume buttons keep their own +// inline class strings because they are the only primary / positive variants. +// Centralizing the neutral string here keeps the next tone tweak to those +// three buttons a one-line edit instead of three duplicated copies. +const GOAL_LIFECYCLE_NEUTRAL_BUTTON_CLASS = + "border-border-light bg-surface-secondary text-foreground hover:bg-surface-tertiary inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm"; + type EditingField = "objective" | "budget" | "turnCap"; export function GoalTab(props: GoalTabProps) { @@ -613,7 +623,7 @@ export function GoalTab(props: GoalTabProps) { {canPause && ( + )} + + ); +} + interface BudgetTileProps { costCents: number; budgetCents: number | null; @@ -853,19 +896,12 @@ function BudgetTile(props: BudgetTileProps) { return (
-
-
Budget
- {canEdit && ( - - )} -
+
{formatGoalCents(costCents)} @@ -939,19 +975,12 @@ function TurnsTile(props: TurnsTileProps) { return (
-
-
Turns
- {canEdit && ( - - )} -
+
{hasCap ? `${turnsUsed} / ${turnCap}` : String(turnsUsed)} diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index b5a9d59feb..61be8f6e08 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -9,7 +9,8 @@ import { } from "@/browser/components/SelectPrimitive/SelectPrimitive"; import { Input } from "@/browser/components/Input/Input"; import { Switch } from "@/browser/components/Switch/Switch"; -import { updatePersistedState, usePersistedState } from "@/browser/hooks/usePersistedState"; +import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { persistChatTranscriptFullWidth } from "@/browser/hooks/useChatTranscriptFullWidth"; import { useAPI } from "@/browser/contexts/API"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { @@ -20,7 +21,6 @@ import { LAUNCH_BEHAVIOR_KEY, BASH_COLLAPSED_SUMMARY_MODE_KEY, BASH_COLLAPSED_SUMMARY_MODES, - CHAT_TRANSCRIPT_FULL_WIDTH_KEY, DEFAULT_BASH_COLLAPSED_SUMMARY_MODE, normalizeBashCollapsedSummaryMode, type BashCollapsedSummaryMode, @@ -283,10 +283,7 @@ export function GeneralSection() { if (chatTranscriptFullWidthNonce === chatTranscriptFullWidthLoadNonceRef.current) { const enabled = cfg.chatTranscriptFullWidth === true; setChatTranscriptFullWidth(enabled); - updatePersistedState( - CHAT_TRANSCRIPT_FULL_WIDTH_KEY, - enabled ? true : undefined - ); + persistChatTranscriptFullWidth(enabled); } if (llmDebugLogsNonce === llmDebugLogsLoadNonceRef.current) { @@ -379,10 +376,7 @@ export function GeneralSection() { // Invalidate any in-flight config load so it does not overwrite the user's selection. chatTranscriptFullWidthLoadNonceRef.current++; setChatTranscriptFullWidth(checked); - updatePersistedState( - CHAT_TRANSCRIPT_FULL_WIDTH_KEY, - checked ? true : undefined - ); + persistChatTranscriptFullWidth(checked); if (!api?.config?.updateChatTranscriptFullWidth) { return; 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} 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: {