Treat agent mode icons as open strings

Old clients pinned AgentModeIcon to a closed enum and rendered
undefined for unknown values, crashing the route in production
Hermes with no stack trace. Loosen the type to a plain string,
let the client skip rendering when an icon is unregistered, and
add the customModeIcons capability so the daemon downgrades
non-legacy icons to ShieldCheck for clients that pre-date the
open-string contract.
This commit is contained in:
Mohamed Boudra
2026-05-27 09:30:50 +07:00
parent a5b82d2a3b
commit 4911b627f2
6 changed files with 62 additions and 17 deletions
@@ -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 { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
@@ -33,13 +41,18 @@ function shouldRenderForPlacement(placement: AgentModeControlPlacement, isCompac
return placement === "footer" ? isCompact : !isCompact; return placement === "footer" ? isCompact : !isCompact;
} }
const MODE_ICONS = { interface ModeIconProps {
size?: number;
color?: string;
}
const MODE_ICONS: Record<string, ComponentType<ModeIconProps>> = {
Bot, Bot,
ShieldCheck, ShieldCheck,
ShieldAlert, ShieldAlert,
ShieldOff, ShieldOff,
ShieldQuestionMark, ShieldQuestionMark,
} as const; };
interface ModePalette { interface ModePalette {
blue: { 500: string }; blue: { 500: string };
@@ -87,9 +100,9 @@ function ModeComboboxOption({
iconColor, iconColor,
}: ModeComboboxOptionProps) { }: ModeComboboxOptionProps) {
const visuals = getModeVisuals(provider, option.id, providerDefinitions); 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( const leadingSlot = useMemo(
() => <IconComponent size={16} color={iconColor} />, () => (IconComponent ? <IconComponent size={16} color={iconColor} /> : null),
[IconComponent, iconColor], [IconComponent, iconColor],
); );
return ( return (
@@ -137,7 +150,7 @@ function AgentModeControlView({
const visuals = selectedMode const visuals = selectedMode
? getModeVisuals(provider, selectedMode.id, providerDefinitions) ? getModeVisuals(provider, selectedMode.id, providerDefinitions)
: undefined; : 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 iconColor = getModeIconColor(visuals?.colorTier, theme.colors.palette);
const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : ""; const selectedModeLabel = selectedMode ? formatAgentModeLabel(selectedMode) : "";
@@ -224,7 +237,7 @@ function AgentModeControlView({
accessibilityLabel={`Select agent mode (${selectedModeLabel})`} accessibilityLabel={`Select agent mode (${selectedModeLabel})`}
testID="mode-control" testID="mode-control"
> >
<Icon size={theme.iconSize.md} color={iconColor} /> {Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
<Text style={labelStyle}>{selectedModeLabel}</Text> <Text style={labelStyle}>{selectedModeLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={iconColor} /> <ChevronDown size={theme.iconSize.sm} color={iconColor} />
</Pressable> </Pressable>
@@ -4264,6 +4264,7 @@ export class DaemonClient {
protocolVersion: 1, protocolVersion: 1,
capabilities: { capabilities: {
[CLIENT_CAPS.reasoningMergeEnum]: true, [CLIENT_CAPS.reasoningMergeEnum]: true,
[CLIENT_CAPS.customModeIcons]: true,
}, },
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
}), }),
@@ -2,12 +2,10 @@ import { z } from "zod";
import type { AgentMode } from "./agent-sdk-types.js"; import type { AgentMode } from "./agent-sdk-types.js";
export type AgentModeColorTier = "safe" | "moderate" | "dangerous" | "planning" | `#${string}`; export type AgentModeColorTier = "safe" | "moderate" | "dangerous" | "planning" | `#${string}`;
export type AgentModeIcon = // Open string by design: the client looks icons up in a registry and falls back
| "Bot" // to a default for unknown values. Daemon downgrades unknown icons for clients
| "ShieldCheck" // that pre-date the open-string contract (see CLIENT_CAPS.customModeIcons).
| "ShieldAlert" export type AgentModeIcon = string;
| "ShieldOff"
| "ShieldQuestionMark";
export interface AgentModeVisuals { export interface AgentModeVisuals {
icon: AgentModeIcon; icon: AgentModeIcon;
+31 -4
View File
@@ -324,6 +324,14 @@ type GitMutationRefreshReason =
// Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject // 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. // the entire session message if they encounter an unknown provider.
const LEGACY_PROVIDER_IDS = new Set(["claude", "codex", "opencode"]); 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<string>([
"ShieldCheck",
"ShieldAlert",
"ShieldOff",
"ShieldQuestionMark",
]);
const MIN_VERSION_ALL_PROVIDERS = "0.1.45"; const MIN_VERSION_ALL_PROVIDERS = "0.1.45";
const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50"; const MIN_VERSION_FLEXIBLE_EDITOR_IDS = "0.1.50";
@@ -994,6 +1002,25 @@ export class Session {
return this.clientCapabilities.has(capability); 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<T extends { icon?: string }>(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<T extends { modes?: { icon?: string }[] }>(
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<void> { async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace); const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
this.syncWorkspaceGitObservers([descriptor]); this.syncWorkspaceGitObservers([descriptor]);
@@ -1244,7 +1271,7 @@ export class Session {
type: "providers_snapshot_update", type: "providers_snapshot_update",
payload: { payload: {
...(snapshotCwd ? { cwd: snapshotCwd } : {}), ...(snapshotCwd ? { cwd: snapshotCwd } : {}),
entries: visibleEntries, entries: this.downgradeEntryModesForClient(visibleEntries),
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
}, },
}); });
@@ -3800,7 +3827,7 @@ export class Session {
type: "list_provider_modes_response", type: "list_provider_modes_response",
payload: { payload: {
provider: msg.provider, provider: msg.provider,
modes: entry.modes ?? [], modes: this.downgradeModeIconsForClient(entry.modes ?? []),
error: null, error: null,
fetchedAt: entry.fetchedAt ?? fetchedAt, fetchedAt: entry.fetchedAt ?? fetchedAt,
requestId: msg.requestId, requestId: msg.requestId,
@@ -3841,7 +3868,7 @@ export class Session {
type: "list_provider_modes_response", type: "list_provider_modes_response",
payload: { payload: {
provider: msg.provider, provider: msg.provider,
modes, modes: this.downgradeModeIconsForClient(modes),
error: null, error: null,
fetchedAt, fetchedAt,
requestId: msg.requestId, requestId: msg.requestId,
@@ -4065,7 +4092,7 @@ export class Session {
this.emit({ this.emit({
type: "get_providers_snapshot_response", type: "get_providers_snapshot_response",
payload: { payload: {
entries, entries: this.downgradeEntryModesForClient(entries),
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
requestId: msg.requestId, requestId: msg.requestId,
}, },
@@ -1,5 +1,10 @@
export const CLIENT_CAPS = { export const CLIENT_CAPS = {
reasoningMergeEnum: "reasoning_merge_enum", 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; } as const;
export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS]; export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS];
+1
View File
@@ -4053,6 +4053,7 @@ export const WSHelloMessageSchema = z.object({
voice: z.boolean().optional(), voice: z.boolean().optional(),
pushNotifications: z.boolean().optional(), pushNotifications: z.boolean().optional(),
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(), [CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
}) })
.passthrough() .passthrough()
.optional(), .optional(),