mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
fix(web): expose Codex Fast and Plan on Create Session (#1017)
* fix(web): expose Codex Fast and Plan on Create Session Wire serviceTier and collaborationMode through spawn so Create can set the same Codex options chat Settings already supports (#1015). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): forward collaborationMode through machine spawn RPC Create Session Plan was accepted by the hub but dropped in apiMachine before buildCliArgs; also preserve collaborationMode on resume spawn. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cli): correct stopSession mock type in spawn RPC test Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): keep Fast mode across Create draft restore while models load Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): preserve pending Fast selection * fix: apply Fast and Plan to imported Codex sessions * test: narrow imported Codex session id * fix: forward explicit Standard service tier * fix: integrate create-session controls with current main * test: close Codex RPC suite * fix: preserve existing session spawn field * fix(web): integrate Codex controls with current New Session form * fix(web): reconcile draft types and submit state * fix(hub): integrate spawn arguments with current resume flow * test(cli): isolate spawn RPC suite --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -421,6 +421,66 @@ describe('ApiMachineClient Codex transcript handlers', () => {
|
||||
client.shutdown()
|
||||
}
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('ApiMachineClient SpawnHappySession handler', () => {
|
||||
let workspaceRoot: string
|
||||
|
||||
beforeEach(() => {
|
||||
ioMock.mockReset()
|
||||
workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-machine-spawn-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(workspaceRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function callSpawnHappySession(
|
||||
client: ApiMachineClient,
|
||||
machineId: string,
|
||||
params: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const manager = (client as unknown as {
|
||||
rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise<string> }
|
||||
}).rpcHandlerManager
|
||||
const raw = await manager.handleRequest({
|
||||
method: `${machineId}:spawn-happy-session`,
|
||||
params: JSON.stringify(params)
|
||||
})
|
||||
return JSON.parse(raw) as unknown
|
||||
}
|
||||
|
||||
it('forwards collaborationMode and serviceTier to spawnSession', async () => {
|
||||
const machine = makeMachine('machine-spawn-1')
|
||||
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
|
||||
const spawnSession = vi.fn(async () => ({ type: 'success' as const, sessionId: 'session-1' }))
|
||||
|
||||
client.setRPCHandlers({
|
||||
spawnSession,
|
||||
stopSession: vi.fn(() => true),
|
||||
requestShutdown: vi.fn()
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await callSpawnHappySession(client, machine.id, {
|
||||
directory: workspaceRoot,
|
||||
agent: 'codex',
|
||||
serviceTier: 'fast',
|
||||
collaborationMode: 'plan'
|
||||
})
|
||||
|
||||
expect(result).toEqual({ type: 'success', sessionId: 'session-1' })
|
||||
expect(spawnSession).toHaveBeenCalledWith(expect.objectContaining({
|
||||
directory: workspaceRoot,
|
||||
agent: 'codex',
|
||||
serviceTier: 'fast',
|
||||
collaborationMode: 'plan'
|
||||
}))
|
||||
} finally {
|
||||
client.shutdown()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ApiMachineClient keepAlive lifecycle', () => {
|
||||
|
||||
@@ -357,7 +357,7 @@ export class ApiMachineClient {
|
||||
|
||||
setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
|
||||
this.rpcHandlerManager.registerHandler(RPC_METHODS.SpawnHappySession, async (params: any) => {
|
||||
const { directory, sessionId, existingSessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, token, sessionType, worktreeName } = params || {}
|
||||
const { directory, sessionId, existingSessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, collaborationMode, token, sessionType, worktreeName } = params || {}
|
||||
|
||||
if (!directory) {
|
||||
throw new Error('Directory is required')
|
||||
@@ -382,6 +382,7 @@ export class ApiMachineClient {
|
||||
yolo,
|
||||
permissionMode,
|
||||
serviceTier,
|
||||
collaborationMode,
|
||||
token,
|
||||
sessionType,
|
||||
worktreeName
|
||||
|
||||
@@ -112,6 +112,15 @@ describe('codexCommand', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards a valid --collaboration-mode to runCodex', async () => {
|
||||
await codexCommand.run(createCommandContext(['--started-by', 'runner', '--collaboration-mode', 'plan']))
|
||||
|
||||
expect(runCodexMock).toHaveBeenCalledWith({
|
||||
startedBy: 'runner',
|
||||
collaborationMode: 'plan'
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an unsupported --service-tier value', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { maybeAutoStartServer } from '@/utils/autoStartServer'
|
||||
import type { CommandDefinition } from './types'
|
||||
import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes'
|
||||
import type { CodexPermissionMode } from '@hapi/protocol/types'
|
||||
import { CodexCollaborationModeSchema } from '@hapi/protocol/schemas'
|
||||
import type { ReasoningEffort } from '@/codex/appServerTypes'
|
||||
import { assertCodexLocalSupported } from '@/codex/utils/codexVersion'
|
||||
import { parseReasoningEffortValue } from '@/codex/utils/reasoningEffort'
|
||||
@@ -19,6 +20,14 @@ function parseServiceTier(value: string): 'fast' | 'standard' {
|
||||
throw new Error('Invalid --service-tier value')
|
||||
}
|
||||
|
||||
function parseCollaborationMode(value: string): 'default' | 'plan' {
|
||||
const parsed = CodexCollaborationModeSchema.safeParse(value.trim().toLowerCase())
|
||||
if (!parsed.success) {
|
||||
throw new Error('Invalid --collaboration-mode value')
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
export const codexCommand: CommandDefinition = {
|
||||
name: 'codex',
|
||||
requiresRuntimeAssets: true,
|
||||
@@ -35,6 +44,7 @@ export const codexCommand: CommandDefinition = {
|
||||
model?: string
|
||||
modelReasoningEffort?: ReasoningEffort
|
||||
serviceTier?: string
|
||||
collaborationMode?: 'default' | 'plan'
|
||||
} = {}
|
||||
const unknownArgs: string[] = []
|
||||
let hasExplicitPermissionMode = false
|
||||
@@ -88,6 +98,12 @@ export const codexCommand: CommandDefinition = {
|
||||
throw new Error('Missing --service-tier value')
|
||||
}
|
||||
options.serviceTier = parseServiceTier(tier)
|
||||
} else if (arg === '--collaboration-mode') {
|
||||
const mode = commandArgs[++i]
|
||||
if (!mode) {
|
||||
throw new Error('Missing --collaboration-mode value')
|
||||
}
|
||||
options.collaborationMode = parseCollaborationMode(mode)
|
||||
} else {
|
||||
unknownArgs.push(arg)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface SpawnSessionOptions {
|
||||
yolo?: boolean
|
||||
permissionMode?: string
|
||||
serviceTier?: string
|
||||
collaborationMode?: 'default' | 'plan'
|
||||
token?: string
|
||||
sessionType?: 'simple' | 'worktree'
|
||||
worktreeName?: string
|
||||
|
||||
@@ -84,6 +84,23 @@ describe('buildCliArgs', () => {
|
||||
expect(args).toContain('fast')
|
||||
})
|
||||
|
||||
it('passes --collaboration-mode through for codex Plan mode', () => {
|
||||
const args = buildCliArgs('codex', {
|
||||
directory: '/tmp',
|
||||
collaborationMode: 'plan',
|
||||
})
|
||||
expect(args).toContain('--collaboration-mode')
|
||||
expect(args).toContain('plan')
|
||||
})
|
||||
|
||||
it('omits --collaboration-mode for default collaboration mode', () => {
|
||||
const args = buildCliArgs('codex', {
|
||||
directory: '/tmp',
|
||||
collaborationMode: 'default',
|
||||
})
|
||||
expect(args).not.toContain('--collaboration-mode')
|
||||
})
|
||||
|
||||
it('does not pass --service-tier for non-codex agents', () => {
|
||||
const args = buildCliArgs('claude', {
|
||||
directory: '/tmp',
|
||||
@@ -143,6 +160,14 @@ describe('buildCliArgs', () => {
|
||||
expect(args).toContain('cursor-csid-1')
|
||||
})
|
||||
|
||||
it('does not pass --collaboration-mode for non-codex agents', () => {
|
||||
const args = buildCliArgs('claude', {
|
||||
directory: '/tmp',
|
||||
collaborationMode: 'plan',
|
||||
})
|
||||
expect(args).not.toContain('--collaboration-mode')
|
||||
})
|
||||
|
||||
it('validates all known permission modes', () => {
|
||||
for (const mode of ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan', 'ask', 'debug', 'autoReview', 'read-only', 'safe-yolo', 'yolo']) {
|
||||
const args = buildCliArgs('claude', {
|
||||
|
||||
@@ -1136,6 +1136,9 @@ export function buildCliArgs(
|
||||
if (options.serviceTier && agent === 'codex') {
|
||||
args.push('--service-tier', options.serviceTier);
|
||||
}
|
||||
if (options.collaborationMode && options.collaborationMode !== 'default' && agent === 'codex') {
|
||||
args.push('--collaboration-mode', options.collaborationMode);
|
||||
}
|
||||
// Pi RPC mode has no permission switching; never pass these flags to it
|
||||
// (the Pi parser rejects --permission-mode and ignores --yolo).
|
||||
if (agent !== 'pi') {
|
||||
|
||||
@@ -157,13 +157,30 @@ export class RpcGateway {
|
||||
effort?: string,
|
||||
permissionMode?: PermissionMode,
|
||||
serviceTier?: string,
|
||||
existingSessionId?: string
|
||||
existingSessionId?: string,
|
||||
collaborationMode?: CodexCollaborationMode
|
||||
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
|
||||
try {
|
||||
const result = await this.machineRpc(
|
||||
machineId,
|
||||
RPC_METHODS.SpawnHappySession,
|
||||
{ type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode, serviceTier, existingSessionId, sessionId: existingSessionId }
|
||||
{
|
||||
type: 'spawn-in-directory',
|
||||
directory,
|
||||
agent,
|
||||
model,
|
||||
modelReasoningEffort,
|
||||
yolo,
|
||||
sessionType,
|
||||
worktreeName,
|
||||
resumeSessionId,
|
||||
effort,
|
||||
permissionMode,
|
||||
serviceTier,
|
||||
existingSessionId,
|
||||
sessionId: existingSessionId,
|
||||
collaborationMode
|
||||
}
|
||||
)
|
||||
if (result && typeof result === 'object') {
|
||||
const obj = result as Record<string, unknown>
|
||||
|
||||
@@ -811,7 +811,8 @@ export class SyncEngine {
|
||||
effort?: string,
|
||||
permissionMode?: PermissionMode,
|
||||
serviceTier?: string,
|
||||
existingSessionId?: string
|
||||
existingSessionId?: string,
|
||||
collaborationMode?: CodexCollaborationMode
|
||||
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
|
||||
return await this.rpcGateway.spawnSession(
|
||||
machineId,
|
||||
@@ -826,7 +827,8 @@ export class SyncEngine {
|
||||
effort,
|
||||
permissionMode,
|
||||
serviceTier,
|
||||
existingSessionId
|
||||
existingSessionId,
|
||||
collaborationMode
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1300,7 +1302,8 @@ export class SyncEngine {
|
||||
session.effort ?? undefined,
|
||||
preferredPermissionMode,
|
||||
session.serviceTier ?? undefined,
|
||||
access.sessionId
|
||||
access.sessionId,
|
||||
session.collaborationMode ?? undefined
|
||||
)
|
||||
|
||||
if (spawnResult.type !== 'success') {
|
||||
|
||||
@@ -857,6 +857,47 @@ describe('Codex Desktop import routes', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('applies selected Standard and Plan config before an imported session is resumed', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-config-test-'))
|
||||
const store = new Store(':memory:')
|
||||
const codexSessionId = '23232323-2323-4232-8232-232323232323'
|
||||
process.env.CODEX_HOME = codexHome
|
||||
|
||||
try {
|
||||
createTranscript(codexHome, codexSessionId, '/home/user/workspace/project')
|
||||
const engine = createImportSyncEngine(store, [
|
||||
createMachine('machine-1', ['/home/user/workspace'])
|
||||
])
|
||||
const applied: Array<{ sessionId: string; config: unknown }> = []
|
||||
;(engine as unknown as { applySessionConfig: (sessionId: string, config: unknown) => Promise<void> }).applySessionConfig = async (sessionId, config) => {
|
||||
applied.push({ sessionId, config })
|
||||
}
|
||||
|
||||
const result = await importSelectedCodexSessions({
|
||||
codexSessionIds: [codexSessionId],
|
||||
store,
|
||||
namespace: 'default',
|
||||
getSyncEngine: () => engine,
|
||||
serviceTier: 'standard',
|
||||
collaborationMode: 'plan'
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
const importedSessionId = result.success ? result.hapiSessionIds?.[0] : undefined
|
||||
expect(importedSessionId).toBeDefined()
|
||||
if (!importedSessionId) {
|
||||
throw new Error('Imported session id missing')
|
||||
}
|
||||
expect(applied).toEqual([{
|
||||
sessionId: importedSessionId,
|
||||
config: { serviceTier: 'standard', collaborationMode: 'plan' }
|
||||
}])
|
||||
} finally {
|
||||
store.close()
|
||||
rmSync(codexHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('binds imported transcripts to the unique online machine that owns the cwd', async () => {
|
||||
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-test-'))
|
||||
const store = new Store(':memory:')
|
||||
|
||||
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { homedir, hostname, platform } from 'node:os'
|
||||
import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol'
|
||||
import type { CodexCollaborationMode } from '@hapi/protocol/types'
|
||||
import { Hono } from 'hono'
|
||||
import type { Machine, SyncEngine } from '../../sync/syncEngine'
|
||||
import type { Store, StoredMessage } from '../../store'
|
||||
@@ -131,6 +132,8 @@ type SyncSessionRequestParseResult = {
|
||||
machineId?: string | null
|
||||
model?: string | null
|
||||
modelReasoningEffort?: string | null
|
||||
serviceTier?: string | null
|
||||
collaborationMode?: CodexCollaborationMode
|
||||
yolo?: boolean
|
||||
error?: string
|
||||
}
|
||||
@@ -1701,7 +1704,7 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult {
|
||||
return { sessionIds: [] }
|
||||
}
|
||||
|
||||
const bodyRecord = body as { sessionIds?: unknown; cwd?: unknown; machineId?: unknown; model?: unknown; modelReasoningEffort?: unknown; yolo?: unknown }
|
||||
const bodyRecord = body as { sessionIds?: unknown; cwd?: unknown; machineId?: unknown; model?: unknown; modelReasoningEffort?: unknown; serviceTier?: unknown; collaborationMode?: unknown; yolo?: unknown }
|
||||
const rawSessionIds = bodyRecord.sessionIds
|
||||
if (!Array.isArray(rawSessionIds)) {
|
||||
return { sessionIds: [], error: 'Invalid sessionIds' }
|
||||
@@ -1720,6 +1723,14 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult {
|
||||
|
||||
const hasModel = Object.prototype.hasOwnProperty.call(bodyRecord, 'model')
|
||||
const hasModelReasoningEffort = Object.prototype.hasOwnProperty.call(bodyRecord, 'modelReasoningEffort')
|
||||
const hasServiceTier = Object.prototype.hasOwnProperty.call(bodyRecord, 'serviceTier')
|
||||
const hasCollaborationMode = Object.prototype.hasOwnProperty.call(bodyRecord, 'collaborationMode')
|
||||
if (hasServiceTier && bodyRecord.serviceTier !== null && bodyRecord.serviceTier !== 'fast' && bodyRecord.serviceTier !== 'standard') {
|
||||
return { sessionIds: [], error: 'Invalid serviceTier' }
|
||||
}
|
||||
if (hasCollaborationMode && bodyRecord.collaborationMode !== 'default' && bodyRecord.collaborationMode !== 'plan') {
|
||||
return { sessionIds: [], error: 'Invalid collaborationMode' }
|
||||
}
|
||||
|
||||
// 中文注释:前端允许多选,这里按 Codex thread 去重,避免重复导入同一条本地 transcript。
|
||||
return {
|
||||
@@ -1728,6 +1739,8 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult {
|
||||
machineId: typeof bodyRecord.machineId === 'string' && bodyRecord.machineId.trim() ? bodyRecord.machineId.trim() : null,
|
||||
model: hasModel ? (typeof bodyRecord.model === 'string' && bodyRecord.model.trim() ? bodyRecord.model.trim() : null) : undefined,
|
||||
modelReasoningEffort: hasModelReasoningEffort ? (typeof bodyRecord.modelReasoningEffort === 'string' && bodyRecord.modelReasoningEffort.trim() ? bodyRecord.modelReasoningEffort.trim() : null) : undefined,
|
||||
serviceTier: hasServiceTier ? bodyRecord.serviceTier as 'fast' | 'standard' | null : undefined,
|
||||
collaborationMode: hasCollaborationMode ? bodyRecord.collaborationMode as CodexCollaborationMode : undefined,
|
||||
yolo: bodyRecord.yolo === true
|
||||
}
|
||||
}
|
||||
@@ -1952,6 +1965,8 @@ export async function importSelectedCodexSessions(options: {
|
||||
localSessions?: RemoteCodexSession[]
|
||||
model?: string | null
|
||||
modelReasoningEffort?: string | null
|
||||
serviceTier?: string | null
|
||||
collaborationMode?: CodexCollaborationMode
|
||||
yolo?: boolean
|
||||
machineId?: string | null
|
||||
}): Promise<ScriptLaunchResponse> {
|
||||
@@ -1976,6 +1991,23 @@ export async function importSelectedCodexSessions(options: {
|
||||
})
|
||||
results.push(result)
|
||||
|
||||
if (result.success && (options.serviceTier !== undefined || options.collaborationMode !== undefined)) {
|
||||
const importedSessionId = result.hapiSessionIds?.[0]
|
||||
const engine = options.getSyncEngine?.() ?? null
|
||||
if (!importedSessionId || !engine) {
|
||||
return createImportErrorResponse(codexSessionIds, 'Imported session config could not be applied before resume')
|
||||
}
|
||||
try {
|
||||
await engine.applySessionConfig(importedSessionId, {
|
||||
...(options.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}),
|
||||
...(options.collaborationMode !== undefined ? { collaborationMode: options.collaborationMode } : {})
|
||||
})
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createImportErrorResponse(codexSessionIds, `Failed to apply imported session config: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
...result,
|
||||
@@ -2110,6 +2142,8 @@ export function createCodexDesktopRoutes(options: {
|
||||
machineId: remote.machineId ?? null,
|
||||
model: parsed.model,
|
||||
modelReasoningEffort: parsed.modelReasoningEffort,
|
||||
serviceTier: parsed.serviceTier,
|
||||
collaborationMode: parsed.collaborationMode,
|
||||
yolo: parsed.yolo
|
||||
})
|
||||
return c.json({
|
||||
|
||||
@@ -51,7 +51,10 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho
|
||||
parsed.data.worktreeName,
|
||||
undefined,
|
||||
parsed.data.effort,
|
||||
parsed.data.permissionMode
|
||||
parsed.data.permissionMode,
|
||||
parsed.data.serviceTier,
|
||||
undefined,
|
||||
parsed.data.collaborationMode
|
||||
)
|
||||
return c.json(result)
|
||||
})
|
||||
|
||||
@@ -326,7 +326,9 @@ export const SpawnSessionRequestSchema = z.object({
|
||||
yolo: z.boolean().optional(),
|
||||
permissionMode: PermissionModeSchema.optional(),
|
||||
sessionType: z.enum(['simple', 'worktree']).optional(),
|
||||
worktreeName: z.string().optional()
|
||||
worktreeName: z.string().optional(),
|
||||
serviceTier: z.enum(['fast', 'standard']).optional(),
|
||||
collaborationMode: CodexCollaborationModeSchema.optional()
|
||||
})
|
||||
|
||||
export type SpawnSessionRequest = z.infer<typeof SpawnSessionRequestSchema>
|
||||
|
||||
@@ -629,7 +629,9 @@ export class ApiClient {
|
||||
sessionType?: 'simple' | 'worktree',
|
||||
worktreeName?: string,
|
||||
effort?: string,
|
||||
permissionMode?: PermissionMode
|
||||
permissionMode?: PermissionMode,
|
||||
serviceTier?: 'fast' | 'standard',
|
||||
collaborationMode?: 'default' | 'plan'
|
||||
): Promise<SpawnResponse> {
|
||||
return await this.request<SpawnResponse>(`/api/machines/${encodeURIComponent(machineId)}/spawn`, {
|
||||
method: 'POST',
|
||||
@@ -642,7 +644,9 @@ export class ApiClient {
|
||||
sessionType,
|
||||
worktreeName,
|
||||
effort,
|
||||
permissionMode
|
||||
permissionMode,
|
||||
serviceTier,
|
||||
collaborationMode
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { getCodexCollaborationModeOptions, type CodexCollaborationMode } from '@hapi/protocol'
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import type { AgentType } from './types'
|
||||
|
||||
export function CollaborationModeSelector(props: {
|
||||
agent: AgentType
|
||||
value: CodexCollaborationMode
|
||||
isDisabled: boolean
|
||||
onChange: (value: CodexCollaborationMode) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (props.agent !== 'codex') {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 px-3 py-3">
|
||||
<label className="text-xs font-medium text-[var(--app-hint)]">
|
||||
{t('newSession.collaborationMode')}{' '}
|
||||
<span className="font-normal">({t('newSession.model.optional')})</span>
|
||||
</label>
|
||||
<select
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(e.target.value as CodexCollaborationMode)}
|
||||
disabled={props.isDisabled}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
|
||||
>
|
||||
{getCodexCollaborationModeOptions().map((option) => (
|
||||
<option key={option.mode} value={option.mode}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useTranslation } from '@/lib/use-translation'
|
||||
import type { NewSessionServiceTier } from './types'
|
||||
|
||||
export type { NewSessionServiceTier }
|
||||
|
||||
export function FastModeSelector(props: {
|
||||
visible: boolean
|
||||
value: NewSessionServiceTier
|
||||
isDisabled: boolean
|
||||
onChange: (value: NewSessionServiceTier) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!props.visible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 px-3 py-3">
|
||||
<label className="text-xs font-medium text-[var(--app-hint)]">
|
||||
{t('newSession.fastMode')}{' '}
|
||||
<span className="font-normal">({t('newSession.model.optional')})</span>
|
||||
</label>
|
||||
<select
|
||||
value={props.value}
|
||||
onChange={(e) => props.onChange(e.target.value as NewSessionServiceTier)}
|
||||
disabled={props.isDisabled}
|
||||
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
|
||||
>
|
||||
<option value="standard">{t('misc.fastModeStandard')}</option>
|
||||
<option value="fast">{t('misc.fastModeFast')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -314,6 +314,8 @@ describe('NewSession launch preferences', () => {
|
||||
machineId: 'machine-1',
|
||||
effort: 'auto',
|
||||
modelReasoningEffort: 'max',
|
||||
serviceTier: 'standard',
|
||||
collaborationMode: 'default',
|
||||
yoloMode: false,
|
||||
grokPermissionMode: 'default',
|
||||
sessionType: 'simple',
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { CodexLocalSessionSummary, Machine } from '@/types/api'
|
||||
import type { GrokPermissionMode } from '@hapi/protocol'
|
||||
import type { CodexCollaborationMode, GrokPermissionMode } from '@hapi/protocol'
|
||||
import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode'
|
||||
import { usePlatform } from '@/hooks/usePlatform'
|
||||
import { useMachinePathsExists } from '@/hooks/useMachinePathsExists'
|
||||
import { useSpawnSession } from '@/hooks/mutations/useSpawnSession'
|
||||
@@ -33,11 +34,13 @@ import {
|
||||
saveNewSessionFormDraft,
|
||||
shouldRestoreNewSessionFormDraft
|
||||
} from './newSessionFormDraft'
|
||||
import type { AgentType, LaunchEffort, CodexReasoningEffort, SessionType } from './types'
|
||||
import type { AgentType, LaunchEffort, CodexReasoningEffort, NewSessionServiceTier, SessionType } from './types'
|
||||
import { ActionButtons } from './ActionButtons'
|
||||
import { AgentSelector } from './AgentSelector'
|
||||
import { CollaborationModeSelector } from './CollaborationModeSelector'
|
||||
import { DirectorySection } from './DirectorySection'
|
||||
import { GrokPermissionModeSelector } from './GrokPermissionModeSelector'
|
||||
import { FastModeSelector } from './FastModeSelector'
|
||||
import { MachineSelector } from './MachineSelector'
|
||||
import { ModelSelector } from './ModelSelector'
|
||||
import { OpencodeModelSelector } from './OpencodeModelSelector'
|
||||
@@ -129,6 +132,8 @@ export function NewSession(props: {
|
||||
const [effort, setEffort] = useState<LaunchEffort>('auto')
|
||||
const [modelReasoningEffort, setModelReasoningEffort] = useState<CodexReasoningEffort>('default')
|
||||
const [opencodeSelectedModel, setOpencodeSelectedModel] = useState<string | null>(null)
|
||||
const [serviceTier, setServiceTier] = useState<NewSessionServiceTier>('standard')
|
||||
const [collaborationMode, setCollaborationMode] = useState<CodexCollaborationMode>('default')
|
||||
const [yoloMode, setYoloMode] = useState(loadPreferredYoloMode)
|
||||
const [grokPermissionMode, setGrokPermissionMode] = useState<GrokPermissionMode>('default')
|
||||
const [sessionType, setSessionType] = useState<SessionType>('simple')
|
||||
@@ -159,6 +164,8 @@ export function NewSession(props: {
|
||||
setEffort('auto')
|
||||
setModelReasoningEffort('default')
|
||||
setGrokPermissionMode('default')
|
||||
setServiceTier('standard')
|
||||
setCollaborationMode('default')
|
||||
if (agent !== 'cursor') {
|
||||
setModel('auto')
|
||||
setCursorSelectedBase('auto')
|
||||
@@ -224,6 +231,8 @@ export function NewSession(props: {
|
||||
setOpencodeSelectedModel(
|
||||
draft.agent === 'opencode' && draft.model !== 'auto' ? draft.model : null
|
||||
)
|
||||
setServiceTier(draft.serviceTier)
|
||||
setCollaborationMode(draft.collaborationMode)
|
||||
setYoloMode(draft.yoloMode)
|
||||
setGrokPermissionMode(draft.grokPermissionMode)
|
||||
setSessionType(draft.sessionType)
|
||||
@@ -313,6 +322,21 @@ export function NewSession(props: {
|
||||
setModel('auto')
|
||||
}
|
||||
}, [agent, codexModelsState.error, codexModelsState.isLoading, codexModelsState.models, model])
|
||||
const showCodexFastMode = agent === 'codex'
|
||||
&& !codexModelsState.error
|
||||
&& codexModelAdvertisesFastTier(model === 'auto' ? null : model, codexModelsState.models)
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for the Codex model catalog to settle before clearing Fast;
|
||||
// otherwise Browse → remount restores serviceTier: 'fast' and this
|
||||
// effect would wipe it while models are still loading.
|
||||
if (agent === 'codex' && codexModelsState.isLoading) {
|
||||
return
|
||||
}
|
||||
if (!showCodexFastMode && serviceTier !== 'standard') {
|
||||
setServiceTier('standard')
|
||||
}
|
||||
}, [agent, codexModelsState.isLoading, showCodexFastMode, serviceTier])
|
||||
const cursorModelsState = useCursorModelsForMachine({
|
||||
api: props.api,
|
||||
machineId,
|
||||
@@ -767,6 +791,8 @@ export function NewSession(props: {
|
||||
machineId,
|
||||
effort,
|
||||
modelReasoningEffort,
|
||||
serviceTier,
|
||||
collaborationMode,
|
||||
yoloMode,
|
||||
grokPermissionMode,
|
||||
sessionType,
|
||||
@@ -782,6 +808,8 @@ export function NewSession(props: {
|
||||
machineId,
|
||||
effort,
|
||||
modelReasoningEffort,
|
||||
serviceTier,
|
||||
collaborationMode,
|
||||
yoloMode,
|
||||
grokPermissionMode,
|
||||
sessionType,
|
||||
@@ -894,6 +922,12 @@ export function NewSession(props: {
|
||||
effort,
|
||||
modelReasoningEffort
|
||||
}
|
||||
const resolvedServiceTier = agent === 'codex' && showCodexFastMode
|
||||
? serviceTier
|
||||
: undefined
|
||||
const resolvedCollaborationMode = agent === 'codex' && collaborationMode !== 'default'
|
||||
? collaborationMode
|
||||
: undefined
|
||||
|
||||
if (agent === 'codex' && selectedCodexImportSession) {
|
||||
setIsImportingCodexSession(true)
|
||||
@@ -903,6 +937,8 @@ export function NewSession(props: {
|
||||
machineId: codexImportMachineId ?? machineId,
|
||||
model: resolvedModel ?? null,
|
||||
modelReasoningEffort: resolvedModelReasoningEffort ?? null,
|
||||
serviceTier: resolvedServiceTier,
|
||||
collaborationMode: resolvedCollaborationMode ?? 'default',
|
||||
yolo: yoloMode
|
||||
})
|
||||
if (result.success) {
|
||||
@@ -941,7 +977,9 @@ export function NewSession(props: {
|
||||
yolo: agent === 'grok' ? undefined : yoloMode,
|
||||
permissionMode: agent === 'grok' ? grokPermissionMode : undefined,
|
||||
sessionType,
|
||||
worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined
|
||||
worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined,
|
||||
serviceTier: resolvedServiceTier,
|
||||
collaborationMode: resolvedCollaborationMode
|
||||
})
|
||||
|
||||
if (result.type === 'success') {
|
||||
@@ -984,12 +1022,16 @@ export function NewSession(props: {
|
||||
deferredDirectoryExists === undefined
|
||||
|| (deferredDirectoryExists === true && opencodeModelsState.isLoading)
|
||||
))
|
||||
const fastModeSelectionPending = agent === 'codex'
|
||||
&& serviceTier === 'fast'
|
||||
&& codexModelsState.isLoading
|
||||
const canCreate = Boolean(
|
||||
machineId
|
||||
&& trimmedDirectory
|
||||
&& !isFormDisabled
|
||||
&& !missingWorktreeDirectory
|
||||
&& !isLaunchPreferenceValidationPending
|
||||
&& !fastModeSelectionPending
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -1148,6 +1190,18 @@ export function NewSession(props: {
|
||||
isDisabled={isFormDisabled}
|
||||
onChange={setGrokPermissionMode}
|
||||
/>
|
||||
<CollaborationModeSelector
|
||||
agent={agent}
|
||||
value={collaborationMode}
|
||||
isDisabled={isFormDisabled}
|
||||
onChange={setCollaborationMode}
|
||||
/>
|
||||
<FastModeSelector
|
||||
visible={showCodexFastMode}
|
||||
value={serviceTier}
|
||||
isDisabled={isFormDisabled}
|
||||
onChange={setServiceTier}
|
||||
/>
|
||||
{agent !== 'grok' ? (
|
||||
<YoloToggle
|
||||
yoloMode={yoloMode}
|
||||
|
||||
@@ -20,6 +20,8 @@ describe('newSessionFormDraft', () => {
|
||||
machineId: 'machine-1',
|
||||
effort: 'auto',
|
||||
modelReasoningEffort: 'default',
|
||||
serviceTier: 'standard',
|
||||
collaborationMode: 'default',
|
||||
yoloMode: false,
|
||||
grokPermissionMode: 'default',
|
||||
sessionType: 'simple',
|
||||
@@ -33,6 +35,8 @@ describe('newSessionFormDraft', () => {
|
||||
machineId: 'machine-1',
|
||||
effort: 'auto',
|
||||
modelReasoningEffort: 'default',
|
||||
serviceTier: 'standard',
|
||||
collaborationMode: 'default',
|
||||
yoloMode: false,
|
||||
grokPermissionMode: 'default',
|
||||
sessionType: 'simple',
|
||||
@@ -59,6 +63,8 @@ describe('newSessionFormDraft', () => {
|
||||
machineId: null,
|
||||
effort: 'auto',
|
||||
modelReasoningEffort: 'default',
|
||||
serviceTier: 'standard',
|
||||
collaborationMode: 'default',
|
||||
yoloMode: false,
|
||||
grokPermissionMode: 'default',
|
||||
sessionType: 'simple',
|
||||
@@ -76,6 +82,8 @@ describe('newSessionFormDraft', () => {
|
||||
machineId: 'machine-a',
|
||||
effort: 'auto',
|
||||
modelReasoningEffort: 'default',
|
||||
serviceTier: 'fast',
|
||||
collaborationMode: 'plan',
|
||||
yoloMode: false,
|
||||
grokPermissionMode: 'default',
|
||||
sessionType: 'simple',
|
||||
@@ -83,6 +91,8 @@ describe('newSessionFormDraft', () => {
|
||||
})
|
||||
const draft = loadNewSessionFormDraft()!
|
||||
expect(newSessionDraftMatchesMachine(draft, 'machine-b')).toBe(false)
|
||||
expect(draft.serviceTier).toBe('fast')
|
||||
expect(draft.collaborationMode).toBe('plan')
|
||||
})
|
||||
|
||||
it('coerces a stale uncreatable agent (gemini) to claude and resets dependent fields', () => {
|
||||
@@ -93,6 +103,8 @@ describe('newSessionFormDraft', () => {
|
||||
machineId: 'machine-1',
|
||||
effort: 'high',
|
||||
modelReasoningEffort: 'high',
|
||||
serviceTier: 'fast',
|
||||
collaborationMode: 'plan',
|
||||
yoloMode: true,
|
||||
grokPermissionMode: 'default',
|
||||
sessionType: 'simple',
|
||||
@@ -106,6 +118,8 @@ describe('newSessionFormDraft', () => {
|
||||
expect(loaded.cursorSelectedBase).toBe('auto')
|
||||
expect(loaded.effort).toBe('auto')
|
||||
expect(loaded.modelReasoningEffort).toBe('default')
|
||||
expect(loaded.serviceTier).toBe('standard')
|
||||
expect(loaded.collaborationMode).toBe('default')
|
||||
// agent-independent fields preserved
|
||||
expect(loaded.yoloMode).toBe(true)
|
||||
expect(loaded.machineId).toBe('machine-1')
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
CREATABLE_AGENT_FLAVORS,
|
||||
GROK_PERMISSION_MODES,
|
||||
type CodexCollaborationMode,
|
||||
type GrokPermissionMode
|
||||
} from '@hapi/protocol'
|
||||
import type { AgentType, LaunchEffort, CodexReasoningEffort, SessionType } from './types'
|
||||
import type { AgentType, LaunchEffort, CodexReasoningEffort, NewSessionServiceTier, SessionType } from './types'
|
||||
|
||||
const DRAFT_STORAGE_KEY = 'hapi:new-session-form-draft'
|
||||
|
||||
@@ -14,6 +15,8 @@ export type NewSessionFormDraft = {
|
||||
machineId: string | null
|
||||
effort: LaunchEffort
|
||||
modelReasoningEffort: CodexReasoningEffort
|
||||
serviceTier: NewSessionServiceTier
|
||||
collaborationMode: CodexCollaborationMode
|
||||
yoloMode: boolean
|
||||
grokPermissionMode: GrokPermissionMode
|
||||
sessionType: SessionType
|
||||
@@ -57,6 +60,8 @@ export function loadNewSessionFormDraft(): NewSessionFormDraft | null {
|
||||
modelReasoningEffort: agentPreserved
|
||||
? ((parsed.modelReasoningEffort as CodexReasoningEffort | undefined) ?? 'default')
|
||||
: 'default',
|
||||
serviceTier: agentPreserved && parsed.serviceTier === 'fast' ? 'fast' : 'standard',
|
||||
collaborationMode: agentPreserved && parsed.collaborationMode === 'plan' ? 'plan' : 'default',
|
||||
yoloMode: Boolean(parsed.yoloMode),
|
||||
grokPermissionMode: agentPreserved
|
||||
&& GROK_PERMISSION_MODES.includes(parsed.grokPermissionMode as GrokPermissionMode)
|
||||
|
||||
@@ -15,6 +15,7 @@ export type CodexReasoningEffort = string
|
||||
// Grok reports effort values dynamically through ACP, while Claude uses the
|
||||
// fixed ClaudeEffortLevel catalog.
|
||||
export type LaunchEffort = string
|
||||
export type NewSessionServiceTier = 'standard' | 'fast'
|
||||
|
||||
function modelPresetOptions<TModel extends string>(
|
||||
presets: readonly TModel[],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import type { AgentFlavor, PermissionMode } from '@hapi/protocol'
|
||||
import type { AgentFlavor, CodexCollaborationMode, PermissionMode } from '@hapi/protocol'
|
||||
import type { ApiClient } from '@/api/client'
|
||||
import type { SpawnResponse } from '@/types/api'
|
||||
import { queryKeys } from '@/lib/query-keys'
|
||||
@@ -15,6 +15,8 @@ type SpawnInput = {
|
||||
permissionMode?: PermissionMode
|
||||
sessionType?: 'simple' | 'worktree'
|
||||
worktreeName?: string
|
||||
serviceTier?: 'fast' | 'standard'
|
||||
collaborationMode?: CodexCollaborationMode
|
||||
}
|
||||
|
||||
export function useSpawnSession(api: ApiClient | null): {
|
||||
@@ -39,7 +41,9 @@ export function useSpawnSession(api: ApiClient | null): {
|
||||
input.sessionType,
|
||||
input.worktreeName,
|
||||
input.effort,
|
||||
input.permissionMode
|
||||
input.permissionMode,
|
||||
input.serviceTier,
|
||||
input.collaborationMode
|
||||
)
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -272,6 +272,8 @@ export default {
|
||||
'newSession.opencodeModel.empty': 'No OpenCode models discovered for this directory',
|
||||
'newSession.opencodeModel.default': 'Default',
|
||||
'newSession.reasoningEffort': 'Reasoning effort',
|
||||
'newSession.collaborationMode': 'Collaboration mode',
|
||||
'newSession.fastMode': 'Fast mode',
|
||||
'newSession.yolo': 'YOLO mode',
|
||||
'newSession.yolo.title': 'Bypass approvals and sandbox',
|
||||
'newSession.yolo.desc': 'Uses dangerous agent flags when spawning.',
|
||||
|
||||
@@ -276,6 +276,8 @@ export default {
|
||||
'newSession.opencodeModel.empty': '未在此目录发现 OpenCode 模型',
|
||||
'newSession.opencodeModel.default': '默认',
|
||||
'newSession.reasoningEffort': '推理强度',
|
||||
'newSession.collaborationMode': '协作模式',
|
||||
'newSession.fastMode': '快速模式',
|
||||
'newSession.yolo': 'YOLO 模式',
|
||||
'newSession.yolo.title': '跳过审批和沙箱',
|
||||
'newSession.yolo.desc': '启动时使用危险的代理标志。',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
CodexCollaborationMode,
|
||||
DecryptedMessage as ProtocolDecryptedMessage,
|
||||
Machine,
|
||||
RunnerState,
|
||||
@@ -219,6 +220,8 @@ export type CodexDesktopSyncRequest = {
|
||||
machineId?: string | null
|
||||
model?: string | null
|
||||
modelReasoningEffort?: string | null
|
||||
serviceTier?: string | null
|
||||
collaborationMode?: CodexCollaborationMode
|
||||
yolo?: boolean
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user