From 576811d9fab6cde6b28bdd807c7d89c9fddb2c35 Mon Sep 17 00:00:00 2001 From: Ame <123734885+luokerenx4@users.noreply.github.com> Date: Wed, 19 Aug 2026 02:12:58 +0800 Subject: [PATCH] feat(ui): order agent runtimes by recent use --- src/core/preferences.spec.ts | 31 ++++++- src/core/preferences.ts | 45 ++++++++- src/webui/routes/preferences.spec.ts | 26 +++++- src/webui/routes/preferences.ts | 22 ++++- ui/src/api/preferences.ts | 12 ++- .../workspace/AgentLaunchControls.spec.tsx | 13 +-- .../workspace/AgentLaunchControls.tsx | 4 +- ui/src/demo/handlers/preferences.ts | 25 ++++- ui/src/hooks/useAgentLaunchConfig.ts | 4 +- ui/src/hooks/useAgentRuntimes.spec.ts | 92 +++++++++---------- ui/src/hooks/useAgentRuntimes.ts | 77 +++++++++++----- ui/src/i18n/locales/en.ts | 2 +- ui/src/i18n/locales/ja.ts | 2 +- ui/src/i18n/locales/zh-Hant.ts | 2 +- ui/src/i18n/locales/zh.ts | 2 +- ui/src/lib/agentRuntimeQuickAccess.spec.ts | 26 +++--- ui/src/lib/agentRuntimeQuickAccess.ts | 19 ++-- .../pages/AgentRuntimesSettingsPage.spec.tsx | 3 +- ui/src/pages/ChatLandingPage.tsx | 3 + ui/src/pages/WorkspaceManagerPage.tsx | 3 + 20 files changed, 301 insertions(+), 112 deletions(-) diff --git a/src/core/preferences.spec.ts b/src/core/preferences.spec.ts index ac3b5bcda..09ffd89d3 100644 --- a/src/core/preferences.spec.ts +++ b/src/core/preferences.spec.ts @@ -11,6 +11,7 @@ import { readPreferences, readQuickChatPreferences, rememberAutoQuantDefaultWorkspace, + rememberAgentRuntimeUse, rememberQuickChatCredential, rememberQuickChatLaunch, rememberRecentChatWorkspace, @@ -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') @@ -171,14 +172,21 @@ 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') @@ -186,10 +194,27 @@ describe('preferences', () => { 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'], + }) + }) }) diff --git a/src/core/preferences.ts b/src/core/preferences.ts index ba39afccb..40a862951 100644 --- a/src/core/preferences.ts +++ b/src/core/preferences.ts @@ -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({ @@ -93,6 +95,7 @@ const preferencesSchema = z.object({ }), agentRuntimes: agentRuntimesPreferencesSchema.default({ quickAccessIds: [], + recentAgentIds: [], }), }) @@ -105,6 +108,7 @@ export type AutoQuantPreferences = z.infer export type HarnessPreferences = z.infer export type AgentRuntimesPreferences = { readonly quickAccessIds: readonly string[] + readonly recentAgentIds: readonly string[] } export type Preferences = z.infer @@ -150,7 +154,10 @@ export async function readAgentRuntimesPreferences( path = preferencesPath(), ): Promise { 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 @@ -285,7 +292,7 @@ export async function saveHarnessPreferences( } export async function saveAgentRuntimesPreferences( - next: AgentRuntimesPreferences, + next: Pick, path = preferencesPath(), ): Promise { const operation = mutationQueue.catch(() => undefined).then(async () => { @@ -293,11 +300,43 @@ export async function saveAgentRuntimesPreferences( 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 { + 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 diff --git a/src/webui/routes/preferences.spec.ts b/src/webui/routes/preferences.spec.ts index 1b4e3caea..1d912de7c 100644 --- a/src/webui/routes/preferences.spec.ts +++ b/src/webui/routes/preferences.spec.ts @@ -266,9 +266,14 @@ 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(), @@ -276,12 +281,14 @@ describe('preferences routes', () => { 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', @@ -289,8 +296,23 @@ describe('preferences routes', () => { 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 () => { diff --git a/src/webui/routes/preferences.ts b/src/webui/routes/preferences.ts index 957e00e64..daa554292 100644 --- a/src/webui/routes/preferences.ts +++ b/src/webui/routes/preferences.ts @@ -7,6 +7,7 @@ import { readAgentRuntimesPreferences, readHarnessPreferences, readQuickChatPreferences, + rememberAgentRuntimeUse, rememberQuickChatCredential, rememberQuickChatLaunch, rememberRecentChatWorkspace, @@ -74,7 +75,8 @@ interface PreferenceRouteDeps { readHarnessPreferences?(): Promise saveHarnessPreferences?(next: HarnessPreferences): Promise readAgentRuntimesPreferences?(): Promise - saveAgentRuntimesPreferences?(next: AgentRuntimesPreferences): Promise + saveAgentRuntimesPreferences?(next: Pick): Promise + rememberAgentRuntimeUse?(agentId: string): Promise getWorkspaceShellStatus(): Promise saveWorkspaceShellPreference(input: { mode: 'auto' | 'custom' @@ -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), } @@ -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()) diff --git a/ui/src/api/preferences.ts b/ui/src/api/preferences.ts index d23ca39f0..975aa848d 100644 --- a/ui/src/api/preferences.ts +++ b/ui/src/api/preferences.ts @@ -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 = @@ -107,11 +109,19 @@ export const preferencesApi = { return fetchJson('/api/preferences/agent-runtimes') }, - saveAgentRuntimes(next: AgentRuntimesPreferences): Promise { + saveAgentRuntimes(next: Pick): Promise { return fetchJson('/api/preferences/agent-runtimes', { method: 'PUT', headers, body: JSON.stringify(next), }) }, + + rememberAgentRuntimeUse(agentId: string): Promise { + return fetchJson('/api/preferences/agent-runtimes/recent', { + method: 'PUT', + headers, + body: JSON.stringify({ agentId }), + }) + }, } diff --git a/ui/src/components/workspace/AgentLaunchControls.spec.tsx b/ui/src/components/workspace/AgentLaunchControls.spec.tsx index e39274552..cd0102466 100644 --- a/ui/src/components/workspace/AgentLaunchControls.spec.tsx +++ b/ui/src/components/workspace/AgentLaunchControls.spec.tsx @@ -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(), }), })) @@ -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() diff --git a/ui/src/components/workspace/AgentLaunchControls.tsx b/ui/src/components/workspace/AgentLaunchControls.tsx index e1ccbd545..353701afc 100644 --- a/ui/src/components/workspace/AgentLaunchControls.tsx +++ b/ui/src/components/workspace/AgentLaunchControls.tsx @@ -436,9 +436,9 @@ export const AgentLaunchSelectors = forwardRef @@ -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 { @@ -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], + }) }), ] diff --git a/ui/src/hooks/useAgentLaunchConfig.ts b/ui/src/hooks/useAgentLaunchConfig.ts index 03e5bb4d7..9504d299a 100644 --- a/ui/src/hooks/useAgentLaunchConfig.ts +++ b/ui/src/hooks/useAgentLaunchConfig.ts @@ -19,7 +19,7 @@ import { type Workspace, type WorkspaceCredentialDetection, } from '../components/workspace/api' -import { useAgentRuntimes, useAgentRuntimesStore } from './useAgentRuntimes' +import { useAgentRuntimes } from './useAgentRuntimes' import { requiresWorkspaceCredential, resolveAgentRuntime } from '../lib/agentRuntime' import { runtimeEffortOptions, @@ -279,12 +279,10 @@ export function useAgentLaunchPreferences(): AgentLaunchPreferencesState { : { ...current.lastCredentialByAgent, [launch.agent]: launch.credentialSlug }, recentLaunch: launch, })) - useAgentRuntimesStore.getState().adoptRecentAgent(launch.agent) try { const saved = await preferencesApi.rememberQuickChatLaunch(launch) if (saved) { setPreferences(saved) - useAgentRuntimesStore.getState().adoptRecentAgent(saved.recentLaunch?.agent ?? launch.agent) window.dispatchEvent(new CustomEvent(AGENT_LAUNCH_PREFERENCES_CHANGED_EVENT, { detail: saved })) } } catch { diff --git a/ui/src/hooks/useAgentRuntimes.spec.ts b/ui/src/hooks/useAgentRuntimes.spec.ts index 60e2a71e6..fc3c1c55f 100644 --- a/ui/src/hooks/useAgentRuntimes.spec.ts +++ b/ui/src/hooks/useAgentRuntimes.spec.ts @@ -10,15 +10,13 @@ import { probeAgentRuntimeReadiness, type AgentInfo, } from '../components/workspace/api' -import { useAgentLaunchPreferences } from './useAgentLaunchConfig' import { resetAgentRuntimesStore, useAgentRuntimes } from './useAgentRuntimes' vi.mock('../api/preferences', () => ({ preferencesApi: { getAgentRuntimes: vi.fn(), saveAgentRuntimes: vi.fn(), - getQuickChat: vi.fn(), - rememberQuickChatLaunch: vi.fn(), + rememberAgentRuntimeUse: vi.fn(), }, })) @@ -77,35 +75,29 @@ describe('useAgentRuntimes', () => { vi.mocked(probeAgentRuntimeReadiness).mockReset() vi.mocked(preferencesApi.getAgentRuntimes).mockReset() vi.mocked(preferencesApi.saveAgentRuntimes).mockReset() - vi.mocked(preferencesApi.getQuickChat).mockReset() - vi.mocked(preferencesApi.rememberQuickChatLaunch).mockReset() + vi.mocked(preferencesApi.rememberAgentRuntimeUse).mockReset() vi.mocked(listAgents).mockResolvedValue(agents) vi.mocked(getAgentRuntimeReadiness).mockResolvedValue(readiness) vi.mocked(probeAgentRuntimeReadiness).mockResolvedValue(readiness) - vi.mocked(preferencesApi.getAgentRuntimes).mockResolvedValue({ quickAccessIds: ['pi', 'grok'] }) - vi.mocked(preferencesApi.saveAgentRuntimes).mockImplementation(async (next) => next) - vi.mocked(preferencesApi.rememberQuickChatLaunch).mockImplementation(async (launch) => ({ - lastCredentialByAgent: {}, - recentChatWorkspaceId: null, - recentLaunch: launch, - })) - vi.mocked(preferencesApi.getQuickChat).mockResolvedValue({ - lastCredentialByAgent: {}, - recentChatWorkspaceId: null, - recentLaunch: { - agent: 'opencode', - credentialSlug: null, - model: null, - reasoningEffort: null, - }, + vi.mocked(preferencesApi.getAgentRuntimes).mockResolvedValue({ + quickAccessIds: ['pi', 'grok'], + recentAgentIds: ['opencode'], }) + vi.mocked(preferencesApi.saveAgentRuntimes).mockImplementation(async (next) => ({ + ...next, + recentAgentIds: ['opencode'], + })) + vi.mocked(preferencesApi.rememberAgentRuntimeUse).mockImplementation(async (agentId) => ({ + quickAccessIds: ['pi', 'grok'], + recentAgentIds: [agentId, 'opencode'].filter((id, index, all) => all.indexOf(id) === index), + })) }) afterEach(() => { resetAgentRuntimesStore() }) - it('starts loading and projects pinned ids before recent and registry fallbacks', async () => { + it('starts loading and projects recent ids before pins and baseline fallbacks', async () => { const { result } = renderHook(() => useAgentRuntimes()) expect(result.current.loading).toBe(true) expect(result.current.primary).toEqual([]) @@ -114,11 +106,11 @@ describe('useAgentRuntimes', () => { await waitFor(() => expect(result.current.loading).toBe(false)) expect(result.current.catalog.map((item) => item.id)).not.toContain('shell') expect(result.current.agents.map((item) => item.id)).toContain('shell') - expect(result.current.primary.map((item) => item.id)).toEqual(['pi', 'grok', 'opencode', 'claude']) + expect(result.current.primary.map((item) => item.id)).toEqual(['opencode', 'pi', 'grok', 'claude']) expect(result.current.notInstalled.map((item) => item.id)).toEqual(['codex']) expect(result.current.readiness).toEqual(readiness) expect(result.current.quickAccessIds).toEqual(['pi', 'grok']) - expect(result.current.recentAgentId).toBe('opencode') + expect(result.current.recentAgentIds).toEqual(['opencode']) }) it('reports a load error without leaving the hook unusable', async () => { @@ -138,7 +130,6 @@ describe('useAgentRuntimes', () => { expect(listAgents).toHaveBeenCalledOnce() expect(getAgentRuntimeReadiness).toHaveBeenCalledOnce() expect(preferencesApi.getAgentRuntimes).toHaveBeenCalledOnce() - expect(preferencesApi.getQuickChat).toHaveBeenCalledOnce() expect(second.result.current.quickAccessIds).toEqual(first.result.current.quickAccessIds) expect(second.result.current.readiness).toBe(first.result.current.readiness) @@ -148,7 +139,7 @@ describe('useAgentRuntimes', () => { expect(preferencesApi.saveAgentRuntimes).toHaveBeenCalledOnce() expect(first.result.current.quickAccessIds).toEqual(['cursor', 'pi', 'omp']) expect(second.result.current.quickAccessIds).toEqual(['cursor', 'pi', 'omp']) - expect(second.result.current.primary.map((item) => item.id)).toEqual(['cursor', 'pi', 'omp', 'opencode']) + expect(second.result.current.primary.map((item) => item.id)).toEqual(['opencode', 'cursor', 'pi', 'omp']) await act(async () => { await second.result.current.refresh('pi') @@ -169,19 +160,20 @@ describe('useAgentRuntimes', () => { }) expect(result.current.quickAccessIds).toEqual(['pi', 'grok']) expect(result.current.error).toBe('write failed') - expect(result.current.primary.map((item) => item.id)).toEqual(['pi', 'grok', 'opencode', 'claude']) + expect(result.current.primary.map((item) => item.id)).toEqual(['opencode', 'pi', 'grok', 'claude']) }) it('keeps a successful stale save as confirmed when the later intent fails', async () => { const { result } = renderHook(() => useAgentRuntimes()) await waitFor(() => expect(result.current.loading).toBe(false)) - let finishA: (value: { quickAccessIds: readonly string[] }) => void = () => undefined - const pendingA = new Promise<{ quickAccessIds: readonly string[] }>((resolve) => { + type RuntimePreferences = { quickAccessIds: readonly string[]; recentAgentIds: readonly string[] } + let finishA: (value: RuntimePreferences) => void = () => undefined + const pendingA = new Promise((resolve) => { finishA = resolve }) let failB: () => void = () => undefined - const pendingB = new Promise<{ quickAccessIds: readonly string[] }>((_resolve, reject) => { + const pendingB = new Promise((_resolve, reject) => { failB = () => reject(new Error('write failed')) }) vi.mocked(preferencesApi.saveAgentRuntimes) @@ -202,7 +194,7 @@ describe('useAgentRuntimes', () => { expect(result.current.quickAccessIds).toEqual(['claude']) await act(async () => { - finishA({ quickAccessIds: ['cursor'] }) + finishA({ quickAccessIds: ['cursor'], recentAgentIds: ['opencode'] }) await saveA }) await waitFor(() => expect(preferencesApi.saveAgentRuntimes).toHaveBeenCalledTimes(2)) @@ -214,28 +206,36 @@ describe('useAgentRuntimes', () => { }) expect(result.current.quickAccessIds).toEqual(['cursor']) expect(result.current.error).toBe('write failed') - expect(result.current.primary.map((item) => item.id)[0]).toBe('cursor') + expect(result.current.primary.map((item) => item.id)[0]).toBe('opencode') }) - it('updates quick-access fallback after an installation-level recent launch is remembered', async () => { + it('promotes only successful launches and persists the complete MRU order', async () => { const runtimes = renderHook(() => useAgentRuntimes()) - const preferences = renderHook(() => useAgentLaunchPreferences()) await waitFor(() => expect(runtimes.result.current.loading).toBe(false)) - expect(runtimes.result.current.recentAgentId).toBe('opencode') - expect(runtimes.result.current.primary.map((item) => item.id)).toEqual(['pi', 'grok', 'opencode', 'claude']) + expect(runtimes.result.current.recentAgentIds).toEqual(['opencode']) + expect(runtimes.result.current.primary.map((item) => item.id)).toEqual(['opencode', 'pi', 'grok', 'claude']) await act(async () => { - await preferences.result.current.rememberLaunch({ - agent: 'cursor', - accessMode: 'auto', - credentialSlug: null, - model: null, - reasoningEffort: null, - }) + await runtimes.result.current.recordSuccessfulUse('cursor') }) - expect(preferencesApi.rememberQuickChatLaunch).toHaveBeenCalledOnce() - expect(runtimes.result.current.recentAgentId).toBe('cursor') - expect(runtimes.result.current.primary.map((item) => item.id)).toEqual(['pi', 'grok', 'cursor', 'claude']) + expect(preferencesApi.rememberAgentRuntimeUse).toHaveBeenCalledWith('cursor') + expect(runtimes.result.current.recentAgentIds).toEqual(['cursor', 'opencode']) + expect(runtimes.result.current.primary.map((item) => item.id)).toEqual(['cursor', 'opencode', 'pi', 'grok']) expect(runtimes.result.current.quickAccessIds).toEqual(['pi', 'grok']) }) + + it('records every successful launch when promotions arrive back-to-back', async () => { + const runtimes = renderHook(() => useAgentRuntimes()) + await waitFor(() => expect(runtimes.result.current.loading).toBe(false)) + + await act(async () => { + await Promise.all([ + runtimes.result.current.recordSuccessfulUse('cursor'), + runtimes.result.current.recordSuccessfulUse('claude'), + ]) + }) + + expect(preferencesApi.rememberAgentRuntimeUse).toHaveBeenNthCalledWith(1, 'cursor') + expect(preferencesApi.rememberAgentRuntimeUse).toHaveBeenNthCalledWith(2, 'claude') + }) }) diff --git a/ui/src/hooks/useAgentRuntimes.ts b/ui/src/hooks/useAgentRuntimes.ts index 4b77dfdfd..700fd2695 100644 --- a/ui/src/hooks/useAgentRuntimes.ts +++ b/ui/src/hooks/useAgentRuntimes.ts @@ -20,12 +20,13 @@ export interface AgentRuntimesState extends AgentRuntimeQuickAccessProjection { readonly agents: readonly AgentInfo[] readonly readiness: AgentRuntimeReadinessSnapshot | null readonly quickAccessIds: readonly string[] - readonly recentAgentId: string | null + readonly recentAgentIds: readonly string[] readonly loading: boolean readonly refreshing: boolean readonly error: string | null refresh(agent?: string): Promise saveQuickAccess(ids: readonly string[]): Promise + recordSuccessfulUse(agentId: string): Promise } interface AgentRuntimesStore { @@ -33,7 +34,8 @@ interface AgentRuntimesStore { readiness: AgentRuntimeReadinessSnapshot | null quickAccessIds: readonly string[] confirmedQuickAccessIds: readonly string[] - recentAgentId: string | null + recentAgentIds: readonly string[] + confirmedRecentAgentIds: readonly string[] loading: boolean refreshing: boolean error: string | null @@ -42,11 +44,12 @@ interface AgentRuntimesStore { loadGeneration: number saveGeneration: number saveQueue: Promise - recentAgentTouched: boolean + recentGeneration: number + recentQueue: Promise ensureLoaded(): Promise refresh(agent?: string): Promise saveQuickAccess(ids: readonly string[]): Promise - adoptRecentAgent(agentId: string | null): void + recordSuccessfulUse(agentId: string): Promise } function errorMessage(cause: unknown): string { @@ -67,13 +70,13 @@ const emptyStoreSlice = { readiness: null as AgentRuntimeReadinessSnapshot | null, quickAccessIds: [] as readonly string[], confirmedQuickAccessIds: [] as readonly string[], - recentAgentId: null as string | null, + recentAgentIds: [] as readonly string[], + confirmedRecentAgentIds: [] as readonly string[], loading: true, refreshing: false, error: null as string | null, loaded: false, inflight: null as Promise | null, - recentAgentTouched: false, } export const useAgentRuntimesStore = create((set, get) => ({ @@ -81,6 +84,8 @@ export const useAgentRuntimesStore = create((set, get) => ({ loadGeneration: 0, saveGeneration: 0, saveQueue: Promise.resolve(), + recentGeneration: 0, + recentQueue: Promise.resolve(), async ensureLoaded() { if (get().loaded) return @@ -89,30 +94,29 @@ export const useAgentRuntimesStore = create((set, get) => ({ const generation = get().loadGeneration const request = (async () => { set({ loading: true }) - const [listed, snapshot, pins, recents] = await Promise.all([ + const [listed, snapshot, preferences] = await Promise.all([ asSettled(() => listAgents()), asSettled(() => getAgentRuntimeReadiness()), asSettled(() => preferencesApi.getAgentRuntimes()), - asSettled(() => preferencesApi.getQuickChat()), ]) if (get().loadGeneration !== generation) return const agents = listed.status === 'fulfilled' ? listed.value ?? [] : get().agents - const quickAccessIds = pins.status === 'fulfilled' - ? normalizeAgentRuntimeQuickAccessIds(pins.value?.quickAccessIds ?? []) + const quickAccessIds = preferences.status === 'fulfilled' + ? normalizeAgentRuntimeQuickAccessIds(preferences.value?.quickAccessIds ?? []) : get().quickAccessIds + const recentAgentIds = preferences.status === 'fulfilled' + ? normalizeAgentRuntimeQuickAccessIds(preferences.value?.recentAgentIds ?? []) + : get().recentAgentIds const failed = [listed, snapshot].find((result) => result.status === 'rejected') set({ agents, readiness: snapshot.status === 'fulfilled' ? snapshot.value ?? null : get().readiness, quickAccessIds, confirmedQuickAccessIds: quickAccessIds, - recentAgentId: get().recentAgentTouched - ? get().recentAgentId - : recents.status === 'fulfilled' - ? recents.value?.recentLaunch?.agent ?? null - : get().recentAgentId, + recentAgentIds, + confirmedRecentAgentIds: recentAgentIds, error: failed && failed.status === 'rejected' ? errorMessage(failed.reason) : null, loading: false, loaded: true, @@ -185,8 +189,32 @@ export const useAgentRuntimesStore = create((set, get) => ({ return operation }, - adoptRecentAgent(agentId: string | null) { - set({ recentAgentId: agentId, recentAgentTouched: true }) + async recordSuccessfulUse(agentId: string) { + const normalizedAgentId = agentId.trim() + if (!normalizedAgentId) return + const nextIds = normalizeAgentRuntimeQuickAccessIds([ + normalizedAgentId, + ...get().recentAgentIds.filter((id) => id !== normalizedAgentId), + ]) + const generation = get().recentGeneration + 1 + set({ recentGeneration: generation, recentAgentIds: nextIds, error: null }) + const operation = get().recentQueue.catch(() => undefined).then(async () => { + try { + const saved = await preferencesApi.rememberAgentRuntimeUse(normalizedAgentId) + const confirmed = normalizeAgentRuntimeQuickAccessIds(saved.recentAgentIds ?? []) + const latest = get().recentGeneration === generation + set({ + confirmedRecentAgentIds: confirmed, + ...(latest ? { recentAgentIds: confirmed, error: null } : {}), + }) + } catch (cause) { + if (get().recentGeneration !== generation) return + set({ recentAgentIds: get().confirmedRecentAgentIds, error: errorMessage(cause) }) + throw cause + } + }) + set({ recentQueue: operation }) + return operation }, })) @@ -197,6 +225,8 @@ export function resetAgentRuntimesStore(): void { loadGeneration: useAgentRuntimesStore.getState().loadGeneration + 1, saveGeneration: useAgentRuntimesStore.getState().saveGeneration + 1, saveQueue: Promise.resolve(), + recentGeneration: useAgentRuntimesStore.getState().recentGeneration + 1, + recentQueue: Promise.resolve(), }) } @@ -209,21 +239,22 @@ export function useAgentRuntimes(): AgentRuntimesState { const agents = useAgentRuntimesStore((state) => state.agents) const readiness = useAgentRuntimesStore((state) => state.readiness) const quickAccessIds = useAgentRuntimesStore((state) => state.quickAccessIds) - const recentAgentId = useAgentRuntimesStore((state) => state.recentAgentId) + const recentAgentIds = useAgentRuntimesStore((state) => state.recentAgentIds) const loading = useAgentRuntimesStore((state) => state.loading) const refreshing = useAgentRuntimesStore((state) => state.refreshing) const error = useAgentRuntimesStore((state) => state.error) const ensureLoaded = useAgentRuntimesStore((state) => state.ensureLoaded) const refreshStore = useAgentRuntimesStore((state) => state.refresh) const saveStore = useAgentRuntimesStore((state) => state.saveQuickAccess) + const recordSuccessfulUseStore = useAgentRuntimesStore((state) => state.recordSuccessfulUse) useEffect(() => { void ensureLoaded() }, [ensureLoaded]) const projection = useMemo( - () => projectAgentRuntimeQuickAccess(agents, quickAccessIds, recentAgentId), - [agents, quickAccessIds, recentAgentId], + () => projectAgentRuntimeQuickAccess(agents, quickAccessIds, recentAgentIds), + [agents, quickAccessIds, recentAgentIds], ) return { @@ -231,7 +262,7 @@ export function useAgentRuntimes(): AgentRuntimesState { agents, readiness, quickAccessIds, - recentAgentId, + recentAgentIds, loading, refreshing, error, @@ -240,5 +271,9 @@ export function useAgentRuntimes(): AgentRuntimesState { (ids: readonly string[]) => saveStore(ids), [saveStore], ), + recordSuccessfulUse: useCallback( + (agentId: string) => recordSuccessfulUseStore(agentId), + [recordSuccessfulUseStore], + ), } } diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 5ebd83ee0..a356d75ed 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -543,7 +543,7 @@ export const en = { title: 'Agent runtimes', description: 'See which native CLIs this AliceProject can launch, repair missing installs, and choose up to four quick-access runtimes.', quickAccess: 'Quick access', - quickAccessDescription: 'These runtimes appear first in launch pickers. Empty slots fill from a recently used installed runtime, then the remaining installed registry order. Uninstalled runtimes never auto-fill.', + quickAccessDescription: 'Successful Session launches move their runtime to the front automatically. Use this list as the fallback order behind that recent history. Uninstalled runtimes never auto-fill.', quickAccessEmpty: 'No pinned runtimes yet. Add up to four from the catalog below.', catalog: 'Discovered runtimes', catalogDescription: 'Installation state is probed on this machine. It is never stored as a preference.', diff --git a/ui/src/i18n/locales/ja.ts b/ui/src/i18n/locales/ja.ts index 709bd869a..d657b0b3a 100644 --- a/ui/src/i18n/locales/ja.ts +++ b/ui/src/i18n/locales/ja.ts @@ -532,7 +532,7 @@ export const ja: Resources = { title: 'Agent ランタイム', description: 'この AliceProject が起動できるネイティブ CLI を確認し、未インストールを直し、最大 4 件のクイックアクセスを選びます。', quickAccess: 'クイックアクセス', - quickAccessDescription: 'これらのランタイムが起動ピッカーの先頭に出ます。空き枠は最近使ったインストール済みランタイム、続けて登録順の残りのインストール済みランタイムで埋まります。未インストールのランタイムは自動では入りません。', + quickAccessDescription: 'Session の作成に成功すると、そのランタイムが自動で先頭に移動します。この一覧は最近の履歴に続くフォールバック順です。未インストールのランタイムは自動では入りません。', quickAccessEmpty: 'まだピン留めがありません。下のカタログから最大 4 件追加できます。', catalog: '検出されたランタイム', catalogDescription: 'インストール状態はこのマシンで調べます。設定としては保存しません。', diff --git a/ui/src/i18n/locales/zh-Hant.ts b/ui/src/i18n/locales/zh-Hant.ts index 929db5079..35d03df7a 100644 --- a/ui/src/i18n/locales/zh-Hant.ts +++ b/ui/src/i18n/locales/zh-Hant.ts @@ -539,7 +539,7 @@ export const zhHant: Resources = { title: 'Agent 執行環境', description: '查看這個 AliceProject 可以啟動的原生 CLI,修復缺失安裝,並選擇最多四個快捷執行環境。', quickAccess: '快捷入口', - quickAccessDescription: '這些執行環境會出現在啟動選擇器最前面。空位會先用最近使用且已安裝的執行環境補齊,再按登錄表中其餘已安裝順序補齊。未安裝的執行環境不會自動填入。', + quickAccessDescription: '成功建立 Session 後,對應執行環境會自動移到最前面。這裡的順序作為最近使用記錄之後的候補基線;未安裝的執行環境不會自動填入。', quickAccessEmpty: '還沒有釘選執行環境。可從下方目錄最多新增四個。', catalog: '已發現的執行環境', catalogDescription: '安裝狀態由本機探測,不會作為偏好儲存。', diff --git a/ui/src/i18n/locales/zh.ts b/ui/src/i18n/locales/zh.ts index 8d58bd2d8..7143f3591 100644 --- a/ui/src/i18n/locales/zh.ts +++ b/ui/src/i18n/locales/zh.ts @@ -531,7 +531,7 @@ export const zh: Resources = { title: 'Agent 运行时', description: '查看这个 AliceProject 可以启动的原生 CLI,修复缺失安装,并选择最多四个快捷运行时。', quickAccess: '快捷入口', - quickAccessDescription: '这些运行时会出现在启动选择器最前面。空位会先用最近使用且已安装的运行时补齐,再按注册表中其余已安装顺序补齐。未安装的运行时不会自动填入。', + quickAccessDescription: '成功创建 Session 后,对应运行时会自动移到最前面。这里的顺序作为最近使用记录之后的候补基线;未安装的运行时不会自动填入。', quickAccessEmpty: '还没有置顶运行时。可从下方目录最多添加四个。', catalog: '已发现的运行时', catalogDescription: '安装状态由本机探测,不会作为偏好保存。', diff --git a/ui/src/lib/agentRuntimeQuickAccess.spec.ts b/ui/src/lib/agentRuntimeQuickAccess.spec.ts index fa3c1a907..10640bec2 100644 --- a/ui/src/lib/agentRuntimeQuickAccess.spec.ts +++ b/ui/src/lib/agentRuntimeQuickAccess.spec.ts @@ -34,10 +34,14 @@ const catalog = [ ] describe('projectAgentRuntimeQuickAccess', () => { - it('fills four installed slots from pinned ids, then recent, then registry order', () => { - const projected = projectAgentRuntimeQuickAccess(catalog, ['pi', 'missing', 'codex', 'grok'], 'opencode') - expect(projected.primary.map((item) => item.id)).toEqual(['pi', 'grok', 'opencode', 'claude']) - expect(projected.others.map((item) => item.id)).toEqual(['codex', 'cursor', 'agy', 'omp']) + it('fills four installed slots from recent use, pinned ids, defaults, then registry order', () => { + const projected = projectAgentRuntimeQuickAccess( + catalog, + ['pi', 'missing', 'codex', 'grok'], + ['opencode', 'cursor'], + ) + expect(projected.primary.map((item) => item.id)).toEqual(['opencode', 'cursor', 'pi', 'grok']) + expect(projected.others.map((item) => item.id)).toEqual(['claude', 'codex', 'agy', 'omp']) expect(projected.installed.map((item) => item.id)).toEqual([ 'claude', 'cursor', 'agy', 'grok', 'omp', 'opencode', 'pi', ]) @@ -46,20 +50,20 @@ describe('projectAgentRuntimeQuickAccess', () => { }) it('never auto-fills an uninstalled runtime into primary', () => { - const projected = projectAgentRuntimeQuickAccess(catalog, ['codex'], 'codex') - expect(projected.primary.map((item) => item.id)).toEqual(['claude', 'cursor', 'agy', 'grok']) + const projected = projectAgentRuntimeQuickAccess(catalog, ['codex'], ['codex']) + expect(projected.primary.map((item) => item.id)).toEqual(['pi', 'claude', 'grok', 'cursor']) expect(projected.primary.every((item) => item.installed !== false)).toBe(true) }) - it('keeps a later selected runtime out of primary unless it already qualifies', () => { - const projected = projectAgentRuntimeQuickAccess(catalog, ['pi'], null) - expect(projected.primary.map((item) => item.id)).toEqual(['pi', 'claude', 'cursor', 'agy']) + it('uses Pi, Codex, Claude Code, and Grok Build as the cold-start baseline', () => { + const projected = projectAgentRuntimeQuickAccess(catalog, [], []) + expect(projected.primary.map((item) => item.id)).toEqual(['pi', 'claude', 'grok', 'cursor']) expect(projected.others.map((item) => item.id)).toContain('opencode') }) it('lets stale uninstalled pins remain listed without occupying a fallback slot', () => { - const projected = projectAgentRuntimeQuickAccess(catalog, ['codex', 'pi'], null) - expect(projected.primary.map((item) => item.id)).toEqual(['pi', 'claude', 'cursor', 'agy']) + const projected = projectAgentRuntimeQuickAccess(catalog, ['codex', 'pi'], []) + expect(projected.primary.map((item) => item.id)).toEqual(['pi', 'claude', 'grok', 'cursor']) expect(canAddAgentRuntimeQuickAccess(['codex', 'pi'], agent('codex', false))).toBe(true) expect(canAddAgentRuntimeQuickAccess(['pi'], agent('codex', false))).toBe(false) expect(canAddAgentRuntimeQuickAccess(['pi'], agent('claude'))).toBe(true) diff --git a/ui/src/lib/agentRuntimeQuickAccess.ts b/ui/src/lib/agentRuntimeQuickAccess.ts index 6b95bb78f..d147a8968 100644 --- a/ui/src/lib/agentRuntimeQuickAccess.ts +++ b/ui/src/lib/agentRuntimeQuickAccess.ts @@ -1,6 +1,12 @@ import type { AgentInfo } from '../components/workspace/api' export const AGENT_RUNTIME_QUICK_ACCESS_LIMIT = 4 +export const DEFAULT_AGENT_RUNTIME_QUICK_ACCESS_IDS = [ + 'pi', + 'codex', + 'claude', + 'grok', +] as const export function normalizeAgentRuntimeQuickAccessIds(ids: readonly unknown[]): string[] { const seen = new Set() @@ -43,15 +49,15 @@ export interface AgentRuntimeQuickAccessProjection { } /** - * Pinned ids first (when they are known and installed), then the recently used - * installed runtime, then remaining installed registry order. Uninstalled - * runtimes never occupy a fallback slot. The full catalog stays in `others` - * plus the complete installed / not-installed partitions. + * Successful launches form an MRU queue. Manually selected quick-access ids + * then provide an installation-level baseline, followed by the product's cold + * start order and finally registry order. Uninstalled runtimes never occupy a + * primary slot. */ export function projectAgentRuntimeQuickAccess( agents: readonly AgentInfo[], pinnedIds: readonly string[], - recentAgentId: string | null, + recentAgentIds: readonly string[], ): AgentRuntimeQuickAccessProjection { const catalog = agentRuntimeCatalog(agents) const byId = new Map(catalog.map((agent) => [agent.id, agent])) @@ -66,8 +72,9 @@ export function projectAgentRuntimeQuickAccess( primary.push(agent) } + for (const id of recentAgentIds) pushInstalled(id) for (const id of pinnedIds) pushInstalled(id) - pushInstalled(recentAgentId) + for (const id of DEFAULT_AGENT_RUNTIME_QUICK_ACCESS_IDS) pushInstalled(id) for (const agent of catalog) pushInstalled(agent.id) return { diff --git a/ui/src/pages/AgentRuntimesSettingsPage.spec.tsx b/ui/src/pages/AgentRuntimesSettingsPage.spec.tsx index c8fc3e9da..c282f0688 100644 --- a/ui/src/pages/AgentRuntimesSettingsPage.spec.tsx +++ b/ui/src/pages/AgentRuntimesSettingsPage.spec.tsx @@ -90,12 +90,13 @@ vi.mock('../hooks/useAgentRuntimes', () => ({ }, }, quickAccessIds: mocks.quickAccessIds, - recentAgentId: null, + recentAgentIds: [], loading: false, refreshing: false, error: null, refresh: mocks.refresh, saveQuickAccess: mocks.saveQuickAccess, + recordSuccessfulUse: vi.fn(), }), })) diff --git a/ui/src/pages/ChatLandingPage.tsx b/ui/src/pages/ChatLandingPage.tsx index 3ec3025f6..7490c0c5c 100644 --- a/ui/src/pages/ChatLandingPage.tsx +++ b/ui/src/pages/ChatLandingPage.tsx @@ -26,6 +26,7 @@ import { RecoverySurface, RefreshNotice } from '../components/StateViews' import { workspaceDisplayTitle } from '../components/workspace/display' import { useWorkspace } from '../tabs/store' import { useAliceProject } from '../hooks/useAliceProject' +import { useAgentRuntimes } from '../hooks/useAgentRuntimes' import { useAgentLaunchConfig, useAgentLaunchPreferences, @@ -64,6 +65,7 @@ function HarnessLandingPage({ }) { const { t } = useTranslation() const { project } = useAliceProject() + const { recordSuccessfulUse } = useAgentRuntimes() const { quickChat, agents, @@ -204,6 +206,7 @@ function HarnessLandingPage({ launchConfig.launchReasoningEffort, launchConfig.accessMode === 'native' ? 'native' : undefined, ) + void recordSuccessfulUse(effectiveAgent).catch(() => undefined) if (mode === 'chat') launchPreferences.adoptRecentChatWorkspace(workspaceId) setValue('') } catch (err) { diff --git a/ui/src/pages/WorkspaceManagerPage.tsx b/ui/src/pages/WorkspaceManagerPage.tsx index 21d34e604..c352113ad 100644 --- a/ui/src/pages/WorkspaceManagerPage.tsx +++ b/ui/src/pages/WorkspaceManagerPage.tsx @@ -28,6 +28,7 @@ import { WebPiView } from '../components/workspace/WebPiView' import { ResumeCta } from '../components/workspace/ResumeCta' import { useWorkspaces } from '../contexts/workspaces-context' import { useAgentLaunchConfig, useAgentLaunchPreferences } from '../hooks/useAgentLaunchConfig' +import { useAgentRuntimes } from '../hooks/useAgentRuntimes' import { isWorkspaceAiAgent } from '../lib/agentRuntime' import { useWorkspace } from '../tabs/store' import type { ViewSpec } from '../tabs/types' @@ -38,6 +39,7 @@ const SUGGESTION_ICONS = [ClipboardCheck, UsersRound, GitMerge, RefreshCw] as co export function WorkspaceManagerPage({ spec }: { spec: ManagerSpec }) { const { t } = useTranslation() + const { recordSuccessfulUse } = useAgentRuntimes() const { agents, defaultAgent, @@ -103,6 +105,7 @@ export function WorkspaceManagerPage({ spec }: { spec: ManagerSpec }) { launchConfig.launchReasoningEffort, launchConfig.accessMode === 'native' ? 'native' : undefined, ) + void recordSuccessfulUse(effectiveAgent).catch(() => undefined) setDraft('') openOrFocus({ kind: 'workspace-manager', params: { sessionId: result.session.id } }) } catch (cause) {