Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/x/apps/main/src/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 28 additions & 0 deletions apps/x/apps/renderer/src/components/code/code-agent-options.ts
Original file line number Diff line number Diff line change
@@ -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'
}
77 changes: 74 additions & 3 deletions apps/x/apps/renderer/src/components/code/code-view.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand All @@ -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 }
Expand All @@ -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)
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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
Expand Down
59 changes: 58 additions & 1 deletion apps/x/apps/renderer/src/components/code/new-session-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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]
Expand All @@ -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)
Expand Down Expand Up @@ -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 && (
Expand Down
Loading