Skip to content

Commit bb7a117

Browse files
committed
feat(happy-app): metadata-driven model/mode selection with sync mode hacks
1 parent ad2052b commit bb7a117

19 files changed

+907
-331
lines changed
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
# Metadata-Driven Model and Mode Selection on Client
2+
3+
## Overview
4+
- Make the client use backend-provided metadata fields (`metadata.models[]`, `metadata.operatingModes[]`, `metadata.currentModelCode`, `metadata.currentOperatingModeCode`) for model and mode selection in active sessions, instead of hardcoding options per agent type
5+
- Principle: if metadata provides options, use them; otherwise fall back to hardcoded defaults
6+
- Models: always prefer metadata when available (all agent types)
7+
- Modes: hardcoded for claude/codex, metadata-driven for others when available
8+
- Send model selection via message meta for all agent types (not just Gemini)
9+
- Both `ModelMode` and `PermissionMode` become structured types `{ key: string; name: string; description?: string | null }` — UI shows `name` everywhere, `key` is the value sent to backend/stored
10+
11+
## Context (from discovery)
12+
- Backend already emits `config_options_update`, `modes_update`, `models_update` events and populates `metadata.models[]`, `metadata.operatingModes[]`, `metadata.currentModelCode`, `metadata.currentOperatingModeCode`
13+
- Metadata shape: `{ code: string; value: string; description?: string | null }` — maps as `{ key: code, name: value, description }`
14+
- Frontend currently hardcodes: Claude (sonnet/opus), Codex (gpt-5-*), Gemini (gemini-2.5-*)
15+
- `ModelMode` type is currently a flat string union
16+
- `PermissionMode` type is currently a flat string union (`'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo'`)
17+
- `Session.modelMode` is restricted to `'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite'`
18+
- `Session.permissionMode` is restricted to the `PermissionMode` string union
19+
- `updateSessionModelMode()` and `updateSessionPermissionMode()` in storage only accept those specific strings
20+
- `sendMessage()` only sends model in meta for Gemini sessions
21+
- Both mode and model selectors are hardcoded per agent type in AgentInput.tsx
22+
23+
## Files involved
24+
| File | Change |
25+
|------|--------|
26+
| `packages/happy-app/sources/components/PermissionModeSelector.tsx` | Change both `ModelMode` and `PermissionMode` to `{ key, name, description }` struct |
27+
| `packages/happy-app/sources/sync/storageTypes.ts` | Change `Session.modelMode` and `Session.permissionMode` to `string | null` (store key only) |
28+
| `packages/happy-app/sources/sync/storage.ts` | Widen both `updateSessionModelMode()` and `updateSessionPermissionMode()` to accept `string` |
29+
| `packages/happy-app/sources/components/AgentInput.tsx` | Accept structs for both, render from metadata or hardcoded, show `name` in UI |
30+
| `packages/happy-app/sources/-session/SessionView.tsx` | Build structs from metadata for both model and mode, pass to AgentInput |
31+
| `packages/happy-app/sources/sync/sync.ts` | Send model key in meta for all agent types, send permission mode key |
32+
| `packages/happy-app/sources/sync/typesMessageMeta.ts` | Ensure meta schema accepts any string for permissionMode |
33+
| `packages/happy-app/sources/app/(app)/new/index.tsx` | Adapt to structs for both model and mode |
34+
35+
## Development Approach
36+
- **Testing approach**: Regular (code first, then tests)
37+
- Complete each task fully before moving to the next
38+
- Make small, focused changes
39+
- **CRITICAL: every task MUST include new/updated tests** for code changes in that task
40+
- **CRITICAL: all tests must pass before starting next task**
41+
- **CRITICAL: update this plan file when scope changes during implementation**
42+
- Run tests after each change
43+
- Maintain backward compatibility
44+
45+
## Testing Strategy
46+
- **Unit tests**: required for every task (see Development Approach above)
47+
48+
## Progress Tracking
49+
- Mark completed items with `[x]` immediately when done
50+
- Add newly discovered tasks with ➕ prefix
51+
- Document issues/blockers with ⚠️ prefix
52+
- Update plan if implementation deviates from original scope
53+
- Keep plan in sync with actual work done
54+
- ⚠️ `yarn workspace happy-app lint` is unavailable because the package has no `lint` script
55+
56+
## Implementation Steps
57+
58+
### Task 1: Change ModelMode and PermissionMode types to structs
59+
- [x] Change `ModelMode` type in `PermissionModeSelector.tsx` from flat string union to `{ key: string; name: string; description?: string | null }`
60+
- [x] Change `PermissionMode` type in `PermissionModeSelector.tsx` from flat string union to `{ key: string; name: string; description?: string | null }`
61+
- [x] Change `Session.modelMode` in `storageTypes.ts` from hardcoded union to `string | null` (stores key only)
62+
- [x] Change `Session.permissionMode` in `storageTypes.ts` from hardcoded union to `string | null` (stores key only)
63+
- [x] Change `updateSessionModelMode()` in `storage.ts` to accept `string`
64+
- [x] Change `updateSessionPermissionMode()` in `storage.ts` to accept `string`
65+
- [x] Update `permissionMode` in `MessageMetaSchema` in `typesMessageMeta.ts` from enum to `z.string()` (keys are now arbitrary strings)
66+
- [x] Fix TypeScript compilation errors from type changes across the codebase
67+
- [x] Run tests - must pass before next task
68+
69+
### Task 2: Build hardcoded PermissionMode and ModelMode struct lists
70+
- [x] Create helper constants for hardcoded Claude permission modes as struct arrays: `[{ key: 'default', name: 'Default' }, { key: 'acceptEdits', name: 'Accept Edits' }, ...]`
71+
- [x] Create helper constants for hardcoded Codex/Gemini permission modes as struct arrays: `[{ key: 'default', name: 'Default' }, { key: 'read-only', name: 'Read Only' }, ...]`
72+
- [x] Create helper constants for hardcoded model modes per agent type (Claude: sonnet/opus, Codex: gpt-5-*, Gemini: gemini-2.5-*)
73+
- [x] Mode `name` is simply the `key` capitalized (e.g. `"plan"``"Plan"`, `"build"``"Build"`) — no translation keys needed for mode names
74+
- [x] Write tests for struct constants having correct keys and names
75+
- [x] Run tests - must pass before next task
76+
77+
### Task 3: Update SessionView to build structs from metadata
78+
- [x] Build `ModelMode` struct for current model: look up `session.modelMode` key in `metadata.models[]`, fall back to `metadata.currentModelCode`, or null
79+
- [x] Build `PermissionMode` struct for current mode: look up `session.permissionMode` key in `metadata.operatingModes[]` (for non-claude/codex) or hardcoded list (for claude/codex)
80+
- [x] Build `availableModels` list: use `metadata.models[]` if non-empty, else hardcoded fallback for known agents
81+
- [x] Build `availableModes` list: use hardcoded for claude/codex, use `metadata.operatingModes[]` for others if non-empty
82+
- [x] Update `updateModelMode` callback: extract `key` from struct, call `updateSessionModelMode(key)`
83+
- [x] Update `updatePermissionMode` callback: extract `key` from struct, call `updateSessionPermissionMode(key)`
84+
- [x] Pass structs and available lists to `AgentInput`
85+
- [x] Write tests for struct construction from metadata
86+
- [x] Write tests for fallback when metadata is empty
87+
- [x] Run tests - must pass before next task
88+
89+
### Task 4: Update AgentInput to render from structs
90+
- [x] Update props: `modelMode``ModelMode | null`, `permissionMode``PermissionMode | null`
91+
- [x] Add `availableModels` and `availableModes` props (arrays of structs)
92+
- [x] In model section: render from `availableModels`, show `name` as label, `description` as subtitle, compare by `key`
93+
- [x] In mode section: render from `availableModes`, show `name` as label, compare by `key`
94+
- [x] On model selection: call `onModelModeChange(struct)` with full struct
95+
- [x] On mode selection: call `onPermissionModeChange(struct)` with full struct
96+
- [x] Update status bar: show `modelMode.name` and `permissionMode.name` instead of hardcoded label lookups
97+
- [x] Update keyboard shortcut (Shift+Tab) to cycle through `availableModes` structs
98+
- [x] Write tests for rendering from struct arrays
99+
- [x] Write tests for selection callbacks passing full structs
100+
- [x] Run tests - must pass before next task
101+
102+
### Task 5: Send model and mode keys in message meta for all agent types
103+
- [x] In `sync.ts` `sendMessage()`, read `session.modelMode` (key string) and send in `meta.model` when set and not `'default'` — for ALL agent types, not just Gemini
104+
- [x] Read `session.permissionMode` (key string) and send in `meta.permissionMode`
105+
- [x] Remove Gemini-specific model logic and hardcoded default fallbacks
106+
- [x] Write tests for sendMessage including model key for non-Gemini agents
107+
- [x] Write tests for sendMessage sending permission mode key
108+
- [x] Run tests - must pass before next task
109+
110+
### Task 6: Update new session wizard
111+
- [x] Update `modelMode` state to `ModelMode | null` struct
112+
- [x] Update `permissionMode` state to `PermissionMode` struct
113+
- [x] Build structs from hardcoded defaults per agent type (metadata not available at creation time)
114+
- [x] On session creation, call `updateSessionModelMode(modelMode.key)` and `updateSessionPermissionMode(permissionMode.key)`
115+
- [x] Update `lastUsedModelMode` / `lastUsedPermissionMode` settings to store/restore keys
116+
- [x] Write tests for struct construction in wizard
117+
- [x] Run tests - must pass before next task
118+
119+
### Task 7: Verify acceptance criteria
120+
- [x] Verify model selector shows `name` from metadata when metadata.models is populated
121+
- [x] Verify model selector falls back to hardcoded names when metadata is empty
122+
- [x] Verify mode selector shows `name` from metadata for non-claude/codex agents
123+
- [x] Verify mode selector shows hardcoded names for claude/codex
124+
- [x] Verify model `key` (not name) is sent in meta.model for all agent types
125+
- [x] Verify permission mode `key` (not name) is sent in meta.permissionMode
126+
- [x] Verify `name` is shown in status bar, selectors, and badges — never raw `key`
127+
- [x] Run full test suite (unit tests)
128+
- [ ] Run linter - all issues must be fixed
129+
130+
### Task 8: [Final] Update documentation
131+
- [x] Update README.md if needed
132+
- [x] Update project knowledge docs if new patterns discovered
133+
134+
## Technical Details
135+
136+
### Shared struct type for both Model and Mode
137+
138+
```typescript
139+
// Both ModelMode and PermissionMode use the same shape
140+
type ModelMode = {
141+
key: string; // Technical ID sent to backend (e.g. "gemini-2.5-pro")
142+
name: string; // Display name shown in UI (e.g. "Gemini 2.5 Pro")
143+
description?: string | null; // Optional subtitle (e.g. "Most capable")
144+
};
145+
146+
type PermissionMode = {
147+
key: string; // Technical ID sent to backend (e.g. "plan", "build")
148+
name: string; // Display name = key capitalized (e.g. "Plan", "Build")
149+
description?: string | null; // Optional subtitle
150+
};
151+
```
152+
153+
### Mapping from metadata
154+
155+
```typescript
156+
// metadata.models[] → ModelMode[]
157+
metadata.models.map(m => ({
158+
key: m.code,
159+
name: m.value,
160+
description: m.description
161+
}))
162+
163+
// metadata.operatingModes[] → PermissionMode[]
164+
metadata.operatingModes.map(m => ({
165+
key: m.code,
166+
name: m.value,
167+
description: m.description
168+
}))
169+
```
170+
171+
### Hardcoded fallbacks (examples)
172+
173+
```typescript
174+
// Claude permission modes — name is just key capitalized
175+
const CLAUDE_PERMISSION_MODES: PermissionMode[] = [
176+
{ key: 'default', name: 'Default' },
177+
{ key: 'acceptEdits', name: 'Accept Edits' },
178+
{ key: 'plan', name: 'Plan' },
179+
{ key: 'bypassPermissions', name: 'Bypass Permissions' },
180+
];
181+
182+
// Gemini models (fallback when metadata not available)
183+
const GEMINI_MODELS: ModelMode[] = [
184+
{ key: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', description: 'Most capable' },
185+
{ key: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', description: 'Fast & efficient' },
186+
{ key: 'gemini-2.5-flash-lite', name: 'Gemini 2.5 Flash Lite', description: 'Fastest' },
187+
];
188+
```
189+
190+
### Storage: stores key only
191+
192+
```typescript
193+
// storageTypes.ts
194+
Session.modelMode?: string | null; // Key only (e.g. "gemini-2.5-pro")
195+
Session.permissionMode?: string | null; // Key only (e.g. "acceptEdits")
196+
197+
// storage.ts
198+
updateSessionModelMode(sessionId: string, key: string)
199+
updateSessionPermissionMode(sessionId: string, key: string)
200+
```
201+
202+
### Data flow (after changes)
203+
204+
```
205+
Backend emits metadata:
206+
metadata.models = [{ code: "gemini-2.5-pro", value: "Gemini 2.5 Pro", description: "Most capable" }, ...]
207+
metadata.currentModelCode = "gemini-2.5-pro"
208+
metadata.operatingModes = [{ code: "default", value: "Default" }, ...]
209+
metadata.currentOperatingModeCode = "default"
210+
211+
SessionView builds structs:
212+
modelMode = { key: "gemini-2.5-pro", name: "Gemini 2.5 Pro", description: "Most capable" }
213+
permissionMode = { key: "default", name: "Default", description: null }
214+
availableModels = metadata.models → ModelMode[]
215+
availableModes = hardcoded (claude/codex) OR metadata.operatingModes → PermissionMode[]
216+
217+
AgentInput renders:
218+
Shows "Gemini 2.5 Pro" (name) in model selector and status bar
219+
Shows "Default" (name) in mode selector and status bar
220+
On selection → calls onChange with full { key, name, description } struct
221+
222+
SessionView handles change:
223+
Extracts key → calls updateSessionModelMode("gemini-2.5-pro")
224+
Extracts key → calls updateSessionPermissionMode("acceptEdits")
225+
226+
sendMessage():
227+
Reads session.modelMode ("gemini-2.5-pro") → sends as meta.model (ALL agents)
228+
Reads session.permissionMode ("acceptEdits") → sends as meta.permissionMode
229+
```
230+
231+
## Post-Completion
232+
233+
**Manual verification:**
234+
- Test with a Gemini ACP session to verify metadata-driven model and mode selectors show names
235+
- Test with a Claude session to verify hardcoded mode names are preserved
236+
- Test with a custom ACP agent that provides metadata to verify dynamic rendering
237+
- Verify keys (not names) are sent in message meta and received by backend
238+
- Verify names (not keys) are shown in all UI locations (selectors, status bar, badges)

packages/happy-app/sources/-session/SessionView.tsx

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { AgentContentView } from '@/components/AgentContentView';
22
import { AgentInput } from '@/components/AgentInput';
3+
import {
4+
getAvailableModels,
5+
getAvailablePermissionModes,
6+
getDefaultModelKey,
7+
getDefaultPermissionModeKey,
8+
resolveCurrentOption,
9+
} from '@/components/modelModeOptions';
310
import { getSuggestions } from '@/components/autocomplete/suggestions';
411
import { ChatHeaderView } from '@/components/ChatHeaderView';
512
import { ChatList } from '@/components/ChatList';
@@ -29,6 +36,7 @@ import { useMemo } from 'react';
2936
import { ActivityIndicator, Platform, Pressable, Text, View } from 'react-native';
3037
import { useSafeAreaInsets } from 'react-native-safe-area-context';
3138
import { useUnistyles } from 'react-native-unistyles';
39+
import type { ModelMode, PermissionMode } from '@/components/PermissionModeSelector';
3240

3341
export const SessionView = React.memo((props: { id: string }) => {
3442
const sessionId = props.id;
@@ -166,11 +174,29 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session:
166174
const isCliOutdated = cliVersion && !isVersionSupported(cliVersion, MINIMUM_CLI_VERSION);
167175
const isAcknowledged = machineId && acknowledgedCliVersions[machineId] === cliVersion;
168176
const shouldShowCliWarning = isCliOutdated && !isAcknowledged;
169-
// Get permission mode from session object, default to 'default'
170-
const permissionMode = session.permissionMode || 'default';
171-
// Get model mode from session object - for Gemini sessions use explicit model, default to gemini-2.5-pro
172-
const isGeminiSession = session.metadata?.flavor === 'gemini';
173-
const modelMode = session.modelMode || (isGeminiSession ? 'gemini-2.5-pro' : 'default');
177+
const flavor = session.metadata?.flavor;
178+
const availableModels = React.useMemo(() => (
179+
getAvailableModels(flavor, session.metadata, t)
180+
), [flavor, session.metadata]);
181+
const availableModes = React.useMemo(() => (
182+
getAvailablePermissionModes(flavor, session.metadata, t)
183+
), [flavor, session.metadata]);
184+
185+
const permissionMode = React.useMemo<PermissionMode | null>(() => (
186+
resolveCurrentOption(availableModes, [
187+
session.permissionMode,
188+
session.metadata?.currentOperatingModeCode,
189+
getDefaultPermissionModeKey(flavor),
190+
])
191+
), [availableModes, session.permissionMode, session.metadata?.currentOperatingModeCode, flavor]);
192+
193+
const modelMode = React.useMemo<ModelMode | null>(() => (
194+
resolveCurrentOption(availableModels, [
195+
session.modelMode,
196+
session.metadata?.currentModelCode,
197+
getDefaultModelKey(flavor),
198+
])
199+
), [availableModels, session.modelMode, session.metadata?.currentModelCode, flavor]);
174200
const sessionStatus = useSessionStatus(session);
175201
const sessionUsage = useSessionUsage(sessionId);
176202
const alwaysShowContextSize = useSetting('alwaysShowContextSize');
@@ -192,13 +218,12 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session:
192218
}, [machineId, cliVersion, acknowledgedCliVersions]);
193219

194220
// Function to update permission mode
195-
const updatePermissionMode = React.useCallback((mode: 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' | 'read-only' | 'safe-yolo' | 'yolo') => {
196-
storage.getState().updateSessionPermissionMode(sessionId, mode);
221+
const updatePermissionMode = React.useCallback((mode: PermissionMode) => {
222+
storage.getState().updateSessionPermissionMode(sessionId, mode.key);
197223
}, [sessionId]);
198224

199-
// Function to update model mode (for Gemini sessions)
200-
const updateModelMode = React.useCallback((mode: 'default' | 'gemini-2.5-pro' | 'gemini-2.5-flash' | 'gemini-2.5-flash-lite') => {
201-
storage.getState().updateSessionModelMode(sessionId, mode);
225+
const updateModelMode = React.useCallback((mode: ModelMode) => {
226+
storage.getState().updateSessionModelMode(sessionId, mode.key);
202227
}, [sessionId]);
203228

204229
// Memoize header-dependent styles to prevent re-renders
@@ -280,8 +305,10 @@ function SessionViewLoaded({ sessionId, session }: { sessionId: string, session:
280305
sessionId={sessionId}
281306
permissionMode={permissionMode}
282307
onPermissionModeChange={updatePermissionMode}
283-
modelMode={modelMode as any}
284-
onModelModeChange={updateModelMode as any}
308+
availableModes={availableModes}
309+
modelMode={modelMode}
310+
availableModels={availableModels}
311+
onModelModeChange={updateModelMode}
285312
metadata={session.metadata}
286313
connectionStatus={{
287314
text: sessionStatus.statusText,

0 commit comments

Comments
 (0)