feat(opencode): support model selection and mid-session model change (#558)

* refactor(opencode): declare ModelChange capability and add model field to OpencodeMode

Mark opencode flavor as supporting model change by adding Capabilities.ModelChange
to FLAVOR_CAPS.opencode. Add optional `model` field to OpencodeMode so the
set-session-config handler can carry a model alongside the existing permissionMode.

Pure structural change: no behavior change yet. The mid-session model change RPC
and UI wiring follow in subsequent commits, gated by this capability.

* feat(acp): branch setModel by flavor, capture session models metadata, expose getSessionModelsMetadata on AgentBackend interface

Adds an optional `flavor` argument to `AcpSdkBackend.setModel` so it can
dispatch the right `session/*` RPC for each agent flavor without changing
the call site Gemini already uses. Both Gemini and OpenCode wire to
`session/set_model`; the OpenCode response only carries `_meta.opencode`,
so the backend updates the cached `currentModelId` optimistically while
preserving the previously captured `availableModels`.

Captures `availableModels` and `currentModelId` from `session/new` /
`session/load` / `session/set_model` responses into per-session metadata,
exposed as `getSessionModelsMetadata(sessionId)` on the `AgentBackend`
interface so the hub can forward the snapshot to the web client.

* feat(opencode): accept model in set-session-config RPC and forward to launcher

Mirror the Gemini set-session-config handler so the web UI can change the
OpenCode model mid-session. Validates incoming model strings, persists null
("Default") for keepalive metadata, and pushes a keepAlive immediately so
the hub UI reflects the change without waiting for the next 2s tick.

Forward the model through opencodeLoop and into queued OpencodeMode entries
so the launcher can detect a per-batch model change. Add a setModel helper
on OpencodeSession to store the chosen model on the shared session base.

Wire --model up the runner path: parse --model <value> in commands/opencode.ts
and stop excluding opencode in buildCliArgs so the runner spawns OpenCode
with the user-selected initial model.

* feat(opencode): switch model mid-session via ACP RPC

Mirror the Gemini pattern from PR #543: when a user picks a different model
between turns, call backend.setModel with flavor='opencode' so the ACP backend
sends session/set_session_config_option (configId='model') to the running
OpenCode CLI. The next turn then runs against the new model.

The first batch on a fresh session seeds currentBackendModel without firing the
RPC — the OpenCode CLI was launched with that model via --model and there is
nothing to switch yet. If the running build does not implement the RPC we learn
that from the first method-not-found response, latch inline switching off, and
notify the user once. Other errors fall back to the previous model and surface
a one-line failure message.

* feat(hub): expose model selection and discovery for OpenCode sessions

Generalize the /sessions/:id/model guard via supportsModelChange so any flavor
that advertises the ModelChange capability becomes accepted automatically. This
piggybacks on the capability SSOT introduced in PR #400 and turns OpenCode on
without listing flavors inline.

Add a /sessions/:id/opencode-models endpoint that mirrors the existing
codex-models pattern. The endpoint forwards a per-session listOpencodeModels
RPC to the running OpenCode launcher, which returns the availableModels and
currentModelId metadata captured from the ACP session/new and
session/set_session_config_option responses. The web UI consumes this to
render the model dropdown without round-tripping ACP itself.

* feat(web): render OpenCode model dropdown in the chat composer

Mirror the Codex pattern in SessionChat: query /sessions/:id/opencode-models
via a new useOpencodeModels hook and feed the result into the composer's
availableModelOptions. The AssistantChat model dropdown now lists the user's
ollama / mlx / OpenCode Zen models with the same provider/model label that the
ACP server reports, so the picker matches the OpenCode TUI.

Stop falling back to the Claude composer model list when the flavor is
opencode and no custom options are supplied — that fallback briefly surfaced
unrelated Claude models in OpenCode sessions before the RPC response landed.
The NewSession flow keeps an empty MODEL_OPTIONS.opencode for now: model
discovery requires an active OpenCode ACP session, so the dropdown becomes
available once the session boots and stays empty (and hidden) at creation
time.

* feat(cli,hub): add cwd-based OpenCode model discovery RPC

Adds a short-lived `opencode acp` probe that runs `initialize` +
`session/new` against a target cwd to read the `availableModels` /
`currentModelId` snapshot, then tears the subprocess down. Results are
cached for 60s per cwd and concurrent probes coalesce into a single
spawn.

Exposes the probe through:
- `listOpencodeModelsForCwd` JSON-RPC handler on the CLI
- `RpcGateway.listOpencodeModelsForCwd` / `SyncEngine.listOpencodeModelsForCwd`
- `GET /api/machines/:id/opencode-models?cwd=...` on the hub

This lets the web NewSession form discover OpenCode models for a chosen
directory before any session is spawned.

* feat(web): add OpenCode model selector to NewSession with loading and default highlight

Adds a `OpencodeModelSelector` panel that the NewSession form swaps in
when the OpenCode flavor is selected. The panel:

- queries the new `GET /api/machines/:id/opencode-models?cwd=...` endpoint
  via `useOpencodeModelsForCwd` (TanStack Query, 60s staleTime, no retry),
- shows a labelled spinner + skeleton rows while discovering,
- renders an inline error with a Retry button when probing fails,
- renders an empty-state message when the directory yields no models,
- highlights the OpenCode-reported `currentModelId` with a "Default" badge
  and auto-selects it (or the first option) so the form has a sensible
  value if the user hits Enter without scrolling.

Selection is reset whenever the agent / machine / directory changes so a
new probe can establish a fresh default. Directory input is wrapped in
`useDeferredValue` so per-keystroke edits do not spawn a fresh
`opencode acp` probe. The chosen model is forwarded on session spawn
via the existing OpenCode `model` parameter.

Adds en + zh-CN locale strings for the loading / failure / empty / retry
/ default-badge labels.

* fix(cli): guard /machines/:id/opencode-models handler with workspace root check

The machine-scoped `listOpencodeModelsForCwd` RPC handler was registered by
`registerCommonHandlers` without any workspace-root check, so a web client
could pass an arbitrary `cwd` and have the runner spawn an `opencode acp`
subprocess plus a `session/new` against that path. That broke runner
isolation, since peer machine-scoped handlers (`list-directory`,
`spawn-happy-session`) already enforce the configured workspace root.

Re-register the handler in `ApiMachineClient` so it reuses the existing
`resolveForWorkspaceCheck` (realpath-based, with missing-tail walking) and
`isWithinWorkspaceRoot` helpers before delegating to the lower-level probe.
The resolved cwd is forwarded down so symlinked-but-contained paths still
work, while traversal attempts are rejected with the same error shape the
peer handlers use. Added unit tests around the new dispatch path. Addresses
HAPI Bot review on PR #558.

* fix(web): gate opencode model discovery on cwd existence

The new-session form previously enabled `useOpencodeModelsForCwd` as
soon as the OpenCode agent, machine, and any non-empty directory string
were present. Because that hook calls `/machines/:id/opencode-models`
and the CLI handler starts an `opencode acp` probe for that cwd, normal
typing through partial paths could launch expensive 30s OpenCode
subprocesses for non-existent directories before the path-existence
result had validated the final cwd.

Reorder NewSession so `useMachinePathsExists` runs before
`useOpencodeModelsForCwd`, then gate the discovery hook on the
directory having been positively confirmed to exist
(`pathExistence[deferredDirectory] === true`). The decision is
factored into a small pure helper `shouldEnableOpencodeModelDiscovery`
so the contract can be unit-tested without provider scaffolding.

* fix(web): keep current opencode model on shortcut without dynamic options

`getNextModelForFlavor` is invoked by the global Ctrl/Cmd+M shortcut in
`HappyComposer`, which is now active for OpenCode sessions because the
agent backend declares the `ModelChange` capability. When the dynamic
OpenCode model list has not yet been loaded — e.g. the user presses the
shortcut before `/opencode-models` returns — the function received an
`undefined`/empty `customOptions` and fell through to the Claude preset
cycler, which would emit `sonnet`/`opus` for an OpenCode session. The
following turn then attempted `session/set_model` with a Claude model id
that no OpenCode provider can serve.

Add an `opencode` branch that returns the (normalized) current model
unchanged when no dynamic options are available, mirroring the existing
empty-list policy of `getModelOptionsForFlavor`. Unit tests cover the
undefined / empty / null-current-model variants and lock the
no-Claude-fallback contract. Addresses HAPI Bot review on PR #558.
This commit is contained in:
Junmo Kim
2026-05-03 12:50:22 +08:00
committed by GitHub
parent 7d55bc1456
commit 9ee014098a
41 changed files with 2093 additions and 31 deletions
+13
View File
@@ -12,6 +12,7 @@ import type {
MachinesResponse,
MessagesResponse,
CodexModelsResponse,
OpencodeModelsResponse,
PermissionMode,
PushSubscriptionPayload,
PushUnsubscribePayload,
@@ -453,6 +454,18 @@ export class ApiClient {
)
}
async getSessionOpencodeModels(sessionId: string): Promise<OpencodeModelsResponse> {
return await this.request<OpencodeModelsResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/opencode-models`
)
}
async getMachineOpencodeModelsForCwd(machineId: string, cwd: string): Promise<OpencodeModelsResponse> {
return await this.request<OpencodeModelsResponse>(
`/api/machines/${encodeURIComponent(machineId)}/opencode-models?cwd=${encodeURIComponent(cwd)}`
)
}
async getSlashCommands(sessionId: string): Promise<SlashCommandsResponse> {
return await this.request<SlashCommandsResponse>(
`/api/sessions/${encodeURIComponent(sessionId)}/slash-commands`
@@ -36,6 +36,32 @@ describe('getModelOptionsForFlavor', () => {
{ value: 'gpt-5.5', label: 'GPT-5.5' }
])
})
it('returns only the supplied custom options for opencode flavor (no claude fallback)', () => {
const options = getModelOptionsForFlavor('opencode', null, [
{ value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama (SER8)/EXAONE 4.5 33B Q8' },
{ value: 'mlx/qwen3:0.6b', label: 'MLX/Qwen3 0.6B' }
])
expect(options).toEqual([
{ value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama (SER8)/EXAONE 4.5 33B Q8' },
{ value: 'mlx/qwen3:0.6b', label: 'MLX/Qwen3 0.6B' }
])
})
it('returns an empty list for opencode flavor before models are discovered (no claude fallback)', () => {
const options = getModelOptionsForFlavor('opencode', null)
expect(options).toEqual([])
})
it('includes the current opencode model when it is missing from explicit options', () => {
const options = getModelOptionsForFlavor('opencode', 'ollama/legacy', [
{ value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama EXAONE' }
])
expect(options).toEqual([
{ value: 'ollama/legacy', label: 'ollama/legacy' },
{ value: 'ollama/exaone:4.5-33b-q8', label: 'Ollama EXAONE' }
])
})
})
describe('getNextModelForFlavor', () => {
@@ -64,4 +90,19 @@ describe('getNextModelForFlavor', () => {
])
expect(next).toBe('gpt-5.5')
})
it('keeps the current opencode model when the dynamic list has not loaded (undefined customOptions)', () => {
const next = getNextModelForFlavor('opencode', 'ollama/exaone:4.5-33b-q8')
expect(next).toBe('ollama/exaone:4.5-33b-q8')
})
it('keeps the current opencode model when the dynamic list is empty', () => {
const next = getNextModelForFlavor('opencode', 'ollama/exaone:4.5-33b-q8', [])
expect(next).toBe('ollama/exaone:4.5-33b-q8')
})
it('returns null for opencode without a current model and without dynamic options (no Claude fallback)', () => {
const next = getNextModelForFlavor('opencode', null, [])
expect(next).toBeNull()
})
})
@@ -56,6 +56,12 @@ export function getModelOptionsForFlavor(
if (flavor === 'gemini') {
return getGeminiModelOptions(currentModel)
}
// OpenCode discovers models dynamically via the listOpencodeModels RPC. Until
// those options arrive, render an empty list rather than the Claude fallback —
// the latter would surface unrelated Claude models in an OpenCode session.
if (flavor === 'opencode') {
return []
}
return getClaudeComposerModelOptions(currentModel)
}
@@ -75,5 +81,13 @@ export function getNextModelForFlavor(
if (flavor === 'gemini') {
return getNextGeminiModel(currentModel)
}
// OpenCode discovers models dynamically via the listOpencodeModels RPC. Until
// those options arrive, pressing the Ctrl/Cmd+M shortcut must not fall through
// to the Claude preset cycler — that would post `sonnet`/`opus` into an
// OpenCode session and the next turn would attempt `session/set_model` with a
// Claude id. Keep the current model unchanged instead.
if (flavor === 'opencode') {
return normalizeCurrentModel(currentModel)
}
return getNextClaudeComposerModel(currentModel)
}
@@ -0,0 +1,102 @@
import { useTranslation } from '@/lib/use-translation'
import type { OpencodeModelSummary } from '@/types/api'
export type OpencodeModelSelectorProps = {
cwd: string
machineId: string | null
isLoading: boolean
error: string | null
availableModels: OpencodeModelSummary[]
currentModelId: string | null
selectedModel: string | null
onModelChange: (modelId: string | null) => void
onRetry?: () => void
}
export function OpencodeModelSelector(props: OpencodeModelSelectorProps) {
const { t } = useTranslation()
if (!props.cwd || !props.machineId) {
return null
}
return (
<div className="flex flex-col gap-2 px-3 py-3">
<label className="text-xs font-medium text-[var(--app-hint)]">
{t('newSession.model')}{' '}
<span className="font-normal">({t('newSession.model.optional')})</span>
</label>
{props.isLoading ? (
<div className="flex flex-col gap-2" data-testid="opencode-model-loading">
<div className="flex items-center gap-2 text-xs text-[var(--app-hint)]">
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-[var(--app-divider)] border-t-[var(--app-link)]" />
<span>{t('newSession.opencodeModel.loading')}</span>
</div>
<div className="flex flex-col gap-1.5" aria-hidden="true">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="h-7 w-full animate-pulse rounded bg-[var(--app-secondary-bg)]"
/>
))}
</div>
</div>
) : props.error ? (
<div className="flex flex-col gap-2" data-testid="opencode-model-error">
<div className="text-xs text-red-600">
{t('newSession.opencodeModel.loadFailed')}: {props.error}
</div>
{props.onRetry ? (
<button
type="button"
onClick={props.onRetry}
className="self-start rounded border border-[var(--app-divider)] px-2 py-1 text-xs text-[var(--app-link)] hover:bg-[var(--app-secondary-bg)]"
>
{t('newSession.opencodeModel.retry')}
</button>
) : null}
</div>
) : props.availableModels.length === 0 ? (
<div className="text-xs text-[var(--app-hint)]" data-testid="opencode-model-empty">
{t('newSession.opencodeModel.empty')}
</div>
) : (
<div className="flex flex-col" data-testid="opencode-model-list">
{props.availableModels.map((model) => {
const isSelected = props.selectedModel === model.modelId
const isDefault = props.currentModelId === model.modelId
return (
<button
key={model.modelId}
type="button"
onClick={() => props.onModelChange(model.modelId)}
className="flex w-full items-center gap-2 rounded px-2 py-2 text-left text-sm transition-colors hover:bg-[var(--app-secondary-bg)]"
>
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 ${
isSelected
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{isSelected && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={`flex-1 truncate ${isSelected ? 'text-[var(--app-link)]' : ''}`}>
{model.name ?? model.modelId}
</span>
{isDefault ? (
<span className="rounded border border-[var(--app-divider)] px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-[var(--app-hint)]">
{t('newSession.opencodeModel.default')}
</span>
) : null}
</button>
)
})}
</div>
)}
</div>
)
}
+66 -12
View File
@@ -5,6 +5,7 @@ import { usePlatform } from '@/hooks/usePlatform'
import { useMachinePathsExists } from '@/hooks/useMachinePathsExists'
import { useSpawnSession } from '@/hooks/mutations/useSpawnSession'
import { useCodexModels } from '@/hooks/queries/useCodexModels'
import { useOpencodeModelsForCwd } from '@/hooks/queries/useOpencodeModelsForCwd'
import { useSessions } from '@/hooks/queries/useSessions'
import { useActiveSuggestions, type Suggestion } from '@/hooks/useActiveSuggestions'
import { useDirectorySuggestions } from '@/hooks/useDirectorySuggestions'
@@ -16,7 +17,9 @@ import { AgentSelector } from './AgentSelector'
import { DirectorySection } from './DirectorySection'
import { MachineSelector } from './MachineSelector'
import { ModelSelector } from './ModelSelector'
import { OpencodeModelSelector } from './OpencodeModelSelector'
import { ClaudeEffortSelector } from './ClaudeEffortSelector'
import { shouldEnableOpencodeModelDiscovery } from './opencodeModelsGate'
import { ReasoningEffortSelector } from './ReasoningEffortSelector'
import {
loadPreferredAgent,
@@ -106,6 +109,7 @@ export function NewSession(props: {
machineId,
enabled: agent === 'codex' && Boolean(machineId)
})
const [opencodeSelectedModel, setOpencodeSelectedModel] = useState<string | null>(null)
const runnerSpawnError = useMemo(
() => formatRunnerSpawnError(selectedMachine),
[selectedMachine]
@@ -148,6 +152,40 @@ export function NewSession(props: {
[allPaths, pathExistence]
)
const deferredDirectoryExists = deferredDirectory
? pathExistence[deferredDirectory]
: undefined
const opencodeModelsState = useOpencodeModelsForCwd({
api: props.api,
machineId,
cwd: deferredDirectory,
// Gate on positive existence: typing partial paths must not spawn an
// expensive `opencode acp` probe for a non-existent cwd while the
// existence check is in flight.
enabled: shouldEnableOpencodeModelDiscovery({
agent,
machineId,
cwd: deferredDirectory,
cwdExists: deferredDirectoryExists,
})
})
useEffect(() => {
// Auto-pick the OpenCode default model when discovery finishes, so the
// form has a sensible value if the user hits Enter without scrolling.
if (agent !== 'opencode') return
if (opencodeSelectedModel !== null) return
const fallback = opencodeModelsState.currentModelId
?? opencodeModelsState.availableModels[0]?.modelId
?? null
if (fallback) {
setOpencodeSelectedModel(fallback)
}
}, [agent, opencodeSelectedModel, opencodeModelsState.currentModelId, opencodeModelsState.availableModels])
useEffect(() => {
// Reset selection when agent / machine / directory changes; new probe = new defaults.
setOpencodeSelectedModel(null)
}, [agent, machineId, deferredDirectory])
const currentDirectoryExists = trimmedDirectory ? pathExistence[trimmedDirectory] : undefined
const needsDirectoryCreationWarning = sessionType === 'simple' && trimmedDirectory !== '' && currentDirectoryExists === false
const missingWorktreeDirectory = sessionType === 'worktree' && trimmedDirectory !== '' && currentDirectoryExists === false
@@ -277,7 +315,9 @@ export function NewSession(props: {
return
}
const resolvedModel = model !== 'auto' && agent !== 'opencode' ? model : undefined
const resolvedModel = agent === 'opencode'
? (opencodeSelectedModel ?? undefined)
: (model !== 'auto' ? model : undefined)
const resolvedEffort = agent === 'claude' && effort !== 'auto' ? effort : undefined
const resolvedModelReasoningEffort = agent === 'codex' && modelReasoningEffort !== 'default'
? modelReasoningEffort
@@ -355,17 +395,31 @@ export function NewSession(props: {
isDisabled={isFormDisabled}
onAgentChange={setAgent}
/>
<ModelSelector
agent={agent}
model={model}
options={agent === 'codex' ? codexModelOptions : undefined}
isDisabled={isFormDisabled || (agent === 'codex' && Boolean(codexModelsState.error))}
isLoading={agent === 'codex' && codexModelsState.isLoading}
error={agent === 'codex' && codexModelsState.error
? `${t('newSession.model.loadFailed')}: ${codexModelsState.error}`
: null}
onModelChange={setModel}
/>
{agent === 'opencode' ? (
<OpencodeModelSelector
cwd={deferredDirectory}
machineId={machineId}
isLoading={opencodeModelsState.isLoading}
error={opencodeModelsState.error}
availableModels={opencodeModelsState.availableModels}
currentModelId={opencodeModelsState.currentModelId}
selectedModel={opencodeSelectedModel}
onModelChange={setOpencodeSelectedModel}
onRetry={opencodeModelsState.refetch}
/>
) : (
<ModelSelector
agent={agent}
model={model}
options={agent === 'codex' ? codexModelOptions : undefined}
isDisabled={isFormDisabled || (agent === 'codex' && Boolean(codexModelsState.error))}
isLoading={agent === 'codex' && codexModelsState.isLoading}
error={agent === 'codex' && codexModelsState.error
? `${t('newSession.model.loadFailed')}: ${codexModelsState.error}`
: null}
onModelChange={setModel}
/>
)}
<ClaudeEffortSelector
agent={agent}
effort={effort}
@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest'
import { shouldEnableOpencodeModelDiscovery } from './opencodeModelsGate'
describe('shouldEnableOpencodeModelDiscovery', () => {
const baseArgs = {
agent: 'opencode' as const,
machineId: 'machine-1',
cwd: '/home/user/project',
cwdExists: true,
}
it('enables discovery when agent, machine, and existing cwd are present', () => {
expect(shouldEnableOpencodeModelDiscovery(baseArgs)).toBe(true)
})
it('disables discovery when cwd existence has not been confirmed yet', () => {
// pathExistence[cwd] is undefined while the existence probe is in flight
expect(
shouldEnableOpencodeModelDiscovery({ ...baseArgs, cwdExists: undefined })
).toBe(false)
})
it('disables discovery when cwd does not exist on the machine', () => {
// typing partial paths must not spawn an opencode acp probe for non-existent dirs
expect(
shouldEnableOpencodeModelDiscovery({ ...baseArgs, cwdExists: false })
).toBe(false)
})
it('disables discovery when agent is not opencode', () => {
expect(
shouldEnableOpencodeModelDiscovery({ ...baseArgs, agent: 'claude' })
).toBe(false)
})
it('disables discovery when machineId is missing', () => {
expect(
shouldEnableOpencodeModelDiscovery({ ...baseArgs, machineId: null })
).toBe(false)
})
it('disables discovery when cwd is empty', () => {
expect(
shouldEnableOpencodeModelDiscovery({ ...baseArgs, cwd: '' })
).toBe(false)
})
})
@@ -0,0 +1,23 @@
import type { AgentType } from './types'
/**
* Decide whether the new-session form should fire OpenCode model discovery
* for the current input state.
*
* Discovery is gated on the cwd having been *positively* confirmed to exist
* on the target machine. While `cwdExists` is undefined (existence probe in
* flight) or false (typing through a partial path), we suppress discovery so
* the CLI does not spawn an `opencode acp` subprocess for a non-existent
* directory only to time out 30 seconds later.
*/
export function shouldEnableOpencodeModelDiscovery(args: {
agent: AgentType
machineId: string | null
cwd: string
cwdExists: boolean | undefined
}): boolean {
if (args.agent !== 'opencode') return false
if (!args.machineId) return false
if (args.cwd.length === 0) return false
return args.cwdExists === true
}
+23 -1
View File
@@ -28,6 +28,7 @@ import { TeamPanel } from '@/components/TeamPanel'
import { usePlatform } from '@/hooks/usePlatform'
import { useSessionActions } from '@/hooks/mutations/useSessionActions'
import { useCodexModels } from '@/hooks/queries/useCodexModels'
import { useOpencodeModels } from '@/hooks/queries/useOpencodeModels'
import { useVoiceOptional } from '@/lib/voice-context'
import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime'
import { isRemoteTerminalSupported } from '@/utils/terminalSupport'
@@ -97,6 +98,21 @@ export function SessionChat(props: {
}
return options
}, [agentFlavor, codexModelsState.models])
const opencodeModelsState = useOpencodeModels({
api: props.api,
sessionId: props.session.id,
enabled: agentFlavor === 'opencode' && props.session.active
})
const opencodeModelOptions = useMemo(() => {
if (agentFlavor !== 'opencode') {
return undefined
}
return opencodeModelsState.availableModels.map((opencodeModel) => ({
value: opencodeModel.modelId,
label: opencodeModel.name ?? opencodeModel.modelId
}))
}, [agentFlavor, opencodeModelsState.availableModels])
const {
abortSession,
switchSession,
@@ -451,7 +467,13 @@ export function SessionChat(props: {
modelReasoningEffort={agentFlavor === 'codex' ? props.session.modelReasoningEffort : undefined}
effort={props.session.effort}
agentFlavor={agentFlavor}
availableModelOptions={agentFlavor === 'codex' ? codexModelOptions : undefined}
availableModelOptions={
agentFlavor === 'codex'
? codexModelOptions
: agentFlavor === 'opencode'
? opencodeModelOptions
: undefined
}
active={props.session.active}
allowSendWhenInactive
thinking={props.session.thinking}
@@ -0,0 +1,49 @@
import { useQuery } from '@tanstack/react-query'
import type { ApiClient } from '@/api/client'
import type { OpencodeModelSummary } from '@/types/api'
import { queryKeys } from '@/lib/query-keys'
export function useOpencodeModels(args: {
api: ApiClient | null
sessionId?: string | null
enabled?: boolean
}): {
availableModels: OpencodeModelSummary[]
currentModelId: string | null
isLoading: boolean
error: string | null
} {
const { api, sessionId } = args
const enabled = Boolean(args.enabled && api && sessionId)
const query = useQuery({
queryKey: sessionId
? queryKeys.sessionOpencodeModels(sessionId)
: ['session-opencode-models', 'unknown'] as const,
queryFn: async () => {
if (!api) {
throw new Error('API unavailable')
}
if (!sessionId) {
throw new Error('OpenCode models target unavailable')
}
return await api.getSessionOpencodeModels(sessionId)
},
enabled,
staleTime: 30_000,
retry: false,
})
return {
availableModels: query.data?.availableModels ?? [],
currentModelId: query.data?.currentModelId ?? null,
isLoading: query.isLoading,
error: query.data?.success === false
? (query.data.error ?? 'Failed to load OpenCode models')
: query.error instanceof Error
? query.error.message
: query.error
? 'Failed to load OpenCode models'
: null,
}
}
@@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query'
import type { ApiClient } from '@/api/client'
import type { OpencodeModelSummary } from '@/types/api'
import { queryKeys } from '@/lib/query-keys'
export function useOpencodeModelsForCwd(args: {
api: ApiClient | null
machineId?: string | null
cwd?: string | null
enabled?: boolean
}): {
availableModels: OpencodeModelSummary[]
currentModelId: string | null
isLoading: boolean
error: string | null
refetch: () => void
} {
const { api, machineId, cwd } = args
const trimmedCwd = typeof cwd === 'string' ? cwd.trim() : ''
const enabled = Boolean(args.enabled && api && machineId && trimmedCwd)
const query = useQuery({
queryKey: machineId && trimmedCwd
? queryKeys.machineOpencodeModelsForCwd(machineId, trimmedCwd)
: ['machine-opencode-models', 'unknown', 'unknown'] as const,
queryFn: async () => {
if (!api) {
throw new Error('API unavailable')
}
if (!machineId || !trimmedCwd) {
throw new Error('OpenCode models target unavailable')
}
return await api.getMachineOpencodeModelsForCwd(machineId, trimmedCwd)
},
enabled,
staleTime: 60_000,
retry: false,
})
return {
availableModels: query.data?.availableModels ?? [],
currentModelId: query.data?.currentModelId ?? null,
isLoading: query.isLoading,
error: query.data?.success === false
? (query.data.error ?? 'Failed to load OpenCode models')
: query.error instanceof Error
? query.error.message
: query.error
? 'Failed to load OpenCode models'
: null,
refetch: () => {
void query.refetch()
}
}
}
+5
View File
@@ -123,6 +123,11 @@ export default {
'newSession.effort': 'Effort',
'newSession.model.optional': 'optional',
'newSession.model.loadFailed': 'Failed to load Codex models',
'newSession.opencodeModel.loading': 'Discovering OpenCode models…',
'newSession.opencodeModel.loadFailed': 'Failed to load OpenCode models',
'newSession.opencodeModel.retry': 'Retry',
'newSession.opencodeModel.empty': 'No OpenCode models discovered for this directory',
'newSession.opencodeModel.default': 'Default',
'newSession.reasoningEffort': 'Reasoning effort',
'newSession.yolo': 'YOLO mode',
'newSession.yolo.title': 'Bypass approvals and sandbox',
+5
View File
@@ -125,6 +125,11 @@ export default {
'newSession.effort': '思考强度',
'newSession.model.optional': '可选',
'newSession.model.loadFailed': '加载 Codex 模型失败',
'newSession.opencodeModel.loading': '正在发现 OpenCode 模型…',
'newSession.opencodeModel.loadFailed': '加载 OpenCode 模型失败',
'newSession.opencodeModel.retry': '重试',
'newSession.opencodeModel.empty': '未在此目录发现 OpenCode 模型',
'newSession.opencodeModel.default': '默认',
'newSession.reasoningEffort': '推理强度',
'newSession.yolo': 'YOLO 模式',
'newSession.yolo.title': '跳过审批和沙箱',
+2
View File
@@ -16,5 +16,7 @@ export const queryKeys = {
] as const,
slashCommands: (sessionId: string) => ['slash-commands', sessionId] as const,
sessionCodexModels: (sessionId: string) => ['session-codex-models', sessionId] as const,
sessionOpencodeModels: (sessionId: string) => ['session-opencode-models', sessionId] as const,
machineOpencodeModelsForCwd: (machineId: string, cwd: string) => ['machine-opencode-models', machineId, cwd] as const,
skills: (sessionId: string) => ['skills', sessionId] as const,
}
+12
View File
@@ -225,6 +225,18 @@ export type CodexModelsResponse = {
error?: string
}
export type OpencodeModelSummary = {
modelId: string
name?: string
}
export type OpencodeModelsResponse = {
success: boolean
availableModels?: OpencodeModelSummary[]
currentModelId?: string | null
error?: string
}
export type PushSubscriptionKeys = {
p256dh: string
auth: string