mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 20:03:13 +08:00
fix(desktop): preserve model selection across stale session refreshes
This commit is contained in:
@@ -371,7 +371,10 @@ function ExitPlanModePermissionDialog({
|
||||
// Local echo so the composer pill reflects the switch immediately; the
|
||||
// server already received the override inside the permission_response,
|
||||
// so deliberately do NOT also send set_runtime_config.
|
||||
useSessionRuntimeStore.getState().setSelection(sessionId, executionRuntime)
|
||||
useSessionRuntimeStore.getState().setSelection(sessionId, {
|
||||
providerId: executionRuntime.providerId,
|
||||
modelId: executionRuntime.modelId,
|
||||
})
|
||||
}
|
||||
respondToPermission(sessionId, requestId, true, {
|
||||
...options,
|
||||
|
||||
@@ -411,8 +411,14 @@ describe('plan mode permission UI', () => {
|
||||
expect(useSessionRuntimeStore.getState().selections['session-1']).toEqual({
|
||||
providerId: null,
|
||||
modelId: 'claude-sonnet-5',
|
||||
effortLevel: 'max',
|
||||
})
|
||||
useChatStore.getState().handleServerMessage('session-1', {
|
||||
type: 'runtime_config_applied', providerId: null, modelId: 'claude-sonnet-5',
|
||||
})
|
||||
useSessionRuntimeStore.getState().syncFromSessions([{
|
||||
id: 'session-1', runtimeProviderId: 'deepseek', runtimeModelId: 'deepseek-v4-flash',
|
||||
} as never])
|
||||
expect(useSessionRuntimeStore.getState().selections['session-1']?.modelId).toBe('deepseek-v4-flash')
|
||||
})
|
||||
|
||||
it('keeps the approval plain when the user re-selects the current model', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentTaskNotification, UIMessage } from '../types/chat'
|
||||
import type { MessageEntry } from '../types/session'
|
||||
import type { MessageEntry, SessionListItem } from '../types/session'
|
||||
import type { SavedProvider } from '../types/provider'
|
||||
import {
|
||||
buildMainSessionActivityModel,
|
||||
@@ -8973,6 +8973,63 @@ describe('chatStore history mapping', () => {
|
||||
expect(sendMock).not.toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'prewarm_session' })
|
||||
})
|
||||
|
||||
it('keeps the selected runtime on reconnect after stopping and receiving stale list metadata', () => {
|
||||
useChatStore.setState({ sessions: {
|
||||
[TEST_SESSION_ID]: makeSession({ chatState: 'streaming' }),
|
||||
} })
|
||||
const oldMetadata = {
|
||||
id: TEST_SESSION_ID, runtimeProviderId: null, runtimeModelId: 'k3[1m]',
|
||||
}
|
||||
useSessionRuntimeStore.getState().syncFromSessions([oldMetadata as SessionListItem])
|
||||
useChatStore.getState().stopGeneration(TEST_SESSION_ID)
|
||||
const next = { providerId: null, modelId: 'deepseek-v4-flash' }
|
||||
useSessionRuntimeStore.getState().setSelection(TEST_SESSION_ID, next)
|
||||
useChatStore.getState().setSessionRuntime(TEST_SESSION_ID, next)
|
||||
useSessionRuntimeStore.getState().syncFromSessions([oldMetadata as SessionListItem])
|
||||
useChatStore.getState().disconnectSession(TEST_SESSION_ID)
|
||||
sendMock.mockClear()
|
||||
useChatStore.getState().connectToSession(TEST_SESSION_ID)
|
||||
|
||||
expect(sendMock).toHaveBeenCalledWith(TEST_SESSION_ID, { type: 'set_runtime_config', ...next })
|
||||
expect(sendMock).not.toHaveBeenCalledWith(TEST_SESSION_ID, expect.objectContaining({ modelId: 'k3[1m]' }))
|
||||
})
|
||||
|
||||
it.each(['before-send', 'after-send', 'after-confirmation'] as const)(
|
||||
'keeps the new model when stopping, switching, and sending despite an old response arriving %s',
|
||||
(arrival) => {
|
||||
useChatStore.setState({ sessions: {
|
||||
[TEST_SESSION_ID]: makeSession({ chatState: 'streaming' }),
|
||||
} })
|
||||
const runtime = useSessionRuntimeStore.getState()
|
||||
const oldMetadata = {
|
||||
id: TEST_SESSION_ID, runtimeProviderId: 'kimi-fixture', runtimeModelId: 'k3[1m]',
|
||||
} as SessionListItem
|
||||
runtime.syncFromSessions([oldMetadata])
|
||||
sendMock.mockClear()
|
||||
useChatStore.getState().stopGeneration(TEST_SESSION_ID)
|
||||
|
||||
const next = { providerId: 'deepseek-fixture', modelId: 'deepseek-v4-flash' }
|
||||
runtime.setSelection(TEST_SESSION_ID, next)
|
||||
useChatStore.getState().setSessionRuntime(TEST_SESSION_ID, next)
|
||||
const pendingList = useSessionRuntimeStore.getState().selections
|
||||
const deliverOldResponse = () => runtime.syncFromSessions([oldMetadata], pendingList)
|
||||
if (arrival === 'before-send') deliverOldResponse()
|
||||
useChatStore.getState().sendMessage(TEST_SESSION_ID, 'Continue with the selected model')
|
||||
if (arrival === 'after-send') deliverOldResponse()
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'runtime_config_applied', ...next,
|
||||
})
|
||||
if (arrival === 'after-confirmation') deliverOldResponse()
|
||||
|
||||
expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]).toEqual(next)
|
||||
expect(sendMock.mock.calls.map(([, message]) => message)).toEqual([
|
||||
{ type: 'stop_generation' },
|
||||
{ type: 'set_runtime_config', ...next },
|
||||
{ type: 'user_message', content: 'Continue with the selected model', attachments: undefined },
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
it('sends explicit runtime overrides over websocket', () => {
|
||||
useChatStore.getState().setSessionRuntime(TEST_SESSION_ID, {
|
||||
providerId: null,
|
||||
@@ -8999,6 +9056,7 @@ describe('chatStore history mapping', () => {
|
||||
[TEST_SESSION_ID]: makeSession({ runtimeConfigReadyCount: 0 }),
|
||||
},
|
||||
})
|
||||
const pendingRequest = useSessionRuntimeStore.getState().selections
|
||||
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'runtime_config_applied',
|
||||
@@ -9015,6 +9073,27 @@ describe('chatStore history mapping', () => {
|
||||
effortLevel: 'high',
|
||||
})
|
||||
expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.runtimeConfigReadyCount).toBe(1)
|
||||
const remote = {
|
||||
id: TEST_SESSION_ID, runtimeProviderId: 'provider-a', runtimeModelId: 'model-a', effortLevel: 'high',
|
||||
} as SessionListItem
|
||||
useSessionRuntimeStore.getState().syncFromSessions([remote], pendingRequest)
|
||||
expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]?.modelId).toBe('model-b')
|
||||
useSessionRuntimeStore.getState().syncFromSessions([remote], useSessionRuntimeStore.getState().selections)
|
||||
expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]?.modelId).toBe('model-a')
|
||||
})
|
||||
|
||||
it.each(['RUNTIME_CONFIG_INVALID', 'CLI_RESTART_FAILED'])('allows fresh metadata to correct a rejected selection (%s)', (code) => {
|
||||
const runtime = useSessionRuntimeStore.getState()
|
||||
runtime.setSelection(TEST_SESSION_ID, { providerId: null, modelId: 'rejected-model' })
|
||||
const staleRequest = useSessionRuntimeStore.getState().selections
|
||||
useChatStore.getState().handleServerMessage(TEST_SESSION_ID, {
|
||||
type: 'error', code, message: 'Fixture runtime switch failed',
|
||||
})
|
||||
const actual = { id: TEST_SESSION_ID, runtimeProviderId: null, runtimeModelId: 'k3' } as SessionListItem
|
||||
runtime.syncFromSessions([actual], staleRequest)
|
||||
expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]?.modelId).toBe('rejected-model')
|
||||
runtime.syncFromSessions([actual], useSessionRuntimeStore.getState().selections)
|
||||
expect(useSessionRuntimeStore.getState().selections[TEST_SESSION_ID]?.modelId).toBe('k3')
|
||||
})
|
||||
|
||||
it('shows AskUserQuestion when permission arrives before the streamed tool block', () => {
|
||||
|
||||
@@ -4855,6 +4855,7 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
||||
selected?.modelId === msg.modelId &&
|
||||
selected?.effortLevel === msg.effortLevel
|
||||
if (matchesCurrentSelection) {
|
||||
useSessionRuntimeStore.getState().settleSelection(sessionId)
|
||||
update((session) => ({
|
||||
runtimeConfigReadyCount: (session.runtimeConfigReadyCount ?? 0) + 1,
|
||||
}))
|
||||
@@ -5540,6 +5541,10 @@ export const useChatStore = create<ChatStore>((setState, get) => {
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
if (msg.code === 'RUNTIME_CONFIG_INVALID' || msg.code === 'CLI_RESTART_FAILED') {
|
||||
// Let a fresh server snapshot reconcile a rejected optimistic choice.
|
||||
useSessionRuntimeStore.getState().settleSelection(sessionId)
|
||||
}
|
||||
const errorMessage: Extract<UIMessage, { type: 'error' }> = {
|
||||
id: nextId(),
|
||||
type: 'error',
|
||||
|
||||
@@ -14,6 +14,53 @@ describe('sessionRuntimeStore runtime cleanup', () => {
|
||||
useSessionRuntimeStore.setState({ selections: {} })
|
||||
})
|
||||
|
||||
it('keeps an explicit model choice through stale, matching, then stale metadata refreshes', () => {
|
||||
const store = useSessionRuntimeStore.getState()
|
||||
const oldSession = {
|
||||
id: 'switch-session', runtimeProviderId: 'kimi', runtimeModelId: 'k3[1m]',
|
||||
} as SessionListItem
|
||||
store.syncFromSessions([oldSession])
|
||||
const next = { providerId: 'deepseek', modelId: 'deepseek-v4-flash' }
|
||||
store.setSelection(oldSession.id, next)
|
||||
const startedWith = useSessionRuntimeStore.getState().selections
|
||||
|
||||
for (const metadata of [oldSession, {
|
||||
...oldSession, runtimeProviderId: next.providerId, runtimeModelId: next.modelId,
|
||||
}, oldSession]) {
|
||||
store.syncFromSessions([metadata], startedWith)
|
||||
expect(useSessionRuntimeStore.getState().selections[oldSession.id]).toEqual(next)
|
||||
expect(JSON.parse(localStorage.getItem('cc-haha-session-runtime')!)[oldSession.id]).toEqual(next)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts later remote changes after confirmation but ignores pre-confirmation requests', () => {
|
||||
const store = useSessionRuntimeStore.getState()
|
||||
const next = { providerId: 'deepseek', modelId: 'deepseek-v4-flash' }
|
||||
store.setSelection('confirmed', next)
|
||||
const oldRequest = useSessionRuntimeStore.getState().selections
|
||||
store.settleSelection('confirmed')
|
||||
const remote = { id: 'confirmed', runtimeProviderId: 'kimi', runtimeModelId: 'k3' } as SessionListItem
|
||||
store.syncFromSessions([remote], oldRequest)
|
||||
expect(useSessionRuntimeStore.getState().selections.confirmed).toEqual(next)
|
||||
store.syncFromSessions([remote], useSessionRuntimeStore.getState().selections)
|
||||
expect(useSessionRuntimeStore.getState().selections.confirmed).toEqual({ providerId: 'kimi', modelId: 'k3' })
|
||||
})
|
||||
|
||||
it('preserves a moved draft choice and releases local ownership when cleared', () => {
|
||||
const store = useSessionRuntimeStore.getState()
|
||||
const next = { providerId: 'deepseek', modelId: 'deepseek-v4-flash' }
|
||||
const metadata = {
|
||||
id: 'new-session', runtimeProviderId: 'kimi', runtimeModelId: 'k3',
|
||||
} as SessionListItem
|
||||
store.setSelection('__draft__', next)
|
||||
store.moveSelection('__draft__', metadata.id)
|
||||
store.syncFromSessions([metadata])
|
||||
expect(useSessionRuntimeStore.getState().selections[metadata.id]).toEqual(next)
|
||||
store.clearSelection(metadata.id)
|
||||
store.syncFromSessions([metadata])
|
||||
expect(useSessionRuntimeStore.getState().selections[metadata.id]).toEqual({ providerId: 'kimi', modelId: 'k3' })
|
||||
})
|
||||
|
||||
it('discards retired Grok selections before persisting them', () => {
|
||||
useSessionRuntimeStore.getState().setSelection('session-grok', {
|
||||
providerId: 'grok-official',
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
import { normalizeRuntimeSelection } from '../lib/runtimeSelection'
|
||||
|
||||
const STORAGE_KEY = 'cc-haha-session-runtime'
|
||||
// Session-list metadata can lag behind runtime changes or arrive out of order.
|
||||
// Protect local choices until the server confirms them. Object identity also
|
||||
// lets callers discard list responses started before a choice/confirmation.
|
||||
// This transient state follows moveSelection without changing persisted JSON.
|
||||
const pendingRuntimes = new WeakSet<RuntimeSelection>()
|
||||
const RETIRED_GROK_MODEL_IDS = new Set([
|
||||
'grok-build',
|
||||
'grok-build-0.1',
|
||||
@@ -24,7 +29,8 @@ type SessionRuntimeStore = {
|
||||
setSelection: (key: string, selection: RuntimeSelection) => void
|
||||
clearSelection: (key: string) => void
|
||||
moveSelection: (fromKey: string, toKey: string) => void
|
||||
syncFromSessions: (sessions: SessionListItem[]) => void
|
||||
settleSelection: (key: string) => void
|
||||
syncFromSessions: (sessions: SessionListItem[], startedWith?: Record<string, RuntimeSelection>) => void
|
||||
}
|
||||
|
||||
function normalizeSelection(selection: RuntimeSelection): RuntimeSelection | null {
|
||||
@@ -105,8 +111,10 @@ export const useSessionRuntimeStore = create<SessionRuntimeStore>((set) => ({
|
||||
set((state) => {
|
||||
const normalized = normalizeSelection(selection)
|
||||
const selections = { ...state.selections }
|
||||
if (normalized) selections[key] = normalized
|
||||
else delete selections[key]
|
||||
if (normalized) {
|
||||
pendingRuntimes.add(normalized)
|
||||
selections[key] = normalized
|
||||
} else delete selections[key]
|
||||
persistSelections(selections)
|
||||
return { selections }
|
||||
}),
|
||||
@@ -132,28 +140,39 @@ export const useSessionRuntimeStore = create<SessionRuntimeStore>((set) => ({
|
||||
return { selections }
|
||||
}),
|
||||
|
||||
syncFromSessions: (sessions) =>
|
||||
settleSelection: (key) =>
|
||||
set((state) => {
|
||||
const current = state.selections[key]
|
||||
if (!current || !pendingRuntimes.has(current)) return state
|
||||
// A new identity invalidates requests started before confirmation/failure.
|
||||
return { selections: { ...state.selections, [key]: { ...current } } }
|
||||
}),
|
||||
|
||||
syncFromSessions: (sessions, startedWith) =>
|
||||
set((state) => {
|
||||
let selections = state.selections
|
||||
for (const session of sessions) {
|
||||
const current = selections[session.id]
|
||||
if (startedWith && startedWith[session.id] !== current) continue
|
||||
if (!session.runtimeModelId || session.runtimeProviderId === undefined) continue
|
||||
const selection = normalizeSelection({
|
||||
providerId: session.runtimeProviderId,
|
||||
modelId: session.runtimeModelId,
|
||||
...(session.effortLevel ? { effortLevel: session.effortLevel } : {}),
|
||||
})
|
||||
const matchesCurrent = selection &&
|
||||
current?.providerId === selection.providerId &&
|
||||
current.modelId === selection.modelId &&
|
||||
current.effortLevel === selection.effortLevel
|
||||
const pending = current && pendingRuntimes.has(current)
|
||||
if (pending && !matchesCurrent) continue
|
||||
if (!selection) {
|
||||
if (!(session.id in selections)) continue
|
||||
if (selections === state.selections) selections = { ...state.selections }
|
||||
delete selections[session.id]
|
||||
continue
|
||||
}
|
||||
const current = selections[session.id]
|
||||
if (
|
||||
current?.providerId === selection.providerId &&
|
||||
current.modelId === selection.modelId &&
|
||||
current.effortLevel === selection.effortLevel
|
||||
) {
|
||||
if (matchesCurrent && !pending) {
|
||||
continue
|
||||
}
|
||||
if (selections === state.selections) selections = { ...state.selections }
|
||||
|
||||
@@ -220,11 +220,12 @@ describe('sessionStore', () => {
|
||||
})
|
||||
|
||||
it('syncs transcript runtime metadata before a session is opened from the sidebar', async () => {
|
||||
useSessionRuntimeStore.getState().setSelection('session-runtime-1', {
|
||||
// Simulate a selection loaded from storage, not a choice made this run.
|
||||
useSessionRuntimeStore.setState({ selections: { 'session-runtime-1': {
|
||||
providerId: null,
|
||||
modelId: 'gpt-5.4',
|
||||
effortLevel: 'max',
|
||||
})
|
||||
} } })
|
||||
listMock.mockResolvedValue({
|
||||
sessions: [{
|
||||
...makeSession('session-runtime-1', '2026-07-13T05:57:05.818Z'),
|
||||
@@ -244,6 +245,26 @@ describe('sessionStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not roll back a model selected while a session-list request is in flight', async () => {
|
||||
const response = {
|
||||
sessions: [{
|
||||
...makeSession('model-switch', '2026-09-20T00:00:00Z'),
|
||||
runtimeProviderId: 'kimi', runtimeModelId: 'k3[1m]',
|
||||
}],
|
||||
total: 1,
|
||||
}
|
||||
const request = createDeferred<typeof response>()
|
||||
listMock.mockReturnValueOnce(request.promise)
|
||||
const refresh = useSessionStore.getState().fetchSessions()
|
||||
const selection = { providerId: 'deepseek', modelId: 'deepseek-v4-flash' }
|
||||
useSessionRuntimeStore.getState().setSelection('model-switch', selection)
|
||||
request.resolve(response)
|
||||
await refresh
|
||||
|
||||
expect(useSessionRuntimeStore.getState().selections['model-switch']).toEqual(selection)
|
||||
expect(useSessionStore.getState().sessions[0]?.id).toBe('model-switch')
|
||||
})
|
||||
|
||||
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')],
|
||||
@@ -270,10 +291,10 @@ describe('sessionStore', () => {
|
||||
runtimeModelId: 'model-current',
|
||||
effortLevel: 'high' as const,
|
||||
}
|
||||
useSessionRuntimeStore.getState().setSelection(historical.id, {
|
||||
useSessionRuntimeStore.setState({ selections: { [historical.id]: {
|
||||
providerId: 'provider-stale',
|
||||
modelId: 'model-stale',
|
||||
})
|
||||
} } })
|
||||
let observedAtActivation: unknown
|
||||
const unsubscribe = useTabStore.subscribe((state) => {
|
||||
if (state.activeTabId !== historical.id) return
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useSettingsStore } from './settingsStore'
|
||||
import { useTabStore } from './tabStore'
|
||||
import type { LocalIndexStatus, SessionListItem } from '../types/session'
|
||||
import type { PermissionMode } from '../types/settings'
|
||||
import type { RuntimeSelection } from '../types/runtime'
|
||||
import { isPlaceholderSessionTitle } from '../lib/sessionTitle'
|
||||
import { invalidateRecentProjectsCache } from '../lib/recentProjectsCache'
|
||||
import { releaseWorkspaceSession } from '../lib/workspace/releaseSession'
|
||||
@@ -56,7 +57,7 @@ type SessionStore = {
|
||||
fetchSessions: (project?: string) => Promise<void>
|
||||
loadMoreProjectSessions: (projectRoot: string) => Promise<void>
|
||||
releaseProjectHistory: (projectRoot: string) => void
|
||||
hydrateHistoricalSessions: (sessions: SessionListItem[]) => SessionListItem[]
|
||||
hydrateHistoricalSessions: (sessions: SessionListItem[], runtimeSelections?: Record<string, RuntimeSelection>) => SessionListItem[]
|
||||
openHistoricalSession: (session: SessionListItem) => void
|
||||
createSession: (workDir?: string, options?: CreateSessionOptions) => Promise<string>
|
||||
branchSession: (
|
||||
@@ -106,13 +107,14 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
|
||||
fetchSessions: async (project?: string) => {
|
||||
const requestId = ++fetchSessionsRequestId
|
||||
const runtimeSelections = useSessionRuntimeStore.getState().selections
|
||||
set({ isLoading: true, error: null, sessionListRequestId: requestId })
|
||||
try {
|
||||
const response = await sessionsApi.list(buildSessionListParams(project))
|
||||
if (requestId !== get().sessionListRequestId) return
|
||||
const raw = response.sessions
|
||||
const indexStatus = response.index ?? null
|
||||
useSessionRuntimeStore.getState().syncFromSessions(raw)
|
||||
useSessionRuntimeStore.getState().syncFromSessions(raw, runtimeSelections)
|
||||
let syncedSessions: SessionListItem[] = []
|
||||
set((state) => {
|
||||
if (requestId !== state.sessionListRequestId) return state
|
||||
@@ -267,13 +269,13 @@ export const useSessionStore = create<SessionStore>((set, get) => ({
|
||||
})
|
||||
},
|
||||
|
||||
hydrateHistoricalSessions: (snapshots) => {
|
||||
hydrateHistoricalSessions: (snapshots, runtimeSelections) => {
|
||||
if (snapshots.length === 0) return []
|
||||
const selected = reconcileSessionSnapshots(snapshots, get().sessions)
|
||||
const selectedIds = new Set(selected.map((session) => session.id))
|
||||
// Hydrate before activating the tab: connecting immediately applies its
|
||||
// runtime selection and the composer reads workspace/permission metadata.
|
||||
useSessionRuntimeStore.getState().syncFromSessions(selected)
|
||||
useSessionRuntimeStore.getState().syncFromSessions(selected, runtimeSelections)
|
||||
set((state) => ({
|
||||
sessions: mergeSessionList([
|
||||
...selected,
|
||||
|
||||
@@ -51,6 +51,24 @@ describe('tabStore', () => {
|
||||
vi.mocked(sessionsApi.getSummary).mockReset().mockRejectedValue(new ApiError(404, 'Session not found'))
|
||||
})
|
||||
|
||||
it.each([false, true])('preserves a confirmed model switch during tab restoration (historical=%s)', async (historical) => {
|
||||
const summary = historicalSummary()
|
||||
localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
|
||||
openTabs: [{ sessionId: summary.id, title: summary.title }], activeTabId: summary.id,
|
||||
}))
|
||||
let resolveList!: (value: Awaited<ReturnType<typeof sessionsApi.list>>) => void
|
||||
vi.mocked(sessionsApi.list).mockImplementationOnce(() => new Promise((resolve) => { resolveList = resolve }))
|
||||
vi.mocked(sessionsApi.getSummary).mockResolvedValue(summary)
|
||||
const restore = useTabStore.getState().restoreTabs()
|
||||
const next = { providerId: 'deepseek', modelId: 'deepseek-v4-flash' }
|
||||
useSessionRuntimeStore.getState().setSelection(summary.id, next)
|
||||
useSessionRuntimeStore.getState().settleSelection(summary.id)
|
||||
resolveList({ sessions: historical ? [] : [summary], total: historical ? 0 : 1 })
|
||||
await restore
|
||||
expect(useSessionRuntimeStore.getState().selections[summary.id]).toEqual(next)
|
||||
expect(useTabStore.getState().activeTabId).toBe(summary.id)
|
||||
})
|
||||
|
||||
it('refreshes an existing tab title when opening the same session again', () => {
|
||||
useTabStore.getState().openTab('session-1', '```json {"title":')
|
||||
useTabStore.getState().openTab('session-1', '使用bash写一个shell,随便写点什么东西')
|
||||
@@ -473,11 +491,11 @@ describe('tabStore', () => {
|
||||
)
|
||||
|
||||
it('hydrates restored tabs with authoritative transcript runtime metadata', async () => {
|
||||
useSessionRuntimeStore.getState().setSelection('session-1', {
|
||||
useSessionRuntimeStore.setState({ selections: { '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',
|
||||
|
||||
@@ -430,6 +430,7 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
restoreTabs: async () => {
|
||||
try {
|
||||
const restoreStartedWith = get()
|
||||
const runtimeSelections = useSessionRuntimeStore.getState().selections
|
||||
const restoreStillCurrent = () => {
|
||||
const current = get()
|
||||
return current.tabs === restoreStartedWith.tabs &&
|
||||
@@ -478,10 +479,10 @@ export const useTabStore = create<TabStore>((set, get) => ({
|
||||
const recentSessions = reconcileSessionSnapshots(sessions, useSessionStore.getState().sessions)
|
||||
for (const session of recentSessions) sessionsById.set(session.id, session)
|
||||
if (historicalSessions.length > 0) {
|
||||
const hydrated = useSessionStore.getState().hydrateHistoricalSessions(historicalSessions)
|
||||
const hydrated = useSessionStore.getState().hydrateHistoricalSessions(historicalSessions, runtimeSelections)
|
||||
for (const session of hydrated) sessionsById.set(session.id, session)
|
||||
}
|
||||
useSessionRuntimeStore.getState().syncFromSessions(recentSessions)
|
||||
useSessionRuntimeStore.getState().syncFromSessions(recentSessions, runtimeSelections)
|
||||
|
||||
const validTabs: Tab[] = data.openTabs
|
||||
.filter((t) => {
|
||||
|
||||
Reference in New Issue
Block a user