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:
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user