From f15e7e4dbe8dc9df6a6e8d7bbaa7ced7ec6d6a86 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Sat, 29 Aug 2026 00:14:45 +0800 Subject: [PATCH 1/6] fix(ui): guide legacy session account recovery --- ...app-shell-session-settings-actions.test.ts | 20 ++- .../__tests__/session-health-notice.test.ts | 87 +++++++++++++ .../app-shell-session-settings-actions.ts | 3 +- apps/desktop/src/renderer/app-shell.tsx | 14 +++ .../src/renderer/chat-message-surface.tsx | 7 +- .../src/renderer/locales/conversation-copy.ts | 22 ++-- .../src/renderer/locales/shell-copy.ts | 3 + .../src/renderer/session-health-notice.ts | 54 ++++++-- .../src/renderer/use-shell-chat-model.ts | 36 +++++- .../cli/src/__tests__/pi-tui-runner.test.ts | 46 +++++++ packages/cli/src/pi-tui-runner.ts | 20 +-- .../composer-model-picker-recovery.test.tsx | 117 ++++++++++++++++++ packages/ui/src/chat-model-switcher.tsx | 19 ++- packages/ui/src/composer.tsx | 13 ++ 14 files changed, 421 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/session-health-notice.test.ts create mode 100644 packages/ui/src/__tests__/composer-model-picker-recovery.test.tsx diff --git a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts index 6889c68786..287f5f5d74 100644 --- a/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts +++ b/apps/desktop/src/main/__tests__/app-shell-session-settings-actions.test.ts @@ -71,6 +71,7 @@ function createHarness(options: { const permissionCalls: string[] = []; const thinkingCalls: string[] = []; const errors: string[] = []; + const errorDescriptions: Array = []; const errorTargets: Array<{ sessionId: string } | undefined> = []; const successes: Array<{ title: string; description?: string }> = []; const newTaskPermissionModes: string[] = []; @@ -121,8 +122,9 @@ function createHarness(options: { }, toastApi: { success: (title, description) => successes.push({ title, description }), - error: (title, _description, _details, target) => { + error: (title, description, _details, target) => { errors.push(title); + errorDescriptions.push(description); errorTargets.push(target); }, confirm: options.confirm ?? (async () => true), @@ -133,6 +135,7 @@ function createHarness(options: { actions, activeIdRef, errors, + errorDescriptions, errorTargets, modelCalls, modelResult, @@ -360,4 +363,19 @@ describe('AppShell session settings actions', () => { harness.modelResult.resolve(session('session-a')); await modelChange; }); + + it('points a failed account-and-model switch at credential recovery', async () => { + const harness = createHarness(); + + const modelChange = harness.actions.setSessionModel({ + llmConnectionId: 'connection-1', + llmConnectionSlug: 'e2e', + model: 'claude-opus', + }); + harness.modelResult.reject(new Error('fixture failure')); + await modelChange; + + assert.match(harness.errorDescriptions[0] ?? '', /设置 · 模型/); + assert.match(harness.errorDescriptions[0] ?? '', /登录或 API Key/); + }); }); diff --git a/apps/desktop/src/main/__tests__/session-health-notice.test.ts b/apps/desktop/src/main/__tests__/session-health-notice.test.ts new file mode 100644 index 0000000000..1eb38c1a61 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-health-notice.test.ts @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { SessionSendProjection } from '@maka/core/session-send-projection'; +import { deriveSessionHealthNotice } from '../../renderer/session-health-notice.js'; + +const legacySession = { + backend: 'ai-sdk', + llmConnectionSlug: 'openrouter', + model: 'openai/gpt-5', + connectionLocked: false, +}; + +function blocked( + reason: Extract['reason'], + hasModelChoices: boolean, + modelChoicesSettled = true, +) { + return deriveSessionHealthNotice({ + locale: 'en', + session: legacySession, + outcome: { kind: 'blocked', reason, connectionLocked: false }, + connections: [], + hasModelChoices, + modelChoicesSettled, + modelPickerDisabled: false, + lastTestStatus: undefined, + }); +} + +test('legacy account recovery opens the existing model picker when choices exist', () => { + const notice = blocked('legacy_connection_identity', true); + assert.equal(notice?.onClickTarget, 'model_picker'); + assert.equal(notice?.actionLabel, 'Choose account'); + assert.match(notice?.tooltip ?? '', /existing accounts and models/); + assert.match(notice?.tooltip ?? '', /sign-in or key in Settings · Models/); +}); + +test('legacy account recovery falls back to Models settings only when no choice exists', () => { + const notice = blocked('legacy_connection_identity', false); + assert.equal(notice?.onClickTarget, 'models'); + assert.equal(notice?.actionLabel, undefined); + assert.match(notice?.tooltip ?? '', /No accounts are currently available/); +}); + +test('an unsettled connection snapshot offers a reload instead of an empty picker', () => { + const notice = blocked('legacy_connection_identity', false, false); + assert.equal(notice?.onClickTarget, 'model_choices_refresh'); + assert.equal(notice?.actionLabel, 'Reload accounts'); +}); + +test('a live turn disables account selection until model switching is safe', () => { + const notice = deriveSessionHealthNotice({ + locale: 'en', + session: legacySession, + outcome: { kind: 'blocked', reason: 'legacy_connection_identity', connectionLocked: false }, + connections: [], + hasModelChoices: true, + modelChoicesSettled: true, + modelPickerDisabled: true, + lastTestStatus: undefined, + }); + assert.equal(notice?.onClickTarget, 'model_picker'); + assert.equal(notice?.actionDisabled, true); +}); + +test('credential repair remains owned by Models settings', () => { + assert.equal(blocked('missing_api_key', true)?.onClickTarget, 'models'); +}); diff --git a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts index 1b01820dce..8c85df64b7 100644 --- a/apps/desktop/src/renderer/app-shell-session-settings-actions.ts +++ b/apps/desktop/src/renderer/app-shell-session-settings-actions.ts @@ -209,9 +209,10 @@ export function createAppShellSessionSettingsActions(deps: { await refreshSessions(); } catch (error) { if (activeIdRef.current === sessionId) { + const detail = localizedShellErrorMessage(error, copy.modelFallback, uiLocale); toastApi.error( copy.modelFailedTitle, - localizedShellErrorMessage(error, copy.modelFallback, uiLocale), + `${detail} ${copy.modelRecoveryHint}`, undefined, { sessionId }, ); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index d532b01bdb..5eebdb677a 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -758,6 +758,9 @@ function AppShellContent({ const [helpOpen, closeHelp, openHelp] = useKeyboardHelp(); const [paletteOpen, openPalette, closePalette] = useCommandPalette(); const composerRef = useRef(null); + const openComposerModelPicker = useCallback(() => { + composerRef.current?.openModelPicker(); + }, []); const retractedWorkspaceReferencesRef = useRef>({}); const [revisionDraft, setRevisionDraft] = useState(null); const revisionDraftRef = useRef(null); @@ -883,7 +886,17 @@ function AppShellContent({ persistedComposerDefaults, usePersistedComposerDefaults: modelSettingsOwnsComposerHost, defaultThinkingLevel: taskEntry.selectors.selectedHost?.chatDefaults.thinkingLevel, + connectionSnapshotReady: activeId ? sessionHostConnections.hasSnapshot : true, + modelPickerDisabled: Boolean( + turnActive || + activeStreamingLive || + activeSession?.status === 'running' || + activeSession?.status === 'waiting_for_user' || + (activeId && pendingSessionModelBySession[activeId] === true) + ), openSettingsSection, + openModelPicker: openComposerModelPicker, + refreshModelChoices: sessionHostConnections.refreshConnections, }); const newChatProviderType = newChatModel ? connections.find((connection) => connection.slug === newChatModel.llmConnectionSlug)?.providerType @@ -2930,6 +2943,7 @@ function AppShellContent({ activeProviderType={activeConnection?.providerType} modelChoices={chatModelChoices} modelSwitchHasHistory={modelSwitchHasHistory} + hideUnavailableCurrentModel={sessionHealthNotice?.onClickTarget === 'model_picker'} renderProviderMark={(type) => } modelChangePending={activeId ? pendingSessionModelBySession[activeId] === true : false} onModelChange={(input) => setSessionModel(input)} diff --git a/apps/desktop/src/renderer/chat-message-surface.tsx b/apps/desktop/src/renderer/chat-message-surface.tsx index 6ce3ac5611..e1bcca0f90 100644 --- a/apps/desktop/src/renderer/chat-message-surface.tsx +++ b/apps/desktop/src/renderer/chat-message-surface.tsx @@ -136,8 +136,8 @@ export function ChatMessageSurface({ const locale = useUiLocale(); const copy = getShellCopy(locale).app; const transcriptCopy = getDesktopConversationCopy(locale).actions; - // Every session-health-notice CTA routes to 设置 · 模型 (U1); this is the - // action button's visible label. + // Configuration notices share the Settings label; identity recovery supplies + // its own label because it opens the composer's account-and-model picker. const goToModelsLabel = copy.goToModels; const handleWorkspaceRecovery = () => { const target = workspaceReadinessRecovery?.target; @@ -297,9 +297,10 @@ export function ChatMessageSurface({ title={sessionHealthNotice.label} description={sessionHealthNotice.tooltip} endContent={