fix(desktop): refine and restore effort selection #1007

This commit is contained in:
程序员阿江(Relakkes) 2026-07-13 14:53:39 +08:00
parent f85b93e9c2
commit c550dbc07a
10 changed files with 149 additions and 7 deletions

View File

@ -47,7 +47,10 @@ describe('ReasoningEffortPopover', () => {
expect(popover).toHaveStyle({ width: '280px' })
expect(popover).toHaveClass('px-4', 'pb-4', 'pt-3.5')
expect(popover.querySelectorAll('svg')).toHaveLength(0)
expect(screen.getByTestId('reasoning-effort-label')).toHaveClass('mb-3', 'text-[15px]')
expect(screen.getByTestId('reasoning-effort-header')).toHaveClass('mb-3', 'justify-between')
expect(screen.getByTestId('reasoning-effort-label')).toHaveClass('text-[15px]')
expect(screen.getByTestId('reasoning-effort-context-label')).toHaveClass('text-[11px]')
expect(screen.getByTestId('reasoning-effort-context-label')).toHaveTextContent('推理强度')
expect(screen.getByRole('slider', { name: '推理强度' })).toHaveClass('h-11')
expect(screen.getByTestId('reasoning-effort-track')).toHaveClass('h-[30px]')
expect(screen.getByTestId('reasoning-effort-thumb')).toHaveClass('h-10', 'w-10')
@ -63,7 +66,8 @@ describe('ReasoningEffortPopover', () => {
expect(slider).toHaveAttribute('aria-valuetext', '极高')
expect(screen.getAllByTestId('reasoning-effort-stop')).toHaveLength(5)
expect(screen.getByText('极高')).toBeInTheDocument()
expect(screen.getByTestId('reasoning-effort-fill')).toHaveClass('bg-[#3798f7]')
expect(screen.getByTestId('reasoning-effort-fill')).toHaveClass('bg-[var(--color-brand)]')
expect(slider).toHaveClass('focus-visible:ring-[var(--color-brand)]')
})
it('selects a discrete stop from the track', () => {

View File

@ -111,10 +111,21 @@ export function ReasoningEffortPopover({
style={{ bottom: position.bottom, left: position.left, width: position.width }}
>
<div
data-testid="reasoning-effort-label"
className="mb-3 text-[15px] font-semibold text-[var(--color-text-secondary)]"
data-testid="reasoning-effort-header"
className="mb-3 flex items-baseline justify-between gap-3"
>
{labels[value]}
<div
data-testid="reasoning-effort-label"
className="text-[15px] font-semibold text-[var(--color-text-secondary)]"
>
{labels[value]}
</div>
<div
data-testid="reasoning-effort-context-label"
className="text-[11px] font-medium tracking-wide text-[var(--color-text-tertiary)]"
>
{ariaLabel}
</div>
</div>
<div
@ -126,7 +137,7 @@ export function ReasoningEffortPopover({
aria-valuemax={maxIndex}
aria-valuenow={selectedIndex}
aria-valuetext={labels[value]}
className="group relative flex h-11 touch-none cursor-pointer items-center outline-none focus-visible:ring-2 focus-visible:ring-[#3798f7] focus-visible:ring-offset-4 focus-visible:ring-offset-[var(--color-surface-container-lowest)]"
className="group relative flex h-11 touch-none cursor-pointer items-center outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-4 focus-visible:ring-offset-[var(--color-surface-container-lowest)]"
onClick={(event) => selectFromClientX(event.clientX)}
onPointerDown={(event) => {
draggingRef.current = true
@ -175,7 +186,7 @@ export function ReasoningEffortPopover({
>
<div
data-testid="reasoning-effort-fill"
className="h-full rounded-full bg-[#3798f7] transition-[width] duration-200 motion-reduce:transition-none"
className="h-full rounded-full bg-[var(--color-brand)] transition-[width] duration-200 motion-reduce:transition-none"
style={{ width: `${fillPercent}%` }}
/>
</div>

View File

@ -1,5 +1,6 @@
import { create } from 'zustand'
import type { RuntimeSelection } from '../types/runtime'
import type { SessionListItem } from '../types/session'
const STORAGE_KEY = 'cc-haha-session-runtime'
@ -10,6 +11,7 @@ type SessionRuntimeStore = {
setSelection: (key: string, selection: RuntimeSelection) => void
clearSelection: (key: string) => void
moveSelection: (fromKey: string, toKey: string) => void
syncFromSessions: (sessions: SessionListItem[]) => void
}
function loadSelections(): Record<string, RuntimeSelection> {
@ -66,4 +68,30 @@ export const useSessionRuntimeStore = create<SessionRuntimeStore>((set) => ({
persistSelections(selections)
return { selections }
}),
syncFromSessions: (sessions) =>
set((state) => {
let selections = state.selections
for (const session of sessions) {
if (!session.runtimeModelId || session.runtimeProviderId === undefined) continue
const selection: RuntimeSelection = {
providerId: session.runtimeProviderId,
modelId: session.runtimeModelId,
...(session.effortLevel ? { effortLevel: session.effortLevel } : {}),
}
const current = selections[session.id]
if (
current?.providerId === selection.providerId &&
current.modelId === selection.modelId &&
current.effortLevel === selection.effortLevel
) {
continue
}
if (selections === state.selections) selections = { ...state.selections }
selections[session.id] = selection
}
if (selections === state.selections) return state
persistSelections(selections)
return { selections }
}),
}))

View File

@ -25,6 +25,7 @@ vi.mock('../lib/recentProjectsCache', () => ({
}))
import { useSessionStore } from './sessionStore'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
import { useSettingsStore } from './settingsStore'
import { useTabStore } from './tabStore'
@ -73,12 +74,14 @@ describe('sessionStore', () => {
})
useSettingsStore.setState({ permissionMode: 'default' })
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionRuntimeStore.setState({ selections: {} })
})
afterEach(() => {
useSessionStore.setState(initialState)
useSettingsStore.setState({ permissionMode: 'default' })
useTabStore.setState({ tabs: [], activeTabId: null })
useSessionRuntimeStore.setState({ selections: {} })
})
it('returns a new session id before the background refresh completes', async () => {
@ -165,6 +168,31 @@ describe('sessionStore', () => {
expect(useTabStore.getState().tabs[0]?.title).toBe('使用bash写一个shell随便写点什么东西')
})
it('syncs transcript runtime metadata before a session is opened from the sidebar', async () => {
useSessionRuntimeStore.getState().setSelection('session-runtime-1', {
providerId: null,
modelId: 'gpt-5.4',
effortLevel: 'max',
})
listMock.mockResolvedValue({
sessions: [{
...makeSession('session-runtime-1', '2026-07-13T05:57:05.818Z'),
runtimeProviderId: 'provider-latest',
runtimeModelId: 'anthropic/claude-opus-4.7',
effortLevel: 'max',
}],
total: 1,
})
await useSessionStore.getState().fetchSessions()
expect(useSessionRuntimeStore.getState().selections['session-runtime-1']).toEqual({
providerId: 'provider-latest',
modelId: 'anthropic/claude-opus-4.7',
effortLevel: 'max',
})
})
it('updates a session message count without changing other metadata', () => {
useSessionStore.setState({
sessions: [makeSession('session-count-1', '2026-05-07T00:00:00.000Z', 'Working session')],

View File

@ -68,6 +68,7 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
try {
const { sessions: raw } = await sessionsApi.list(buildSessionListParams(project))
if (requestId !== fetchSessionsRequestId) return
useSessionRuntimeStore.getState().syncFromSessions(raw)
let syncedSessions: SessionListItem[] = []
set((state) => {
const sessions = mergeSessionList(raw, state.sessions)

View File

@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { sessionsApi } from '../api/sessions'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
import { SETTINGS_TAB_ID, MARKET_TAB_ID, useTabStore } from './tabStore'
vi.mock('../api/sessions', () => ({
@ -12,6 +13,7 @@ describe('tabStore', () => {
beforeEach(() => {
useTabStore.setState({ tabs: [], activeTabId: null })
localStorage.clear()
useSessionRuntimeStore.setState({ selections: {} })
vi.mocked(sessionsApi.list).mockResolvedValue({ sessions: [] } as never)
})
@ -196,6 +198,36 @@ describe('tabStore', () => {
expect(useTabStore.getState().activeTabId).toBe(MARKET_TAB_ID)
})
it('hydrates restored tabs with authoritative transcript runtime metadata', async () => {
useSessionRuntimeStore.getState().setSelection('session-1', {
providerId: null,
modelId: 'gpt-5.4',
effortLevel: 'max',
})
localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
openTabs: [{ sessionId: 'session-1', title: 'Runtime session', type: 'session' }],
activeTabId: 'session-1',
}))
vi.mocked(sessionsApi.list).mockResolvedValue({
sessions: [{
id: 'session-1',
title: 'Runtime session',
runtimeProviderId: 'provider-latest',
runtimeModelId: 'anthropic/claude-opus-4.7',
effortLevel: 'max',
}],
total: 1,
} as never)
await useTabStore.getState().restoreTabs()
expect(useSessionRuntimeStore.getState().selections['session-1']).toEqual({
providerId: 'provider-latest',
modelId: 'anthropic/claude-opus-4.7',
effortLevel: 'max',
})
})
it('canonicalizes mismatched persisted special tab ids and types during restore', async () => {
localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
openTabs: [

View File

@ -2,6 +2,7 @@ import { create } from 'zustand'
import { sessionsApi } from '../api/sessions'
import { dropSession as dropVirtualHeightSession } from '../components/chat/virtualHeightCache'
import { destroyTerminalRuntime } from '../lib/terminalRuntime'
import { useSessionRuntimeStore } from './sessionRuntimeStore'
const TAB_STORAGE_KEY = 'cc-haha-open-tabs'
@ -351,6 +352,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
) {
return
}
useSessionRuntimeStore.getState().syncFromSessions(sessions)
const existingIds = new Set(sessions.map((s) => s.id))
const validTabs: Tab[] = data.openTabs

View File

@ -1,5 +1,7 @@
// Source: src/server/services/sessionService.ts
import type { ReasoningEffortLevel } from './settings'
export type SessionListItem = {
id: string
title: string
@ -11,6 +13,9 @@ export type SessionListItem = {
workDir: string | null
workDirExists: boolean
permissionMode?: string
runtimeProviderId?: string | null
runtimeModelId?: string
effortLevel?: ReasoningEffortLevel
}
export type MessageUsage = {

View File

@ -1686,6 +1686,29 @@ describe('SessionService', () => {
expect((await service.getSessionLaunchInfo(sessionId))?.permissionMode).toBe('auto')
})
it('should expose the latest runtime selection in the session list', async () => {
const workDir = '/tmp/runtime-list-metadata'
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile(sanitizePath(workDir), sessionId, [
makeSnapshotEntry(),
{
...makeSessionMetaEntry(workDir),
runtimeProviderId: 'provider-latest',
runtimeModelId: 'anthropic/claude-opus-4.7',
effortLevel: 'max',
},
makeUserEntry('Use the latest runtime metadata'),
])
const listed = (await service.listSessions()).sessions.find((session) => session.id === sessionId)
expect(listed).toMatchObject({
runtimeProviderId: 'provider-latest',
runtimeModelId: 'anthropic/claude-opus-4.7',
effortLevel: 'max',
})
})
it('should not append duplicate runtime metadata when it already matches', async () => {
const workDir = '/tmp/runtime-idempotent'
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'

View File

@ -61,6 +61,9 @@ export type SessionListItem = {
workDir: string | null
workDirExists: boolean
permissionMode?: string
runtimeProviderId?: string | null
runtimeModelId?: string
effortLevel?: string
}
export type DeleteSessionFailure = {
@ -2459,6 +2462,11 @@ export class SessionService {
workDir,
workDirExists,
permissionMode: summary.permissionMode,
...(summary.runtimeProviderId !== undefined
? { runtimeProviderId: summary.runtimeProviderId }
: {}),
...(summary.runtimeModelId ? { runtimeModelId: summary.runtimeModelId } : {}),
...(summary.effortLevel ? { effortLevel: summary.effortLevel } : {}),
})
} catch {
// Skip unreadable files