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
31 changes: 28 additions & 3 deletions src/core/preferences.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
readPreferences,
readQuickChatPreferences,
rememberAutoQuantDefaultWorkspace,
rememberAgentRuntimeUse,
rememberQuickChatCredential,
rememberQuickChatLaunch,
rememberRecentChatWorkspace,
Expand Down Expand Up @@ -38,7 +39,7 @@ describe('preferences', () => {
quickChat: { lastCredentialByAgent: {}, recentChatWorkspaceId: null, recentLaunch: null },
autoQuant: { defaultWorkspaceId: null },
harness: { showHeadlessBornSessions: false },
agentRuntimes: { quickAccessIds: [] },
agentRuntimes: { quickAccessIds: [], recentAgentIds: [] },
})

await writeFile(path, '{not-json', 'utf-8')
Expand Down Expand Up @@ -171,25 +172,49 @@ describe('preferences', () => {
expect(normalizeAgentRuntimeQuickAccessIds(['pi', 'pi', '', 'codex', ' grok ', 'omp', 'claude'])).toEqual([
'pi', 'codex', 'grok', 'omp',
])
expect(await readAgentRuntimesPreferences(path)).toEqual({ quickAccessIds: [] })
expect(await readAgentRuntimesPreferences(path)).toEqual({
quickAccessIds: [],
recentAgentIds: [],
})

const saved = await saveAgentRuntimesPreferences({
quickAccessIds: ['pi', 'codex', 'pi', 'grok', 'omp', 'claude'],
}, path)
expect(saved).toEqual({ quickAccessIds: ['pi', 'codex', 'grok', 'omp'] })
expect(saved).toEqual({
quickAccessIds: ['pi', 'codex', 'grok', 'omp'],
recentAgentIds: [],
})
expect(await readAgentRuntimesPreferences(path)).toEqual({
quickAccessIds: ['pi', 'codex', 'grok', 'omp'],
recentAgentIds: [],
})
expect(await readFile(path, 'utf-8')).not.toContain('installed')
expect(await readFile(path, 'utf-8')).not.toContain('binPath')

await rememberQuickChatCredential('pi', 'minimax-1', path)
expect(await readAgentRuntimesPreferences(path)).toEqual({
quickAccessIds: ['pi', 'codex', 'grok', 'omp'],
recentAgentIds: [],
})
expect(await readQuickChatPreferences(path)).toEqual({
lastCredentialByAgent: { pi: 'minimax-1' },
recentChatWorkspaceId: null,
})
})

it('stores successful runtime use as a bounded MRU without changing the fallback baseline', async () => {
const path = await preferenceFile()
await saveAgentRuntimesPreferences({ quickAccessIds: ['pi', 'codex'] }, path)
await rememberAgentRuntimeUse('claude', path)
await rememberAgentRuntimeUse('grok', path)
await rememberAgentRuntimeUse('claude', path)
await rememberAgentRuntimeUse('opencode', path)
await rememberAgentRuntimeUse('cursor', path)
const saved = await rememberAgentRuntimeUse('omp', path)

expect(saved).toEqual({
quickAccessIds: ['pi', 'codex'],
recentAgentIds: ['omp', 'cursor', 'opencode', 'claude'],
})
})
})
45 changes: 42 additions & 3 deletions src/core/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export function normalizeAgentRuntimeQuickAccessIds(ids: unknown): string[] {

const agentRuntimesPreferencesSchema = z.object({
quickAccessIds: z.unknown().default([]).transform(normalizeAgentRuntimeQuickAccessIds),
/** Successful Session launches, newest first. This is presentation history, not runtime config. */
recentAgentIds: z.unknown().default([]).transform(normalizeAgentRuntimeQuickAccessIds),
})

const preferencesSchema = z.object({
Expand All @@ -93,6 +95,7 @@ const preferencesSchema = z.object({
}),
agentRuntimes: agentRuntimesPreferencesSchema.default({
quickAccessIds: [],
recentAgentIds: [],
}),
})

Expand All @@ -105,6 +108,7 @@ export type AutoQuantPreferences = z.infer<typeof autoQuantPreferencesSchema>
export type HarnessPreferences = z.infer<typeof harnessPreferencesSchema>
export type AgentRuntimesPreferences = {
readonly quickAccessIds: readonly string[]
readonly recentAgentIds: readonly string[]
}
export type Preferences = z.infer<typeof preferencesSchema>

Expand Down Expand Up @@ -150,7 +154,10 @@ export async function readAgentRuntimesPreferences(
path = preferencesPath(),
): Promise<AgentRuntimesPreferences> {
const preferences = await readPreferences(path)
return { quickAccessIds: [...preferences.agentRuntimes.quickAccessIds] }
return {
quickAccessIds: [...preferences.agentRuntimes.quickAccessIds],
recentAgentIds: [...preferences.agentRuntimes.recentAgentIds],
}
}

// Alice is single-writer at the process level, but two UI requests can still
Expand Down Expand Up @@ -285,19 +292,51 @@ export async function saveHarnessPreferences(
}

export async function saveAgentRuntimesPreferences(
next: AgentRuntimesPreferences,
next: Pick<AgentRuntimesPreferences, 'quickAccessIds'>,
path = preferencesPath(),
): Promise<AgentRuntimesPreferences> {
const operation = mutationQueue.catch(() => undefined).then(async () => {
const preferences = await readPreferences(path)
const updated = preferencesSchema.parse({
...preferences,
agentRuntimes: {
...preferences.agentRuntimes,
quickAccessIds: normalizeAgentRuntimeQuickAccessIds(next.quickAccessIds),
},
})
await writePreferences(updated, path)
return { quickAccessIds: [...updated.agentRuntimes.quickAccessIds] }
return {
quickAccessIds: [...updated.agentRuntimes.quickAccessIds],
recentAgentIds: [...updated.agentRuntimes.recentAgentIds],
}
})
mutationQueue = operation
return operation
}

/** Promote a runtime only after a Session was created successfully. */
export async function rememberAgentRuntimeUse(
agentId: string,
path = preferencesPath(),
): Promise<AgentRuntimesPreferences> {
const operation = mutationQueue.catch(() => undefined).then(async () => {
const preferences = await readPreferences(path)
const recentAgentIds = normalizeAgentRuntimeQuickAccessIds([
agentId,
...preferences.agentRuntimes.recentAgentIds.filter((id) => id !== agentId),
])
const updated = preferencesSchema.parse({
...preferences,
agentRuntimes: {
...preferences.agentRuntimes,
recentAgentIds,
},
})
await writePreferences(updated, path)
return {
quickAccessIds: [...updated.agentRuntimes.quickAccessIds],
recentAgentIds: [...updated.agentRuntimes.recentAgentIds],
}
})
mutationQueue = operation
return operation
Expand Down
26 changes: 24 additions & 2 deletions src/webui/routes/preferences.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,31 +266,53 @@ describe('preferences routes', () => {
})

it('reads and persists an ordered agent-runtime quick-access list', async () => {
const read = vi.fn(async () => ({ quickAccessIds: ['pi', 'codex'] }))
const read = vi.fn(async () => ({ quickAccessIds: ['pi', 'codex'], recentAgentIds: ['grok'] }))
const save = vi.fn(async (next: { quickAccessIds: readonly string[] }) => ({
quickAccessIds: [...next.quickAccessIds],
recentAgentIds: ['grok'],
}))
const rememberUse = vi.fn(async (agentId: string) => ({
quickAccessIds: ['grok', 'opencode', 'pi'],
recentAgentIds: [agentId, 'grok'],
}))
const app = createPreferencesRoutes({
readQuickChatPreferences: vi.fn(),
rememberQuickChatCredential: vi.fn(),
rememberRecentChatWorkspace: unusedRecentWorkspace,
readAgentRuntimesPreferences: read,
saveAgentRuntimesPreferences: save,
rememberAgentRuntimeUse: rememberUse,
getWorkspaceShellStatus: unusedShellStatus,
saveWorkspaceShellPreference: unusedShellSave,
})

expect(await (await app.request('/agent-runtimes')).json()).toEqual({
quickAccessIds: ['pi', 'codex'],
recentAgentIds: ['grok'],
})
const response = await app.request('/agent-runtimes', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ quickAccessIds: ['grok', 'opencode', 'pi'] }),
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({ quickAccessIds: ['grok', 'opencode', 'pi'] })
expect(await response.json()).toEqual({
quickAccessIds: ['grok', 'opencode', 'pi'],
recentAgentIds: ['grok'],
})
expect(save).toHaveBeenCalledWith({ quickAccessIds: ['grok', 'opencode', 'pi'] })

const recentResponse = await app.request('/agent-runtimes/recent', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ agentId: 'claude' }),
})
expect(recentResponse.status).toBe(200)
expect(await recentResponse.json()).toEqual({
quickAccessIds: ['grok', 'opencode', 'pi'],
recentAgentIds: ['claude', 'grok'],
})
expect(rememberUse).toHaveBeenCalledWith('claude')
})

it('rejects unknown, utility, duplicate, or oversized runtime quick-access lists', async () => {
Expand Down
22 changes: 21 additions & 1 deletion src/webui/routes/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
readAgentRuntimesPreferences,
readHarnessPreferences,
readQuickChatPreferences,
rememberAgentRuntimeUse,
rememberQuickChatCredential,
rememberQuickChatLaunch,
rememberRecentChatWorkspace,
Expand Down Expand Up @@ -74,7 +75,8 @@ interface PreferenceRouteDeps {
readHarnessPreferences?(): Promise<HarnessPreferences>
saveHarnessPreferences?(next: HarnessPreferences): Promise<HarnessPreferences>
readAgentRuntimesPreferences?(): Promise<AgentRuntimesPreferences>
saveAgentRuntimesPreferences?(next: AgentRuntimesPreferences): Promise<AgentRuntimesPreferences>
saveAgentRuntimesPreferences?(next: Pick<AgentRuntimesPreferences, 'quickAccessIds'>): Promise<AgentRuntimesPreferences>
rememberAgentRuntimeUse?(agentId: string): Promise<AgentRuntimesPreferences>
getWorkspaceShellStatus(): Promise<WindowsWorkspaceShellStatus>
saveWorkspaceShellPreference(input: {
mode: 'auto' | 'custom'
Expand All @@ -92,6 +94,7 @@ const defaultDeps: PreferenceRouteDeps = {
saveHarnessPreferences: (next) => saveHarnessPreferences(next),
readAgentRuntimesPreferences: () => readAgentRuntimesPreferences(),
saveAgentRuntimesPreferences: (next) => saveAgentRuntimesPreferences(next),
rememberAgentRuntimeUse: (agentId) => rememberAgentRuntimeUse(agentId),
getWorkspaceShellStatus: () => getWindowsWorkspaceShellStatus(),
saveWorkspaceShellPreference: (input) => saveWindowsWorkspaceShellPreference(input),
}
Expand Down Expand Up @@ -201,6 +204,23 @@ export function createPreferencesRoutes(
}
})

app.put('/agent-runtimes/recent', async (c) => {
const parsed = z.object({
agentId: z.string().trim().min(1).max(128),
}).safeParse(await c.req.json().catch(() => null))
const adapter = parsed.success ? adapterRegistry.get(parsed.data.agentId) : null
if (!parsed.success || !adapter || !isAgentRuntime(adapter)) {
return c.json({ error: 'invalid_agent_runtime_preference' }, 400)
}
try {
return c.json(await (deps.rememberAgentRuntimeUse ?? defaultDeps.rememberAgentRuntimeUse!)(
parsed.data.agentId,
))
} catch (error) {
return c.json({ error: 'preferences_write_failed', message: String(error) }, 500)
}
})

app.get('/workspace-shell', async (c) => {
try {
return c.json(await deps.getWorkspaceShellStatus())
Expand Down
12 changes: 11 additions & 1 deletion ui/src/api/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@ export const DEFAULT_HARNESS_PREFERENCES: HarnessPreferences = {

export interface AgentRuntimesPreferences {
readonly quickAccessIds: readonly string[]
readonly recentAgentIds: readonly string[]
}

export const DEFAULT_AGENT_RUNTIMES_PREFERENCES: AgentRuntimesPreferences = {
quickAccessIds: [],
recentAgentIds: [],
}

export type WorkspaceShellStatus =
Expand Down Expand Up @@ -107,11 +109,19 @@ export const preferencesApi = {
return fetchJson('/api/preferences/agent-runtimes')
},

saveAgentRuntimes(next: AgentRuntimesPreferences): Promise<AgentRuntimesPreferences> {
saveAgentRuntimes(next: Pick<AgentRuntimesPreferences, 'quickAccessIds'>): Promise<AgentRuntimesPreferences> {
return fetchJson('/api/preferences/agent-runtimes', {
method: 'PUT',
headers,
body: JSON.stringify(next),
})
},

rememberAgentRuntimeUse(agentId: string): Promise<AgentRuntimesPreferences> {
return fetchJson('/api/preferences/agent-runtimes/recent', {
method: 'PUT',
headers,
body: JSON.stringify({ agentId }),
})
},
}
13 changes: 7 additions & 6 deletions ui/src/components/workspace/AgentLaunchControls.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ vi.mock('../../hooks/useAgentRuntimes', () => ({
notInstalled: [],
readiness: null,
quickAccessIds: [],
recentAgentId: null,
recentAgentIds: [],
loading: false,
refreshing: false,
error: null,
refresh: vi.fn(),
saveQuickAccess: vi.fn(),
recordSuccessfulUse: vi.fn(),
}),
}))

Expand Down Expand Up @@ -140,18 +141,18 @@ describe('AgentLaunchSelectors keyboard menus', () => {
const openCode = screen.getByRole('menuitem', { name: /OpenCode/ })
const pi = screen.getByRole('menuitem', { name: /^Pi/ })
const others = screen.getByRole('menuitem', { name: i18n.t('chatLanding.otherRuntimes') })
expect(document.activeElement).toBe(openCode)
expect(document.activeElement).toBe(pi)

await user.keyboard('{ArrowDown}')
expect(document.activeElement).toBe(pi)
await user.keyboard('{Home}')
expect(document.activeElement).toBe(openCode)
await user.keyboard('{Home}')
expect(document.activeElement).toBe(pi)
await user.keyboard('{End}')
expect(document.activeElement).toBe(others)
await user.keyboard('{Home}')
expect(document.activeElement).toBe(openCode)
await user.keyboard('{ArrowDown}')
expect(document.activeElement).toBe(pi)
await user.keyboard('{ArrowDown}')
expect(document.activeElement).toBe(openCode)

await user.keyboard('{Escape}')
expect(screen.queryByRole('menu')).toBeNull()
Expand Down
4 changes: 2 additions & 2 deletions ui/src/components/workspace/AgentLaunchControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -436,9 +436,9 @@ export const AgentLaunchSelectors = forwardRef<AgentLaunchSelectorsHandle, Agent
return projectAgentRuntimeQuickAccess(
pickerAgents,
discovery.quickAccessIds,
discovery.recentAgentId,
discovery.recentAgentIds,
).primary
}, [discovery.catalog.length, discovery.primary, discovery.quickAccessIds, discovery.recentAgentId, pickerAgents])
}, [discovery.catalog.length, discovery.primary, discovery.quickAccessIds, discovery.recentAgentIds, pickerAgents])
const workspaceAccess = config.accessMode === 'auto' && config.detectedCredential?.configured === true
const nativeAccess = config.accessMode === 'native' || (
config.accessMode === 'auto' && !workspaceAccess && config.effectiveCredential === null
Expand Down
25 changes: 23 additions & 2 deletions ui/src/demo/handlers/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ let recentLaunch = {
}
let showHeadlessBornSessions = false
let agentRuntimeQuickAccessIds: string[] = []
let recentAgentRuntimeIds: string[] = []

export const preferencesHandlers = [
http.get('/api/preferences/quick-chat', () =>
Expand Down Expand Up @@ -84,7 +85,10 @@ export const preferencesHandlers = [
return HttpResponse.json({ showHeadlessBornSessions })
}),
http.get('/api/preferences/agent-runtimes', () =>
HttpResponse.json({ quickAccessIds: [...agentRuntimeQuickAccessIds] }),
HttpResponse.json({
quickAccessIds: [...agentRuntimeQuickAccessIds],
recentAgentIds: [...recentAgentRuntimeIds],
}),
),
http.put('/api/preferences/agent-runtimes', async ({ request }) => {
const body = (await request.json().catch(() => null)) as {
Expand All @@ -101,6 +105,23 @@ export const preferencesHandlers = [
ids.push(id)
}
agentRuntimeQuickAccessIds = ids
return HttpResponse.json({ quickAccessIds: [...agentRuntimeQuickAccessIds] })
return HttpResponse.json({
quickAccessIds: [...agentRuntimeQuickAccessIds],
recentAgentIds: [...recentAgentRuntimeIds],
})
}),
http.put('/api/preferences/agent-runtimes/recent', async ({ request }) => {
const body = (await request.json().catch(() => null)) as { agentId?: unknown } | null
if (!body || typeof body.agentId !== 'string' || body.agentId.trim().length === 0) {
return HttpResponse.json({ error: 'invalid_agent_runtime_preference' }, { status: 400 })
}
recentAgentRuntimeIds = [
body.agentId,
...recentAgentRuntimeIds.filter((id) => id !== body.agentId),
].slice(0, 4)
return HttpResponse.json({
quickAccessIds: [...agentRuntimeQuickAccessIds],
recentAgentIds: [...recentAgentRuntimeIds],
})
}),
]
Loading
Loading