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