From 1323cb13c4a65ffc3a278a2fcc9727c9d348e31c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 18 Mar 2026 22:31:31 +0700 Subject: [PATCH] Add agent mode visuals (icons and color tiers) to UI components --- .../agent-form/agent-form-dropdowns.tsx | 22 +++- .../app/src/components/agent-input-area.tsx | 12 +- .../app/src/components/agent-status-bar.tsx | 114 +++++++++++++++--- packages/app/src/components/message-input.tsx | 28 ++--- packages/app/src/components/ui/combobox.tsx | 25 ++-- packages/app/src/panels/draft-panel.tsx | 4 +- .../workspace/workspace-desktop-tabs-row.tsx | 4 +- .../screens/workspace/workspace-screen.tsx | 4 +- .../src/server/agent/provider-manifest.ts | 81 +++++-------- 9 files changed, 190 insertions(+), 104 deletions(-) diff --git a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx index dfbdeb17b..3c754cbb2 100644 --- a/packages/app/src/components/agent-form/agent-form-dropdowns.tsx +++ b/packages/app/src/components/agent-form/agent-form-dropdowns.tsx @@ -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 = { + ShieldCheck, + ShieldAlert, + ShieldOff, +}; + +const MODE_COLOR_MAP: Record = { + 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 ( @@ -603,7 +621,7 @@ export function AgentConfigRow({ placeholder="Default" disabled={disabled || modeOptions.length === 0} onSelect={onSelectMode} - icon={} + icon={} showLabel={false} testID="draft-mode-select" /> diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index abec6dd01..71f38fe24 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -699,7 +699,7 @@ export function AgentInputArea({ {isCancellingAgent ? ( ) : ( - + )} @@ -728,7 +728,7 @@ export function AgentInputArea({ {voice?.isVoiceSwitching ? ( ) : ( - + )} @@ -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], diff --git a/packages/app/src/components/agent-status-bar.tsx b/packages/app/src/components/agent-status-bar.tsx index ccd6a9c5c..7fdd57111 100644 --- a/packages/app/src/components/agent-status-bar.tsx +++ b/packages/app/src/components/agent-status-bar.tsx @@ -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 ( + } + /> + ) + }, + [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" > - {displayMode} - + {ModeIconComponent ? ( + + ) : ( + + )} ) : null} @@ -355,19 +417,27 @@ function ControlledStatusBar({ accessibilityLabel="Select agent mode" testID="agent-preferences-mode" > + {ModeIconComponent ? ( + + ) : null} {displayMode} - {modeOptions.map((mode) => ( - onSelectMode?.(mode.id)} - > - {mode.label} - - ))} + {modeOptions.map((mode) => { + const visuals = getModeVisuals(provider, mode.id) + const Icon = visuals?.icon ? MODE_ICONS[visuals.icon] : ShieldCheck + return ( + onSelectMode?.(mode.id)} + leading={} + > + {mode.label} + + ) + })} @@ -504,6 +574,7 @@ export function AgentStatusBar({ agentId, serverId }: AgentStatusBarProps) { return ( 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(() => { @@ -592,6 +666,7 @@ export function DraftAgentStatusBar({ return ( 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, }, diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index d7d5cfff9..70a636456 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -946,7 +946,7 @@ export const MessageInput = forwardRef(funct accessibilityRole="button" style={[styles.attachButton, (!isConnected || disabled) && styles.buttonDisabled]} > - + Attach images @@ -979,11 +979,11 @@ export const MessageInput = forwardRef(funct ]} > {isDictating ? ( - + ) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? ( - + ) : ( - + )} @@ -1012,7 +1012,7 @@ export const MessageInput = forwardRef(funct accessibilityRole="button" style={[styles.queueButton, (!isConnected || disabled) && styles.buttonDisabled]} > - + @@ -1034,7 +1034,7 @@ export const MessageInput = forwardRef(funct {isSubmitLoading ? ( ) : ( - + )} @@ -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', diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 648ec3859..661893178 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -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 ? ( + {leadingSlot} + ) : kind === 'directory' || kind === 'file' ? ( + + {kind === 'directory' ? ( + + ) : ( + + )} + + ) : null + return ( - {kind === 'directory' || kind === 'file' ? ( - - {kind === 'directory' ? ( - - ) : ( - - )} - - ) : null} + {leadingContent} {label} diff --git a/packages/app/src/panels/draft-panel.tsx b/packages/app/src/panels/draft-panel.tsx index eb1cdcda0..499a7b8cd 100644 --- a/packages/app/src/panels/draft-panel.tsx +++ b/packages/app/src/panels/draft-panel.tsx @@ -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, }; } diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 626f03d0a..5b15908ba 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -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, ]} > - + diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index ecc6ac734..3195edeaa 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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, ]} > - + diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index 48add004f..b9ceab433 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -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 }; +}