diff --git a/packages/app/src/composer/agent-controls/mode-control.tsx b/packages/app/src/composer/agent-controls/mode-control.tsx index d9ffbb642..79c7ee151 100644 --- a/packages/app/src/composer/agent-controls/mode-control.tsx +++ b/packages/app/src/composer/agent-controls/mode-control.tsx @@ -1,4 +1,12 @@ -import { memo, useCallback, useMemo, useRef, useState, type ReactElement } from "react"; +import { + memo, + useCallback, + useMemo, + useRef, + useState, + type ComponentType, + type ReactElement, +} from "react"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; @@ -33,13 +41,18 @@ function shouldRenderForPlacement(placement: AgentModeControlPlacement, isCompac return placement === "footer" ? isCompact : !isCompact; } -const MODE_ICONS = { +interface ModeIconProps { + size?: number; + color?: string; +} + +const MODE_ICONS: Record> = { Bot, ShieldCheck, ShieldAlert, ShieldOff, ShieldQuestionMark, -} as const; +}; interface ModePalette { blue: { 500: string }; @@ -87,9 +100,9 @@ function ModeComboboxOption({ iconColor, }: ModeComboboxOptionProps) { const visuals = getModeVisuals(provider, option.id, providerDefinitions); - const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck; + const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : undefined; const leadingSlot = useMemo( - () => , + () => (IconComponent ? : null), [IconComponent, iconColor], ); return ( @@ -137,7 +150,7 @@ function AgentModeControlView({ const visuals = selectedMode ? getModeVisuals(provider, selectedMode.id, providerDefinitions) : undefined; - const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck; + const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : undefined; const iconColor = getModeIconColor(visuals?.colorTier, theme.colors.palette); const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : ""; @@ -224,7 +237,7 @@ function AgentModeControlView({ accessibilityLabel={`Select agent mode (${selectedModeLabel})`} testID="mode-control" > - + {Icon ? : null} {selectedModeLabel} diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 481d06f1a..602ee14a9 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -4264,6 +4264,7 @@ export class DaemonClient { protocolVersion: 1, capabilities: { [CLIENT_CAPS.reasoningMergeEnum]: true, + [CLIENT_CAPS.customModeIcons]: true, }, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), }), diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index e4b1161aa..2b94745ff 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -2,12 +2,10 @@ import { z } from "zod"; import type { AgentMode } from "./agent-sdk-types.js"; export type AgentModeColorTier = "safe" | "moderate" | "dangerous" | "planning" | `#${string}`; -export type AgentModeIcon = - | "Bot" - | "ShieldCheck" - | "ShieldAlert" - | "ShieldOff" - | "ShieldQuestionMark"; +// Open string by design: the client looks icons up in a registry and falls back +// to a default for unknown values. Daemon downgrades unknown icons for clients +// that pre-date the open-string contract (see CLIENT_CAPS.customModeIcons). +export type AgentModeIcon = string; export interface AgentModeVisuals { icon: AgentModeIcon; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 460d62c2e..d5d88c64c 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -324,6 +324,14 @@ type GitMutationRefreshReason = // Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject // the entire session message if they encounter an unknown provider. const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]); +// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any +// other icon name is downgraded to "ShieldCheck" for those clients. +const LEGACY_MODE_ICONS = new Set([ + "ShieldCheck", + "ShieldAlert", + "ShieldOff", + "ShieldQuestionMark", +]); const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50"; @@ -994,6 +1002,25 @@ export class Session { return this.clientCapabilities.has(capability); } + // COMPAT(customModeIcons): rewrite icons unknown to v0.1.83 clients (whose MODE_ICONS + // map is a closed enum and would render `undefined`, crashing in render). Drop + // this and the cap gate when floor >= v0.1.84. + private downgradeModeIconsForClient(modes: T[]): T[] { + if (this.supports(CLIENT_CAPS.customModeIcons)) return modes; + return modes.map((mode) => + mode.icon && !LEGACY_MODE_ICONS.has(mode.icon) ? { ...mode, icon: "ShieldCheck" } : mode, + ); + } + + private downgradeEntryModesForClient( + entries: T[], + ): T[] { + if (this.supports(CLIENT_CAPS.customModeIcons)) return entries; + return entries.map((entry) => + entry.modes ? { ...entry, modes: this.downgradeModeIconsForClient(entry.modes) } : entry, + ); + } + async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise { const descriptor = await this.describeWorkspaceRecordWithGitData(workspace); this.syncWorkspaceGitObservers([descriptor]); @@ -1244,7 +1271,7 @@ export class Session { type: "providers_snapshot_update", payload: { ...(snapshotCwd ? { cwd: snapshotCwd } : {}), - entries: visibleEntries, + entries: this.downgradeEntryModesForClient(visibleEntries), generatedAt: new Date().toISOString(), }, }); @@ -3800,7 +3827,7 @@ export class Session { type: "list_provider_modes_response", payload: { provider: msg.provider, - modes: entry.modes ?? [], + modes: this.downgradeModeIconsForClient(entry.modes ?? []), error: null, fetchedAt: entry.fetchedAt ?? fetchedAt, requestId: msg.requestId, @@ -3841,7 +3868,7 @@ export class Session { type: "list_provider_modes_response", payload: { provider: msg.provider, - modes, + modes: this.downgradeModeIconsForClient(modes), error: null, fetchedAt, requestId: msg.requestId, @@ -4065,7 +4092,7 @@ export class Session { this.emit({ type: "get_providers_snapshot_response", payload: { - entries, + entries: this.downgradeEntryModesForClient(entries), generatedAt: new Date().toISOString(), requestId: msg.requestId, }, diff --git a/packages/server/src/shared/client-capabilities.ts b/packages/server/src/shared/client-capabilities.ts index d7d3ba6ec..0b602c137 100644 --- a/packages/server/src/shared/client-capabilities.ts +++ b/packages/server/src/shared/client-capabilities.ts @@ -1,5 +1,10 @@ export const CLIENT_CAPS = { reasoningMergeEnum: "reasoning_merge_enum", + // COMPAT(customModeIcons): added in v0.1.84. Old clients pin AgentModeIcon to + // a closed enum and crash rendering unknown values; daemon downgrades icons + // outside the legacy set to "ShieldCheck" when this cap is absent. Drop the + // gate when floor >= v0.1.84. + customModeIcons: "custom_mode_icons", } as const; export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS]; diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index f50ec22f7..d7894503f 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -4053,6 +4053,7 @@ export const WSHelloMessageSchema = z.object({ voice: z.boolean().optional(), pushNotifications: z.boolean().optional(), [CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(), + [CLIENT_CAPS.customModeIcons]: z.boolean().optional(), }) .passthrough() .optional(),