mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-15 21:11:08 +02:00
feat(code-mode): per-session model + effort, and keep output on nav (#632)
Two improvements to the Code section: - Fix: leaving the Code section and returning no longer drops the open session's output. The selected session id is persisted to localStorage (mirroring the terminal-height pattern) and restored on remount, so the right-hand chat pane re-binds instead of falling back to the empty state. - Feature: choose the coding agent's model and reasoning effort per session. Choices are discovered live from the engine (the same list `/model` shows) via a new `codeMode:listModelOptions` IPC, cached per agent — never hardcoded, so they track whatever the provider currently offers. Claude exposes model + effort as separate axes (with explicit Opus/Sonnet/Haiku alias rows surfaced for clarity); Codex folds effort into the model id and reports no separate effort. Selections persist on the CodeSession and are re-applied to the ACP session each turn (best-effort), editable from both the new-session dialog and the session header.
This commit is contained in:
parent
c8d801a123
commit
45188e7c1c
9 changed files with 360 additions and 9 deletions
|
|
@ -41,6 +41,7 @@ import type { ICodeProjectsRepo } from '@x/core/dist/code-mode/projects/repo.js'
|
|||
import type { ICodeSessionsRepo } from '@x/core/dist/code-mode/sessions/repo.js';
|
||||
import { CodeSessionService } from '@x/core/dist/code-mode/sessions/service.js';
|
||||
import { CodeSessionStatusTracker } from '@x/core/dist/code-mode/sessions/status-tracker.js';
|
||||
import type { CodeModeManager } from '@x/core/dist/code-mode/acp/manager.js';
|
||||
import * as codeGit from '@x/core/dist/code-mode/git/service.js';
|
||||
import { readProjectDir, readProjectFile } from '@x/core/dist/code-mode/projects/fs.js';
|
||||
import { ensureTerminal, writeTerminal, resizeTerminal, disposeTerminal } from './terminal.js';
|
||||
|
|
@ -972,6 +973,10 @@ export function setupIpcHandlers() {
|
|||
const service = container.resolve<CodeSessionService>('codeSessionService');
|
||||
return { session: await service.update(args.sessionId, args.patch) };
|
||||
},
|
||||
'codeMode:listModelOptions': async (_event, args) => {
|
||||
const manager = container.resolve<CodeModeManager>('codeModeManager');
|
||||
return manager.listModelOptions(args.agent);
|
||||
},
|
||||
'codeSession:delete': async (_event, args) => {
|
||||
const service = container.resolve<CodeSessionService>('codeSessionService');
|
||||
disposeTerminal(args.sessionId);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import type { CodingAgent } from '@x/shared/src/code-mode.js'
|
||||
import type { CodeAgentModelOptions, CodeAgentOption } from '@x/shared/src/code-sessions.js'
|
||||
|
||||
// Model + effort choices for a coding agent, discovered live from the engine
|
||||
// (the same list `/model` shows) via the main process, which caches per agent.
|
||||
// We memoize the in-flight/resolved promise per agent here too so reopening the
|
||||
// picker doesn't re-hit IPC. A failed lookup resolves to empty lists so the UI
|
||||
// just falls back to the engine default.
|
||||
const EMPTY: CodeAgentModelOptions = { models: [], efforts: [] }
|
||||
const cache = new Map<CodingAgent, Promise<CodeAgentModelOptions>>()
|
||||
|
||||
export function fetchCodeAgentOptions(agent: CodingAgent): Promise<CodeAgentModelOptions> {
|
||||
let pending = cache.get(agent)
|
||||
if (!pending) {
|
||||
pending = window.ipc.invoke('codeMode:listModelOptions', { agent }).catch(() => EMPTY)
|
||||
cache.set(agent, pending)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
// Always offer a Default fallback even before options load (or if discovery fails).
|
||||
export function withDefault(options: CodeAgentOption[]): CodeAgentOption[] {
|
||||
return options.some((o) => o.value === 'default') ? options : [{ value: 'default', label: 'Default' }, ...options]
|
||||
}
|
||||
|
||||
export function optionLabel(options: CodeAgentOption[], value: string | undefined): string {
|
||||
return options.find((o) => o.value === (value ?? 'default'))?.label ?? value ?? 'Default'
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Bot, ChevronDown, ChevronUp, Code2, GitBranch, Terminal as TerminalIcon } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionStatus } from '@x/shared/src/code-sessions.js'
|
||||
import type { CodeSession, CodeSessionStatus, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
|
||||
import { fetchCodeAgentOptions, withDefault, optionLabel } from './code-agent-options'
|
||||
import type { ApprovalPolicy } from '@x/shared/src/code-mode.js'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
|
@ -31,6 +32,16 @@ const TERMINAL_HEIGHT_STORAGE_KEY = 'x:code-terminal-height'
|
|||
const TERMINAL_MIN_HEIGHT = 120
|
||||
const TERMINAL_MAX_HEIGHT = 600
|
||||
|
||||
// Remember which session was open so leaving the Code section (which unmounts
|
||||
// this view) and coming back restores the selection — and with it the chat
|
||||
// output in the right pane — instead of dropping back to the empty state.
|
||||
const SELECTED_SESSION_STORAGE_KEY = 'x:code-selected-session'
|
||||
|
||||
function readStoredSelectedSessionId(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return window.localStorage.getItem(SELECTED_SESSION_STORAGE_KEY) || null
|
||||
}
|
||||
|
||||
function readStoredTerminalHeight(): number {
|
||||
if (typeof window === 'undefined') return 240
|
||||
const raw = Number(window.localStorage.getItem(TERMINAL_HEIGHT_STORAGE_KEY))
|
||||
|
|
@ -70,7 +81,7 @@ export function CodeView({
|
|||
onDiffOpened?: () => void
|
||||
}) {
|
||||
const { projects, sessions, statusOf, refresh } = useCodeSessions()
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(readStoredSelectedSessionId)
|
||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<CodeSession | null>(null)
|
||||
const [terminalOpen, setTerminalOpen] = useState(false)
|
||||
|
|
@ -81,6 +92,11 @@ export function CodeView({
|
|||
window.localStorage.setItem(TERMINAL_HEIGHT_STORAGE_KEY, String(terminalHeight))
|
||||
}, [terminalHeight])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSessionId) window.localStorage.setItem(SELECTED_SESSION_STORAGE_KEY, selectedSessionId)
|
||||
else window.localStorage.removeItem(SELECTED_SESSION_STORAGE_KEY)
|
||||
}, [selectedSessionId])
|
||||
|
||||
const handleTerminalDragStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
dragStateRef.current = { startY: e.clientY, startHeight: terminalHeight }
|
||||
|
|
@ -104,6 +120,17 @@ export function CodeView({
|
|||
const selectedStatus = selectedSession ? statusOf(selectedSession.id) : 'idle'
|
||||
const newSessionProject = projects.find((p) => p.project.id === newSessionProjectId) ?? null
|
||||
|
||||
// Live model/effort choices for the selected session's agent, for the header
|
||||
// pickers. Discovered from the engine and cached, so this is cheap to re-run.
|
||||
const [modelOpts, setModelOpts] = useState<CodeAgentModelOptions>({ models: [], efforts: [] })
|
||||
const selectedAgent = selectedSession?.agent
|
||||
useEffect(() => {
|
||||
if (!selectedAgent) { setModelOpts({ models: [], efforts: [] }); return }
|
||||
let cancelled = false
|
||||
void fetchCodeAgentOptions(selectedAgent).then((opts) => { if (!cancelled) setModelOpts(opts) })
|
||||
return () => { cancelled = true }
|
||||
}, [selectedAgent])
|
||||
|
||||
// Tell App which session (and status) owns the right-hand chat pane.
|
||||
useEffect(() => {
|
||||
onSessionSelected?.(selectedSession ? { session: selectedSession, status: selectedStatus } : null)
|
||||
|
|
@ -152,7 +179,7 @@ export function CodeView({
|
|||
}
|
||||
}, [refresh, selectedSessionId])
|
||||
|
||||
const handleUpdateSession = useCallback(async (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex' }) => {
|
||||
const handleUpdateSession = useCallback(async (patch: { mode?: 'direct' | 'rowboat'; policy?: ApprovalPolicy; agent?: 'claude' | 'codex'; agentModel?: string; agentEffort?: string }) => {
|
||||
if (!selectedSessionId) return
|
||||
try {
|
||||
await window.ipc.invoke('codeSession:update', { sessionId: selectedSessionId, patch })
|
||||
|
|
@ -201,6 +228,50 @@ export function CodeView({
|
|||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
title="Coding agent model"
|
||||
>
|
||||
<span className="whitespace-nowrap">{optionLabel(modelOpts.models, selectedSession.agentModel)}</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-80 overflow-y-auto">
|
||||
{withDefault(modelOpts.models).map((m) => (
|
||||
<DropdownMenuItem key={m.value} onClick={() => void handleUpdateSession({ agentModel: m.value })}>
|
||||
{m.label}
|
||||
{(selectedSession.agentModel ?? 'default') === m.value && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{modelOpts.efforts.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2 text-xs text-muted-foreground"
|
||||
title="Reasoning effort"
|
||||
>
|
||||
<span className="whitespace-nowrap">{optionLabel(modelOpts.efforts, selectedSession.agentEffort)}</span>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{withDefault(modelOpts.efforts).map((e) => (
|
||||
<DropdownMenuItem key={e.value} onClick={() => void handleUpdateSession({ agentEffort: e.value })}>
|
||||
{e.label}
|
||||
{(selectedSession.agentEffort ?? 'default') === e.value && <span className="ml-auto">✓</span>}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Bot, GitBranch, Loader2, Terminal } from 'lucide-react'
|
||||
import type { CodeSession, CodeSessionMode } from '@x/shared/src/code-sessions.js'
|
||||
import type { CodeSession, CodeSessionMode, CodeAgentModelOptions } from '@x/shared/src/code-sessions.js'
|
||||
import { fetchCodeAgentOptions, withDefault } from './code-agent-options'
|
||||
import type { ApprovalPolicy, CodingAgent } from '@x/shared/src/code-mode.js'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
|
@ -86,6 +87,11 @@ export function NewSessionDialog({
|
|||
const [modelOptions, setModelOptions] = useState<ModelOption[]>([])
|
||||
// 'default' = let the backend use the configured default model.
|
||||
const [modelKey, setModelKey] = useState('default')
|
||||
// The coding agent's own model + reasoning effort. 'default' leaves the
|
||||
// engine default. Choices are discovered live per agent (see effect below).
|
||||
const [agentModel, setAgentModel] = useState('default')
|
||||
const [agentEffort, setAgentEffort] = useState('default')
|
||||
const [modelOpts, setModelOpts] = useState<CodeAgentModelOptions>({ models: [], efforts: [] })
|
||||
|
||||
const git = projectRow?.git
|
||||
const worktreeAvailable = !!git?.isGitRepo && !!git?.hasCommits
|
||||
|
|
@ -97,6 +103,8 @@ export function NewSessionDialog({
|
|||
setIsolation('in-repo')
|
||||
setMode('rowboat')
|
||||
setModelKey('default')
|
||||
setAgentModel('default')
|
||||
setAgentEffort('default')
|
||||
void loadModelOptions().then(setModelOptions)
|
||||
void window.ipc.invoke('codeMode:checkAgentStatus', null).then((status) => {
|
||||
setAgentStatus(status)
|
||||
|
|
@ -108,6 +116,18 @@ export function NewSessionDialog({
|
|||
})
|
||||
}, [open])
|
||||
|
||||
// Model/effort choices are per-agent (and the saved value from one agent is
|
||||
// meaningless for the other), so reset to defaults and (re)load the live list
|
||||
// whenever the agent changes.
|
||||
useEffect(() => {
|
||||
setAgentModel('default')
|
||||
setAgentEffort('default')
|
||||
setModelOpts({ models: [], efforts: [] })
|
||||
let cancelled = false
|
||||
void fetchCodeAgentOptions(agent).then((opts) => { if (!cancelled) setModelOpts(opts) })
|
||||
return () => { cancelled = true }
|
||||
}, [agent])
|
||||
|
||||
const agentReady = (a: CodingAgent): boolean => {
|
||||
if (!agentStatus) return true
|
||||
const s = agentStatus[a]
|
||||
|
|
@ -129,6 +149,8 @@ export function NewSessionDialog({
|
|||
policy,
|
||||
isolation,
|
||||
...(picked ? { model: picked.model, provider: picked.provider } : {}),
|
||||
...(agentModel !== 'default' ? { agentModel } : {}),
|
||||
...(modelOpts.efforts.length > 0 && agentEffort !== 'default' ? { agentEffort } : {}),
|
||||
})
|
||||
onOpenChange(false)
|
||||
onCreated(res.session)
|
||||
|
|
@ -278,6 +300,41 @@ export function NewSessionDialog({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* The coding agent's own model + reasoning effort, discovered live
|
||||
from the engine and applied to the ACP session each turn (so they
|
||||
stay editable from the session header later). Effort is a separate
|
||||
axis only for Claude; Codex folds it into the model id. */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Model</label>
|
||||
<Select value={agentModel} onValueChange={setAgentModel}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{withDefault(modelOpts.models).map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{modelOpts.efforts.length > 0 && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium">Effort</label>
|
||||
<Select value={agentEffort} onValueChange={setAgentEffort}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{withDefault(modelOpts.efforts).map((e) => (
|
||||
<SelectItem key={e.value} value={e.value}>{e.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* The model only powers Rowboat's own turns; the coding agent uses its
|
||||
own configured model, so hide this entirely for direct sessions. */}
|
||||
{mode === 'rowboat' && modelOptions.length > 0 && (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue