Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions src/browser/components/CommandPalette/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -291,11 +291,10 @@ export const CommandPalette: React.FC<CommandPaletteProps> = ({ getSlashContext
const suggestions = getSlashCommandSuggestions(q, {
agentSkills,
variant: ctx.workspaceId ? "workspace" : "creation",
isExperimentEnabled: (experimentId) =>
resolveSlashCommandExperimentValue(experimentId, {
goals: goalsExperimentEnabled,
workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled,
}),
isExperimentEnabled: createSlashCommandExperimentResolver({
goals: goalsExperimentEnabled,
workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled,
}),
});
const section = "Slash Commands";
const groups: PaletteGroup[] = [
Expand Down
20 changes: 9 additions & 11 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,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";
Expand Down Expand Up @@ -1404,11 +1404,10 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
const suggestions = getSlashCommandSuggestions(input, {
agentSkills: agentSkillDescriptors,
variant,
isExperimentEnabled: (experimentId) =>
resolveSlashCommandExperimentValue(experimentId, {
goals: goalsExperimentEnabled,
workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled,
}),
isExperimentEnabled: createSlashCommandExperimentResolver({
goals: goalsExperimentEnabled,
workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled,
}),
});
setCommandSuggestions((prev) => replaceSuggestions(prev, suggestions));
setShowCommandSuggestions(suggestions.length > 0);
Expand All @@ -1424,11 +1423,10 @@ const ChatInputInner: React.FC<ChatInputProps> = (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, {
goals: goalsExperimentEnabled,
workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled,
}),
isExperimentEnabled: createSlashCommandExperimentResolver({
goals: goalsExperimentEnabled,
workspaceHeartbeats: workspaceHeartbeatsExperimentEnabled,
}),
});

// Load agent skills for suggestions
Expand Down
12 changes: 7 additions & 5 deletions src/browser/features/Messages/Mermaid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)) {
Expand All @@ -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);
}
Expand Down
14 changes: 14 additions & 0 deletions src/browser/utils/slashCommands/experimentVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,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);
}
29 changes: 16 additions & 13 deletions src/node/services/agentStatusService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,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<string, "running" | "done"> = {
"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 };
Expand All @@ -488,20 +503,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}]`;
}

Expand Down
6 changes: 6 additions & 0 deletions src/node/services/tools/imageArtifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
3 changes: 2 additions & 1 deletion src/node/services/tools/image_edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
getImageDimensions,
getImageDimensionsFromMetadata,
getImageOutputDir,
IMAGE_TOOL_EXCESS_COUNT_SETUP_HINT,
IMAGE_TOOL_PROVIDER_SETUP_HINT,
processImageArtifacts,
reportImageToolUsage,
Expand Down Expand Up @@ -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,
};
}

Expand Down
3 changes: 2 additions & 1 deletion src/node/services/tools/image_generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
buildOpenAIImageProviderOptions,
formatImageModelError,
getImageOutputDir,
IMAGE_TOOL_EXCESS_COUNT_SETUP_HINT,
IMAGE_TOOL_PROVIDER_SETUP_HINT,
processImageArtifacts,
reportImageToolUsage,
Expand Down Expand Up @@ -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,
};
}

Expand Down
Loading