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/contexts/WorkspaceContext.tsx b/src/browser/contexts/WorkspaceContext.tsx index ac82319ce0..943831d4eb 100644 --- a/src/browser/contexts/WorkspaceContext.tsx +++ b/src/browser/contexts/WorkspaceContext.tsx @@ -73,6 +73,10 @@ import type { APIClient } from "@/browser/contexts/API"; import { getErrorMessage } from "@/common/utils/errors"; import type { WorkspaceCreationScope } from "@/common/utils/subProjects"; +// Single source of truth for the guard used when the IPC API client is unavailable, so the +// message can't drift between the workspace-context operations that surface it. +const API_NOT_CONNECTED_ERROR = "API not connected"; + /** * One-time best-effort migration: if the backend doesn't have model preferences yet, * persist explicit localStorage values so future port/origin changes keep them. @@ -1290,7 +1294,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { trunkBranch: string, runtimeConfig?: RuntimeConfig ) => { - if (!api) throw new Error("API not connected"); + if (!api) throw new Error(API_NOT_CONNECTED_ERROR); console.assert( typeof trunkBranch === "string" && trunkBranch.trim().length > 0, "Expected trunk branch to be provided when creating a workspace" @@ -1333,7 +1337,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { workspaceId: string, options?: { force?: boolean } ): Promise<{ success: boolean; error?: string }> => { - if (!api) return { success: false, error: "API not connected" }; + if (!api) return { success: false, error: API_NOT_CONNECTED_ERROR }; // Capture state before the async operation. // We check currentWorkspaceId (from URL) rather than selectedWorkspace @@ -1403,7 +1407,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { workspaceId: string, newTitle: string ): Promise<{ success: boolean; error?: string }> => { - if (!api) return { success: false, error: "API not connected" }; + if (!api) return { success: false, error: API_NOT_CONNECTED_ERROR }; try { const result = await api.workspace.updateTitle({ workspaceId, title: newTitle }); if (result.success) { @@ -1428,7 +1432,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { async ( workspaceId: string ): Promise<{ success: boolean; error?: string; data?: ArchivePreflightResult }> => { - if (!api) return { success: false, error: "API not connected" }; + if (!api) return { success: false, error: API_NOT_CONNECTED_ERROR }; try { const result = await api.workspace.preflightArchive({ workspaceId }); @@ -1448,7 +1452,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { workspaceId: string, options?: { acknowledgedUntrackedPaths?: string[] } ): Promise<{ success: boolean; error?: string; data?: ArchiveWorkspaceResult }> => { - if (!api) return { success: false, error: "API not connected" }; + if (!api) return { success: false, error: API_NOT_CONNECTED_ERROR }; try { const result = await api.workspace.archive({ @@ -1500,7 +1504,7 @@ export function WorkspaceProvider(props: WorkspaceProviderProps) { const unarchiveWorkspace = useCallback( async (workspaceId: string): Promise<{ success: boolean; error?: string }> => { - if (!api) return { success: false, error: "API not connected" }; + if (!api) return { success: false, error: API_NOT_CONNECTED_ERROR }; try { const result = await api.workspace.unarchive({ workspaceId }); if (result.success) { diff --git a/src/browser/features/ChatInput/ChatInputToasts.tsx b/src/browser/features/ChatInput/ChatInputToasts.tsx index 97b671a9f9..b3ff6c027d 100644 --- a/src/browser/features/ChatInput/ChatInputToasts.tsx +++ b/src/browser/features/ChatInput/ChatInputToasts.tsx @@ -5,6 +5,10 @@ import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; import type { SendMessageError as SendMessageErrorType } from "@/common/types/errors"; import { formatSendMessageError } from "@/common/utils/errors/formatSendError"; +// Shared across the provider-related error toasts (missing key, OAuth, disabled, +// unsupported) so the providers docs link is defined in exactly one place. +const providersDocsLink = mux.coder.com/providers; + export function createInvalidCompactModelToast(model: string): Toast { return { id: Date.now().toString(), @@ -136,7 +140,7 @@ export const createErrorToast = (error: SendMessageErrorType): Toast => { Fix: {formatted.resolutionHint ?? "Open Settings → Providers and add an API key."}
- mux.coder.com/providers + {providersDocsLink} ), }; @@ -154,7 +158,7 @@ export const createErrorToast = (error: SendMessageErrorType): Toast => { Fix: {formatted.resolutionHint ?? "Open Settings → Providers and connect your account."}
- mux.coder.com/providers + {providersDocsLink} ), }; @@ -172,7 +176,7 @@ export const createErrorToast = (error: SendMessageErrorType): Toast => { Fix: {formatted.resolutionHint ?? "Open Settings → Providers and enable this provider."}
- mux.coder.com/providers + {providersDocsLink} ), }; @@ -190,7 +194,7 @@ export const createErrorToast = (error: SendMessageErrorType): Toast => { Try This: Choose a supported provider in Settings → Providers.
- mux.coder.com/providers + {providersDocsLink} ), }; diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index d1772bae2e..bcf8ef73c1 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 @@ -2849,13 +2847,13 @@ const ChatInputInner: React.FC = (props) => { onDrop={handleDrop} onEscapeInNormalMode={handleEscapeInNormalMode} suppressKeys={ - showAtMentionSuggestions + // @file and $skill menus share the same key set; collapse the + // duplicate FILE_SUGGESTION_KEYS branches into one condition. + showAtMentionSuggestions || showSkillSuggestions ? FILE_SUGGESTION_KEYS - : showSkillSuggestions - ? FILE_SUGGESTION_KEYS - : showCommandSuggestions - ? COMMAND_SUGGESTION_KEYS - : undefined + : showCommandSuggestions + ? COMMAND_SUGGESTION_KEYS + : undefined } placeholder={placeholder} disabled={!editingMessageForUi && (disabled || sendInFlightBlocksInput)} diff --git a/src/browser/features/ChatInput/placeholderTips.test.ts b/src/browser/features/ChatInput/placeholderTips.test.ts index 44f44ed518..30e27721d5 100644 --- a/src/browser/features/ChatInput/placeholderTips.test.ts +++ b/src/browser/features/ChatInput/placeholderTips.test.ts @@ -1,12 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { PLACEHOLDER_TIPS, getPlaceholderTip } from "./placeholderTips"; +import { PLACEHOLDER_TIPS, TIP_ROTATION_INTERVAL_MS, getPlaceholderTip } from "./placeholderTips"; interface StorybookGlobal { __MUX_STORYBOOK__?: boolean; } -const TWENTY_MIN_MS = 20 * 60 * 1000; - describe("PLACEHOLDER_TIPS", () => { test("every tip references a slash command", () => { for (const tip of PLACEHOLDER_TIPS) { @@ -43,25 +41,26 @@ describe("getPlaceholderTip", () => { // to the same tip. If they don't, switching workspaces / re-rendering // inside the same bucket would reshuffle the tip — which is the exact // flicker we're trying to prevent. - const bucketStart = TWENTY_MIN_MS * 100; // arbitrary aligned anchor + const bucketStart = TIP_ROTATION_INTERVAL_MS * 100; // arbitrary aligned anchor const tip = getPlaceholderTip(bucketStart); expect(getPlaceholderTip(bucketStart + 1)).toBe(tip); - expect(getPlaceholderTip(bucketStart + TWENTY_MIN_MS - 1)).toBe(tip); + expect(getPlaceholderTip(bucketStart + TIP_ROTATION_INTERVAL_MS - 1)).toBe(tip); }); test("advances to the next tip when the bucket boundary crosses", () => { // Crossing the boundary must rotate — otherwise the carousel is silently // stuck and the discoverability rationale is broken. - const bucketStart = TWENTY_MIN_MS * 100; + const bucketStart = TIP_ROTATION_INTERVAL_MS * 100; const before = getPlaceholderTip(bucketStart); - const after = getPlaceholderTip(bucketStart + TWENTY_MIN_MS); + const after = getPlaceholderTip(bucketStart + TIP_ROTATION_INTERVAL_MS); expect(after).not.toBe(before); }); test("wraps with modulo so long-running clocks never lose the placeholder", () => { // Far-future timestamps should still resolve to a tip rather than // undefined / out-of-bounds. - const bigFuture = TWENTY_MIN_MS * PLACEHOLDER_TIPS.length * 5 + TWENTY_MIN_MS * 3; + const bigFuture = + TIP_ROTATION_INTERVAL_MS * PLACEHOLDER_TIPS.length * 5 + TIP_ROTATION_INTERVAL_MS * 3; expect(PLACEHOLDER_TIPS).toContain(getPlaceholderTip(bigFuture)); }); @@ -87,9 +86,9 @@ describe("getPlaceholderTip", () => { // Explicit nowMs must still rotate even with the flag set, otherwise // unit tests that depend on rotation math would silently no-op when // someone forgets to clear the flag. - const bucketStart = TWENTY_MIN_MS * 100; + const bucketStart = TIP_ROTATION_INTERVAL_MS * 100; const before = getPlaceholderTip(bucketStart); - const after = getPlaceholderTip(bucketStart + TWENTY_MIN_MS); + const after = getPlaceholderTip(bucketStart + TIP_ROTATION_INTERVAL_MS); expect(after).not.toBe(before); }); }); diff --git a/src/browser/features/ChatInput/placeholderTips.ts b/src/browser/features/ChatInput/placeholderTips.ts index fb3e14a70c..f992407bed 100644 --- a/src/browser/features/ChatInput/placeholderTips.ts +++ b/src/browser/features/ChatInput/placeholderTips.ts @@ -20,8 +20,9 @@ * sure the command you're surfacing isn't gated. */ -/** Bucket length for tip rotation. */ -const TIP_ROTATION_INTERVAL_MS = 20 * 60 * 1000; // 20 minutes +/** Bucket length for tip rotation. Exported so tests anchor their bucket math + * to the real interval instead of a hardcoded copy that silently drifts. */ +export const TIP_ROTATION_INTERVAL_MS = 20 * 60 * 1000; // 20 minutes /** * Tip index pinned for Storybook/Chromatic snapshots. 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/ImmersiveMinimap.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveMinimap.tsx index 4ab030466d..7ec7172dd8 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveMinimap.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveMinimap.tsx @@ -30,6 +30,18 @@ const readThemeColor = (cssVariableName: string, fallback: string): string => { return resolvedColor.length > 0 ? resolvedColor : fallback; }; +// The minimap reads thumb geometry from a scroll container in three places +// (initial draw, thumb press, and drag-follow), each needing the same +// scrollTop/scrollHeight/clientHeight triple. Funnel them through one wrapper +// so the call sites stay in sync; pure forwarding, no behavior change. +const getScrollContainerThumbMetrics = (scrollContainer: HTMLElement, trackHeight: number) => + getThumbMetrics( + scrollContainer.scrollTop, + scrollContainer.scrollHeight, + scrollContainer.clientHeight, + trackHeight + ); + export const ImmersiveMinimap: React.FC = (props) => { // Computed synchronously during render — React Compiler memoizes based on // props.content. Avoids the useState+useEffect flash where the component @@ -105,10 +117,8 @@ export const ImmersiveMinimap: React.FC = (props) => { const scrollContainer = props.scrollContainerRef.current; if (scrollContainer) { - const { thumbTop, thumbHeight } = getThumbMetrics( - scrollContainer.scrollTop, - scrollContainer.scrollHeight, - scrollContainer.clientHeight, + const { thumbTop, thumbHeight } = getScrollContainerThumbMetrics( + scrollContainer, trackHeight ); @@ -196,12 +206,7 @@ export const ImmersiveMinimap: React.FC = (props) => { const pointerY = event.clientY - bounds.top; if (scrollContainer) { - const thumbMetrics = getThumbMetrics( - scrollContainer.scrollTop, - scrollContainer.scrollHeight, - scrollContainer.clientHeight, - bounds.height - ); + const thumbMetrics = getScrollContainerThumbMetrics(scrollContainer, bounds.height); const isPressingThumb = thumbMetrics.maxThumbTop > 0 && @@ -223,10 +228,8 @@ export const ImmersiveMinimap: React.FC = (props) => { const latestBounds = latestCanvas.getBoundingClientRect(); const nextPointerY = moveEvent.clientY - latestBounds.top; - const nextMetrics = getThumbMetrics( - latestScrollContainer.scrollTop, - latestScrollContainer.scrollHeight, - latestScrollContainer.clientHeight, + const nextMetrics = getScrollContainerThumbMetrics( + latestScrollContainer, latestBounds.height ); const nextThumbTop = clamp(nextPointerY - pointerOffset, 0, nextMetrics.maxThumbTop); diff --git a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx index 5bf1bad1d7..82e663728f 100644 --- a/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx +++ b/src/browser/features/RightSidebar/CodeReview/ImmersiveReviewView.tsx @@ -1561,6 +1561,22 @@ export const ImmersiveReviewView: React.FC = (props) = const contentChanged = previousContentRef.current !== overlayData.content; previousContentRef.current = overlayData.content; + // Clear the file-switch reveal gate on the next frame once the initial scroll + // has been issued. Both the no-line-element retry path and the post-scroll path + // need the identical "cancel any pending frame, then schedule a one-shot clear + // guarded by the captured file path" sequence, so keep it in one place. + const scheduleRevealCompletion = (revealFilePath: string) => { + if (revealAnimationFrameRef.current !== null) { + cancelAnimationFrame(revealAnimationFrameRef.current); + } + revealAnimationFrameRef.current = window.requestAnimationFrame(() => { + setPendingRevealFilePath((pendingFilePath) => + pendingFilePath === revealFilePath ? null : pendingFilePath + ); + revealAnimationFrameRef.current = null; + }); + }; + const previousLineElement = highlightedLineElementRef.current; if (previousLineElement) { previousLineElement.style.outline = ""; @@ -1596,17 +1612,7 @@ export const ImmersiveReviewView: React.FC = (props) = return; } - if (revealAnimationFrameRef.current !== null) { - cancelAnimationFrame(revealAnimationFrameRef.current); - } - - const revealFilePath = activeFilePath; - revealAnimationFrameRef.current = window.requestAnimationFrame(() => { - setPendingRevealFilePath((pendingFilePath) => - pendingFilePath === revealFilePath ? null : pendingFilePath - ); - revealAnimationFrameRef.current = null; - }); + scheduleRevealCompletion(activeFilePath); return; } @@ -1627,17 +1633,7 @@ export const ImmersiveReviewView: React.FC = (props) = return; } - if (revealAnimationFrameRef.current !== null) { - cancelAnimationFrame(revealAnimationFrameRef.current); - } - - const revealFilePath = activeFilePath; - revealAnimationFrameRef.current = window.requestAnimationFrame(() => { - setPendingRevealFilePath((pendingFilePath) => - pendingFilePath === revealFilePath ? null : pendingFilePath - ); - revealAnimationFrameRef.current = null; - }); + scheduleRevealCompletion(activeFilePath); }, [ activeFilePath, activeLineIndex, 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/Components/OnePasswordPicker.tsx b/src/browser/features/Settings/Components/OnePasswordPicker.tsx index 20b1ddd713..6365efdd11 100644 --- a/src/browser/features/Settings/Components/OnePasswordPicker.tsx +++ b/src/browser/features/Settings/Components/OnePasswordPicker.tsx @@ -6,6 +6,10 @@ import { useAPI } from "@/browser/contexts/API"; import assert from "@/common/utils/assert"; import { getErrorMessage } from "@/common/utils/errors"; +// Single source of truth for the guard shown when the IPC API client is unavailable, so the +// message can't drift between the four picker fetch handlers that surface it. +const API_NOT_CONNECTED_ERROR = "Mux API not connected."; + interface OnePasswordVault { id: string; title: string; @@ -50,7 +54,7 @@ export function OnePasswordPicker(props: OnePasswordPickerProps) { useEffect(() => { if (!api) { setVaults([]); - setError("Mux API not connected."); + setError(API_NOT_CONNECTED_ERROR); setLoading(false); return; } @@ -112,7 +116,7 @@ export function OnePasswordPicker(props: OnePasswordPickerProps) { assert(vault.title.length > 0, "OnePasswordPicker: vault.title must be non-empty"); if (!api) { - setError("Mux API not connected."); + setError(API_NOT_CONNECTED_ERROR); return; } @@ -148,7 +152,7 @@ export function OnePasswordPicker(props: OnePasswordPickerProps) { assert(item.title.length > 0, "OnePasswordPicker: item.title must be non-empty"); if (!api) { - setError("Mux API not connected."); + setError(API_NOT_CONNECTED_ERROR); return; } @@ -191,7 +195,7 @@ export function OnePasswordPicker(props: OnePasswordPickerProps) { assert(field.title.length > 0, "OnePasswordPicker: field.title must be non-empty"); if (!api) { - setError("Mux API not connected."); + setError(API_NOT_CONNECTED_ERROR); return; } 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/ProvidersSection.tsx b/src/browser/features/Settings/Sections/ProvidersSection.tsx index 4bf8560d72..526fca62b8 100644 --- a/src/browser/features/Settings/Sections/ProvidersSection.tsx +++ b/src/browser/features/Settings/Sections/ProvidersSection.tsx @@ -86,6 +86,10 @@ type CopilotLoginStatus = "idle" | "starting" | "waiting" | "success" | "error"; const OPENAI_SERVICE_TIER_UNSET = "unset"; +// Single source of truth for the guard shown when the IPC API client is unavailable, so the +// message can't drift between the several provider login/submit handlers that surface it. +const API_NOT_CONNECTED_ERROR = "Mux API not connected."; + type OpenAIServiceTier = ServiceTier; type OpenAIServiceTierSelectValue = typeof OPENAI_SERVICE_TIER_UNSET | OpenAIServiceTier; @@ -493,7 +497,7 @@ export function ProvidersSection() { if (!api) { setCodexOauthStatus("error"); - setCodexOauthError("Mux API not connected."); + setCodexOauthError(API_NOT_CONNECTED_ERROR); return; } @@ -609,7 +613,7 @@ export function ProvidersSection() { if (!api) { setCodexOauthStatus("error"); - setCodexOauthError("Mux API not connected."); + setCodexOauthError(API_NOT_CONNECTED_ERROR); return; } @@ -683,7 +687,7 @@ export function ProvidersSection() { if (!api) { setCodexOauthStatus("error"); - setCodexOauthError("Mux API not connected."); + setCodexOauthError(API_NOT_CONNECTED_ERROR); return; } @@ -801,7 +805,7 @@ export function ProvidersSection() { if (isDesktop) { if (!api) { setMuxGatewayLoginStatus("error"); - setMuxGatewayLoginError("Mux API not connected."); + setMuxGatewayLoginError(API_NOT_CONNECTED_ERROR); return; } @@ -1414,7 +1418,7 @@ export function ProvidersSection() { setCustomProviderSubmitAttempted(true); if (!api) { - setCustomProviderSubmitError("Mux API not connected."); + setCustomProviderSubmitError(API_NOT_CONNECTED_ERROR); return; } @@ -1503,7 +1507,7 @@ export function ProvidersSection() { if (!api) { setCustomProviderRemoveErrors((prev) => ({ ...prev, - [provider]: "Mux API not connected.", + [provider]: API_NOT_CONNECTED_ERROR, })); 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/AdvisorToolCall.tsx b/src/browser/features/Tools/AdvisorToolCall.tsx index 0422329bc9..049bd5db03 100644 --- a/src/browser/features/Tools/AdvisorToolCall.tsx +++ b/src/browser/features/Tools/AdvisorToolCall.tsx @@ -144,16 +144,24 @@ function formatAdvisorModel(advisorModel: string): { displayName: string; rawMod }; } +// The `advice`, `error`, and executing/fallback cases all present their state +// through the shared status-display helper and differ only in the status they +// pass. Centralize that { status, content } shape so the three branches don't +// each re-derive it; `limit_reached` keeps its custom warning content. +function defaultAdvisorStatusPresentation(status: ToolStatus): AdvisorStatusPresentation { + return { + status, + content: getStatusDisplay(status), + }; +} + function getAdvisorStatusPresentation( result: AdvisorToolResult | null, fallbackStatus: ToolStatus ): AdvisorStatusPresentation { switch (result?.type) { case "advice": - return { - status: "completed", - content: getStatusDisplay("completed"), - }; + return defaultAdvisorStatusPresentation("completed"); case "limit_reached": return { status: "completed", @@ -166,15 +174,9 @@ function getAdvisorStatusPresentation( ), }; case "error": - return { - status: "failed", - content: getStatusDisplay("failed"), - }; + return defaultAdvisorStatusPresentation("failed"); default: - return { - status: fallbackStatus, - content: getStatusDisplay(fallbackStatus), - }; + return defaultAdvisorStatusPresentation(fallbackStatus); } } 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: {