Add agent mode visuals (icons and color tiers) to UI components

This commit is contained in:
Mohamed Boudra
2026-03-18 22:31:31 +07:00
parent 3287f2d00b
commit 1323cb13c4
9 changed files with 190 additions and 104 deletions
@@ -17,7 +17,7 @@ import {
BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, Shield } from "lucide-react-native";
import { ChevronDown, ChevronRight, Pencil, Check, X, Bot, Brain, ShieldCheck, ShieldAlert, ShieldOff } from "lucide-react-native";
import { theme as defaultTheme } from "@/styles/theme";
import type {
AgentMode,
@@ -25,7 +25,21 @@ import type {
AgentProvider,
} from "@server/server/agent/agent-sdk-types";
import type { AgentProviderDefinition } from "@server/server/agent/provider-manifest";
import { getModeVisuals, type AgentModeIcon } from "@server/server/agent/provider-manifest";
import { Combobox, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox";
import { baseColors } from "@/styles/theme";
const MODE_ICON_MAP: Record<AgentModeIcon, typeof ShieldCheck> = {
ShieldCheck,
ShieldAlert,
ShieldOff,
};
const MODE_COLOR_MAP: Record<string, string> = {
safe: baseColors.green[500],
moderate: baseColors.amber[500],
dangerous: baseColors.red[500],
};
type DropdownTriggerRenderProps = {
label: string;
@@ -563,6 +577,10 @@ export function AgentConfigRow({
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
const selectedModeVisuals = getModeVisuals(selectedProvider, effectiveSelectedMode);
const ModeIcon = MODE_ICON_MAP[selectedModeVisuals?.icon ?? "ShieldCheck"];
const modeIconColor = MODE_COLOR_MAP[selectedModeVisuals?.colorTier ?? "safe"];
return (
<View style={styles.agentConfigRow}>
<View style={styles.agentConfigColumn}>
@@ -603,7 +621,7 @@ export function AgentConfigRow({
placeholder="Default"
disabled={disabled || modeOptions.length === 0}
onSelect={onSelectMode}
icon={<Shield size={defaultTheme.iconSize.md} color={defaultTheme.colors.foregroundMuted} />}
icon={<ModeIcon size={defaultTheme.iconSize.md} color={modeIconColor} />}
showLabel={false}
testID="draft-mode-select"
/>
@@ -699,7 +699,7 @@ export function AgentInputArea({
{isCancellingAgent ? (
<ActivityIndicator size="small" color="white" />
) : (
<Square size={theme.iconSize.lg} color="white" fill="white" />
<Square size={theme.iconSize.md} color="white" fill="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -728,7 +728,7 @@ export function AgentInputArea({
{voice?.isVoiceSwitching ? (
<ActivityIndicator size="small" color="white" />
) : (
<AudioLines size={theme.iconSize.lg} color={theme.colors.foreground} />
<AudioLines size={theme.iconSize.md} color={theme.colors.foreground} />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -885,8 +885,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({
zIndex: 30,
},
cancelButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.palette.red[600],
alignItems: 'center',
@@ -898,8 +898,8 @@ const styles = StyleSheet.create(((theme: Theme) => ({
gap: theme.spacing[2],
},
realtimeVoiceButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface0,
borderWidth: theme.borderWidth[1],
@@ -1,7 +1,14 @@
import { useCallback, useMemo, useRef, useState } from 'react'
import { View, Text, Platform, Pressable } from 'react-native'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { Brain, ChevronDown, SlidersHorizontal } from 'lucide-react-native'
import {
Brain,
ChevronDown,
ShieldAlert,
ShieldCheck,
ShieldOff,
SlidersHorizontal,
} from 'lucide-react-native'
import { useQuery } from '@tanstack/react-query'
import { useSessionStore } from '@/stores/session-store'
import {
@@ -10,7 +17,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
import { Combobox, ComboboxItem, type ComboboxOption } from '@/components/ui/combobox'
import { AdaptiveModalSheet } from '@/components/adaptive-modal-sheet'
import type {
AgentMode,
@@ -18,6 +25,11 @@ import type {
AgentProvider,
} from '@server/server/agent/agent-sdk-types'
import type { AgentProviderDefinition } from '@server/server/agent/provider-manifest'
import {
getModeVisuals,
type AgentModeColorTier,
type AgentModeIcon,
} from '@server/server/agent/provider-manifest'
import { normalizeModelId, resolveAgentModelSelection } from '@/components/agent-status-bar.utils'
type StatusOption = {
@@ -26,6 +38,7 @@ type StatusOption = {
}
type ControlledAgentStatusBarProps = {
provider: string
providerOptions?: StatusOption[]
selectedProviderId?: string
onSelectProvider?: (providerId: string) => void
@@ -72,7 +85,31 @@ function findOptionLabel(options: StatusOption[] | undefined, selectedId: string
return selected?.label ?? fallback
}
const MODE_ICONS = {
ShieldCheck,
ShieldAlert,
ShieldOff,
} as const
function getModeIconColor(
colorTier: AgentModeColorTier | undefined,
palette: { green: { 500: string }; amber: { 500: string }; red: { 500: string } }
): string {
switch (colorTier) {
case 'safe':
return palette.green[500]
case 'moderate':
return palette.amber[500]
case 'dangerous':
return palette.red[500]
default:
return palette.green[500]
}
}
function ControlledStatusBar({
provider,
providerOptions,
selectedProviderId,
onSelectProvider,
@@ -113,6 +150,10 @@ function ControlledStatusBar({
: findOptionLabel(modelOptions, selectedModelId, 'Auto')
const displayThinking = findOptionLabel(thinkingOptions, selectedThinkingOptionId, 'auto')
const modeVisuals = selectedModeId ? getModeVisuals(provider, selectedModeId) : undefined
const ModeIconComponent = modeVisuals?.icon ? MODE_ICONS[modeVisuals.icon] : null
const modeIconColor = getModeIconColor(modeVisuals?.colorTier, theme.colors.palette)
const hasAnyControl =
Boolean(providerOptions?.length) ||
Boolean(modeOptions?.length) ||
@@ -144,6 +185,23 @@ function ControlledStatusBar({
[thinkingOptions]
)
const renderModeOption = useCallback(
({ option, selected, active, onPress }: { option: ComboboxOption; selected: boolean; active: boolean; onPress: () => void }) => {
const visuals = getModeVisuals(provider, option.id)
const IconComponent = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
return (
<ComboboxItem
label={option.label}
selected={selected}
active={active}
onPress={onPress}
leadingSlot={<IconComponent size={16} color={theme.colors.foreground} />}
/>
)
},
[provider, theme.colors.foreground]
)
const handleOpenChange = useCallback(
(selector: 'provider' | 'mode' | 'model' | 'thinking') => (nextOpen: boolean) => {
setOpenSelector(nextOpen ? selector : null)
@@ -196,17 +254,20 @@ function ControlledStatusBar({
disabled={disabled || !canSelectMode}
onPress={() => setOpenSelector(openSelector === 'mode' ? null : 'mode')}
style={({ pressed, hovered }) => [
styles.modeBadge,
styles.modeIconBadge,
hovered && styles.modeBadgeHovered,
(pressed || openSelector === 'mode') && styles.modeBadgePressed,
(disabled || !canSelectMode) && styles.disabledBadge,
]}
accessibilityRole="button"
accessibilityLabel="Select agent mode"
accessibilityLabel={`Select agent mode (${displayMode})`}
testID="agent-mode-selector"
>
<Text style={styles.modeBadgeText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : (
<ShieldCheck size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
)}
</Pressable>
<Combobox
options={comboboxModeOptions}
@@ -217,6 +278,7 @@ function ControlledStatusBar({
onOpenChange={handleOpenChange('mode')}
anchorRef={modeAnchorRef}
desktopPlacement="top-start"
renderOption={renderModeOption}
/>
</>
) : null}
@@ -355,19 +417,27 @@ function ControlledStatusBar({
accessibilityLabel="Select agent mode"
testID="agent-preferences-mode"
>
{ModeIconComponent ? (
<ModeIconComponent size={theme.iconSize.md} color={modeIconColor} />
) : null}
<Text style={styles.sheetSelectText}>{displayMode}</Text>
<ChevronDown size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="start">
{modeOptions.map((mode) => (
<DropdownMenuItem
key={mode.id}
selected={mode.id === selectedModeId}
onSelect={() => onSelectMode?.(mode.id)}
>
{mode.label}
</DropdownMenuItem>
))}
{modeOptions.map((mode) => {
const visuals = getModeVisuals(provider, mode.id)
const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck
return (
<DropdownMenuItem
key={mode.id}
selected={mode.id === selectedModeId}
onSelect={() => onSelectMode?.(mode.id)}
leading={<Icon size={16} color={theme.colors.foreground} />}
>
{mode.label}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
</View>
@@ -504,6 +574,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) {
return (
<ControlledStatusBar
provider={agent.provider}
modeOptions={
modeOptions.length > 0
? modeOptions
@@ -571,7 +642,10 @@ export function DraftAgentStatusBar({
if (modeOptions.length === 0) {
return [{ id: '', label: 'Default' }]
}
return modeOptions.map((mode) => ({ id: mode.id, label: mode.label }))
return modeOptions.map((mode) => ({
id: mode.id,
label: mode.label,
}))
}, [modeOptions])
const modelOptions = useMemo<StatusOption[]>(() => {
@@ -592,6 +666,7 @@ export function DraftAgentStatusBar({
return (
<ControlledStatusBar
provider={selectedProvider}
providerOptions={providerOptions}
selectedProviderId={selectedProvider}
onSelectProvider={(providerId) => onSelectProvider(providerId as AgentProvider)}
@@ -625,6 +700,13 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius['2xl'],
},
modeIconBadge: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
padding: theme.spacing[1],
borderRadius: theme.borderRadius.full,
},
modeBadgeHovered: {
backgroundColor: theme.colors.surface2,
},
+14 -14
View File
@@ -946,7 +946,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
accessibilityRole="button"
style={[styles.attachButton, (!isConnected || disabled) && styles.buttonDisabled]}
>
<Paperclip size={theme.iconSize.lg} color={theme.colors.foreground} />
<Paperclip size={theme.iconSize.md} color={theme.colors.foreground} />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text>
@@ -979,11 +979,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
]}
>
{isDictating ? (
<Square size={theme.iconSize.lg} color="white" fill="white" />
<Square size={theme.iconSize.md} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff size={theme.iconSize.lg} color={theme.colors.foreground} />
<MicOff size={theme.iconSize.md} color={theme.colors.foreground} />
) : (
<Mic size={theme.iconSize.lg} color={theme.colors.foreground} />
<Mic size={theme.iconSize.md} color={theme.colors.foreground} />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -1012,7 +1012,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
accessibilityRole="button"
style={[styles.queueButton, (!isConnected || disabled) && styles.buttonDisabled]}
>
<Plus size={theme.iconSize.lg} color="white" />
<Plus size={theme.iconSize.md} color="white" />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
@@ -1034,7 +1034,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />
) : (
<ArrowUp size={theme.iconSize.lg} color="white" />
<ArrowUp size={theme.iconSize.md} color="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
@@ -1183,15 +1183,15 @@ const styles = StyleSheet.create(((theme: any) => ({
gap: theme.spacing[2],
},
attachButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
},
voiceButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
@@ -1200,16 +1200,16 @@ const styles = StyleSheet.create(((theme: any) => ({
backgroundColor: theme.colors.destructive,
},
queueButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface1,
alignItems: 'center',
justifyContent: 'center',
},
sendButton: {
width: 34,
height: 34,
width: 28,
height: 28,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: 'center',
+16 -9
View File
@@ -144,6 +144,7 @@ export interface ComboboxItemProps {
label: string
description?: string
kind?: 'directory' | 'file'
leadingSlot?: ReactNode
selected?: boolean
active?: boolean
onPress: () => void
@@ -154,12 +155,26 @@ export function ComboboxItem({
label,
description,
kind,
leadingSlot,
selected,
active,
onPress,
testID,
}: ComboboxItemProps): ReactElement {
const { theme } = useUnistyles()
const leadingContent = leadingSlot ? (
<View style={styles.comboboxItemLeadingSlot}>{leadingSlot}</View>
) : kind === 'directory' || kind === 'file' ? (
<View style={styles.comboboxItemLeadingSlot}>
{kind === 'directory' ? (
<Folder size={16} color={theme.colors.foregroundMuted} />
) : (
<File size={16} color={theme.colors.foregroundMuted} />
)}
</View>
) : null
return (
<Pressable
testID={testID}
@@ -171,15 +186,7 @@ export function ComboboxItem({
active && styles.comboboxItemActive,
]}
>
{kind === 'directory' || kind === 'file' ? (
<View style={styles.comboboxItemLeadingSlot}>
{kind === 'directory' ? (
<Folder size={16} color={theme.colors.foregroundMuted} />
) : (
<File size={16} color={theme.colors.foregroundMuted} />
)}
</View>
) : null}
{leadingContent}
<View style={styles.comboboxItemContent}>
<Text numberOfLines={1} style={styles.comboboxItemLabel}>
{label}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Pencil } from "lucide-react-native";
import { SquarePen } from "lucide-react-native";
import invariant from "tiny-invariant";
import { WorkspaceDraftAgentTab } from "@/screens/workspace/workspace-draft-agent-tab";
import { usePaneContext } from "@/panels/pane-context";
@@ -11,7 +11,7 @@ function useDraftPanelDescriptor() {
label: "New Agent",
subtitle: "New Agent",
titleState: "ready" as const,
icon: Pencil,
icon: SquarePen,
statusBucket: null,
};
}
@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react";
import { ActivityIndicator, Platform, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native";
import { Columns2, Plus, Rows2, SquareTerminal, X } from "lucide-react-native";
import { Columns2, Rows2, SquarePen, SquareTerminal, X } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { SortableInlineList } from "@/components/sortable-inline-list";
import {
@@ -438,7 +438,7 @@ export function WorkspaceDesktopTabsRow({
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<View style={styles.newTabTooltipRow}>
@@ -17,7 +17,7 @@ import {
Ellipsis,
EllipsisVertical,
PanelRight,
Plus,
SquarePen,
SquareTerminal,
} from "lucide-react-native";
import { GestureDetector } from "react-native-gesture-handler";
@@ -422,7 +422,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<View style={styles.newTabTooltipRow}>
@@ -1,12 +1,22 @@
import { z } from "zod";
import type { AgentMode } from "./agent-sdk-types.js";
export type AgentModeColorTier = 'safe' | 'moderate' | 'dangerous';
export type AgentModeIcon = 'ShieldCheck' | 'ShieldAlert' | 'ShieldOff';
export interface AgentModeVisuals {
icon: AgentModeIcon;
colorTier: AgentModeColorTier;
}
export interface AgentProviderModeDefinition extends AgentMode, AgentModeVisuals {}
export interface AgentProviderDefinition {
id: string;
label: string;
description: string;
defaultModeId: string | null;
modes: AgentMode[];
modes: AgentProviderModeDefinition[];
voice?: {
enabled: boolean;
defaultModeId: string;
@@ -14,60 +24,22 @@ export interface AgentProviderDefinition {
};
}
const CLAUDE_MODES: AgentMode[] = [
{
id: "default",
label: "Always Ask",
description: "Prompts for permission the first time a tool is used",
},
{
id: "acceptEdits",
label: "Accept File Edits",
description: "Automatically approves edit-focused tools without prompting",
},
{
id: "plan",
label: "Plan Mode",
description: "Analyze the codebase without executing tools or edits",
},
{
id: "bypassPermissions",
label: "Bypass",
description: "Skip all permission prompts (use with caution)",
},
const CLAUDE_MODES: AgentProviderModeDefinition[] = [
{ id: "default", label: "Always Ask", description: "Prompts for permission the first time a tool is used", icon: "ShieldCheck", colorTier: "safe" },
{ id: "acceptEdits", label: "Accept File Edits", description: "Automatically approves edit-focused tools without prompting", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "plan", label: "Plan Mode", description: "Analyze the codebase without executing tools or edits", icon: "ShieldCheck", colorTier: "safe" },
{ id: "bypassPermissions", label: "Bypass", description: "Skip all permission prompts (use with caution)", icon: "ShieldOff", colorTier: "dangerous" },
];
const CODEX_MODES: AgentMode[] = [
{
id: "read-only",
label: "Read Only",
description:
"Read files and answer questions. Manual approval required for edits, commands, or network ops.",
},
{
id: "auto",
label: "Auto",
description:
"Edit files and run commands but still request approval before escalating scope.",
},
{
id: "full-access",
label: "Full Access",
description: "Edit files, run commands, and access the network without additional prompts.",
},
const CODEX_MODES: AgentProviderModeDefinition[] = [
{ id: "read-only", label: "Read Only", description: "Read files and answer questions. Manual approval required for edits, commands, or network ops.", icon: "ShieldCheck", colorTier: "safe" },
{ id: "auto", label: "Auto", description: "Edit files and run commands but still request approval before escalating scope.", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "full-access", label: "Full Access", description: "Edit files, run commands, and access the network without additional prompts.", icon: "ShieldOff", colorTier: "dangerous" },
];
const OPENCODE_MODES: AgentMode[] = [
{
id: "build",
label: "Build",
description: "Allows edits and tool execution for implementation work",
},
{
id: "plan",
label: "Plan",
description: "Read-only planning mode that avoids file edits",
},
const OPENCODE_MODES: AgentProviderModeDefinition[] = [
{ id: "build", label: "Build", description: "Allows edits and tool execution for implementation work", icon: "ShieldAlert", colorTier: "moderate" },
{ id: "plan", label: "Plan", description: "Read-only planning mode that avoids file edits", icon: "ShieldCheck", colorTier: "safe" },
];
export const AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [
@@ -126,3 +98,10 @@ export const AgentProviderSchema = z.enum(AGENT_PROVIDER_IDS);
export function isValidAgentProvider(value: string): boolean {
return AGENT_PROVIDER_IDS.includes(value);
}
export function getModeVisuals(provider: string, modeId: string): AgentModeVisuals | undefined {
const definition = AGENT_PROVIDER_DEFINITIONS.find((entry) => entry.id === provider);
const mode = definition?.modes.find((m) => m.id === modeId);
if (!mode) return undefined;
return { icon: mode.icon, colorTier: mode.colorTier };
}