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 { 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<string, ComponentType<ModeIconProps>> = {
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 size={16} color={iconColor} />,
() => (IconComponent ? <IconComponent size={16} color={iconColor} /> : 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 size={theme.iconSize.md} color={iconColor} />
{Icon ? <Icon size={theme.iconSize.md} color={iconColor} /> : null}
<Text style={labelStyle}>{selectedModeLabel}</Text>
<ChevronDown size={theme.iconSize.sm} color={iconColor} />
</Pressable>
@@ -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 } : {}),
}),
@@ -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;
+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
// 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<string>([
"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<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> {
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,
},
@@ -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];
+1
View File
@@ -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(),