From 50047128018aeab6a49a3e57c19c98ac812b5a5e Mon Sep 17 00:00:00 2001 From: bashrusakh Date: Wed, 15 Jul 2026 19:47:54 +1100 Subject: [PATCH 01/57] fix(sync): route directory-less todo updates --- packages/ui/src/sync/DOCUMENTATION.md | 4 + .../__tests__/session-switch-resync.test.ts | 83 ++++++++++++++++++- packages/ui/src/sync/event-reducer.ts | 3 + packages/ui/src/sync/sync-context.tsx | 18 +++- 4 files changed, 101 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 47a01834..9a021d60 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -168,6 +168,10 @@ Keep this in sync with `handleDirectoryEvent` in `sync-context.tsx`: | `question.asked/replied/rejected` | `question` | | `lsp.updated` | `lsp` | +### Directory-less session events + +The global stream can omit a directory for a session-addressed event. Resolve it through the session routing index first. If the index is briefly stale during a session transition, route only when the event session matches the active session and that directory store exists; otherwise leave it un-routed rather than updating another directory. + ## Adding a new event type 1. Add the case to the event reducer (`event-reducer.ts`) diff --git a/packages/ui/src/sync/__tests__/session-switch-resync.test.ts b/packages/ui/src/sync/__tests__/session-switch-resync.test.ts index 7962067d..cd7ecd27 100644 --- a/packages/ui/src/sync/__tests__/session-switch-resync.test.ts +++ b/packages/ui/src/sync/__tests__/session-switch-resync.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test, beforeEach, mock } from "bun:test" import { create, type StoreApi } from "zustand" -import type { PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client" +import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2/client" const listPendingQuestionsCalls: Array<{ directories?: Array }> = [] const listPendingPermissionsCalls: Array<{ directories?: Array }> = [] +const todoPersistWrites: Array<{ sessionID: string; todos: unknown }> = [] let pendingQuestionsResponse: QuestionRequest[] = [] let pendingPermissionsResponse: PermissionRequest[] = [] let pendingQuestionsShouldThrow = false @@ -41,7 +42,22 @@ mock.module("@/stores/useConfigStore", () => ({ })) mock.module("@/stores/useTodosPersistStore", () => ({ - useTodosPersistStore: { getState: () => ({}) }, + useTodosPersistStore: { + getState: () => ({ + setSessionTodos: (sessionID: string, todos: unknown) => { + todoPersistWrites.push({ sessionID, todos }) + }, + }), + }, +})) + +mock.module("sonner", () => ({ + toast: { + dismiss: () => undefined, + error: () => undefined, + info: () => undefined, + success: () => undefined, + }, })) mock.module("@/components/ui", () => ({ @@ -49,8 +65,13 @@ mock.module("@/components/ui", () => ({ })) import { INITIAL_STATE, type State } from "../types" -import type { DirectoryStore } from "../child-store" -import { resyncBlockingRequestsForDirectory } from "../sync-context" +import { ChildStoreManager, type DirectoryStore } from "../child-store" +const { + createEventRoutingIndex, + handleEvent, + resyncBlockingRequestsForDirectory, + setActiveSession, +} = await import("../sync-context") function buildQuestion(overrides: Partial = {}): QuestionRequest { return { @@ -91,6 +112,8 @@ describe("resyncBlockingRequestsForDirectory", () => { pendingPermissionsResponse = [] pendingQuestionsShouldThrow = false pendingPermissionsShouldThrow = false + todoPersistWrites.length = 0 + setActiveSession("", "") }) test("calls listPendingQuestions and listPendingPermissions exactly once for the directory", async () => { @@ -205,4 +228,56 @@ describe("resyncBlockingRequestsForDirectory", () => { expect(store.getState().question["ses_a"]?.[0]?.id).toBe("que_1") expect(listPendingPermissionsCalls).toHaveLength(1) }) + + test("routes a directory-less todo snapshot to its active session during a multi-store routing-index gap", () => { + const childStores = new ChildStoreManager() + const store = childStores.ensureChild("/target", { bootstrap: false }) + childStores.ensureChild("/other", { bootstrap: false }) + const todos = [ + { content: "Finish plan", status: "completed", priority: "high" }, + { content: "Implement changes", status: "in_progress", priority: "high" }, + ] + const event = { + type: "todo.updated", + properties: { sessionID: "ses_a", todos }, + } as Event + const routingIndex = createEventRoutingIndex() + + expect(childStores.children.size).toBe(2) + expect(routingIndex.sessionDirectoryById.size).toBe(0) + for (const candidate of childStores.children.values()) { + const state = candidate.getState() + expect(state.session).toEqual([]) + expect(state.message.ses_a).toBe(undefined) + expect(state.session_status.ses_a).toBe(undefined) + } + + let storeWrites = 0 + const unsubscribe = store.subscribe(() => { + storeWrites += 1 + }) + setActiveSession("/target", "ses_a") + handleEvent("global", event, childStores, routingIndex) + + expect(store.getState().todo.ses_a).toEqual(todos) + expect(todoPersistWrites).toEqual([{ sessionID: "ses_a", todos }]) + expect(storeWrites).toBe(1) + + const stateAfterFirstSnapshot = store.getState() + const duplicateTodos = todos.map((todo) => ({ ...todo })) + const duplicateEvent = { + type: "todo.updated", + properties: { sessionID: "ses_a", todos: duplicateTodos }, + } as Event + expect(duplicateTodos).not.toBe(todos) + expect(duplicateTodos).toEqual(todos) + + handleEvent("global", duplicateEvent, childStores, routingIndex) + + expect(store.getState()).toBe(stateAfterFirstSnapshot) + expect(todoPersistWrites).toEqual([{ sessionID: "ses_a", todos }]) + expect(storeWrites).toBe(1) + unsubscribe() + childStores.disposeAll() + }) }) diff --git a/packages/ui/src/sync/event-reducer.ts b/packages/ui/src/sync/event-reducer.ts index 5288b140..884ce8c2 100644 --- a/packages/ui/src/sync/event-reducer.ts +++ b/packages/ui/src/sync/event-reducer.ts @@ -286,6 +286,9 @@ export function applyDirectoryEvent( case "todo.updated": { const props = event.properties as { sessionID: string; todos: Todo[] } + if (areJsonEquivalent(draft.todo[props.sessionID], props.todos)) { + return false + } draft.todo[props.sessionID] = props.todos callbacks?.onSetSessionTodo?.(props.sessionID, props.todos) return true diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index 0e5a301b..1e7c246b 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -606,7 +606,7 @@ const dispatchVSCodeRuntimeNotificationEvent = (directory: string, payload: Even })) } -const createEventRoutingIndex = (): EventRoutingIndex => ({ +export const createEventRoutingIndex = (): EventRoutingIndex => ({ sessionDirectoryById: new Map(), messageSessionById: new Map(), sessionMessageIdsById: new Map(), @@ -896,8 +896,12 @@ const childStoreHasMessagePartState = ( return Object.prototype.hasOwnProperty.call(store.getState().part, messageID) } -const getActiveDirectoryFallback = (childStores: ChildStoreManager): string | null => { +const getActiveDirectoryFallback = ( + childStores: ChildStoreManager, + sessionID?: string | null, +): string | null => { if (!_activeDirectory || !_activeSession) return null + if (sessionID && sessionID !== _activeSession) return null return childStores.getChild(_activeDirectory) ? _activeDirectory : null } @@ -927,6 +931,14 @@ const resolveDirectoryFromRoutingIndex = ( if (found) { return found } + + // The global stream does not always include a directory. During a session + // transition, its routing index can lag the active session briefly; route + // a session-addressed event only when that session is the one being viewed. + const activeDirectory = getActiveDirectoryFallback(childStores, sessionID) + if (activeDirectory) { + return activeDirectory + } } const messageID = getMessageIdFromPayload(payload) @@ -1351,7 +1363,7 @@ async function resyncDirectoryAfterReconnect( ingestDirectoryStateIntoRoutingIndex(routingIndex, directory, store.getState()) } -function handleEvent( +export function handleEvent( rawDirectory: string, payload: Event, childStores: ChildStoreManager, From 483ac6875ef47e9911aa14108b98a7511ca812e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pascal=20Andr=C3=A9?= Date: Sun, 26 Jul 2026 17:53:19 +0200 Subject: [PATCH 02/57] fix(settings): persist collapsed message preference --- packages/web/server/lib/opencode/settings-helpers.js | 3 +++ packages/web/server/lib/opencode/settings-helpers.test.js | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index b95e1145..82aefd82 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -552,6 +552,9 @@ export const createSettingsHelpers = (dependencies) => { result.userMessageRenderingMode = mode; } } + if (typeof candidate.collapsibleUserMessages === 'boolean') { + result.collapsibleUserMessages = candidate.collapsibleUserMessages; + } if (typeof candidate.stickyUserHeader === 'boolean') { result.stickyUserHeader = candidate.stickyUserHeader; } diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index 20387b55..9ac08512 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -74,6 +74,14 @@ describe('settings helpers', () => { expect(helpers.sanitizeSettingsUpdate({ wideChatLayoutEnabled: 'true' })).toEqual({}); }); + it('accepts only booleans for collapsible user messages', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: true })).toEqual({ collapsibleUserMessages: true }); + expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: false })).toEqual({ collapsibleUserMessages: false }); + expect(helpers.sanitizeSettingsUpdate({ collapsibleUserMessages: 'true' })).toEqual({}); + }); + it('accepts messageStreamTransport as a persisted shared setting', () => { const helpers = createTestHelpers(); From abb396e080d7eda0afb103a8ca2584e271ae667c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 11:29:58 +0000 Subject: [PATCH 03/57] fix(walkthrough): block unauthenticated providers with a friendly refusal When the walkthrough small model resolves to a provider with no usable login, readiness was still ready and generate returned a raw 500 message. Refuse up front with no-provider-login and surface a blocker instead. Closes openchamber/openchamber#2607 Co-authored-by: Serhii Dziupin --- .../views/walkthrough/WalkthroughBlocker.tsx | 9 +- .../views/walkthrough/WalkthroughView.tsx | 1 + packages/ui/src/lib/i18n/messages/de.ts | 3 + packages/ui/src/lib/i18n/messages/en.ts | 3 + packages/ui/src/lib/i18n/messages/es.ts | 3 + packages/ui/src/lib/i18n/messages/fr.ts | 3 + packages/ui/src/lib/i18n/messages/ja.ts | 3 + packages/ui/src/lib/i18n/messages/ko.ts | 3 + packages/ui/src/lib/i18n/messages/pl.ts | 3 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 3 + packages/ui/src/lib/i18n/messages/uk.ts | 3 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 3 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 3 + packages/ui/src/lib/walkthrough/types.ts | 1 + .../server/lib/small-model/DOCUMENTATION.md | 15 +- packages/web/server/lib/small-model/call.js | 22 ++- .../web/server/lib/small-model/call.test.js | 11 +- packages/web/server/lib/small-model/index.js | 11 +- .../web/server/lib/small-model/index.test.js | 21 ++- .../server/lib/walkthrough/DOCUMENTATION.md | 7 + packages/web/server/lib/walkthrough/index.js | 16 ++ .../lib/walkthrough/reproduce-2607.test.js | 151 ++++++++++++++++++ 22 files changed, 286 insertions(+), 12 deletions(-) create mode 100644 packages/web/server/lib/walkthrough/reproduce-2607.test.js diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx index 2f0af021..f23696be 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx @@ -41,7 +41,8 @@ export const WalkthroughBlocker = ({ // Settings. const canChooseModel = reason === 'context-too-small' || reason === 'structured-output-unsupported' - || reason === 'output-exhausted'; + || reason === 'output-exhausted' + || reason === 'no-provider-login'; useEffect(() => { if (!canChooseModel || providers !== undefined) return; @@ -100,6 +101,11 @@ export const WalkthroughBlocker = ({ const description = () => { if (reason === 'no-model') return t('walkthrough.blocked.noModel.description'); + if (reason === 'no-provider-login') { + return label + ? t('walkthrough.blocked.noProviderLogin.description', { model: label }) + : t('walkthrough.blocked.noProviderLogin.descriptionUnknownModel'); + } if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.description'); if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description'); if (reason === 'output-exhausted') { @@ -123,6 +129,7 @@ export const WalkthroughBlocker = ({ const title = () => { if (reason === 'no-model') return t('walkthrough.blocked.noModel.title'); + if (reason === 'no-provider-login') return t('walkthrough.blocked.noProviderLogin.title'); if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.title'); if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title'); if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title'); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index c0403b75..15b9baa8 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -406,6 +406,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const blockedReason = entry.error?.code === 'context-too-small' || entry.error?.code === 'structured-output-unsupported' || entry.error?.code === 'no-model' + || entry.error?.code === 'no-provider-login' || entry.error?.code === 'empty-diff' || entry.error?.code === 'only-generated' || entry.error?.code === 'output-exhausted' diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 0db76c86..4f353026 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2866,6 +2866,9 @@ export const dict = { 'walkthrough.importance.context': 'Kontext', 'walkthrough.blocked.noModel.title': 'Kein Modell ausgewählt', 'walkthrough.blocked.noModel.description': 'Wählen Sie zuerst ein Modell aus.', + 'walkthrough.blocked.noProviderLogin.title': 'Dieser Anbieter ist nicht angemeldet', + 'walkthrough.blocked.noProviderLogin.description': '{model} braucht eine Anmeldung bei seinem Anbieter. Melde dich an oder wähle ein Modell eines Anbieters, den du bereits nutzt.', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'Das ausgewählte Modell braucht eine Anmeldung bei seinem Anbieter. Melde dich an oder wähle ein Modell eines Anbieters, den du bereits nutzt.', 'walkthrough.blocked.emptyDiff.title': 'Kein Diff vorhanden', 'walkthrough.blocked.emptyDiff.description': 'Es gibt keine Änderungen, die zusammengefasst werden können.', 'walkthrough.blocked.contextTooSmall.title': 'Kontext zu klein', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 17d137e5..53929a2f 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1147,6 +1147,9 @@ export const dict = { 'walkthrough.importance.context': 'Context', 'walkthrough.blocked.noModel.title': 'No small model available', 'walkthrough.blocked.noModel.description': 'Sign in to a model provider to generate a review.', + 'walkthrough.blocked.noProviderLogin.title': 'This provider is not signed in', + 'walkthrough.blocked.noProviderLogin.description': '{model} needs a login for its provider. Sign in, or choose a model from a provider you already use.', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'The selected model needs a login for its provider. Sign in, or choose a model from a provider you already use.', 'walkthrough.blocked.emptyDiff.title': 'Nothing to review', 'walkthrough.blocked.emptyDiff.description': 'There are no changes in this scope yet.', 'walkthrough.blocked.contextTooSmall.title': 'This diff is too large for the current model', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index ea5086d0..140e686e 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1148,6 +1148,9 @@ export const dict: Record = { "walkthrough.importance.context": "Contexto", "walkthrough.blocked.noModel.title": "No hay ningún modelo pequeño disponible", "walkthrough.blocked.noModel.description": "Inicia sesión en un proveedor de modelos para generar una revisión.", + "walkthrough.blocked.noProviderLogin.title": "Este proveedor no tiene sesión iniciada", + "walkthrough.blocked.noProviderLogin.description": "{model} necesita un inicio de sesión en su proveedor. Inicia sesión o elige un modelo de un proveedor que ya uses.", + "walkthrough.blocked.noProviderLogin.descriptionUnknownModel": "El modelo seleccionado necesita un inicio de sesión en su proveedor. Inicia sesión o elige un modelo de un proveedor que ya uses.", "walkthrough.blocked.emptyDiff.title": "Nada que revisar", "walkthrough.blocked.emptyDiff.description": "Todavía no hay cambios en este ámbito.", "walkthrough.blocked.contextTooSmall.title": "Este diff es demasiado grande para el modelo actual", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 3a625450..479033dc 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -972,6 +972,9 @@ export const dict = { 'walkthrough.importance.context': 'Contexte', 'walkthrough.blocked.noModel.title': 'Aucun petit modèle disponible', 'walkthrough.blocked.noModel.description': 'Connectez-vous à un fournisseur de modèles pour générer une revue.', + 'walkthrough.blocked.noProviderLogin.title': 'Ce fournisseur n’est pas connecté', + 'walkthrough.blocked.noProviderLogin.description': '{model} nécessite une connexion à son fournisseur. Connectez-vous, ou choisissez un modèle d’un fournisseur que vous utilisez déjà.', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'Le modèle sélectionné nécessite une connexion à son fournisseur. Connectez-vous, ou choisissez un modèle d’un fournisseur que vous utilisez déjà.', 'walkthrough.blocked.emptyDiff.title': 'Rien à examiner', 'walkthrough.blocked.emptyDiff.description': 'Il n’y a encore aucune modification dans cette portée.', 'walkthrough.blocked.contextTooSmall.title': 'Ce diff est trop volumineux pour le modèle actuel', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 181ec3e9..ed810851 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1144,6 +1144,9 @@ export const dict: Record = { 'walkthrough.importance.context': '補足', 'walkthrough.blocked.noModel.title': '利用できるスモールモデルがありません', 'walkthrough.blocked.noModel.description': 'レビューを生成するにはモデルプロバイダーにサインインしてください。', + 'walkthrough.blocked.noProviderLogin.title': 'このプロバイダーにはサインインしていません', + 'walkthrough.blocked.noProviderLogin.description': '{model} にはプロバイダーへのログインが必要です。サインインするか、すでに使っているプロバイダーのモデルを選んでください。', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '選択したモデルにはプロバイダーへのログインが必要です。サインインするか、すでに使っているプロバイダーのモデルを選んでください。', 'walkthrough.blocked.emptyDiff.title': 'レビュー対象がありません', 'walkthrough.blocked.emptyDiff.description': 'この範囲にはまだ変更がありません。', 'walkthrough.blocked.contextTooSmall.title': 'この差分は現在のモデルには大きすぎます', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 150a56ce..d9d68816 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1148,6 +1148,9 @@ export const dict: Record = { 'walkthrough.importance.context': '참고', 'walkthrough.blocked.noModel.title': '사용할 수 있는 스몰 모델이 없습니다', 'walkthrough.blocked.noModel.description': '리뷰를 생성하려면 모델 제공자에 로그인하세요.', + 'walkthrough.blocked.noProviderLogin.title': '이 제공자에 로그인되어 있지 않습니다', + 'walkthrough.blocked.noProviderLogin.description': '{model}을(를) 쓰려면 해당 제공자에 로그인해야 합니다. 로그인하거나, 이미 사용 중인 제공자의 모델을 선택하세요.', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '선택한 모델을 쓰려면 해당 제공자에 로그인해야 합니다. 로그인하거나, 이미 사용 중인 제공자의 모델을 선택하세요.', 'walkthrough.blocked.emptyDiff.title': '리뷰할 내용이 없습니다', 'walkthrough.blocked.emptyDiff.description': '이 범위에는 아직 변경 사항이 없습니다.', 'walkthrough.blocked.contextTooSmall.title': '이 diff는 현재 모델에 너무 큽니다', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 8f93e1f6..949b745f 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1460,6 +1460,9 @@ export const dict: Record = { 'walkthrough.importance.context': 'Kontekst', 'walkthrough.blocked.noModel.title': 'Brak dostępnego małego modelu', 'walkthrough.blocked.noModel.description': 'Zaloguj się u dostawcy modeli, aby wygenerować przegląd.', + 'walkthrough.blocked.noProviderLogin.title': 'Ten dostawca nie jest zalogowany', + 'walkthrough.blocked.noProviderLogin.description': '{model} wymaga logowania u swojego dostawcy. Zaloguj się albo wybierz model u dostawcy, którego już używasz.', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'Wybrany model wymaga logowania u swojego dostawcy. Zaloguj się albo wybierz model u dostawcy, którego już używasz.', 'walkthrough.blocked.emptyDiff.title': 'Nie ma czego przeglądać', 'walkthrough.blocked.emptyDiff.description': 'W tym zakresie nie ma jeszcze zmian.', 'walkthrough.blocked.contextTooSmall.title': 'Te różnice są za duże dla bieżącego modelu', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 44fef67a..6c79996a 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1148,6 +1148,9 @@ export const dict: Record = { "walkthrough.importance.context": "Contexto", "walkthrough.blocked.noModel.title": "Nenhum modelo pequeno disponível", "walkthrough.blocked.noModel.description": "Entre em um provedor de modelos para gerar uma revisão.", + "walkthrough.blocked.noProviderLogin.title": "Este provedor não está conectado", + "walkthrough.blocked.noProviderLogin.description": "{model} precisa de login no provedor. Entre na conta ou escolha um modelo de um provedor que você já usa.", + "walkthrough.blocked.noProviderLogin.descriptionUnknownModel": "O modelo selecionado precisa de login no provedor. Entre na conta ou escolha um modelo de um provedor que você já usa.", "walkthrough.blocked.emptyDiff.title": "Nada para revisar", "walkthrough.blocked.emptyDiff.description": "Ainda não há mudanças neste escopo.", "walkthrough.blocked.contextTooSmall.title": "Este diff é grande demais para o modelo atual", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index c8663cea..6005df66 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1148,6 +1148,9 @@ export const dict: Record = { "walkthrough.importance.context": "Контекст", "walkthrough.blocked.noModel.title": "Немає доступної small model", "walkthrough.blocked.noModel.description": "Увійдіть до провайдера моделей, щоб створити розбір.", + "walkthrough.blocked.noProviderLogin.title": "У цей провайдер не ввійшли", + "walkthrough.blocked.noProviderLogin.description": "{model} потребує входу в його провайдер. Увійдіть або виберіть модель у провайдера, яким ви вже користуєтесь.", + "walkthrough.blocked.noProviderLogin.descriptionUnknownModel": "Вибрана модель потребує входу в її провайдер. Увійдіть або виберіть модель у провайдера, яким ви вже користуєтесь.", "walkthrough.blocked.emptyDiff.title": "Немає що оглядати", "walkthrough.blocked.emptyDiff.description": "У цій області поки що немає змін.", "walkthrough.blocked.contextTooSmall.title": "Цей diff завеликий для поточної моделі", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 76ffac2f..551b95cf 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1148,6 +1148,9 @@ export const dict: Record = { 'walkthrough.importance.context': '背景', 'walkthrough.blocked.noModel.title': '没有可用的小模型', 'walkthrough.blocked.noModel.description': '请登录模型提供方后再生成评审。', + 'walkthrough.blocked.noProviderLogin.title': '尚未登录此提供方', + 'walkthrough.blocked.noProviderLogin.description': '{model} 需要登录其提供方。请先登录,或改选你已在使用的提供方中的模型。', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '所选模型需要登录其提供方。请先登录,或改选你已在使用的提供方中的模型。', 'walkthrough.blocked.emptyDiff.title': '没有可评审的内容', 'walkthrough.blocked.emptyDiff.description': '该范围内暂无改动。', 'walkthrough.blocked.contextTooSmall.title': '当前模型无法容纳这份差异', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 3538d59c..95b0af8b 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1160,6 +1160,9 @@ export const dict: Record = { 'walkthrough.importance.context': '背景', 'walkthrough.blocked.noModel.title': '沒有可用的小模型', 'walkthrough.blocked.noModel.description': '請先登入模型供應商再產生審閱。', + 'walkthrough.blocked.noProviderLogin.title': '尚未登入此供應商', + 'walkthrough.blocked.noProviderLogin.description': '{model} 需要登入其供應商。請先登入,或改選你已在使用的供應商中的模型。', + 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '所選模型需要登入其供應商。請先登入,或改選你已在使用的供應商中的模型。', 'walkthrough.blocked.emptyDiff.title': '沒有可審閱的內容', 'walkthrough.blocked.emptyDiff.description': '此範圍目前沒有變更。', 'walkthrough.blocked.contextTooSmall.title': '目前模型無法容納這份差異', diff --git a/packages/ui/src/lib/walkthrough/types.ts b/packages/ui/src/lib/walkthrough/types.ts index 8c6b5a23..40bfd62e 100644 --- a/packages/ui/src/lib/walkthrough/types.ts +++ b/packages/ui/src/lib/walkthrough/types.ts @@ -91,6 +91,7 @@ export type WalkthroughStage = 'collecting' | 'asking' | 'retrying' | 'assemblin export type WalkthroughBlockedReason = | 'no-model' + | 'no-provider-login' | 'empty-diff' | 'only-generated' | 'context-too-small' diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index a9d60a99..1776f1e1 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -69,10 +69,17 @@ other runtime API. - `timeoutMs` overrides the 60s default per call; `signal` lets a caller abort a request that is no longer wanted. Both apply to every wire format. - `describeSmallModel()` additionally reports `inputCharBudget`, - `contextTokens`, `contextKnown`, and `structuredOutput`. The last is - tri-state: `true`/`false` from the catalog, `null` when the catalog omits the - field — which it does for roughly half of all models, aggregators and proxies - especially. Callers must treat `null` as "try it", not "unsupported". + `contextTokens`, `contextKnown`, `structuredOutput`, and `hasLogin`. The last + is whether the resolved provider has a usable credential (`auth.json` or + config `provider..options.apiKey`) — settings/config overrides can name a + provider with none, and callers such as the walkthrough refuse before the + request. `structuredOutput` is tri-state: `true`/`false` from the catalog, + `null` when the catalog omits the field — which it does for roughly half of + all models, aggregators and proxies especially. Callers must treat `null` as + "try it", not "unsupported". +- Missing credentials throw with `statusCode: 401` and + `code: 'no-provider-login'` rather than a bare `Error`, so UI callers can show + a blocker instead of a raw 500 message. - `call.js` — wire formats and per-provider auth, replicating OpenCode's plugin auth loaders: - **GitHub Copilot**: fetches the requested model's authenticated `/models` diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index 17c8949c..c3a090a1 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -566,15 +566,31 @@ const readProviderConfig = (workingDirectory, providerID) => { // Dispatch // --------------------------------------------------------------------------- +/** + * Same credential resolution the request path uses: config + * `provider..options.apiKey` wins, then the auth.json entry. + * Callers that need to refuse before spending a request (walkthrough readiness) + * must use this rather than inventing a second rule. + */ +export function resolveProviderLogin({ auth, workingDirectory, providerID }) { + const providerConfig = readProviderConfig(workingDirectory, providerID); + return providerConfig?.auth || getAuthEntryForProvider(auth, providerID) || null; +} + export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens, responseSchema, timeoutMs, signal }) { const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS; const providerConfig = readProviderConfig(workingDirectory, providerID); // Match OpenCode's resolveSDK precedence: - // config provider..options.apiKey (providerConfig.auth) wins; the - // auth.json entry is only a fallback. + // config provider..options.apiKey wins; the auth.json entry is only a fallback. const entry = providerConfig?.auth || getAuthEntryForProvider(auth, providerID); if (!entry) { - throw new Error(`No OpenCode login found for provider "${providerID}"`); + // Structured so the walkthrough (and any other caller) can show a blocker + // instead of a raw 500 banner with this developer-oriented sentence. + throw Object.assign(new Error(`No OpenCode login found for provider "${providerID}"`), { + statusCode: 401, + code: 'no-provider-login', + providerID, + }); } if (providerID === 'github-copilot') { diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 78ef733f..154fd986 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -171,14 +171,21 @@ describe('callSmallModel — custom provider config', () => { provider: { custom: { options: { baseURL: 'https://proxy.example.test/v1' } } }, }); - await expect(callSmallModel({ + const error = await callSmallModel({ auth: {}, catalog: {}, workingDirectory: '/proj', providerID: 'custom', modelID: 'gpt-4o-mini', prompt: 'hi', - })).rejects.toThrow('No OpenCode login found for provider "custom"'); + }).then(() => null, (e) => e); + + expect(error).toMatchObject({ + message: 'No OpenCode login found for provider "custom"', + code: 'no-provider-login', + statusCode: 401, + providerID: 'custom', + }); // The credential gate fires before any network call. expect(fetchMock).not.toHaveBeenCalled(); diff --git a/packages/web/server/lib/small-model/index.js b/packages/web/server/lib/small-model/index.js index 40e5e7f5..955797e6 100644 --- a/packages/web/server/lib/small-model/index.js +++ b/packages/web/server/lib/small-model/index.js @@ -5,7 +5,7 @@ import { readAuthFile } from '../opencode/auth.js'; import { readConfigLayers } from '../opencode/shared.js'; import { getModelCatalog } from './catalog.js'; import { resolveSmallModel, parseModelRef, isUsableAuthEntry, getAuthEntryForProvider } from './resolve.js'; -import { callSmallModel } from './call.js'; +import { callSmallModel, resolveProviderLogin } from './call.js'; const OPENCHAMBER_SETTINGS_FILE = path.join( process.env.OPENCHAMBER_DATA_DIR @@ -252,8 +252,17 @@ export async function describeSmallModel({ directory, preferredProviderID, prefe outputReserveTokens: reserveTokens, }); + // Settings/config/request overrides can name a provider with no usable login. + // Report that here so readiness can refuse before the user pays for a 401. + const hasLogin = Boolean(resolveProviderLogin({ + auth, + workingDirectory: directory, + providerID: resolved.providerID, + })); + return { ...resolved, + hasLogin, inputCharBudget: maxChars, contextTokens, contextKnown, diff --git a/packages/web/server/lib/small-model/index.test.js b/packages/web/server/lib/small-model/index.test.js index 14e5f015..fc5d741c 100644 --- a/packages/web/server/lib/small-model/index.test.js +++ b/packages/web/server/lib/small-model/index.test.js @@ -18,7 +18,13 @@ vi.mock('./catalog.js', () => ({ getModelCatalog: vi.fn(), getCatalogProvider: vi.fn(), })); -vi.mock('./call.js', () => ({ callSmallModel: vi.fn() })); +vi.mock('./call.js', () => ({ + callSmallModel: vi.fn(), + resolveProviderLogin: vi.fn(({ auth, providerID }) => { + const entry = auth?.[providerID]; + return entry && typeof entry === 'object' ? entry : null; + }), +})); const { generateSmallModelText, describeSmallModel } = await import('./index.js'); const { readAuthFile } = await import('../opencode/auth.js'); @@ -126,6 +132,19 @@ describe('describeSmallModel — capability reporting', () => { contextTokens: 8_000, contextKnown: true, structuredOutput: true, + hasLogin: true, + }); + }); + + it('reports hasLogin false when the resolved provider has no usable credential', async () => { + readAuthFile.mockReturnValue({}); + + const described = await describeSmallModel({ directory: '/proj' }); + + expect(described).toMatchObject({ + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + hasLogin: false, }); }); diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md index 43d59d16..63997184 100644 --- a/packages/web/server/lib/walkthrough/DOCUMENTATION.md +++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md @@ -118,6 +118,13 @@ model picker, only shows providers with a usable login. The in-panel picker on a blocked walkthrough writes this setting too, so recovering from a refusal never silently changes the model behind commit messages. +A settings or `opencode.json` `small_model` override can still name a provider +with no usable login (neither `auth.json` nor `provider..options.apiKey`). +`describeSmallModel` reports that as `hasLogin: false`, readiness refuses with +`code: 'no-provider-login'`, and generation maps the same code to HTTP 401 — +so the panel shows a blocker with a model picker instead of looking ready and +then dumping the raw `No OpenCode login found for provider "…"` string. + ## Output language A walkthrough its reader cannot read is worth nothing, so the prose language is diff --git a/packages/web/server/lib/walkthrough/index.js b/packages/web/server/lib/walkthrough/index.js index 600b8af4..057a2c58 100644 --- a/packages/web/server/lib/walkthrough/index.js +++ b/packages/web/server/lib/walkthrough/index.js @@ -333,6 +333,12 @@ function computeReadiness({ model, digest, files, fileCount, hunkCount, generate return { ready: false, reason, model, generatedFileCount }; } + // A resolved override/config model can still have no usable login. Refuse up + // front so the panel does not look ready and then dump a raw auth error. + if (model.hasLogin === false) { + return { ready: false, reason: 'no-provider-login', model }; + } + // Built with the same language the generation would use: the instruction is // part of the prompt, so a readiness answer computed without it would be // measuring a request nobody is going to send. @@ -392,6 +398,13 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit if (!model) { throw fail('No model is available — sign in to a provider first', 404, { code: 'no-model' }); } + if (model.hasLogin === false) { + throw fail( + `No OpenCode login found for provider "${model.providerID}" — sign in or choose a different model`, + 401, + { code: 'no-provider-login', model }, + ); + } const { digest, files, idByAlias, fileCount, hunkCount, generatedFileCount } = await loadCurrentDiff(directory, source, deps); setStage(repoRoot, key, 'asking'); @@ -494,6 +507,9 @@ async function runGeneration({ directory, source, repoRoot, key, force, explicit if (error?.code === 'output-exhausted') { return fail(error.message, 409, { code: 'output-exhausted', model }); } + if (error?.code === 'no-provider-login') { + return fail(error.message, 401, { code: 'no-provider-login', model }); + } return null; }; diff --git a/packages/web/server/lib/walkthrough/reproduce-2607.test.js b/packages/web/server/lib/walkthrough/reproduce-2607.test.js new file mode 100644 index 00000000..7ef6c15c --- /dev/null +++ b/packages/web/server/lib/walkthrough/reproduce-2607.test.js @@ -0,0 +1,151 @@ +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import express from 'express'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; + +// --------------------------------------------------------------------------- +// Regression for https://github.com/openchamber/openchamber/issues/2607 +// "[Bug] Why say so?" (walkthrough panel) +// +// Before the fix, a walkthrough small model whose provider had no usable login +// reported readiness ready:true, then generation returned HTTP 500 with the raw +// message `No OpenCode login found for provider "deepseek"` — shown in the +// error banner above the "No walkthrough yet" empty state. +// +// After the fix: readiness refuses with `no-provider-login`, and generation +// answers 401 with the same structured code so the UI can show a blocker. +// --------------------------------------------------------------------------- + +const TEMP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-home-2607-')); +process.env.HOME = TEMP_HOME; +process.env.OPENCHAMBER_DATA_DIR = path.join(TEMP_HOME, '.config', 'openchamber'); + +const CATALOG = { + deepseek: { + id: 'deepseek', + name: 'DeepSeek', + api: 'https://api.deepseek.com', + models: { + 'deepseek-v4-flash': { + id: 'deepseek-v4-flash', + name: 'DeepSeek V4 Flash', + family: 'deepseek-flash', + limit: { context: 128_000 }, + }, + }, + }, +}; + +vi.mock('../../opencode/models-metadata.js', () => ({ + getModelsMetadata: vi.fn(async () => ({ metadata: CATALOG, fromCache: false })), +})); + +const SOURCE = { kind: 'working-tree', scope: 'all' }; +const REPO_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-repo-2607-')); + +const setupGitRepo = () => { + const run = (args) => { + try { + return execFileSync('git', args, { cwd: REPO_DIR, encoding: 'utf8' }); + } catch (error) { + throw new Error(`git ${args.join(' ')} failed: ${error.stderr?.toString() ?? error.message}`); + } + }; + + run(['init', '-b', 'main']); + run(['config', 'user.email', 'test@example.com']); + run(['config', 'user.name', 'Test']); + fs.mkdirSync(path.join(REPO_DIR, 'src'), { recursive: true }); + fs.writeFileSync(path.join(REPO_DIR, 'src', 'a.ts'), 'export const a = 1;\n', 'utf8'); + run(['add', 'src/a.ts']); + run(['commit', '-m', 'init']); + fs.writeFileSync(path.join(REPO_DIR, 'src', 'a.ts'), 'export const a = 1;\nexport const b = 2;\n', 'utf8'); +}; + +let walkthrough; +let callSmallModel; + +describe('issue 2607 — walkthrough blocks unauthenticated providers', () => { + beforeAll(async () => { + setupGitRepo(); + fs.writeFileSync( + path.join(REPO_DIR, 'opencode.json'), + JSON.stringify({ small_model: 'deepseek/deepseek-v4-flash' }, null, 2), + 'utf8', + ); + + walkthrough = await import('./index.js'); + callSmallModel = await import('../small-model/call.js'); + }); + + afterAll(() => { + fs.rmSync(TEMP_HOME, { recursive: true, force: true }); + fs.rmSync(REPO_DIR, { recursive: true, force: true }); + }); + + it('resolves the deepseek model but reports not ready without a login', async () => { + const result = await walkthrough.getWalkthrough({ directory: REPO_DIR, source: SOURCE }); + + expect(result.readiness.ready).toBe(false); + expect(result.readiness.reason).toBe('no-provider-login'); + expect(result.readiness.model).toMatchObject({ + providerID: 'deepseek', + modelID: 'deepseek-v4-flash', + hasLogin: false, + }); + }); + + it('callSmallModel throws a structured no-provider-login error', async () => { + const error = await callSmallModel.callSmallModel({ + auth: {}, + catalog: CATALOG, + workingDirectory: REPO_DIR, + providerID: 'deepseek', + modelID: 'deepseek-v4-flash', + prompt: 'x', + }).then(() => null, (e) => e); + + expect(error).toBeInstanceOf(Error); + expect(error.message).toBe('No OpenCode login found for provider "deepseek"'); + expect(error.code).toBe('no-provider-login'); + expect(error.statusCode).toBe(401); + }); + + it('generateWalkthrough rejects with structured no-provider-login', async () => { + const error = await walkthrough.generateWalkthrough({ directory: REPO_DIR, source: SOURCE }) + .then(() => null, (e) => e); + + expect(error).toBeInstanceOf(Error); + expect(error.code).toBe('no-provider-login'); + expect(error.statusCode).toBe(401); + expect(error.model).toMatchObject({ providerID: 'deepseek', modelID: 'deepseek-v4-flash' }); + }); + + it('answers the generate route with HTTP 401 and code no-provider-login', async () => { + const service = { ...walkthrough, getPullRequestDiff: async () => { throw new Error('not used'); } }; + const app = express(); + app.use(express.json()); + const { registerWalkthroughRoutes } = await import('./routes.js'); + registerWalkthroughRoutes(app, { getWalkthroughService: async () => service }); + + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const base = `http://127.0.0.1:${server.address().port}`; + try { + const response = await fetch(`${base}/api/walkthrough/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ directory: REPO_DIR, source: SOURCE }), + }); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body.code).toBe('no-provider-login'); + expect(body.model).toMatchObject({ providerID: 'deepseek', modelID: 'deepseek-v4-flash' }); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); +}); From 35f17e9e96be0ca5ef6a940bf7c95d45859ea57f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 12:22:54 +0000 Subject: [PATCH 04/57] fix(walkthrough): hide unauthenticated models and disable Generate Do not present a provider without a login as the selected walkthrough model, and grey out Generate when readiness is false instead of showing a login-error blocker or raw auth banner. Co-authored-by: Serhii Dziupin --- .../views/walkthrough/WalkthroughBlocker.tsx | 9 +--- .../views/walkthrough/WalkthroughView.tsx | 46 +++++++++++++++---- packages/ui/src/lib/i18n/messages/de.ts | 3 -- packages/ui/src/lib/i18n/messages/en.ts | 3 -- packages/ui/src/lib/i18n/messages/es.ts | 3 -- packages/ui/src/lib/i18n/messages/fr.ts | 3 -- packages/ui/src/lib/i18n/messages/ja.ts | 3 -- packages/ui/src/lib/i18n/messages/ko.ts | 3 -- packages/ui/src/lib/i18n/messages/pl.ts | 3 -- packages/ui/src/lib/i18n/messages/pt-BR.ts | 3 -- packages/ui/src/lib/i18n/messages/uk.ts | 3 -- packages/ui/src/lib/i18n/messages/zh-CN.ts | 3 -- packages/ui/src/lib/i18n/messages/zh-TW.ts | 3 -- packages/ui/src/lib/walkthrough/types.ts | 2 + .../server/lib/walkthrough/DOCUMENTATION.md | 7 +-- packages/web/server/lib/walkthrough/index.js | 5 +- .../lib/walkthrough/reproduce-2607.test.js | 7 +-- 17 files changed, 49 insertions(+), 60 deletions(-) diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx index f23696be..2f0af021 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx @@ -41,8 +41,7 @@ export const WalkthroughBlocker = ({ // Settings. const canChooseModel = reason === 'context-too-small' || reason === 'structured-output-unsupported' - || reason === 'output-exhausted' - || reason === 'no-provider-login'; + || reason === 'output-exhausted'; useEffect(() => { if (!canChooseModel || providers !== undefined) return; @@ -101,11 +100,6 @@ export const WalkthroughBlocker = ({ const description = () => { if (reason === 'no-model') return t('walkthrough.blocked.noModel.description'); - if (reason === 'no-provider-login') { - return label - ? t('walkthrough.blocked.noProviderLogin.description', { model: label }) - : t('walkthrough.blocked.noProviderLogin.descriptionUnknownModel'); - } if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.description'); if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.description'); if (reason === 'output-exhausted') { @@ -129,7 +123,6 @@ export const WalkthroughBlocker = ({ const title = () => { if (reason === 'no-model') return t('walkthrough.blocked.noModel.title'); - if (reason === 'no-provider-login') return t('walkthrough.blocked.noProviderLogin.title'); if (reason === 'empty-diff') return t('walkthrough.blocked.emptyDiff.title'); if (reason === 'only-generated') return t('walkthrough.blocked.onlyGenerated.title'); if (reason === 'output-exhausted') return t('walkthrough.blocked.outputExhausted.title'); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 15b9baa8..2c53641d 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -323,15 +323,34 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { // Explicit pick first, then the model that actually produced what is on // screen, then whatever settings resolve to. The middle step is what makes // reopening a review show the model behind it rather than the default. - const activeModel = selectedModel - ?? (entry.result?.model ? `${entry.result.model.providerID}/${entry.result.model.modelID}` : undefined) - ?? (entry.readiness?.model ? `${entry.readiness.model.providerID}/${entry.readiness.model.modelID}` : undefined); - const [activeProviderId, ...activeModelParts] = (activeModel ?? '').split('/'); - const activeModelId = activeModelParts.join('/'); - + // Never present a provider without a usable login as the current selection — + // the picker already hides them from the menu; showing one as selected was + // the whole "why say so?" failure mode. const modelsMetadata = useConfigStore((state) => state.modelsMetadata); const [modelProviders, setModelProviders] = useState(undefined); + const providerIsAuthenticated = (providerId: string | undefined) => { + if (!providerId) return false; + if (modelProviders === undefined) return true; + return modelProviders.includes(providerId); + }; + const readinessModelRef = entry.readiness?.model + && entry.readiness.model.hasLogin !== false + && providerIsAuthenticated(entry.readiness.model.providerID) + ? `${entry.readiness.model.providerID}/${entry.readiness.model.modelID}` + : undefined; + const resultModelRef = entry.result?.model + && providerIsAuthenticated(entry.result.model.providerID) + ? `${entry.result.model.providerID}/${entry.result.model.modelID}` + : undefined; + const selectedModelUsable = selectedModel + && providerIsAuthenticated(selectedModel.split('/')[0]) + ? selectedModel + : undefined; + const activeModel = selectedModelUsable ?? resultModelRef ?? readinessModelRef; + const [activeProviderId, ...activeModelParts] = (activeModel ?? '').split('/'); + const activeModelId = activeModelParts.join('/'); + useEffect(() => { if (modelProviders !== undefined) return; let cancelled = false; @@ -403,15 +422,17 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const showStages = startedFromEmptyRef.current && (entry.status === 'generating' || stageProgress.holding); + // Auth/login gaps are not a full-panel blocker: hide the unusable model and + // disable Generate instead of explaining a raw provider error. const blockedReason = entry.error?.code === 'context-too-small' || entry.error?.code === 'structured-output-unsupported' || entry.error?.code === 'no-model' - || entry.error?.code === 'no-provider-login' || entry.error?.code === 'empty-diff' || entry.error?.code === 'only-generated' || entry.error?.code === 'output-exhausted' ? entry.error.code : entry.readiness && !entry.readiness.ready && !view + && entry.readiness.reason !== 'no-provider-login' ? entry.readiness.reason : undefined; @@ -421,11 +442,16 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const blockedRequiredChars = entry.error?.requiredChars ?? entry.readiness?.requiredChars; const blockedAvailableChars = entry.error?.availableChars ?? entry.readiness?.availableChars; + // Not ready means Generate must not look actionable — including when the + // resolved model has no login (reason no-provider-login). + const generateDisabled = Boolean(entry.readiness && !entry.readiness.ready); + const handleGenerate = useCallback( (force: boolean) => { + if (generateDisabled) return; void generate(directory, source, { force, language: activeLanguage }); }, - [activeLanguage, directory, generate, source] + [activeLanguage, directory, generate, generateDisabled, source] ); return ( @@ -594,6 +620,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { variant="outline" size="sm" className={WALKTHROUGH_ACTION_CLASS} + disabled={generateDisabled} aria-label={compactHeader ? (view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate')) : undefined} @@ -647,6 +674,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { variant="ghost" size="xs" className="ml-auto" + disabled={generateDisabled} // Not forced: if an entry for this exact request existed the banner // would not be here, and a forced run would refuse the cache it may // find on the way. @@ -678,7 +706,7 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { )} - {entry.error && !blockedReason && ( + {entry.error && !blockedReason && entry.error.code !== 'no-provider-login' && (
{/* Provider errors arrive as raw JSON bodies. Show a readable amount diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 4f353026..0db76c86 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2866,9 +2866,6 @@ export const dict = { 'walkthrough.importance.context': 'Kontext', 'walkthrough.blocked.noModel.title': 'Kein Modell ausgewählt', 'walkthrough.blocked.noModel.description': 'Wählen Sie zuerst ein Modell aus.', - 'walkthrough.blocked.noProviderLogin.title': 'Dieser Anbieter ist nicht angemeldet', - 'walkthrough.blocked.noProviderLogin.description': '{model} braucht eine Anmeldung bei seinem Anbieter. Melde dich an oder wähle ein Modell eines Anbieters, den du bereits nutzt.', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'Das ausgewählte Modell braucht eine Anmeldung bei seinem Anbieter. Melde dich an oder wähle ein Modell eines Anbieters, den du bereits nutzt.', 'walkthrough.blocked.emptyDiff.title': 'Kein Diff vorhanden', 'walkthrough.blocked.emptyDiff.description': 'Es gibt keine Änderungen, die zusammengefasst werden können.', 'walkthrough.blocked.contextTooSmall.title': 'Kontext zu klein', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 53929a2f..17d137e5 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1147,9 +1147,6 @@ export const dict = { 'walkthrough.importance.context': 'Context', 'walkthrough.blocked.noModel.title': 'No small model available', 'walkthrough.blocked.noModel.description': 'Sign in to a model provider to generate a review.', - 'walkthrough.blocked.noProviderLogin.title': 'This provider is not signed in', - 'walkthrough.blocked.noProviderLogin.description': '{model} needs a login for its provider. Sign in, or choose a model from a provider you already use.', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'The selected model needs a login for its provider. Sign in, or choose a model from a provider you already use.', 'walkthrough.blocked.emptyDiff.title': 'Nothing to review', 'walkthrough.blocked.emptyDiff.description': 'There are no changes in this scope yet.', 'walkthrough.blocked.contextTooSmall.title': 'This diff is too large for the current model', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 140e686e..ea5086d0 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1148,9 +1148,6 @@ export const dict: Record = { "walkthrough.importance.context": "Contexto", "walkthrough.blocked.noModel.title": "No hay ningún modelo pequeño disponible", "walkthrough.blocked.noModel.description": "Inicia sesión en un proveedor de modelos para generar una revisión.", - "walkthrough.blocked.noProviderLogin.title": "Este proveedor no tiene sesión iniciada", - "walkthrough.blocked.noProviderLogin.description": "{model} necesita un inicio de sesión en su proveedor. Inicia sesión o elige un modelo de un proveedor que ya uses.", - "walkthrough.blocked.noProviderLogin.descriptionUnknownModel": "El modelo seleccionado necesita un inicio de sesión en su proveedor. Inicia sesión o elige un modelo de un proveedor que ya uses.", "walkthrough.blocked.emptyDiff.title": "Nada que revisar", "walkthrough.blocked.emptyDiff.description": "Todavía no hay cambios en este ámbito.", "walkthrough.blocked.contextTooSmall.title": "Este diff es demasiado grande para el modelo actual", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 479033dc..3a625450 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -972,9 +972,6 @@ export const dict = { 'walkthrough.importance.context': 'Contexte', 'walkthrough.blocked.noModel.title': 'Aucun petit modèle disponible', 'walkthrough.blocked.noModel.description': 'Connectez-vous à un fournisseur de modèles pour générer une revue.', - 'walkthrough.blocked.noProviderLogin.title': 'Ce fournisseur n’est pas connecté', - 'walkthrough.blocked.noProviderLogin.description': '{model} nécessite une connexion à son fournisseur. Connectez-vous, ou choisissez un modèle d’un fournisseur que vous utilisez déjà.', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'Le modèle sélectionné nécessite une connexion à son fournisseur. Connectez-vous, ou choisissez un modèle d’un fournisseur que vous utilisez déjà.', 'walkthrough.blocked.emptyDiff.title': 'Rien à examiner', 'walkthrough.blocked.emptyDiff.description': 'Il n’y a encore aucune modification dans cette portée.', 'walkthrough.blocked.contextTooSmall.title': 'Ce diff est trop volumineux pour le modèle actuel', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index ed810851..181ec3e9 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1144,9 +1144,6 @@ export const dict: Record = { 'walkthrough.importance.context': '補足', 'walkthrough.blocked.noModel.title': '利用できるスモールモデルがありません', 'walkthrough.blocked.noModel.description': 'レビューを生成するにはモデルプロバイダーにサインインしてください。', - 'walkthrough.blocked.noProviderLogin.title': 'このプロバイダーにはサインインしていません', - 'walkthrough.blocked.noProviderLogin.description': '{model} にはプロバイダーへのログインが必要です。サインインするか、すでに使っているプロバイダーのモデルを選んでください。', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '選択したモデルにはプロバイダーへのログインが必要です。サインインするか、すでに使っているプロバイダーのモデルを選んでください。', 'walkthrough.blocked.emptyDiff.title': 'レビュー対象がありません', 'walkthrough.blocked.emptyDiff.description': 'この範囲にはまだ変更がありません。', 'walkthrough.blocked.contextTooSmall.title': 'この差分は現在のモデルには大きすぎます', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index d9d68816..150a56ce 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1148,9 +1148,6 @@ export const dict: Record = { 'walkthrough.importance.context': '참고', 'walkthrough.blocked.noModel.title': '사용할 수 있는 스몰 모델이 없습니다', 'walkthrough.blocked.noModel.description': '리뷰를 생성하려면 모델 제공자에 로그인하세요.', - 'walkthrough.blocked.noProviderLogin.title': '이 제공자에 로그인되어 있지 않습니다', - 'walkthrough.blocked.noProviderLogin.description': '{model}을(를) 쓰려면 해당 제공자에 로그인해야 합니다. 로그인하거나, 이미 사용 중인 제공자의 모델을 선택하세요.', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '선택한 모델을 쓰려면 해당 제공자에 로그인해야 합니다. 로그인하거나, 이미 사용 중인 제공자의 모델을 선택하세요.', 'walkthrough.blocked.emptyDiff.title': '리뷰할 내용이 없습니다', 'walkthrough.blocked.emptyDiff.description': '이 범위에는 아직 변경 사항이 없습니다.', 'walkthrough.blocked.contextTooSmall.title': '이 diff는 현재 모델에 너무 큽니다', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 949b745f..8f93e1f6 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1460,9 +1460,6 @@ export const dict: Record = { 'walkthrough.importance.context': 'Kontekst', 'walkthrough.blocked.noModel.title': 'Brak dostępnego małego modelu', 'walkthrough.blocked.noModel.description': 'Zaloguj się u dostawcy modeli, aby wygenerować przegląd.', - 'walkthrough.blocked.noProviderLogin.title': 'Ten dostawca nie jest zalogowany', - 'walkthrough.blocked.noProviderLogin.description': '{model} wymaga logowania u swojego dostawcy. Zaloguj się albo wybierz model u dostawcy, którego już używasz.', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': 'Wybrany model wymaga logowania u swojego dostawcy. Zaloguj się albo wybierz model u dostawcy, którego już używasz.', 'walkthrough.blocked.emptyDiff.title': 'Nie ma czego przeglądać', 'walkthrough.blocked.emptyDiff.description': 'W tym zakresie nie ma jeszcze zmian.', 'walkthrough.blocked.contextTooSmall.title': 'Te różnice są za duże dla bieżącego modelu', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 6c79996a..44fef67a 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1148,9 +1148,6 @@ export const dict: Record = { "walkthrough.importance.context": "Contexto", "walkthrough.blocked.noModel.title": "Nenhum modelo pequeno disponível", "walkthrough.blocked.noModel.description": "Entre em um provedor de modelos para gerar uma revisão.", - "walkthrough.blocked.noProviderLogin.title": "Este provedor não está conectado", - "walkthrough.blocked.noProviderLogin.description": "{model} precisa de login no provedor. Entre na conta ou escolha um modelo de um provedor que você já usa.", - "walkthrough.blocked.noProviderLogin.descriptionUnknownModel": "O modelo selecionado precisa de login no provedor. Entre na conta ou escolha um modelo de um provedor que você já usa.", "walkthrough.blocked.emptyDiff.title": "Nada para revisar", "walkthrough.blocked.emptyDiff.description": "Ainda não há mudanças neste escopo.", "walkthrough.blocked.contextTooSmall.title": "Este diff é grande demais para o modelo atual", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 6005df66..c8663cea 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1148,9 +1148,6 @@ export const dict: Record = { "walkthrough.importance.context": "Контекст", "walkthrough.blocked.noModel.title": "Немає доступної small model", "walkthrough.blocked.noModel.description": "Увійдіть до провайдера моделей, щоб створити розбір.", - "walkthrough.blocked.noProviderLogin.title": "У цей провайдер не ввійшли", - "walkthrough.blocked.noProviderLogin.description": "{model} потребує входу в його провайдер. Увійдіть або виберіть модель у провайдера, яким ви вже користуєтесь.", - "walkthrough.blocked.noProviderLogin.descriptionUnknownModel": "Вибрана модель потребує входу в її провайдер. Увійдіть або виберіть модель у провайдера, яким ви вже користуєтесь.", "walkthrough.blocked.emptyDiff.title": "Немає що оглядати", "walkthrough.blocked.emptyDiff.description": "У цій області поки що немає змін.", "walkthrough.blocked.contextTooSmall.title": "Цей diff завеликий для поточної моделі", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 551b95cf..76ffac2f 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1148,9 +1148,6 @@ export const dict: Record = { 'walkthrough.importance.context': '背景', 'walkthrough.blocked.noModel.title': '没有可用的小模型', 'walkthrough.blocked.noModel.description': '请登录模型提供方后再生成评审。', - 'walkthrough.blocked.noProviderLogin.title': '尚未登录此提供方', - 'walkthrough.blocked.noProviderLogin.description': '{model} 需要登录其提供方。请先登录,或改选你已在使用的提供方中的模型。', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '所选模型需要登录其提供方。请先登录,或改选你已在使用的提供方中的模型。', 'walkthrough.blocked.emptyDiff.title': '没有可评审的内容', 'walkthrough.blocked.emptyDiff.description': '该范围内暂无改动。', 'walkthrough.blocked.contextTooSmall.title': '当前模型无法容纳这份差异', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 95b0af8b..3538d59c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1160,9 +1160,6 @@ export const dict: Record = { 'walkthrough.importance.context': '背景', 'walkthrough.blocked.noModel.title': '沒有可用的小模型', 'walkthrough.blocked.noModel.description': '請先登入模型供應商再產生審閱。', - 'walkthrough.blocked.noProviderLogin.title': '尚未登入此供應商', - 'walkthrough.blocked.noProviderLogin.description': '{model} 需要登入其供應商。請先登入,或改選你已在使用的供應商中的模型。', - 'walkthrough.blocked.noProviderLogin.descriptionUnknownModel': '所選模型需要登入其供應商。請先登入,或改選你已在使用的供應商中的模型。', 'walkthrough.blocked.emptyDiff.title': '沒有可審閱的內容', 'walkthrough.blocked.emptyDiff.description': '此範圍目前沒有變更。', 'walkthrough.blocked.contextTooSmall.title': '目前模型無法容納這份差異', diff --git a/packages/ui/src/lib/walkthrough/types.ts b/packages/ui/src/lib/walkthrough/types.ts index 40bfd62e..5f8ef80a 100644 --- a/packages/ui/src/lib/walkthrough/types.ts +++ b/packages/ui/src/lib/walkthrough/types.ts @@ -105,6 +105,8 @@ export interface WalkthroughReadiness { inputCharBudget?: number; contextTokens?: number; structuredOutput?: boolean | null; + /** False when the resolved provider has no usable OpenCode login. */ + hasLogin?: boolean; }; requiredChars?: number; availableChars?: number; diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md index 63997184..0f23fac5 100644 --- a/packages/web/server/lib/walkthrough/DOCUMENTATION.md +++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md @@ -121,9 +121,10 @@ silently changes the model behind commit messages. A settings or `opencode.json` `small_model` override can still name a provider with no usable login (neither `auth.json` nor `provider..options.apiKey`). `describeSmallModel` reports that as `hasLogin: false`, readiness refuses with -`code: 'no-provider-login'`, and generation maps the same code to HTTP 401 — -so the panel shows a blocker with a model picker instead of looking ready and -then dumping the raw `No OpenCode login found for provider "…"` string. +`reason: 'no-provider-login'` and omits the unusable model so the panel cannot +present it as selected, and generation maps the same code to HTTP 401. The UI +disables Generate and keeps the picker on authenticated providers only — it does +not surface a raw auth error or a special login blocker for this case. ## Output language diff --git a/packages/web/server/lib/walkthrough/index.js b/packages/web/server/lib/walkthrough/index.js index 057a2c58..f65d3e2f 100644 --- a/packages/web/server/lib/walkthrough/index.js +++ b/packages/web/server/lib/walkthrough/index.js @@ -334,9 +334,10 @@ function computeReadiness({ model, digest, files, fileCount, hunkCount, generate } // A resolved override/config model can still have no usable login. Refuse up - // front so the panel does not look ready and then dump a raw auth error. + // front and omit the model — offering an unauthenticated selection in the + // picker is what made the old raw auth error feel like a product bug. if (model.hasLogin === false) { - return { ready: false, reason: 'no-provider-login', model }; + return { ready: false, reason: 'no-provider-login' }; } // Built with the same language the generation would use: the instruction is diff --git a/packages/web/server/lib/walkthrough/reproduce-2607.test.js b/packages/web/server/lib/walkthrough/reproduce-2607.test.js index 7ef6c15c..8cca6e55 100644 --- a/packages/web/server/lib/walkthrough/reproduce-2607.test.js +++ b/packages/web/server/lib/walkthrough/reproduce-2607.test.js @@ -90,11 +90,8 @@ describe('issue 2607 — walkthrough blocks unauthenticated providers', () => { expect(result.readiness.ready).toBe(false); expect(result.readiness.reason).toBe('no-provider-login'); - expect(result.readiness.model).toMatchObject({ - providerID: 'deepseek', - modelID: 'deepseek-v4-flash', - hasLogin: false, - }); + // Unusable models must not be offered as the current selection. + expect(result.readiness.model).toBeUndefined(); }); it('callSmallModel throws a structured no-provider-login error', async () => { From 5c6eee331ea6f8018f8b2b7d14b80c006d599a01 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 12:35:25 +0000 Subject: [PATCH 05/57] fix(walkthrough): keep unauthenticated models out of the picker Treat an empty allowedProviderIds list as allow-none, disable Generate when no usable model is selected, and mute the button styling so it reads as unavailable rather than actionable. Co-authored-by: Serhii Dziupin --- .../model-picker/ModelPickerList.tsx | 5 ++++- .../views/walkthrough/WalkthroughBlocker.tsx | 2 +- .../views/walkthrough/WalkthroughView.tsx | 18 ++++++++++++------ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/components/model-picker/ModelPickerList.tsx b/packages/ui/src/components/model-picker/ModelPickerList.tsx index f06dc190..99080f9c 100644 --- a/packages/ui/src/components/model-picker/ModelPickerList.tsx +++ b/packages/ui/src/components/model-picker/ModelPickerList.tsx @@ -436,7 +436,10 @@ export const ModelPickerList: React.FC = ({ ); const allowedProviderSet = React.useMemo(() => { - if (!allowedProviderIds || allowedProviderIds.length === 0) return null; + // undefined = no restriction; [] = allow none. Treating empty like + // "unrestricted" would resurface providers without a login in pickers that + // intentionally pass the authenticated-only list. + if (!allowedProviderIds) return null; return new Set(allowedProviderIds); }, [allowedProviderIds]); diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx index 2f0af021..a9a620ac 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughBlocker.tsx @@ -150,7 +150,7 @@ export const WalkthroughBlocker = ({ onChange={(providerId, modelId) => { void handleModelChange(providerId, modelId); }} - allowedProviderIds={providers} + allowedProviderIds={providers ?? []} isModelAllowed={isStructuredOutputCapable} />
diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 2c53641d..25a19097 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -331,7 +331,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const providerIsAuthenticated = (providerId: string | undefined) => { if (!providerId) return false; - if (modelProviders === undefined) return true; + // Until the auth list loads, do not present a candidate as selected — + // otherwise an unauthenticated config model flashes in the picker. + if (modelProviders === undefined) return false; return modelProviders.includes(providerId); }; const readinessModelRef = entry.readiness?.model @@ -442,9 +444,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const blockedRequiredChars = entry.error?.requiredChars ?? entry.readiness?.requiredChars; const blockedAvailableChars = entry.error?.availableChars ?? entry.readiness?.availableChars; - // Not ready means Generate must not look actionable — including when the - // resolved model has no login (reason no-provider-login). - const generateDisabled = Boolean(entry.readiness && !entry.readiness.ready); + // Not ready, or no usable selected model, means Generate must not look + // actionable — including when the resolved model has no login. + const generateDisabled = !activeModel || Boolean(entry.readiness && !entry.readiness.ready); const handleGenerate = useCallback( (force: boolean) => { @@ -571,7 +573,8 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { onChange={(providerId, modelId) => { selectModel(directory, source, providerId && modelId ? `${providerId}/${modelId}` : null); }} - allowedProviderIds={modelProviders} + // While the auth list is loading, allow none — not every provider. + allowedProviderIds={modelProviders ?? []} isModelAllowed={isStructuredOutputCapable} tooltipsEnabled={false} dropdownPortalToBody @@ -619,7 +622,10 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { type="button" variant="outline" size="sm" - className={WALKTHROUGH_ACTION_CLASS} + className={cn( + WALKTHROUGH_ACTION_CLASS, + generateDisabled && 'border-border bg-transparent text-muted-foreground hover:bg-transparent hover:text-muted-foreground', + )} disabled={generateDisabled} aria-label={compactHeader ? (view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate')) From bc24b8a8366f2c7ab1d84b36177fba48f47a075a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 12:43:31 +0000 Subject: [PATCH 06/57] fix(walkthrough): drop info tint from disabled Generate button The status-info classes were winning over muted disabled styles, so the button still looked actionable when no model was selected. Co-authored-by: Serhii Dziupin --- .../src/components/views/walkthrough/WalkthroughView.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 25a19097..246a818c 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -622,10 +622,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { type="button" variant="outline" size="sm" - className={cn( - WALKTHROUGH_ACTION_CLASS, - generateDisabled && 'border-border bg-transparent text-muted-foreground hover:bg-transparent hover:text-muted-foreground', - )} + className={generateDisabled + ? 'border-border text-muted-foreground' + : WALKTHROUGH_ACTION_CLASS} disabled={generateDisabled} aria-label={compactHeader ? (view ? t('walkthrough.action.regenerate') : t('walkthrough.action.generate')) From 65a1eec782f0d496fa026038499133e964566ca3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 12:46:48 +0000 Subject: [PATCH 07/57] fix(ui): keep manual model override after delegated subtask completes Synthetic subagent-completion nudges were treated as the latest user model choice and rehydrated the agent default, while setAgent preferred the agent pin over the session override. Skip synthetic prompts for restore, preserve manual selection-store overrides, and prefer session agent models in setAgent. Closes openchamber/openchamber#2404 Co-authored-by: Serhii Dziupin --- .../ui/src/components/chat/ModelControls.tsx | 63 +++--- .../src/lib/messages/userModelChoice.test.ts | 145 +++++++++++++ .../ui/src/lib/messages/userModelChoice.ts | 103 +++++++++ packages/ui/src/stores/useConfigStore.test.ts | 67 ++++++ packages/ui/src/stores/useConfigStore.ts | 30 +-- .../ui/src/sync/__tests__/issue-2404.test.ts | 200 ++++++++++++++++++ packages/ui/src/sync/session-ui-store.ts | 39 ++-- 7 files changed, 579 insertions(+), 68 deletions(-) create mode 100644 packages/ui/src/lib/messages/userModelChoice.test.ts create mode 100644 packages/ui/src/lib/messages/userModelChoice.ts create mode 100644 packages/ui/src/sync/__tests__/issue-2404.test.ts diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 3fbf959b..262664b9 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -40,6 +40,11 @@ import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { useOpenCodeReadiness } from '@/hooks/useOpenCodeReadiness'; import { eventMatchesShortcut, getEffectiveShortcutCombo, normalizeCombo } from '@/lib/shortcuts'; import { markStartupTrace } from '@/lib/startupTrace'; +import { + findLatestUserModelChoice, + shouldPreserveManualModelOverride, +} from '@/lib/messages/userModelChoice'; +import { getSyncParts } from '@/sync/sync-refs'; type IconComponent = IconName; @@ -645,37 +650,14 @@ export const ModelControls: React.FC = ({ currentSessionDirectory ?? undefined, ); const currentSessionMessagesFromSync = useSessionMessages(currentSessionId ?? '', currentSessionDirectory ?? undefined); + // Skip synthetic subagent-completion nudges — restoring from them resets a + // manual model override back to the agent default (issue #2404). const latestLoadedUserChoice = React.useMemo(() => { - for (let i = currentSessionMessagesFromSync.length - 1; i >= 0; i -= 1) { - const message = currentSessionMessagesFromSync[i] as typeof currentSessionMessagesFromSync[number] & { - model?: { providerID?: string; modelID?: string; variant?: string }; - variant?: string; - mode?: string; - }; - if (message.role !== 'user') { - continue; - } - - const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0 - ? message.model.providerID - : undefined; - const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0 - ? message.model.modelID - : undefined; - const agent = typeof message.agent === 'string' && message.agent.trim().length > 0 - ? message.agent - : (typeof message.mode === 'string' && message.mode.trim().length > 0 ? message.mode : undefined); - // OpenCode 1.4.0 moved variant from top-level to model.variant. - // Prefer the new location, fall back to the legacy one for older servers. - const variantCandidate = message.model?.variant ?? message.variant; - const variant = typeof variantCandidate === 'string' && variantCandidate.trim().length > 0 - ? variantCandidate - : undefined; - - return { id: message.id, agent, providerID, modelID, variant }; - } - return null; - }, [currentSessionMessagesFromSync]); + return findLatestUserModelChoice( + currentSessionMessagesFromSync, + (messageId) => getSyncParts(messageId, currentSessionDirectory ?? undefined), + ); + }, [currentSessionDirectory, currentSessionMessagesFromSync]); const tryApplyModelSelection = React.useCallback( (providerId: string, modelId: string, agentName?: string): ModelApplyResult => { @@ -828,6 +810,25 @@ export const ModelControls: React.FC = ({ return; } + // Manual session override wins over historical / synthetic message metadata. + const savedSessionModel = getSessionModelSelection(currentSessionId); + if (shouldPreserveManualModelOverride({ + selectionSource: useConfigStore.getState().selectionSource, + savedSessionModel, + candidate: latestLoadedUserChoice, + })) { + if (savedSessionModel) { + applyModelSelectionWithVariant( + savedSessionModel.providerId, + savedSessionModel.modelId, + resolveModelVariantSelection(savedSessionModel.providerId, savedSessionModel.modelId), + currentAgentName || undefined, + ); + } + latestLoadedUserChoiceRestoreRef.current = restoreKey; + return; + } + if (latestLoadedUserChoice.agent && currentAgentName !== latestLoadedUserChoice.agent) { setAgent(latestLoadedUserChoice.agent); } @@ -869,6 +870,8 @@ export const ModelControls: React.FC = ({ setAgent, applyModelSelectionWithVariant, getModelVariantOptions, + getSessionModelSelection, + resolveModelVariantSelection, saveSessionAgentSelection, saveAgentModelVariantForSession, saveSessionModelSelection, diff --git a/packages/ui/src/lib/messages/userModelChoice.test.ts b/packages/ui/src/lib/messages/userModelChoice.test.ts new file mode 100644 index 00000000..e0c43ef9 --- /dev/null +++ b/packages/ui/src/lib/messages/userModelChoice.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, test } from 'bun:test' +import type { Message, Part } from '@opencode-ai/sdk/v2' + +import { + extractUserModelChoice, + findLatestUserModelChoice, + shouldPreserveManualModelOverride, +} from './userModelChoice' + +const userMessage = ( + id: string, + model: { providerID: string; modelID: string }, + agent = 'custom-agent', +): Message => ({ + id, + sessionID: 'ses_1', + role: 'user', + time: { created: 1 }, + agent, + model, +} as Message) + +const assistantMessage = (id: string): Message => ({ + id, + sessionID: 'ses_1', + role: 'assistant', + time: { created: 2 }, + parentID: 'u1', + modelID: 'model-a', + providerID: 'provider', +} as Message) + +const textPart = (id: string, text: string, synthetic = false): Part => ({ + id, + sessionID: 'ses_1', + messageID: 'u1', + type: 'text', + text, + ...(synthetic ? { synthetic: true } : {}), +} as Part) + +describe('findLatestUserModelChoice', () => { + test('returns the latest real user prompt model', () => { + const messages = [ + userMessage('u1', { providerID: 'provider', modelID: 'model-a' }), + assistantMessage('a1'), + userMessage('u2', { providerID: 'provider', modelID: 'model-b' }), + ] + const partsById: Record = { + u1: [textPart('p1', 'first')], + u2: [textPart('p2', 'second')], + } + + const choice = findLatestUserModelChoice(messages, (id) => partsById[id]) + expect(choice?.id).toBe('u2') + expect(choice?.modelID).toBe('model-b') + expect(choice?.providerID).toBe('provider') + expect(choice?.agent).toBe('custom-agent') + }) + + test('[issue-2404] skips synthetic subagent-completion nudges so manual override is not clobbered', () => { + // Real prompt sent with the manual override (model-b). + const realPrompt = userMessage('u-real', { providerID: 'provider', modelID: 'model-b' }) + // After a delegated child session goes idle, OpenCode injects a synthetic + // user nudge that often carries the agent default model (model-a). + const syntheticNudge = userMessage('u-nudge', { providerID: 'provider', modelID: 'model-a' }) + const messages = [realPrompt, assistantMessage('a1'), syntheticNudge] + const partsById: Record = { + 'u-real': [textPart('p-real', 'please investigate', false)], + 'u-nudge': [textPart('p-nudge', 'Subagent finished.', true)], + } + + const choice = findLatestUserModelChoice(messages, (id) => partsById[id]) + expect(choice?.id).toBe('u-real') + expect(choice?.modelID).toBe('model-b') + }) + + test('skips user messages whose parts have not loaded yet', () => { + const messages = [ + userMessage('u1', { providerID: 'provider', modelID: 'model-a' }), + userMessage('u2', { providerID: 'provider', modelID: 'model-b' }), + ] + const partsById: Record = { + u1: [textPart('p1', 'first')], + // u2 parts missing + } + + const choice = findLatestUserModelChoice(messages, (id) => partsById[id]) + expect(choice?.id).toBe('u1') + expect(choice?.modelID).toBe('model-a') + }) + + test('returns null when only synthetic user messages exist', () => { + const messages = [userMessage('u-nudge', { providerID: 'provider', modelID: 'model-a' })] + const partsById: Record = { + 'u-nudge': [textPart('p-nudge', 'Subagent finished.', true)], + } + + expect(findLatestUserModelChoice(messages, (id) => partsById[id])).toBeNull() + }) +}) + +describe('shouldPreserveManualModelOverride', () => { + test('preserves manual override when it differs from the candidate message model', () => { + expect(shouldPreserveManualModelOverride({ + selectionSource: 'manual', + savedSessionModel: { providerId: 'provider', modelId: 'model-b' }, + candidate: { providerID: 'provider', modelID: 'model-a' }, + })).toBe(true) + }) + + test('does not preserve when selection matches the candidate', () => { + expect(shouldPreserveManualModelOverride({ + selectionSource: 'manual', + savedSessionModel: { providerId: 'provider', modelId: 'model-b' }, + candidate: { providerID: 'provider', modelID: 'model-b' }, + })).toBe(false) + }) + + test('does not preserve auto selections', () => { + expect(shouldPreserveManualModelOverride({ + selectionSource: 'auto', + savedSessionModel: { providerId: 'provider', modelId: 'model-b' }, + candidate: { providerID: 'provider', modelID: 'model-a' }, + })).toBe(false) + }) + + test('preserves manual override when candidate has no model', () => { + expect(shouldPreserveManualModelOverride({ + selectionSource: 'manual', + savedSessionModel: { providerId: 'provider', modelId: 'model-b' }, + candidate: { providerID: undefined, modelID: undefined }, + })).toBe(true) + }) +}) + +describe('extractUserModelChoice', () => { + test('reads variant from model.variant', () => { + const message = { + ...userMessage('u1', { providerID: 'provider', modelID: 'model-b' }), + model: { providerID: 'provider', modelID: 'model-b', variant: 'high' }, + } as Message + expect(extractUserModelChoice(message as never)?.variant).toBe('high') + }) +}) diff --git a/packages/ui/src/lib/messages/userModelChoice.ts b/packages/ui/src/lib/messages/userModelChoice.ts new file mode 100644 index 00000000..a94bfbfc --- /dev/null +++ b/packages/ui/src/lib/messages/userModelChoice.ts @@ -0,0 +1,103 @@ +import type { Message, Part } from '@opencode-ai/sdk/v2' + +import { isFullySyntheticMessage } from './synthetic' + +type UserModelChoice = { + id: string + agent?: string + providerID?: string + modelID?: string + variant?: string +} + +type MessageLike = Message & { + model?: { providerID?: string; modelID?: string; variant?: string } + variant?: string + mode?: string +} + +/** + * Extract agent/model selection metadata from a user message, if present. + */ +export const extractUserModelChoice = (message: MessageLike): UserModelChoice | null => { + if (message.role !== 'user') { + return null + } + + const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0 + ? message.model.providerID + : undefined + const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0 + ? message.model.modelID + : undefined + const agent = typeof message.agent === 'string' && message.agent.trim().length > 0 + ? message.agent + : (typeof message.mode === 'string' && message.mode.trim().length > 0 ? message.mode : undefined) + // OpenCode 1.4.0 moved variant from top-level to model.variant. + const variantCandidate = message.model?.variant ?? message.variant + const variant = typeof variantCandidate === 'string' && variantCandidate.trim().length > 0 + ? variantCandidate + : undefined + + return { id: message.id, agent, providerID, modelID, variant } +} + +/** + * Find the latest *real* user prompt's model/agent choice. + * + * Synthetic user messages (e.g. subagent-completion nudges injected when a + * delegated child session goes idle) must not drive the composer model + * selector — restoring from them clobber a manual session override and reset + * to the agent default. + * + * Messages whose parts have not been loaded yet are skipped so an incomplete + * snapshot cannot be treated as authoritative. + */ +export const findLatestUserModelChoice = ( + messages: readonly MessageLike[], + getParts: (messageId: string) => Part[] | undefined, +): UserModelChoice | null => { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] + if (message.role !== 'user') { + continue + } + + const parts = getParts(message.id) + if (!Array.isArray(parts) || parts.length === 0) { + continue + } + if (isFullySyntheticMessage(parts)) { + continue + } + + return extractUserModelChoice(message) + } + + return null +} + +/** + * When the user has a manual session model override, historical (or synthetic) + * user-message metadata must not overwrite it. After a real send the selection + * store is updated to match the message, so a conflict means the picker was + * changed after the last prompt — keep the override. + */ +export const shouldPreserveManualModelOverride = ({ + selectionSource, + savedSessionModel, + candidate, +}: { + selectionSource: 'auto' | 'manual' | undefined + savedSessionModel: { providerId: string; modelId: string } | null | undefined + candidate: Pick | null | undefined +}): boolean => { + if (selectionSource !== 'manual' || !savedSessionModel?.providerId || !savedSessionModel.modelId) { + return false + } + if (!candidate?.providerID || !candidate.modelID) { + return true + } + return savedSessionModel.providerId !== candidate.providerID + || savedSessionModel.modelId !== candidate.modelID +} diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index 0b2b18c6..52dbe3e6 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -522,6 +522,73 @@ describe('useConfigStore provider persistence', () => { expect(state.currentVariant).toBe('high'); }); + test('[issue-2404] setAgent keeps session model override over agent default model', () => { + // Custom agent default is model-a; user manually overrode to model-b for this session. + // Re-applying setAgent (e.g. after delegated subtask completion rematerializes the + // parent) must keep model-b rather than resetting to the agent pin. + const sessionId = 'ses_2404_model_override'; + const multiModelProvider = { + ...provider('provider', 'model-a'), + models: [ + provider('provider', 'model-a').models[0], + provider('provider', 'model-b').models[0], + ], + }; + useSessionUIStore.setState({ currentSessionId: sessionId }); + useSelectionStore.getState().saveSessionModelSelection(sessionId, 'provider', 'model-b'); + useSelectionStore.getState().saveAgentModelForSession(sessionId, 'custom-agent', 'provider', 'model-b'); + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [multiModelProvider], + agents: [testAgent('custom-agent', { model: { providerID: 'provider', modelID: 'model-a' } })], + currentProviderId: 'provider', + currentModelId: 'model-b', + currentAgentName: 'custom-agent', + selectionSource: 'manual', + currentVariant: undefined, + directoryScoped: {}, + }); + + useConfigStore.getState().setAgent('custom-agent'); + + const state = useConfigStore.getState(); + expect(state.currentProviderId).toBe('provider'); + expect(state.currentModelId).toBe('model-b'); + expect(useSelectionStore.getState().getAgentModelForSession(sessionId, 'custom-agent')).toEqual({ + providerId: 'provider', + modelId: 'model-b', + }); + }); + + test('[issue-2404] setAgent uses agent default when no session override exists', () => { + const sessionId = 'ses_2404_agent_default'; + const multiModelProvider = { + ...provider('provider', 'model-a'), + models: [ + provider('provider', 'model-a').models[0], + provider('provider', 'model-b').models[0], + ], + }; + useSessionUIStore.setState({ currentSessionId: sessionId }); + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [multiModelProvider], + agents: [testAgent('custom-agent', { model: { providerID: 'provider', modelID: 'model-a' } })], + currentProviderId: 'provider', + currentModelId: 'model-b', + currentAgentName: undefined, + selectionSource: 'auto', + currentVariant: undefined, + directoryScoped: {}, + }); + + useConfigStore.getState().setAgent('custom-agent'); + + const state = useConfigStore.getState(); + expect(state.currentProviderId).toBe('provider'); + expect(state.currentModelId).toBe('model-a'); + }); + test('loadAgents does not fetch OpenCode config directly', async () => { useConfigStore.setState({ activeDirectoryKey: DIRECTORY, diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 4fe82ee2..4a7dfd2e 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -2503,20 +2503,13 @@ export const useConfigStore = create()( return undefined; }; - // Prefer the selected agent's configured model when switching agents. const agent = agents.find((candidate) => candidate.name === agentName); - const agentModelSelection = agent?.model; - if (agentModelSelection?.providerID && agentModelSelection?.modelID) { - const { providerID, modelID } = agentModelSelection; - const agentProvider = providers.find((provider) => provider.id === providerID); - const agentModel = agentProvider?.models.find((model) => model.id === modelID); - - if (agentModel) { - applyResolvedModelSelection(providerID, modelID, resolveVariantForModel(providerID, modelID, agent?.variant)); - return; - } - } + // Prefer a session-level manual override for this agent over the + // agent's configured default. Re-applying setAgent after subtask + // completion / rematerialization must not clobber the override + // (issue #2404). Explicit agent-picker switches still force the + // agent default via ModelControls' shouldPreferAgentModel path. if (currentSessionId) { const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName); if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) { @@ -2532,6 +2525,19 @@ export const useConfigStore = create()( } } + // No session override — use the agent's configured/pinned model. + const agentModelSelection = agent?.model; + if (agentModelSelection?.providerID && agentModelSelection?.modelID) { + const { providerID, modelID } = agentModelSelection; + const agentProvider = providers.find((provider) => provider.id === providerID); + const agentModel = agentProvider?.models.find((model) => model.id === modelID); + + if (agentModel) { + applyResolvedModelSelection(providerID, modelID, resolveVariantForModel(providerID, modelID, agent?.variant)); + return; + } + } + // If the agent has no preferred model, use settings default. if (settingsDefaultModel) { const parsed = parseModelString(settingsDefaultModel); diff --git a/packages/ui/src/sync/__tests__/issue-2404.test.ts b/packages/ui/src/sync/__tests__/issue-2404.test.ts new file mode 100644 index 00000000..16bf87a5 --- /dev/null +++ b/packages/ui/src/sync/__tests__/issue-2404.test.ts @@ -0,0 +1,200 @@ +import { beforeEach, describe, expect, test } from 'bun:test' +import type { Message, Part } from '@opencode-ai/sdk/v2' + +import { + findLatestUserModelChoice, + shouldPreserveManualModelOverride, +} from '@/lib/messages/userModelChoice' + +/** + * Regression for openchamber/openchamber#2404: + * custom agent default model A → manual override to B → delegate subtask → + * after child completes, synthetic completion nudge must not revert to A. + */ +describe('issue #2404 model override persistence across delegated subtask', () => { + const sessionId = 'ses_2404' + const agentName = 'custom-agent' + const agentDefault = { providerID: 'provider', modelID: 'model-a' } + const manualOverride = { providerID: 'provider', modelID: 'model-b' } + + let sessionModelSelections: Map + let sessionAgentModelSelections: Map> + let selectionSource: 'auto' | 'manual' + let currentProviderId: string + let currentModelId: string + + beforeEach(() => { + sessionModelSelections = new Map() + sessionAgentModelSelections = new Map() + selectionSource = 'auto' + currentProviderId = agentDefault.providerID + currentModelId = agentDefault.modelID + }) + + const createSessionWithAgentDefault = () => { + // Session starts on the custom agent's pinned model A. + currentProviderId = agentDefault.providerID + currentModelId = agentDefault.modelID + selectionSource = 'auto' + sessionModelSelections.set(sessionId, { + providerId: agentDefault.providerID, + modelId: agentDefault.modelID, + }) + sessionAgentModelSelections.set(sessionId, new Map([ + [agentName, { providerId: agentDefault.providerID, modelId: agentDefault.modelID }], + ])) + } + + const setManualModelOverride = () => { + selectionSource = 'manual' + currentProviderId = manualOverride.providerID + currentModelId = manualOverride.modelID + sessionModelSelections.set(sessionId, { + providerId: manualOverride.providerID, + modelId: manualOverride.modelID, + }) + const agentMap = sessionAgentModelSelections.get(sessionId) ?? new Map() + agentMap.set(agentName, { + providerId: manualOverride.providerID, + modelId: manualOverride.modelID, + }) + sessionAgentModelSelections.set(sessionId, agentMap) + } + + const completeDelegatedSubtask = () => { + // Parent already has the real user prompt (sent with override B) plus a + // synthetic subagent-completion nudge that carries the agent default A. + const messages: Message[] = [ + { + id: 'u-real', + sessionID: sessionId, + role: 'user', + time: { created: 1 }, + agent: agentName, + model: manualOverride, + } as Message, + { + id: 'a1', + sessionID: sessionId, + role: 'assistant', + time: { created: 2 }, + parentID: 'u-real', + providerID: manualOverride.providerID, + modelID: manualOverride.modelID, + } as Message, + { + id: 'u-nudge', + sessionID: sessionId, + role: 'user', + time: { created: 3 }, + agent: agentName, + model: agentDefault, + } as Message, + ] + const partsById: Record = { + 'u-real': [{ + id: 'p-real', + sessionID: sessionId, + messageID: 'u-real', + type: 'text', + text: 'Delegate a subtask', + } as Part], + 'u-nudge': [{ + id: 'p-nudge', + sessionID: sessionId, + messageID: 'u-nudge', + type: 'text', + text: 'Subagent finished.', + synthetic: true, + } as Part], + } + + const latestChoice = findLatestUserModelChoice(messages, (id) => partsById[id]) + const saved = sessionModelSelections.get(sessionId) ?? null + + // Composer restore must ignore the synthetic nudge and keep the override. + expect(latestChoice?.modelID).toBe(manualOverride.modelID) + expect(shouldPreserveManualModelOverride({ + selectionSource, + savedSessionModel: saved, + candidate: { + providerID: agentDefault.providerID, + modelID: agentDefault.modelID, + }, + })).toBe(true) + + // Re-applying the session agent (as ModelControls may after rematerialization) + // must also prefer the stored override over the agent pin. + const agentOverride = sessionAgentModelSelections.get(sessionId)?.get(agentName) + if (agentOverride) { + currentProviderId = agentOverride.providerId + currentModelId = agentOverride.modelId + } else { + currentProviderId = agentDefault.providerID + currentModelId = agentDefault.modelID + } + } + + test('manual override survives delegated subtask completion', () => { + createSessionWithAgentDefault() + setManualModelOverride() + completeDelegatedSubtask() + + expect(selectionSource).toBe('manual') + expect(currentProviderId).toBe(manualOverride.providerID) + expect(currentModelId).toBe(manualOverride.modelID) + expect(sessionModelSelections.get(sessionId)).toEqual({ + providerId: manualOverride.providerID, + modelId: manualOverride.modelID, + }) + }) + + test('agent default is used when no manual override was set', () => { + createSessionWithAgentDefault() + // No setManualModelOverride — stay on agent default through subtask completion. + const messages: Message[] = [ + { + id: 'u-real', + sessionID: sessionId, + role: 'user', + time: { created: 1 }, + agent: agentName, + model: agentDefault, + } as Message, + { + id: 'u-nudge', + sessionID: sessionId, + role: 'user', + time: { created: 2 }, + agent: agentName, + model: agentDefault, + } as Message, + ] + const partsById: Record = { + 'u-real': [{ + id: 'p-real', + sessionID: sessionId, + messageID: 'u-real', + type: 'text', + text: 'Delegate a subtask', + } as Part], + 'u-nudge': [{ + id: 'p-nudge', + sessionID: sessionId, + messageID: 'u-nudge', + type: 'text', + text: 'Subagent finished.', + synthetic: true, + } as Part], + } + + const latestChoice = findLatestUserModelChoice(messages, (id) => partsById[id]) + expect(latestChoice?.modelID).toBe(agentDefault.modelID) + expect(shouldPreserveManualModelOverride({ + selectionSource: 'auto', + savedSessionModel: sessionModelSelections.get(sessionId), + candidate: latestChoice, + })).toBe(false) + expect(currentModelId).toBe(agentDefault.modelID) + }) +}) diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 895f5062..4ecdb87d 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -30,6 +30,7 @@ import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { normalizePath } from "@/lib/pathNormalization" import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" +import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice" import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree" import { waitForWorktreeBootstrap } from "@/lib/worktrees/worktreeBootstrap" import { getWorktreeSetupWaitEnabled } from "@/lib/openchamberConfig" @@ -1713,33 +1714,19 @@ export const useSessionUIStore = create()((set, get) => ({ getLastUserChoice: (sessionId) => { const directory = get().getDirectoryForSession(sessionId) ?? undefined const messages = getSyncMessages(sessionId, directory) - for (let i = messages.length - 1; i >= 0; i -= 1) { - const message = messages[i] as Message & { - model?: { providerID?: string; modelID?: string; variant?: string } - variant?: string - mode?: string - } - if (message.role !== "user") { - continue - } - - const providerID = typeof message.model?.providerID === "string" && message.model.providerID.trim().length > 0 - ? message.model.providerID - : undefined - const modelID = typeof message.model?.modelID === "string" && message.model.modelID.trim().length > 0 - ? message.model.modelID - : undefined - const agent = typeof message.agent === "string" && message.agent.trim().length > 0 - ? message.agent - : (typeof message.mode === "string" && message.mode.trim().length > 0 ? message.mode : undefined) - const variantCandidate = message.model?.variant ?? message.variant - const variant = typeof variantCandidate === "string" && variantCandidate.trim().length > 0 - ? variantCandidate - : undefined - - return { agent, providerID, modelID, variant } + const choice = findLatestUserModelChoice( + messages, + (messageId) => getSyncParts(messageId, directory), + ) + if (!choice) { + return null + } + return { + agent: choice.agent, + providerID: choice.providerID, + modelID: choice.modelID, + variant: choice.variant, } - return null }, getCurrentAgent: (sessionId) => { From bcae0fcfc34018e951bca13b0c759ece12f9ad5d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 4 Aug 2026 15:09:25 +0300 Subject: [PATCH 08/57] fix(walkthrough): stop the importance tag reading as a review finding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Critical" pill was painted in the status-error colour, so a stop marked because it drives the change read as a severity reported against the code — the one thing this feature never does. It is now "Key change", carries its emphasis with weight and an outline rather than a status colour, and both tags state their meaning in a tooltip. The panel links the guide from its header, and the guide gained a section on what the tags mean and what they do not. Also corrects two German strings that translated the noun "stop" as the verb. --- packages/docs/content/docs/de/walkthrough.mdx | 14 +++++++++ packages/docs/content/docs/walkthrough.mdx | 14 +++++++++ .../views/walkthrough/WalkthroughStream.tsx | 29 +++++++++++++++---- .../views/walkthrough/WalkthroughView.tsx | 27 +++++++++++++++++ packages/ui/src/lib/i18n/messages/de.ts | 9 ++++-- packages/ui/src/lib/i18n/messages/en.ts | 5 +++- packages/ui/src/lib/i18n/messages/es.ts | 5 +++- packages/ui/src/lib/i18n/messages/fr.ts | 5 +++- packages/ui/src/lib/i18n/messages/ja.ts | 5 +++- packages/ui/src/lib/i18n/messages/ko.ts | 5 +++- packages/ui/src/lib/i18n/messages/pl.ts | 5 +++- packages/ui/src/lib/i18n/messages/pt-BR.ts | 5 +++- packages/ui/src/lib/i18n/messages/uk.ts | 5 +++- packages/ui/src/lib/i18n/messages/zh-CN.ts | 5 +++- packages/ui/src/lib/i18n/messages/zh-TW.ts | 5 +++- 15 files changed, 124 insertions(+), 19 deletions(-) diff --git a/packages/docs/content/docs/de/walkthrough.mdx b/packages/docs/content/docs/de/walkthrough.mdx index 04744a4f..e4be894b 100644 --- a/packages/docs/content/docs/de/walkthrough.mdx +++ b/packages/docs/content/docs/de/walkthrough.mdx @@ -11,6 +11,20 @@ Es erklärt und ordnet. Es bewertet Ihren Code nicht und fällt kein Urteil — Öffnen Sie es über das **Walkthrough**-Symbol in der rechten Leiste oder über die Schaltfläche **AI walkthrough** in den Bereichen Changes und Pull Request. Beides öffnet nur das Panel; generiert wird erst, wenn Sie **Generate walkthrough** drücken. +## Wie ein Stop markiert ist + +Jeder Stop benennt sein Thema, erklärt es in ein bis zwei Sätzen und zeigt danach genau den Code, den er beschreibt. Manche Stops tragen eine kleine Markierung neben dem Titel: + +| Markierung | Bedeutung | +| --- | --- | +| **Kernänderung** | Dieser Stop trägt die eigentliche Änderung oder den größten Teil ihres Risikos. Lesen Sie ihn genau und zuerst. | +| **Kontext** | Eine unterstützende Änderung, damit der Rest verständlich bleibt. Kann überflogen werden. | +| *(ohne Markierung)* | Ein gewöhnlicher Schritt in der Lesereihenfolge. | + +Die Markierung sagt, **wo Sie Ihre Aufmerksamkeit investieren sollten**, und nichts über die Qualität des Codes. Ein Stop wird nie markiert, weil darin etwas Falsches gefunden wurde — das Walkthrough meldet keine Funde, keine Schweregrade und keine Urteile. Wenn Code bewertet werden soll, ist das die Aktion **Review** in [Git & GitHub](/git/). + +Die einzigen Markierungen, die tatsächlich auf ein Problem hinweisen, sind **Veraltet** und **Nicht abgedeckt** — und beide betreffen das Veralten des Walkthroughs selbst, nicht Ihren Code. Siehe unten. + ## Was es prüfen kann | Bereich | Was enthalten ist | diff --git a/packages/docs/content/docs/walkthrough.mdx b/packages/docs/content/docs/walkthrough.mdx index 6d372bf5..742a47a0 100644 --- a/packages/docs/content/docs/walkthrough.mdx +++ b/packages/docs/content/docs/walkthrough.mdx @@ -11,6 +11,20 @@ It explains and orders. It does not judge your code or hand out verdicts — tha Open it from the **Walkthrough** icon in the right rail, or from the **AI walkthrough** button in the Changes and Pull Request panels. Both just open the panel; nothing is generated until you press **Generate walkthrough**. +## How a stop is marked + +Each stop names what it is about, explains it in a sentence or two, and then shows exactly the code it describes. Some stops carry a small tag next to the title: + +| Tag | What it means | +| --- | --- | +| **Key change** | This stop drives the rest of the change, or carries most of its risk. Read it closely and read it first. | +| **Context** | A supporting change, included so the rest makes sense. Safe to skim. | +| *(no tag)* | An ordinary step in the reading order. | + +The tag is about **where to spend your attention**, not about the quality of the code. A stop is never marked because something was found wrong in it — the walkthrough reports no findings, no severities, and no verdicts. If you want code judged, that is the **Review** action in [Git & GitHub](/git/). + +The only marks that do report a problem are **Outdated** and **Not covered**, and both are about the walkthrough itself going out of date rather than about your code — see below. + ## What it can review | Scope | What it covers | diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughStream.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughStream.tsx index 8b4e9f1c..bed04795 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughStream.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughStream.tsx @@ -2,6 +2,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Icon } from '@/components/icon/Icon'; import { FileTypeIcon } from '@/components/icons/FileTypeIcon'; import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useI18n } from '@/lib/i18n'; import { groupHunksByFile } from '@/lib/walkthrough/model'; import type { WalkthroughStopView, WalkthroughView } from '@/lib/walkthrough/model'; @@ -20,8 +21,13 @@ interface WalkthroughStreamProps { wrapLines: boolean; } +// Importance says where to spend attention, not what is wrong: a stop is marked +// because it drives the rest of the change, never because something was found in +// it. A red pill said the opposite — status colours are read as findings, and a +// walkthrough deliberately hands out no verdicts — so the emphasis is carried by +// weight and an outline instead, and the tooltip states the axis outright. const IMPORTANCE_CLASS: Record = { - critical: 'bg-status-error/10 text-status-error', + critical: 'border border-[var(--interactive-border)] font-medium text-foreground', normal: 'bg-surface-muted text-muted-foreground', context: 'bg-surface-muted text-muted-foreground', }; @@ -41,11 +47,22 @@ const StopHeader = ({ stopView }: { stopView: WalkthroughStopView }) => { exactly as tall as one without: vertical padding on a smaller type size was pushing past the tallest element in the row. */} {stop.importance !== 'normal' && ( - - {stop.importance === 'critical' - ? t('walkthrough.importance.critical') - : t('walkthrough.importance.context')} - + + + {stop.importance === 'critical' + ? t('walkthrough.importance.critical') + : t('walkthrough.importance.context')} + + +

+ {stop.importance === 'critical' + ? t('walkthrough.importance.criticalHint') + : t('walkthrough.importance.contextHint')} +

+
+
)}

{stop.prose}

diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 246a818c..00c8e6dd 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -10,7 +10,9 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useI18n, type Locale } from '@/lib/i18n'; +import { openExternalUrl } from '@/lib/url'; import { buildWalkthroughView } from '@/lib/walkthrough/model'; import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types'; import { ModelSelector } from '@/components/sections/agents/ModelSelector'; @@ -41,6 +43,12 @@ interface WalkthroughViewProps { const SCOPES: WalkthroughWorkingTreeScope[] = ['all', 'staged', 'working']; +// What a walkthrough is — and what it deliberately is not — cannot be read off +// the panel: the first question users asked about it was whether its marks were +// review findings. The guide answers that, so it is reachable from the surface +// itself rather than only from the release announcement. +const WALKTHROUGH_GUIDE_URL = 'https://docs.openchamber.dev/walkthrough/'; + // DropdownMenuLabel defaults to the same size and weight as its items, which // makes a heading read as another choice. This matches SelectLabel, the // treatment used by the worktree picker. @@ -524,6 +532,25 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => {
+ + + + + +

{t('walkthrough.help.guide')}

+
+
+ {/* A walkthrough nobody can read is worth nothing, so the prose language is a per-review choice like the model — defaulting to the interface language, which is the best evidence of what the reader diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 0db76c86..4784df7b 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2853,17 +2853,20 @@ export const dict = { 'walkthrough.empty.title': 'Noch nichts vorhanden', 'walkthrough.empty.description': 'Wählen Sie Inhalte aus, um einen Walkthrough zu erstellen.', 'walkthrough.stale.banner': 'Der Code hat sich nach diesem Review geändert. Veraltete Schritte: {count}', - 'walkthrough.stop.staleAll': 'Alle veralteten Inhalte stoppen', + 'walkthrough.stop.staleAll': 'Der gesamte Code, den dieser Schritt beschrieben hat, hat sich geändert.', 'walkthrough.stop.stalePartial': 'Ein Teil des vom Schritt beschriebenen Codes hat sich geändert. Fehlende Teile: {count}', - 'walkthrough.stop.staleShort': 'Veraltete stoppen', + 'walkthrough.stop.staleShort': 'Veraltet', 'walkthrough.stop.noCode': 'Kein Code vorhanden', 'walkthrough.uncovered.title': 'Vom Review ausgelassene Änderungen: {count}', 'walkthrough.uncovered.description': 'Diese Bereiche wurden noch nicht in den Walkthrough aufgenommen.', 'walkthrough.toc.moreFiles': 'Weitere Dateien: {count}', 'walkthrough.toc.uncovered': 'Nicht abgedeckt: {count}', 'walkthrough.toc.resize': 'Größe ändern', - 'walkthrough.importance.critical': 'Kritisch', + 'walkthrough.importance.critical': 'Kernänderung', + 'walkthrough.importance.criticalHint': 'Dieser Schritt trägt die eigentliche Änderung, lesen Sie ihn genau. Es ist kein in Ihrem Code gefundenes Problem.', 'walkthrough.importance.context': 'Kontext', + 'walkthrough.importance.contextHint': 'Eine unterstützende Änderung, damit der Rest verständlich bleibt.', + 'walkthrough.help.guide': 'So funktionieren Walkthroughs', 'walkthrough.blocked.noModel.title': 'Kein Modell ausgewählt', 'walkthrough.blocked.noModel.description': 'Wählen Sie zuerst ein Modell aus.', 'walkthrough.blocked.emptyDiff.title': 'Kein Diff vorhanden', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 17d137e5..09cba1a6 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1143,8 +1143,11 @@ export const dict = { 'walkthrough.toc.moreFiles': 'More files: {count}', 'walkthrough.toc.uncovered': 'Not covered: {count}', 'walkthrough.toc.resize': 'Resize the contents column', - 'walkthrough.importance.critical': 'Critical', + 'walkthrough.importance.critical': 'Key change', + 'walkthrough.importance.criticalHint': 'This step drives the rest of the change, so read it closely. It is not a problem found in your code.', 'walkthrough.importance.context': 'Context', + 'walkthrough.importance.contextHint': 'A supporting change, included so the rest makes sense.', + 'walkthrough.help.guide': 'How walkthroughs work', 'walkthrough.blocked.noModel.title': 'No small model available', 'walkthrough.blocked.noModel.description': 'Sign in to a model provider to generate a review.', 'walkthrough.blocked.emptyDiff.title': 'Nothing to review', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index ea5086d0..dd514138 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1144,8 +1144,11 @@ export const dict: Record = { "walkthrough.toc.moreFiles": "Más archivos: {count}", "walkthrough.toc.uncovered": "Sin cubrir: {count}", "walkthrough.toc.resize": "Cambiar el ancho de la columna de contenidos", - "walkthrough.importance.critical": "Crítico", + "walkthrough.importance.critical": "Cambio clave", + "walkthrough.importance.criticalHint": "Este paso impulsa el resto del cambio, así que léelo con atención. No es un problema detectado en tu código.", "walkthrough.importance.context": "Contexto", + "walkthrough.importance.contextHint": "Un cambio de apoyo, incluido para que el resto tenga sentido.", + "walkthrough.help.guide": "Cómo funcionan los walkthroughs", "walkthrough.blocked.noModel.title": "No hay ningún modelo pequeño disponible", "walkthrough.blocked.noModel.description": "Inicia sesión en un proveedor de modelos para generar una revisión.", "walkthrough.blocked.emptyDiff.title": "Nada que revisar", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 3a625450..7bcdacee 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -968,8 +968,11 @@ export const dict = { 'walkthrough.toc.moreFiles': 'Autres fichiers : {count}', 'walkthrough.toc.uncovered': 'Non traité : {count}', 'walkthrough.toc.resize': 'Redimensionner la colonne du sommaire', - 'walkthrough.importance.critical': 'Critique', + 'walkthrough.importance.critical': 'Changement clé', + 'walkthrough.importance.criticalHint': "Cette étape porte l'essentiel du changement, lisez-la attentivement. Ce n'est pas un problème détecté dans votre code.", 'walkthrough.importance.context': 'Contexte', + 'walkthrough.importance.contextHint': 'Un changement de soutien, présent pour que le reste ait du sens.', + 'walkthrough.help.guide': 'Comment fonctionnent les walkthroughs', 'walkthrough.blocked.noModel.title': 'Aucun petit modèle disponible', 'walkthrough.blocked.noModel.description': 'Connectez-vous à un fournisseur de modèles pour générer une revue.', 'walkthrough.blocked.emptyDiff.title': 'Rien à examiner', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 181ec3e9..d344d9ae 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1140,8 +1140,11 @@ export const dict: Record = { 'walkthrough.toc.moreFiles': 'その他のファイル: {count}', 'walkthrough.toc.uncovered': '未対応: {count}', 'walkthrough.toc.resize': '目次の列幅を変更', - 'walkthrough.importance.critical': '重要', + 'walkthrough.importance.critical': '主要な変更', + 'walkthrough.importance.criticalHint': 'このステップが変更全体を動かしているため、じっくり読んでください。コードで見つかった問題ではありません。', 'walkthrough.importance.context': '補足', + 'walkthrough.importance.contextHint': '全体を理解するために添えられた補助的な変更です。', + 'walkthrough.help.guide': 'ウォークスルーの仕組み', 'walkthrough.blocked.noModel.title': '利用できるスモールモデルがありません', 'walkthrough.blocked.noModel.description': 'レビューを生成するにはモデルプロバイダーにサインインしてください。', 'walkthrough.blocked.emptyDiff.title': 'レビュー対象がありません', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 150a56ce..99244e62 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1144,8 +1144,11 @@ export const dict: Record = { 'walkthrough.toc.moreFiles': '다른 파일: {count}', 'walkthrough.toc.uncovered': '미포함: {count}', 'walkthrough.toc.resize': '목차 열 너비 조절', - 'walkthrough.importance.critical': '중요', + 'walkthrough.importance.critical': '핵심 변경', + 'walkthrough.importance.criticalHint': '이 단계가 변경 전체를 이끌고 있으니 꼼꼼히 읽어 보세요. 코드에서 발견된 문제가 아닙니다.', 'walkthrough.importance.context': '참고', + 'walkthrough.importance.contextHint': '나머지를 이해하는 데 도움이 되도록 함께 실은 보조 변경입니다.', + 'walkthrough.help.guide': '워크스루 작동 방식', 'walkthrough.blocked.noModel.title': '사용할 수 있는 스몰 모델이 없습니다', 'walkthrough.blocked.noModel.description': '리뷰를 생성하려면 모델 제공자에 로그인하세요.', 'walkthrough.blocked.emptyDiff.title': '리뷰할 내용이 없습니다', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 8f93e1f6..1dfa3db7 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1456,8 +1456,11 @@ export const dict: Record = { 'walkthrough.toc.moreFiles': 'Więcej plików: {count}', 'walkthrough.toc.uncovered': 'Nieuwzględnione: {count}', 'walkthrough.toc.resize': 'Zmień szerokość kolumny spisu treści', - 'walkthrough.importance.critical': 'Krytyczne', + 'walkthrough.importance.critical': 'Kluczowa zmiana', + 'walkthrough.importance.criticalHint': 'Ten krok napędza resztę zmiany, więc przeczytaj go uważnie. To nie jest problem znaleziony w Twoim kodzie.', 'walkthrough.importance.context': 'Kontekst', + 'walkthrough.importance.contextHint': 'Zmiana pomocnicza, dołączona po to, by reszta miała sens.', + 'walkthrough.help.guide': 'Jak działają walkthroughy', 'walkthrough.blocked.noModel.title': 'Brak dostępnego małego modelu', 'walkthrough.blocked.noModel.description': 'Zaloguj się u dostawcy modeli, aby wygenerować przegląd.', 'walkthrough.blocked.emptyDiff.title': 'Nie ma czego przeglądać', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 44fef67a..29cf2c9c 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1144,8 +1144,11 @@ export const dict: Record = { "walkthrough.toc.moreFiles": "Mais arquivos: {count}", "walkthrough.toc.uncovered": "Sem cobertura: {count}", "walkthrough.toc.resize": "Redimensionar a coluna de conteúdo", - "walkthrough.importance.critical": "Crítico", + "walkthrough.importance.critical": "Mudança principal", + "walkthrough.importance.criticalHint": "Este passo conduz o restante da mudança, então leia com atenção. Não é um problema encontrado no seu código.", "walkthrough.importance.context": "Contexto", + "walkthrough.importance.contextHint": "Uma mudança de apoio, incluída para que o restante faça sentido.", + "walkthrough.help.guide": "Como funcionam os walkthroughs", "walkthrough.blocked.noModel.title": "Nenhum modelo pequeno disponível", "walkthrough.blocked.noModel.description": "Entre em um provedor de modelos para gerar uma revisão.", "walkthrough.blocked.emptyDiff.title": "Nada para revisar", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index c8663cea..f435acec 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1144,8 +1144,11 @@ export const dict: Record = { "walkthrough.toc.moreFiles": "Ще файлів: {count}", "walkthrough.toc.uncovered": "Не описано: {count}", "walkthrough.toc.resize": "Змінити ширину колонки змісту", - "walkthrough.importance.critical": "Критично", + "walkthrough.importance.critical": "Ключова зміна", + "walkthrough.importance.criticalHint": "Цей крок веде за собою решту зміни, тож прочитайте його уважно. Це не знайдена у вашому коді проблема.", "walkthrough.importance.context": "Контекст", + "walkthrough.importance.contextHint": "Допоміжна зміна, додана, щоб решта мала сенс.", + "walkthrough.help.guide": "Як працюють walkthrough", "walkthrough.blocked.noModel.title": "Немає доступної small model", "walkthrough.blocked.noModel.description": "Увійдіть до провайдера моделей, щоб створити розбір.", "walkthrough.blocked.emptyDiff.title": "Немає що оглядати", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 76ffac2f..f8789892 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1144,8 +1144,11 @@ export const dict: Record = { 'walkthrough.toc.moreFiles': '其他文件:{count}', 'walkthrough.toc.uncovered': '未涵盖:{count}', 'walkthrough.toc.resize': '调整目录栏宽度', - 'walkthrough.importance.critical': '关键', + 'walkthrough.importance.critical': '关键改动', + 'walkthrough.importance.criticalHint': '这一步带动了其余改动,值得仔细阅读。它不是在你的代码中发现的问题。', 'walkthrough.importance.context': '背景', + 'walkthrough.importance.contextHint': '辅助性的改动,列在这里是为了让其余部分说得通。', + 'walkthrough.help.guide': 'Walkthrough 的工作方式', 'walkthrough.blocked.noModel.title': '没有可用的小模型', 'walkthrough.blocked.noModel.description': '请登录模型提供方后再生成评审。', 'walkthrough.blocked.emptyDiff.title': '没有可评审的内容', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 3538d59c..bd475161 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1156,8 +1156,11 @@ export const dict: Record = { 'walkthrough.toc.moreFiles': '其他檔案:{count}', 'walkthrough.toc.uncovered': '未涵蓋:{count}', 'walkthrough.toc.resize': '調整目錄欄寬度', - 'walkthrough.importance.critical': '關鍵', + 'walkthrough.importance.critical': '關鍵變更', + 'walkthrough.importance.criticalHint': '這一步帶動了其餘變更,值得仔細閱讀。它不是在你的程式碼中發現的問題。', 'walkthrough.importance.context': '背景', + 'walkthrough.importance.contextHint': '輔助性的變更,列在這裡是為了讓其餘部分說得通。', + 'walkthrough.help.guide': 'Walkthrough 的運作方式', 'walkthrough.blocked.noModel.title': '沒有可用的小模型', 'walkthrough.blocked.noModel.description': '請先登入模型供應商再產生審閱。', 'walkthrough.blocked.emptyDiff.title': '沒有可審閱的內容', From 8c37061886e010707dd7094593fd6829a3ee09ad Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 4 Aug 2026 15:15:47 +0300 Subject: [PATCH 09/57] fix(walkthrough): name an outdated server instead of failing to parse its HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A server without these routes does not answer 404 with JSON. The unmatched /api path reaches the OpenCode proxy, and OpenCode serves its embedded web UI for anything it does not recognise — HTML, status 200 — so a client newer than its server parsed a web page as JSON and put "Unexpected token '<', " )} - {(reason === 'empty-diff' || reason === 'only-generated') && ( + {/* Retry is the whole remedy once the server is updated, so it stays in + reach rather than sending the user back through the panel header. */} + {(reason === 'empty-diff' || reason === 'only-generated' || reason === 'server-unsupported') && ( diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 00c8e6dd..9934a276 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -440,6 +440,9 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { || entry.error?.code === 'empty-diff' || entry.error?.code === 'only-generated' || entry.error?.code === 'output-exhausted' + // Client-detected rather than reported: the server answered something that + // was not JSON, so it has no walkthrough routes at all. + || entry.error?.code === 'server-unsupported' ? entry.error.code : entry.readiness && !entry.readiness.ready && !view && entry.readiness.reason !== 'no-provider-login' diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 4784df7b..b6bd1b81 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2881,6 +2881,8 @@ export const dict = { 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Das kleine Modell hat sein gesamtes Ausgabelimit fürs Nachdenken verbraucht und nichts zurückgegeben. Denkende Modelle tun das bei großen Diffs oft — ein Modell, das weniger denkt, oder ein schmalerer Review-Bereich reicht eher aus.', 'walkthrough.blocked.onlyGenerated.title': 'Nur generierter Inhalt', 'walkthrough.blocked.onlyGenerated.description': 'Es ist nur generierter Inhalt vorhanden.', + 'walkthrough.blocked.serverUnsupported.title': 'Dieser Server unterstützt keine Walkthroughs', + 'walkthrough.blocked.serverUnsupported.description': 'Der OpenChamber-Server, mit dem diese App verbunden ist, hat die Walkthrough-API nicht beantwortet — er ist also älter als die App. Aktualisieren Sie den Server auf 1.18 oder neuer und aktualisieren Sie dann die Ansicht.', 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Das kleine Modell passt in etwa {available}K Zeichen, und dieser Diff braucht etwa {required}K. Nichts wird abgeschnitten — wähle stattdessen ein Modell mit größerem Kontext.', 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Das kleine Modell unterstützt die strukturierten Antworten nicht, die ein Walkthrough benötigt.', 'contextRail.surface.plan.description': 'Plankontext', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 09cba1a6..042c5cf8 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1162,6 +1162,8 @@ export const dict = { 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'The small model spent its whole output allowance on reasoning and returned nothing. Reasoning models often do this on large diffs — a model that thinks less, or reviewing a narrower scope, will get through.', 'walkthrough.blocked.onlyGenerated.title': 'Only generated files changed', 'walkthrough.blocked.onlyGenerated.description': 'Every change here is a lockfile or other tool-produced output, which the review deliberately skips.', + 'walkthrough.blocked.serverUnsupported.title': 'This server has no walkthrough support', + 'walkthrough.blocked.serverUnsupported.description': 'The OpenChamber server this app is connected to did not answer the walkthrough API, which means it is older than the app. Update the server to 1.18 or newer, then refresh.', 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'The small model fits about {available}K characters and this diff needs about {required}K. Nothing gets truncated — pick a model with a larger context instead.', 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'The small model does not support the structured responses a walkthrough needs.', 'contextRail.surface.plan.description': 'View the current plan', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index dd514138..6209796c 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1163,6 +1163,8 @@ export const dict: Record = { "walkthrough.blocked.outputExhausted.descriptionUnknownModel": "El modelo pequeño gastó todo su margen de salida razonando y no devolvió nada. Los modelos de razonamiento suelen hacerlo con diffs grandes: prueba con un modelo que razone menos o revisa un ámbito más reducido.", "walkthrough.blocked.onlyGenerated.title": "Solo cambiaron archivos generados", "walkthrough.blocked.onlyGenerated.description": "Todos los cambios son archivos de bloqueo u otra salida generada por herramientas, que la revisión omite a propósito.", + "walkthrough.blocked.serverUnsupported.title": "Este servidor no admite walkthroughs", + "walkthrough.blocked.serverUnsupported.description": "El servidor de OpenChamber al que está conectada esta app no respondió a la API de walkthrough, así que es más antiguo que la app. Actualiza el servidor a 1.18 o posterior y vuelve a intentarlo.", "walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "El modelo pequeño admite unos {available} mil caracteres y este diff necesita unos {required} mil. No se recorta nada: elige un modelo con más contexto.", "walkthrough.blocked.structuredOutput.descriptionUnknownModel": "El modelo pequeño no admite las respuestas estructuradas que necesita un recorrido.", "contextRail.surface.plan.description": "Ver el plan actual", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 7bcdacee..09357712 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -987,6 +987,8 @@ export const dict = { 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Le petit modèle a dépensé toute sa marge de sortie en raisonnement et n’a rien renvoyé. Les modèles de raisonnement le font souvent sur de gros diffs : essayez un modèle qui réfléchit moins, ou une portée plus étroite.', 'walkthrough.blocked.onlyGenerated.title': 'Seuls des fichiers générés ont changé', 'walkthrough.blocked.onlyGenerated.description': 'Toutes les modifications concernent des fichiers de verrouillage ou d’autres sorties générées, que la revue ignore délibérément.', + 'walkthrough.blocked.serverUnsupported.title': 'Ce serveur ne prend pas en charge les walkthroughs', + 'walkthrough.blocked.serverUnsupported.description': "Le serveur OpenChamber auquel cette application est connectée n'a pas répondu à l'API walkthrough : il est donc plus ancien que l'application. Mettez le serveur à jour en 1.18 ou plus récent, puis actualisez.", 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Le petit modèle accepte environ {available} k caractères et ce diff en demande environ {required} k. Rien n’est tronqué : choisissez un modèle au contexte plus large.', 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Le petit modèle ne prend pas en charge les réponses structurées nécessaires à un parcours.', 'contextRail.surface.plan.description': 'Voir le plan actuel', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index d344d9ae..f8aab496 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1159,6 +1159,8 @@ export const dict: Record = { 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'スモールモデルは出力枠をすべて推論に使い、回答を返しませんでした。推論モデルは大きな差分でよくこうなります。推論の少ないモデルを選ぶか、対象範囲を絞ってください。', 'walkthrough.blocked.onlyGenerated.title': '生成ファイルのみが変更されています', 'walkthrough.blocked.onlyGenerated.description': 'ここでの変更はロックファイルなどツールが生成した出力だけで、レビューは意図的にこれらを対象外にしています。', + 'walkthrough.blocked.serverUnsupported.title': 'このサーバーはウォークスルーに対応していません', + 'walkthrough.blocked.serverUnsupported.description': 'このアプリが接続している OpenChamber サーバーはウォークスルー API に応答しませんでした。つまりアプリより古いバージョンです。サーバーを 1.18 以降に更新してから再読み込みしてください。', 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'スモールモデルが扱えるのは約 {available} 千文字ですが、この差分には約 {required} 千文字が必要です。切り詰めは行いません。コンテキストの大きいモデルを選んでください。', 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'スモールモデルはウォークスルーに必要な構造化応答をサポートしていません。', 'contextRail.surface.plan.description': '現在のプランを表示', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 99244e62..ee0e0464 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1163,6 +1163,8 @@ export const dict: Record = { 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '스몰 모델이 출력 예산을 모두 추론에 쓰고 아무것도 반환하지 않았습니다. 추론 모델은 큰 diff에서 흔히 이렇게 됩니다. 덜 추론하는 모델을 고르거나 범위를 좁혀 보세요.', 'walkthrough.blocked.onlyGenerated.title': '생성된 파일만 변경되었습니다', 'walkthrough.blocked.onlyGenerated.description': '여기의 변경은 모두 잠금 파일이거나 도구가 만든 산출물이며, 리뷰는 이런 파일을 의도적으로 건너뜁니다.', + 'walkthrough.blocked.serverUnsupported.title': '이 서버는 워크스루를 지원하지 않습니다', + 'walkthrough.blocked.serverUnsupported.description': '이 앱이 연결된 OpenChamber 서버가 워크스루 API에 응답하지 않았습니다. 즉 앱보다 오래된 버전입니다. 서버를 1.18 이상으로 업데이트한 뒤 새로 고치세요.', 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '스몰 모델은 약 {available}천 자를 담을 수 있는데 이 diff에는 약 {required}천 자가 필요합니다. 잘라내지 않으니 컨텍스트가 더 큰 모델을 선택하세요.', 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '스몰 모델은 워크스루에 필요한 구조화된 응답을 지원하지 않습니다.', 'contextRail.surface.plan.description': '현재 계획 보기', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 1dfa3db7..120967fd 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1475,6 +1475,8 @@ export const dict: Record = { 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': 'Mały model zużył cały limit wyjścia na rozumowanie i nic nie zwrócił. Modele rozumujące często tak robią przy dużych różnicach — pomoże model mniej „myślący” albo węższy zakres przeglądu.', 'walkthrough.blocked.onlyGenerated.title': 'Zmieniły się tylko pliki generowane', 'walkthrough.blocked.onlyGenerated.description': 'Wszystkie zmiany to pliki blokad lub inne wyniki pracy narzędzi, które przegląd celowo pomija.', + 'walkthrough.blocked.serverUnsupported.title': 'Ten serwer nie obsługuje walkthroughów', + 'walkthrough.blocked.serverUnsupported.description': 'Serwer OpenChamber, z którym połączona jest ta aplikacja, nie odpowiedział na API walkthroughu — jest więc starszy niż aplikacja. Zaktualizuj serwer do wersji 1.18 lub nowszej i odśwież.', 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': 'Mały model mieści około {available} tys. znaków, a te różnice potrzebują około {required} tys. Nic nie jest obcinane — wybierz model z większym kontekstem.', 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': 'Mały model nie obsługuje ustrukturyzowanych odpowiedzi wymaganych przez przewodnik.', 'contextRail.surface.plan.description': 'Zobacz bieżący plan', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 29cf2c9c..c6fc84f4 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1163,6 +1163,8 @@ export const dict: Record = { "walkthrough.blocked.outputExhausted.descriptionUnknownModel": "O modelo pequeno gastou toda a margem de saída raciocinando e não devolveu nada. Modelos de raciocínio costumam fazer isso em diffs grandes — escolha um modelo que raciocine menos ou revise um escopo menor.", "walkthrough.blocked.onlyGenerated.title": "Só mudaram arquivos gerados", "walkthrough.blocked.onlyGenerated.description": "Todas as mudanças são arquivos de lock ou outra saída gerada por ferramentas, que a revisão ignora de propósito.", + "walkthrough.blocked.serverUnsupported.title": "Este servidor não oferece walkthroughs", + "walkthrough.blocked.serverUnsupported.description": "O servidor OpenChamber ao qual este app está conectado não respondeu à API de walkthrough, ou seja, é mais antigo que o app. Atualize o servidor para 1.18 ou mais recente e atualize a visualização.", "walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "O modelo pequeno comporta cerca de {available} mil caracteres e este diff precisa de cerca de {required} mil. Nada é cortado — escolha um modelo com contexto maior.", "walkthrough.blocked.structuredOutput.descriptionUnknownModel": "O modelo pequeno não suporta as respostas estruturadas que um percurso exige.", "contextRail.surface.plan.description": "Ver o plano atual", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index f435acec..803b791b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1163,6 +1163,8 @@ export const dict: Record = { "walkthrough.blocked.outputExhausted.descriptionUnknownModel": "Small model витратила весь бюджет виводу на роздуми й нічого не повернула. Reasoning-моделі часто так поводяться на великих diff — допоможе модель, яка менше «думає», або вужча область огляду.", "walkthrough.blocked.onlyGenerated.title": "Змінились лише згенеровані файли", "walkthrough.blocked.onlyGenerated.description": "Усі зміни тут — це lock-файли чи інший результат роботи інструментів, які розбір свідомо пропускає.", + "walkthrough.blocked.serverUnsupported.title": "Цей сервер не підтримує walkthrough", + "walkthrough.blocked.serverUnsupported.description": "Сервер OpenChamber, до якого підключено застосунок, не відповів на walkthrough API — отже, він старіший за застосунок. Оновіть сервер до 1.18 або новішої версії та оновіть панель.", "walkthrough.blocked.contextTooSmall.descriptionUnknownModel": "Small model вміщає близько {available} тис. символів, а цьому diff потрібно близько {required} тис. Нічого не обрізається — оберіть модель із більшим контекстом.", "walkthrough.blocked.structuredOutput.descriptionUnknownModel": "Small model не підтримує структуровані відповіді, потрібні для розбору.", "contextRail.surface.plan.description": "Перегляд поточного плану", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index f8789892..db955a7e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1163,6 +1163,8 @@ export const dict: Record = { 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '小模型把全部输出额度用在了推理上,没有返回结果。推理模型在大差异上经常如此——可以换一个少推理的模型,或缩小评审范围。', 'walkthrough.blocked.onlyGenerated.title': '只有生成文件发生了改动', 'walkthrough.blocked.onlyGenerated.description': '这里的改动全部是锁文件或其他工具生成的产物,评审会有意跳过它们。', + 'walkthrough.blocked.serverUnsupported.title': '该服务器不支持 walkthrough', + 'walkthrough.blocked.serverUnsupported.description': '此应用连接的 OpenChamber 服务器没有响应 walkthrough API,说明它比应用更旧。请将服务器升级到 1.18 或更高版本后刷新。', 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '小模型大约可容纳 {available} 千字符,而这份差异约需 {required} 千字符。我们不会截断内容,请改选上下文更大的模型。', 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '小模型不支持导读所需的结构化响应。', 'contextRail.surface.plan.description': '查看当前计划', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index bd475161..13cbd616 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1175,6 +1175,8 @@ export const dict: Record = { 'walkthrough.blocked.outputExhausted.descriptionUnknownModel': '小模型把全部輸出額度用在推理上,沒有回傳結果。推理模型在大型差異上經常如此——可以改用較少推理的模型,或縮小審閱範圍。', 'walkthrough.blocked.onlyGenerated.title': '只有產生的檔案有變動', 'walkthrough.blocked.onlyGenerated.description': '這裡的變更全部是鎖定檔或其他工具產生的輸出,審閱會刻意略過它們。', + 'walkthrough.blocked.serverUnsupported.title': '該伺服器不支援 walkthrough', + 'walkthrough.blocked.serverUnsupported.description': '此應用程式連線的 OpenChamber 伺服器沒有回應 walkthrough API,代表它比應用程式更舊。請將伺服器升級到 1.18 或更新版本後重新整理。', 'walkthrough.blocked.contextTooSmall.descriptionUnknownModel': '小模型大約可容納 {available} 千字元,而這份差異約需 {required} 千字元。我們不會截斷內容,請改選上下文更大的模型。', 'walkthrough.blocked.structuredOutput.descriptionUnknownModel': '小模型不支援導讀所需的結構化回應。', 'contextRail.surface.plan.description': '檢視目前計畫', diff --git a/packages/ui/src/lib/walkthrough/api.test.ts b/packages/ui/src/lib/walkthrough/api.test.ts new file mode 100644 index 00000000..0ad57a7e --- /dev/null +++ b/packages/ui/src/lib/walkthrough/api.test.ts @@ -0,0 +1,88 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test'; + +// A server older than this client does not answer 404-with-JSON: unmatched +// `/api/*` reaches the OpenCode proxy, and OpenCode serves its embedded web UI +// for any unknown path — HTML, status 200. These tests pin that the panel gets +// an actionable code instead of a JSON parser error. + +let nextResponse: Response = new Response('{}', { headers: { 'Content-Type': 'application/json' } }); + +mock.module('@/lib/runtime-fetch', () => ({ + runtimeFetch: mock(async () => nextResponse), +})); + +const { fetchWalkthrough, generateWalkthrough } = await import('./api'); +const { WalkthroughError } = await import('./types'); +import type { WalkthroughSource } from './types'; + +const SOURCE: WalkthroughSource = { kind: 'working-tree', scope: 'all' }; + +const html = (status: number) => + new Response('OpenCode', { + status, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + +describe('walkthrough api', () => { + beforeEach(() => { + nextResponse = new Response('{}', { headers: { 'Content-Type': 'application/json' } }); + }); + + test('reads a JSON answer', async () => { + nextResponse = new Response(JSON.stringify({ hunkCount: 3 }), { + headers: { 'Content-Type': 'application/json' }, + }); + + const result = await fetchWalkthrough('/repo', SOURCE); + + expect(result.hunkCount).toBe(3); + }); + + test('reports HTML served with 200 as a server without the routes', async () => { + nextResponse = html(200); + + const error = await fetchWalkthrough('/repo', SOURCE).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(WalkthroughError); + expect((error as InstanceType).code).toBe('server-unsupported'); + expect((error as Error).message).not.toContain('JSON'); + }); + + test('reports a non-JSON 404 the same way', async () => { + nextResponse = html(404); + + const error = await generateWalkthrough('/repo', SOURCE).catch((caught: unknown) => caught); + + expect((error as InstanceType).code).toBe('server-unsupported'); + }); + + test('keeps a server-side failure rather than blaming the server version', async () => { + nextResponse = new Response(JSON.stringify({ error: 'model exploded', code: 'output-exhausted' }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + + const error = await generateWalkthrough('/repo', SOURCE).catch((caught: unknown) => caught); + + expect((error as InstanceType).code).toBe('output-exhausted'); + expect((error as Error).message).toBe('model exploded'); + }); + + test('a 5xx that is not JSON is a broken server, not a missing route', async () => { + nextResponse = html(502); + + const error = await fetchWalkthrough('/repo', SOURCE).catch((caught: unknown) => caught); + + expect((error as InstanceType).code).toBe(undefined); + expect((error as Error).message).toBe('Failed to load walkthrough'); + }); + + test('JSON that does not parse is reported without the parser wording', async () => { + nextResponse = new Response('{"walkthrough":', { headers: { 'Content-Type': 'application/json' } }); + + const error = await fetchWalkthrough('/repo', SOURCE).catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(WalkthroughError); + expect((error as Error).message).toBe('The server returned a malformed walkthrough response'); + }); +}); diff --git a/packages/ui/src/lib/walkthrough/api.ts b/packages/ui/src/lib/walkthrough/api.ts index 344bf853..ef06d2b0 100644 --- a/packages/ui/src/lib/walkthrough/api.ts +++ b/packages/ui/src/lib/walkthrough/api.ts @@ -16,9 +16,30 @@ interface ErrorPayload { availableChars?: unknown; } +const isJsonResponse = (response: Response): boolean => + /^application\/(?:[\w.+-]+\+)?json\b/i.test(response.headers.get('content-type') ?? ''); + +/** + * A server without these routes does not answer 404 with JSON. Unmatched + * `/api/*` falls through to the OpenCode proxy, and OpenCode serves its embedded + * web UI for any path it does not know — HTML, status 200. Parsing that as JSON + * surfaced `Unexpected token '<', " + new WalkthroughError('This OpenChamber server has no walkthrough API', { code: 'server-unsupported' }); + +const looksUnsupported = (response: Response): boolean => + !isJsonResponse(response) && (response.ok || response.status === 404); + // An authoritative read that fails must never look like "there is nothing // here" — the caller would clear a perfectly good walkthrough off the screen. const throwFromResponse = async (response: Response, fallback: string): Promise => { + if (looksUnsupported(response)) throw serverUnsupported(); const payload = (await response.json().catch(() => null)) as ErrorPayload | null; throw new WalkthroughError(typeof payload?.error === 'string' ? payload.error : fallback, { code: typeof payload?.code === 'string' ? (payload.code as WalkthroughError['code']) : undefined, @@ -28,6 +49,17 @@ const throwFromResponse = async (response: Response, fallback: string): Promise< }); }; +const readJson = async (response: Response): Promise => { + if (!isJsonResponse(response)) throw serverUnsupported(); + try { + return (await response.json()) as T; + } catch { + // Declared JSON, arrived truncated or empty: still not an answer, and the + // parser's own message says nothing a reader can act on. + throw new WalkthroughError('The server returned a malformed walkthrough response'); + } +}; + export async function fetchWalkthrough( directory: string, source: WalkthroughSource, @@ -45,7 +77,7 @@ export async function fetchWalkthrough( if (!response.ok) { return throwFromResponse(response, 'Failed to load walkthrough'); } - return response.json(); + return readJson(response); } export async function generateWalkthrough( @@ -68,7 +100,7 @@ export async function generateWalkthrough( if (!response.ok) { return throwFromResponse(response, 'Failed to generate walkthrough'); } - return response.json(); + return readJson(response); } /** diff --git a/packages/ui/src/lib/walkthrough/types.ts b/packages/ui/src/lib/walkthrough/types.ts index 5f8ef80a..e714a0ef 100644 --- a/packages/ui/src/lib/walkthrough/types.ts +++ b/packages/ui/src/lib/walkthrough/types.ts @@ -89,6 +89,7 @@ export interface WalkthroughResult { */ export type WalkthroughStage = 'collecting' | 'asking' | 'retrying' | 'assembling'; +/** Reasons the server reports for refusing to generate. */ export type WalkthroughBlockedReason = | 'no-model' | 'no-provider-login' @@ -98,6 +99,13 @@ export type WalkthroughBlockedReason = | 'structured-output-unsupported' | 'output-exhausted'; +/** + * Everything the panel can render as a blocking screen. `server-unsupported` is + * never sent by a server — it is what the client concludes when the answer is + * not JSON at all, which is how a server too old to have these routes replies. + */ +export type WalkthroughBlockedState = WalkthroughBlockedReason | 'server-unsupported'; + export interface WalkthroughReadiness { ready: boolean; reason?: WalkthroughBlockedReason; @@ -116,7 +124,7 @@ export interface WalkthroughReadiness { } export class WalkthroughError extends Error { - readonly code?: WalkthroughBlockedReason | 'invalid-walkthrough' | 'github-not-connected' | 'no-github-remote'; + readonly code?: WalkthroughBlockedState | 'invalid-walkthrough' | 'github-not-connected' | 'no-github-remote'; readonly model?: WalkthroughModel; readonly requiredChars?: number; readonly availableChars?: number; diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md index 0f23fac5..abeb7e51 100644 --- a/packages/web/server/lib/walkthrough/DOCUMENTATION.md +++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md @@ -377,6 +377,21 @@ endpoint nothing calls is a maintenance surface that rots untested. Registered lazily from `feature-routes-runtime.js`. `/api/walkthrough` is in the JSON body-parser allowlist in `core-routes.js`. +## A server that does not have these routes + +An `/api/*` path no OpenChamber route claims reaches the OpenCode proxy, and +OpenCode answers any path it does not know with its embedded web UI — HTML, with +status **200**. So a client newer than the server it is connected to is not told +"no such route"; it is handed a web page. Parsing that as JSON is where +`Unexpected token '<', " Date: Tue, 4 Aug 2026 17:09:20 +0300 Subject: [PATCH 10/57] fix(providers): complete OAuth logins that finish in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode's authorize response reports how the client must finish: `code` expects a pasted code, while `auto` requires the client to call oauth/callback immediately and hold it open — upstream blocks in there polling for the device code or waiting on its loopback redirect, and only that call persists the credential. Every auth plugin OpenCode ships uses `auto`; none use `code`. The page implemented only `code`. It opened the browser, showed a paste field no provider can fill, and never called back, so a successful sign-in stored nothing and the app sat unchanged. Authorization now drives the UI: `auto` chains straight into the callback behind a waiting state with a cancel, and the paste field appears only when a provider actually asks for a code. Two smaller failures shared that surface. Prompts were never collected, which put GitHub Copilot Enterprise out of reach entirely, so a method that declares them now asks first and passes the answers to authorize. Device codes are also recovered from the instructions text, where they actually live — the old code read fields the API does not return, so the copy button never appeared. The callback is exempt from the ordinary proxy deadline and gets a 15-minute budget, bounded by the shortest upstream expiry we know of. A human sign-in with 2FA does not fit in four minutes, and expiring it turned a completed login into a 504. --- .../providers/ProviderOAuthMethods.tsx | 455 ++++++++++++++++++ .../sections/providers/ProvidersPage.tsx | 318 ++---------- .../sections/providers/provider-oauth.test.ts | 236 +++++++++ .../sections/providers/provider-oauth.ts | 244 ++++++++++ .../sections/providers/providerAuth.ts | 2 + .../ui/src/lib/i18n/messages/de.settings.ts | 15 +- .../ui/src/lib/i18n/messages/en.settings.ts | 15 +- .../ui/src/lib/i18n/messages/es.settings.ts | 15 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 15 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 15 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 15 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 15 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 15 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 15 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 15 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 15 +- .../web/server/lib/opencode/DOCUMENTATION.md | 2 + packages/web/server/lib/opencode/proxy.js | 24 +- packages/web/server/opencode-proxy.test.js | 83 ++++ 19 files changed, 1239 insertions(+), 290 deletions(-) create mode 100644 packages/ui/src/components/sections/providers/ProviderOAuthMethods.tsx create mode 100644 packages/ui/src/components/sections/providers/provider-oauth.test.ts create mode 100644 packages/ui/src/components/sections/providers/provider-oauth.ts diff --git a/packages/ui/src/components/sections/providers/ProviderOAuthMethods.tsx b/packages/ui/src/components/sections/providers/ProviderOAuthMethods.tsx new file mode 100644 index 00000000..a293fdbe --- /dev/null +++ b/packages/ui/src/components/sections/providers/ProviderOAuthMethods.tsx @@ -0,0 +1,455 @@ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { toast } from '@/components/ui'; +import { Icon } from '@/components/icon/Icon'; +import { + SETTINGS_SELECT_ROW_TRIGGER_CLASS, + SETTINGS_SELECT_SIZE, +} from '@/components/sections/shared/SettingsSection'; +import { useI18n, type I18nKey } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; +import { copyTextToClipboard } from '@/lib/clipboard'; +import { openExternalUrl } from '@/lib/url'; +import { opencodeClient } from '@/lib/opencode/client'; +import { + collectPromptInputs, + defaultPromptValues, + describeOAuthError, + firstUnansweredPrompt, + parseAuthPrompts, + parseAuthorization, + visiblePrompts, + type AuthPrompt, + type OAuthAuthorization, +} from './provider-oauth'; + +export interface ProviderOAuthMethod { + /** Index into the provider's full auth-method list, which is what OpenCode's `method` parameter addresses. */ + index: number; + label: string; + prompts?: unknown; +} + +interface ProviderOAuthMethodsProps { + providerId: string; + methods: ProviderOAuthMethod[]; + /** Called once a credential has been stored, so the caller can reload providers. */ + onConnected: () => void | Promise; + /** Layout only — the caller owns separation from whatever sits above. */ + className?: string; +} + +type Flow = + | { phase: 'idle' } + | { phase: 'prompting'; methodIndex: number; prompts: AuthPrompt[]; error: string | null } + | { phase: 'authorizing'; methodIndex: number } + /** `auto`: the callback request is in flight and blocks until the browser sign-in finishes. */ + | { phase: 'waiting'; methodIndex: number; authorization: OAuthAuthorization } + /** `code`: waiting for the user to paste a code out of the browser. */ + | { phase: 'awaitingCode'; methodIndex: number; authorization: OAuthAuthorization; submitting: boolean } + | { phase: 'failed'; methodIndex: number; message: string }; + +const IDLE: Flow = { phase: 'idle' }; + +/** + * OAuth sign-in for a provider's auth methods. + * + * The completion method reported by `authorize` drives everything: `auto` + * chains straight into `callback` and holds it open until the user finishes in + * the browser, `code` collects a pasted code first. See `provider-oauth.ts`. + * + * Only one method can run at a time, and the in-flight callback is aborted when + * this component unmounts. Mount it with `key={providerId}` so switching + * providers starts from a clean flow. + */ +export const ProviderOAuthMethods: React.FC = ({ + providerId, + methods, + onConnected, + className, +}) => { + const { t } = useI18n(); + const [flow, setFlow] = React.useState(IDLE); + const [promptValues, setPromptValues] = React.useState>({}); + const [codeInput, setCodeInput] = React.useState(''); + const callbackAbortRef = React.useRef(null); + + React.useEffect(() => () => callbackAbortRef.current?.abort(), []); + + const activeIndex = flow.phase === 'idle' ? null : flow.methodIndex; + const busy = flow.phase === 'authorizing' + || flow.phase === 'waiting' + || (flow.phase === 'awaitingCode' && flow.submitting); + + const copy = async (value: string, successKey: I18nKey, failureKey: I18nKey) => { + const result = await copyTextToClipboard(value); + if (result.ok) { + toast.success(t(successKey)); + return; + } + console.error('Failed to copy OAuth value:', result.error); + toast.error(t(failureKey)); + }; + + /** + * Runs the blocking half of the flow. Never throws: the caller has already + * handed control to the user, so a failure here is a flow state, not an + * exception to unwind. + */ + const runCallback = async (methodIndex: number, code?: string) => { + const controller = new AbortController(); + callbackAbortRef.current?.abort(); + callbackAbortRef.current = controller; + + try { + const result = await opencodeClient.getSdkClient().provider.oauth.callback( + { + providerID: providerId, + method: methodIndex, + ...(code ? { code } : {}), + }, + { signal: controller.signal }, + ); + if (controller.signal.aborted) { + return; + } + if (result.error) { + throw result.error; + } + + setFlow(IDLE); + toast.success(t('settings.providers.page.toast.oauthCompleted')); + await onConnected(); + } catch (error) { + if (controller.signal.aborted) { + return; + } + console.error('Failed to complete OAuth flow:', error); + setFlow({ + phase: 'failed', + methodIndex, + message: describeOAuthError(error, t, 'settings.providers.page.toast.oauthCompleteFailed'), + }); + } finally { + if (callbackAbortRef.current === controller) { + callbackAbortRef.current = null; + } + } + }; + + const runAuthorize = async (methodIndex: number, inputs: Record) => { + setFlow({ phase: 'authorizing', methodIndex }); + + let authorization: OAuthAuthorization; + try { + const result = await opencodeClient.getSdkClient().provider.oauth.authorize({ + providerID: providerId, + method: methodIndex, + ...(Object.keys(inputs).length > 0 ? { inputs } : {}), + }); + if (result.error) { + throw result.error; + } + + const parsed = parseAuthorization(result.data); + if (!parsed) { + setFlow({ + phase: 'failed', + methodIndex, + message: t('settings.providers.page.toast.oauthDetailsMissing'), + }); + return; + } + authorization = parsed; + } catch (error) { + console.error('Failed to start OAuth flow:', error); + setFlow({ + phase: 'failed', + methodIndex, + message: describeOAuthError(error, t, 'settings.providers.page.toast.oauthStartFailed'), + }); + return; + } + + if (authorization.url) { + void openExternalUrl(authorization.url); + } + + if (authorization.method === 'code') { + setCodeInput(''); + setFlow({ phase: 'awaitingCode', methodIndex, authorization, submitting: false }); + return; + } + + setFlow({ phase: 'waiting', methodIndex, authorization }); + await runCallback(methodIndex); + }; + + const beginConnect = (method: ProviderOAuthMethod) => { + const prompts = parseAuthPrompts(method.prompts); + if (prompts.length === 0) { + void runAuthorize(method.index, {}); + return; + } + setPromptValues(defaultPromptValues(prompts)); + setFlow({ phase: 'prompting', methodIndex: method.index, prompts, error: null }); + }; + + const submitPrompts = () => { + if (flow.phase !== 'prompting') { + return; + } + const unanswered = firstUnansweredPrompt(flow.prompts, promptValues); + if (unanswered) { + setFlow({ + ...flow, + error: t('settings.providers.page.auth.oauth.promptRequired', { field: unanswered.message }), + }); + return; + } + void runAuthorize(flow.methodIndex, collectPromptInputs(flow.prompts, promptValues)); + }; + + const submitCode = () => { + if (flow.phase !== 'awaitingCode') { + return; + } + const code = codeInput.trim(); + if (!code) { + return; + } + setFlow({ ...flow, submitting: true }); + void runCallback(flow.methodIndex, code); + }; + + /** + * Stops tracking the attempt. Upstream keeps its pending authorization until + * a new `authorize` replaces it, so reconnecting is always safe. + */ + const cancel = () => { + callbackAbortRef.current?.abort(); + callbackAbortRef.current = null; + setFlow(IDLE); + }; + + const renderPrompt = (prompt: AuthPrompt) => { + const value = promptValues[prompt.key] ?? ''; + const setValue = (next: string) => + setPromptValues((prev) => ({ ...prev, [prompt.key]: next })); + + return ( +
+ + {prompt.type === 'select' ? ( + + ) : ( + setValue(event.target.value)} + placeholder={prompt.placeholder} + className="max-w-[24rem] text-xs" + /> + )} +
+ ); + }; + + const renderAuthorizationDetails = (authorization: OAuthAuthorization) => ( + <> + {authorization.instructions && ( +

+ {authorization.instructions} +

+ )} + + {authorization.userCode && ( +
+ + +
+ )} + + {authorization.url && ( +
+ +
+ + +
+
+ )} + + ); + + return ( +
+ {methods.map((method) => { + const isActive = activeIndex === method.index; + + return ( +
+
+
{method.label}
+ +
+ + {isActive && flow.phase === 'prompting' && ( +
+ {visiblePrompts(flow.prompts, promptValues).map(renderPrompt)} + {flow.error && ( +

{flow.error}

+ )} +
+ + +
+
+ )} + + {isActive && flow.phase === 'authorizing' && ( +

+ + {t('settings.providers.page.auth.oauth.starting')} +

+ )} + + {isActive && flow.phase === 'waiting' && ( +
+ {renderAuthorizationDetails(flow.authorization)} +
+

+ + {t('settings.providers.page.auth.oauth.waiting')} +

+ +
+

+ {t('settings.providers.page.auth.oauth.waitingHint')} +

+
+ )} + + {isActive && flow.phase === 'awaitingCode' && ( +
+ {renderAuthorizationDetails(flow.authorization)} +

+ {t('settings.providers.page.auth.oauth.codeHint')} +

+
+ setCodeInput(event.target.value)} + placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')} + className="font-mono text-xs" + disabled={flow.submitting} + /> + + +
+
+ )} + + {isActive && flow.phase === 'failed' && ( +
+

{flow.message}

+ +
+ )} +
+ ); + })} +
+ ); +}; diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 008b04e2..a7594c1c 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -19,8 +19,6 @@ import { Icon } from "@/components/icon/Icon"; import type { IconName } from "@/components/icon/icons"; import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; import { cn } from '@/lib/utils'; -import { copyTextToClipboard } from '@/lib/clipboard'; -import { openExternalUrl } from '@/lib/url'; import type { ModelMetadata } from '@/types'; import { getCurrentIntlLocale, useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; @@ -31,8 +29,10 @@ import { parseAuthPayload, shouldShowApiKeyAuth, type AuthMethod, + type OAuthAuthMethodEntry, } from './providerAuth'; import { CustomProviderForm } from './CustomProviderForm'; +import { ProviderOAuthMethods, type ProviderOAuthMethod } from './ProviderOAuthMethods'; import { buildAuthSetRequest, buildProviderUpsertRequest, @@ -85,6 +85,16 @@ interface ProviderSources { const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; +const toOAuthMethods = ( + entries: OAuthAuthMethodEntry[], + fallbackLabel: (index: number) => string, +): ProviderOAuthMethod[] => + entries.map(({ method, methodIndex }) => ({ + index: methodIndex, + label: method.label || method.name || fallbackLabel(methodIndex), + prompts: method.prompts, + })); + const normalizeProviderEntry = (entry: unknown): ProviderOption | null => { if (typeof entry === 'string') { return { id: entry }; @@ -147,9 +157,6 @@ export const ProvidersPage: React.FC = () => { const [apiKeyInputs, setApiKeyInputs] = React.useState>({}); const [authBusyKey, setAuthBusyKey] = React.useState(null); const [modelQuery, setModelQuery] = React.useState(''); - const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null); - const [oauthCodes, setOauthCodes] = React.useState>({}); - const [oauthDetails, setOauthDetails] = React.useState>({}); const [availableProviders, setAvailableProviders] = React.useState([]); const [availableLoading, setAvailableLoading] = React.useState(false); const [availableError, setAvailableError] = React.useState(null); @@ -181,7 +188,8 @@ export const ProvidersPage: React.FC = () => { React.useEffect(() => { // Auth methods drive which credential UI to show (API key vs OAuth). Keep // them loaded for the active provider view so OAuth-only plugins never fall - // back to an API key form merely because methods were never fetched. + // back to an API key form merely because methods were never fetched, and so + // an already-listed provider can still offer re-authentication. if (!selectedProviderId) { return; } @@ -458,117 +466,13 @@ export const ProvidersPage: React.FC = () => { } }; - const handleOAuthStart = async (providerId: string, methodIndex: number) => { - const busyKey = `oauth:${providerId}:${methodIndex}`; - setAuthBusyKey(busyKey); + const oauthMethodFallbackLabel = (index: number) => + t('settings.providers.page.auth.oauthMethodFallback', { index: String(index + 1) }); - try { - const result = await opencodeClient.getSdkClient().provider.oauth.authorize({ - providerID: providerId, - method: methodIndex, - }); - if (result.error) { - throw new Error(t('settings.providers.page.toast.oauthStartFailed')); - } - - const payloadRecord: Record = isRecord(result.data) ? result.data : {}; - const nestedData = payloadRecord.data; - const dataRecord: Record = isRecord(nestedData) ? nestedData : payloadRecord; - const urlCandidate = - (typeof dataRecord.url === 'string' && dataRecord.url) || - (typeof dataRecord.verification_uri_complete === 'string' && dataRecord.verification_uri_complete) || - (typeof dataRecord.verification_uri === 'string' && dataRecord.verification_uri) || - undefined; - const instructions = - (typeof dataRecord.instructions === 'string' && dataRecord.instructions) || - (typeof dataRecord.message === 'string' && dataRecord.message) || - undefined; - const userCode = - (typeof dataRecord.user_code === 'string' && dataRecord.user_code) || - (typeof dataRecord.code === 'string' && dataRecord.code) || - (typeof dataRecord.userCode === 'string' && dataRecord.userCode) || - undefined; - - if (!urlCandidate && !instructions && !userCode) { - throw new Error(t('settings.providers.page.toast.oauthDetailsMissing')); - } - - const detailsKey = `${providerId}:${methodIndex}`; - setOauthDetails((prev) => ({ - ...prev, - [detailsKey]: { - url: urlCandidate, - instructions, - userCode, - }, - })); - - if (urlCandidate) { - void openExternalUrl(urlCandidate); - } - setPendingOAuth({ providerId, methodIndex }); - toast.message(t('settings.providers.page.toast.completeOAuthInBrowser')); - } catch (error) { - console.error('Failed to start OAuth flow:', error); - toast.error(t('settings.providers.page.toast.oauthStartFailed')); - } finally { - setAuthBusyKey(null); - } - }; - - const handleOAuthComplete = async (providerId: string, methodIndex: number) => { - const codeKey = `${providerId}:${methodIndex}`; - const code = oauthCodes[codeKey]?.trim(); - - const busyKey = `oauth-complete:${providerId}:${methodIndex}`; - setAuthBusyKey(busyKey); - - try { - const requestBody: { method: number; code?: string } = { method: methodIndex }; - if (code) { - requestBody.code = code; - } - - const result = await opencodeClient.getSdkClient().provider.oauth.callback({ - providerID: providerId, - method: requestBody.method, - code: requestBody.code, - }); - if (result.error) { - throw new Error(t('settings.providers.page.toast.oauthCompleteFailed')); - } - - toast.success(t('settings.providers.page.toast.oauthCompleted')); - setOauthCodes((prev) => ({ ...prev, [codeKey]: '' })); - setPendingOAuth(null); - await reloadOpenCodeConfiguration({ scopes: ["providers"], mode: "active" }); - setSelectedProvider(providerId); - } catch (error) { - console.error('Failed to complete OAuth flow:', error); - toast.error(t('settings.providers.page.toast.oauthCompleteFailed')); - } finally { - setAuthBusyKey(null); - } - }; - - const handleCopyOAuthLink = async (url: string) => { - const result = await copyTextToClipboard(url); - if (result.ok) { - toast.success(t('settings.providers.page.toast.oauthLinkCopied')); - return; - } - console.error('Failed to copy OAuth link:', result.error); - toast.error(t('settings.providers.page.toast.oauthLinkCopyFailed')); - }; - - const handleCopyOAuthCode = async (code: string) => { - const result = await copyTextToClipboard(code); - if (result.ok) { - toast.success(t('settings.providers.page.toast.deviceCodeCopied')); - return; - } - console.error('Failed to copy device code:', result.error); - toast.error(t('settings.providers.page.toast.deviceCodeCopyFailed')); + const handleOAuthConnected = async (providerId: string) => { + setShowAuthPanel(false); + await reloadOpenCodeConfiguration({ scopes: ['providers'], mode: 'active' }); + setSelectedProvider(providerId); }; const handleDisconnectProvider = async (providerId: string) => { @@ -777,7 +681,10 @@ export const ProvidersPage: React.FC = () => { <> {(() => { const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? []; - const candidateOAuthMethods = getOAuthAuthMethods(candidateAuthMethods); + const candidateOAuthMethods = toOAuthMethods( + getOAuthAuthMethods(candidateAuthMethods), + oauthMethodFallbackLabel, + ); const showApiKey = shouldShowApiKeyAuth(candidateAuthMethods); return ( @@ -814,85 +721,13 @@ export const ProvidersPage: React.FC = () => { ) : null} {candidateOAuthMethods.length > 0 ? ( -
- {candidateOAuthMethods.map(({ method, methodIndex }) => { - const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) }); - const codeKey = `${candidateProviderId}:${methodIndex}`; - const isPending = - pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === methodIndex; - - return ( -
-
-
-
{methodLabel}
- {(method.description || method.help) && ( -
- {String(method.description || method.help)} -
- )} -
- -
- - {oauthDetails[codeKey]?.instructions && ( -

- {oauthDetails[codeKey]?.instructions} -

- )} - - {oauthDetails[codeKey]?.userCode && ( -
- - -
- )} - - {oauthDetails[codeKey]?.url && ( -
- -
- - -
-
- )} - - {isPending && ( -
- - setOauthCodes((prev) => ({ - ...prev, - [codeKey]: event.target.value, - })) - } - placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')} - className="font-mono text-xs" - /> - -
- )} -
- ); - })} -
+ handleOAuthConnected(candidateProviderId)} + className={cn(showApiKey && 'border-t border-[var(--surface-subtle)] pt-2')} + /> ) : null} ); @@ -919,7 +754,10 @@ export const ProvidersPage: React.FC = () => { const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : []; const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? []; - const oauthAuthMethods = getOAuthAuthMethods(providerAuthMethods); + const oauthAuthMethods = toOAuthMethods( + getOAuthAuthMethods(providerAuthMethods), + oauthMethodFallbackLabel, + ); const showApiKeyAuth = shouldShowApiKeyAuth(providerAuthMethods); const sourcesLoaded = Boolean(selectedSources); const isEditableCustomProvider = sourcesLoaded @@ -1062,85 +900,13 @@ export const ProvidersPage: React.FC = () => { ) : null} {oauthAuthMethods.length > 0 && ( -
- {oauthAuthMethods.map(({ method, methodIndex }) => { - const methodLabel = method.label || method.name || t('settings.providers.page.auth.oauthMethodFallback', { index: String(methodIndex + 1) }); - const codeKey = `${selectedProvider.id}:${methodIndex}`; - const isPending = - pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === methodIndex; - - return ( -
-
-
-
{methodLabel}
- {(method.description || method.help) && ( -
- {String(method.description || method.help)} -
- )} -
- -
- - {oauthDetails[codeKey]?.instructions && ( -

- {oauthDetails[codeKey]?.instructions} -

- )} - - {oauthDetails[codeKey]?.userCode && ( -
- - -
- )} - - {oauthDetails[codeKey]?.url && ( -
- -
- - -
-
- )} - - {isPending && ( -
- - setOauthCodes((prev) => ({ - ...prev, - [codeKey]: event.target.value, - })) - } - placeholder={t('settings.providers.page.auth.pasteAuthorizationCodePlaceholder')} - className="font-mono text-xs" - /> - -
- )} -
- ); - })} -
+ handleOAuthConnected(selectedProvider.id)} + className={cn(showApiKeyAuth && 'border-t border-[var(--surface-subtle)] pt-2')} + /> )}
)} diff --git a/packages/ui/src/components/sections/providers/provider-oauth.test.ts b/packages/ui/src/components/sections/providers/provider-oauth.test.ts new file mode 100644 index 00000000..93f8e762 --- /dev/null +++ b/packages/ui/src/components/sections/providers/provider-oauth.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from 'bun:test'; +import { + collectPromptInputs, + defaultPromptValues, + describeOAuthError, + firstUnansweredPrompt, + isPromptVisible, + parseAuthPrompts, + parseAuthorization, + visiblePrompts, + type AuthPrompt, + type ProviderOAuthTranslator, +} from './provider-oauth'; + +/** Mirrors the github-copilot auth method shipped by OpenCode. */ +const copilotPrompts = [ + { + type: 'select', + key: 'deploymentType', + message: 'Select GitHub deployment type', + options: [ + { label: 'GitHub.com', value: 'github.com', hint: 'Public' }, + { label: 'GitHub Enterprise', value: 'enterprise' }, + ], + }, + { + type: 'text', + key: 'enterpriseUrl', + message: 'Enter your GitHub Enterprise URL or domain', + placeholder: 'company.ghe.com', + when: { key: 'deploymentType', op: 'eq', value: 'enterprise' }, + }, +]; + +describe('parseAuthPrompts', () => { + test('parses select and conditional text prompts', () => { + const prompts = parseAuthPrompts(copilotPrompts); + + expect(prompts).toHaveLength(2); + expect(prompts[0]).toEqual({ + type: 'select', + key: 'deploymentType', + message: 'Select GitHub deployment type', + options: [ + { value: 'github.com', label: 'GitHub.com', hint: 'Public' }, + { value: 'enterprise', label: 'GitHub Enterprise' }, + ], + }); + expect(prompts[1]).toEqual({ + type: 'text', + key: 'enterpriseUrl', + message: 'Enter your GitHub Enterprise URL or domain', + options: [], + placeholder: 'company.ghe.com', + when: { key: 'deploymentType', op: 'eq', value: 'enterprise' }, + }); + }); + + test('returns an empty list for a method without prompts', () => { + expect(parseAuthPrompts(undefined)).toEqual([]); + expect(parseAuthPrompts(null)).toEqual([]); + expect(parseAuthPrompts({})).toEqual([]); + }); + + test('drops entries that could never be answered', () => { + const prompts = parseAuthPrompts([ + { type: 'text', message: 'no key' }, + { type: 'select', key: 'empty', message: 'no options', options: [] }, + { type: 'text', key: 'keep', message: 'keep me' }, + ]); + + expect(prompts.map((prompt) => prompt.key)).toEqual(['keep']); + }); + + test('falls back to the key when a message is missing', () => { + expect(parseAuthPrompts([{ type: 'text', key: 'token' }])[0]?.message).toBe('token'); + }); + + test('ignores a malformed when condition instead of hiding the prompt', () => { + const [prompt] = parseAuthPrompts([ + { type: 'text', key: 'url', message: 'URL', when: { key: 'other', op: 'contains', value: 'x' } }, + ]); + + expect(prompt.when).toBe(undefined); + expect(isPromptVisible(prompt, {})).toBe(true); + }); +}); + +describe('prompt visibility', () => { + const prompts = parseAuthPrompts(copilotPrompts); + + test('hides a conditional prompt until its branch is selected', () => { + expect(visiblePrompts(prompts, { deploymentType: 'github.com' }).map((p) => p.key)) + .toEqual(['deploymentType']); + expect(visiblePrompts(prompts, { deploymentType: 'enterprise' }).map((p) => p.key)) + .toEqual(['deploymentType', 'enterpriseUrl']); + }); + + test('supports neq conditions', () => { + const prompt: AuthPrompt = { + type: 'text', + key: 'custom', + message: 'Custom', + options: [], + when: { key: 'mode', op: 'neq', value: 'default' }, + }; + + expect(isPromptVisible(prompt, { mode: 'default' })).toBe(false); + expect(isPromptVisible(prompt, { mode: 'other' })).toBe(true); + expect(isPromptVisible(prompt, {})).toBe(true); + }); +}); + +describe('prompt answers', () => { + const prompts = parseAuthPrompts(copilotPrompts); + + test('preselects the first select option so the form starts answerable', () => { + expect(defaultPromptValues(prompts)).toEqual({ deploymentType: 'github.com', enterpriseUrl: '' }); + expect(firstUnansweredPrompt(prompts, defaultPromptValues(prompts))).toBeNull(); + }); + + test('reports the hidden-then-revealed field as unanswered', () => { + const values = { deploymentType: 'enterprise', enterpriseUrl: ' ' }; + + expect(firstUnansweredPrompt(prompts, values)?.key).toBe('enterpriseUrl'); + }); + + test('omits answers whose prompt is no longer visible', () => { + const values = { deploymentType: 'github.com', enterpriseUrl: 'left-over.ghe.com' }; + + expect(collectPromptInputs(prompts, values)).toEqual({ deploymentType: 'github.com' }); + }); + + test('trims submitted answers', () => { + const values = { deploymentType: 'enterprise', enterpriseUrl: ' company.ghe.com ' }; + + expect(collectPromptInputs(prompts, values)).toEqual({ + deploymentType: 'enterprise', + enterpriseUrl: 'company.ghe.com', + }); + }); +}); + +describe('parseAuthorization', () => { + test('reads a device-code authorization and recovers the code from instructions', () => { + const authorization = parseAuthorization({ + url: 'https://github.com/login/device', + instructions: 'Enter code: 1A2B-3C4D', + method: 'auto', + }); + + expect(authorization).toEqual({ + method: 'auto', + url: 'https://github.com/login/device', + instructions: 'Enter code: 1A2B-3C4D', + userCode: '1A2B-3C4D', + }); + }); + + test('keeps an explicitly reported code over the instructions match', () => { + expect(parseAuthorization({ + url: 'https://example.com', + instructions: 'Enter code: AAAA-BBBB', + user_code: 'ZZZZ-9999', + method: 'auto', + })?.userCode).toBe('ZZZZ-9999'); + }); + + test('preserves the code method', () => { + expect(parseAuthorization({ url: 'https://example.com', method: 'code' })?.method).toBe('code'); + }); + + test('treats a missing or unknown method as auto', () => { + expect(parseAuthorization({ url: 'https://example.com' })?.method).toBe('auto'); + expect(parseAuthorization({ url: 'https://example.com', method: 'device' })?.method).toBe('auto'); + }); + + test('unwraps a nested data envelope', () => { + expect(parseAuthorization({ data: { url: 'https://example.com', method: 'code' } })).toEqual({ + method: 'code', + url: 'https://example.com', + }); + }); + + test('accepts device-authorization field names', () => { + expect(parseAuthorization({ + verification_uri_complete: 'https://example.com/activate?code=1', + message: 'Open the link', + })).toEqual({ + method: 'auto', + url: 'https://example.com/activate?code=1', + instructions: 'Open the link', + }); + }); + + test('returns null when nothing is actionable', () => { + expect(parseAuthorization(null)).toBeNull(); + expect(parseAuthorization({})).toBeNull(); + expect(parseAuthorization({ method: 'auto' })).toBeNull(); + }); +}); + +describe('describeOAuthError', () => { + const t: ProviderOAuthTranslator = (key) => key; + const fallback = 'settings.providers.page.toast.oauthCompleteFailed'; + + /** Names come from OpenCode's ProviderAuthApiError schema. */ + test('maps each provider auth error name to its own message', () => { + expect(describeOAuthError({ name: 'ProviderAuthOauthMissing', data: {} }, t, fallback)) + .toBe('settings.providers.page.auth.oauth.error.sessionExpired'); + expect(describeOAuthError({ name: 'ProviderAuthOauthCodeMissing', data: {} }, t, fallback)) + .toBe('settings.providers.page.auth.oauth.error.codeRequired'); + expect(describeOAuthError({ name: 'ProviderAuthOauthCallbackFailed', data: {} }, t, fallback)) + .toBe('settings.providers.page.auth.oauth.error.declined'); + }); + + test('surfaces the plugin-authored validation message verbatim', () => { + const error = { + name: 'ProviderAuthValidationFailed', + data: { field: 'enterpriseUrl', message: 'URL or domain is required' }, + }; + + expect(describeOAuthError(error, t, fallback)).toBe('URL or domain is required'); + }); + + test('falls back when a validation failure carries no message', () => { + expect(describeOAuthError({ name: 'ProviderAuthValidationFailed', data: {} }, t, fallback)) + .toBe('settings.providers.page.auth.oauth.error.invalidInput'); + }); + + test('falls back for unknown, empty, and non-object errors', () => { + expect(describeOAuthError({ name: 'BadRequest', data: {} }, t, fallback)).toBe(fallback); + expect(describeOAuthError(new Error('network down'), t, fallback)).toBe(fallback); + expect(describeOAuthError(undefined, t, fallback)).toBe(fallback); + }); +}); diff --git a/packages/ui/src/components/sections/providers/provider-oauth.ts b/packages/ui/src/components/sections/providers/provider-oauth.ts new file mode 100644 index 00000000..f85a8ef6 --- /dev/null +++ b/packages/ui/src/components/sections/providers/provider-oauth.ts @@ -0,0 +1,244 @@ +/** + * Provider OAuth flow helpers. + * + * `POST /provider/{id}/oauth/authorize` answers with the completion method that + * decides what the client has to do next: + * + * - `auto` — the client must call `oauth/callback` right away and hold that + * request open. Upstream blocks inside it (device-code polling, or waiting on + * a loopback redirect) until the user finishes signing in, and only then + * persists the credential. Nothing is stored if the client never calls it. + * - `code` — the user copies a code out of the browser and hands it to + * `oauth/callback`. + * + * Every auth plugin shipped with OpenCode uses `auto`; `code` stays supported + * for third-party auth plugins that still return it. + */ + +import type { I18nKey, I18nParams } from '@/lib/i18n'; + +export type OAuthCompletionMethod = 'auto' | 'code'; + +export type ProviderOAuthTranslator = (key: I18nKey, params?: I18nParams) => string; + +export interface OAuthAuthorization { + method: OAuthCompletionMethod; + url?: string; + instructions?: string; + /** Device code surfaced separately so it can be copied on its own. */ + userCode?: string; +} + +export interface AuthPromptOption { + label: string; + value: string; + hint?: string; +} + +export interface AuthPromptCondition { + key: string; + op: 'eq' | 'neq'; + value: string; +} + +export interface AuthPrompt { + type: 'text' | 'select'; + key: string; + message: string; + placeholder?: string; + options: AuthPromptOption[]; + when?: AuthPromptCondition; +} + +/** + * Device codes are only carried inside the human-readable instructions + * (`Enter code: ABCD-1234`), so they are recovered by shape. + */ +const DEVICE_CODE_PATTERN = /[A-Z0-9]{4}-[A-Z0-9]{4,5}/; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const asText = (value: unknown): string | undefined => + typeof value === 'string' && value.length > 0 ? value : undefined; + +const parsePromptOptions = (value: unknown): AuthPromptOption[] => { + if (!Array.isArray(value)) { + return []; + } + const options: AuthPromptOption[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + const optionValue = asText(entry.value); + if (optionValue === undefined) { + continue; + } + options.push({ + value: optionValue, + label: asText(entry.label) ?? optionValue, + ...(asText(entry.hint) ? { hint: asText(entry.hint)! } : {}), + }); + } + return options; +}; + +const parsePromptCondition = (value: unknown): AuthPromptCondition | undefined => { + if (!isRecord(value)) { + return undefined; + } + const key = asText(value.key); + const op = value.op === 'eq' || value.op === 'neq' ? value.op : undefined; + if (!key || !op || typeof value.value !== 'string') { + return undefined; + } + return { key, op, value: value.value }; +}; + +/** Parses the `prompts` an auth method wants answered before `authorize`. */ +export const parseAuthPrompts = (value: unknown): AuthPrompt[] => { + if (!Array.isArray(value)) { + return []; + } + const prompts: AuthPrompt[] = []; + for (const entry of value) { + if (!isRecord(entry)) { + continue; + } + const key = asText(entry.key); + if (!key) { + continue; + } + const type = entry.type === 'select' ? 'select' : 'text'; + const options = type === 'select' ? parsePromptOptions(entry.options) : []; + // A select with no usable option can never be answered; skipping it would + // silently drop a required input, so treat the whole method as unusable. + if (type === 'select' && options.length === 0) { + continue; + } + const when = parsePromptCondition(entry.when); + prompts.push({ + type, + key, + message: asText(entry.message) ?? key, + options, + ...(asText(entry.placeholder) ? { placeholder: asText(entry.placeholder)! } : {}), + ...(when ? { when } : {}), + }); + } + return prompts; +}; + +/** True when a prompt's `when` condition is satisfied by the answers so far. */ +export const isPromptVisible = (prompt: AuthPrompt, values: Record): boolean => { + if (!prompt.when) { + return true; + } + const current = values[prompt.when.key] ?? ''; + return prompt.when.op === 'eq' + ? current === prompt.when.value + : current !== prompt.when.value; +}; + +export const visiblePrompts = ( + prompts: AuthPrompt[], + values: Record, +): AuthPrompt[] => prompts.filter((prompt) => isPromptVisible(prompt, values)); + +/** Selects preselect their first option so the form always starts answerable. */ +export const defaultPromptValues = (prompts: AuthPrompt[]): Record => { + const values: Record = {}; + for (const prompt of prompts) { + values[prompt.key] = prompt.type === 'select' ? (prompt.options[0]?.value ?? '') : ''; + } + return values; +}; + +/** First visible prompt still left blank, or `null` when the form is complete. */ +export const firstUnansweredPrompt = ( + prompts: AuthPrompt[], + values: Record, +): AuthPrompt | null => + visiblePrompts(prompts, values).find((prompt) => (values[prompt.key] ?? '').trim().length === 0) ?? null; + +/** + * Builds the `inputs` payload for `authorize`. Hidden prompts are dropped so a + * stale answer from a since-changed branch is never sent upstream. + */ +export const collectPromptInputs = ( + prompts: AuthPrompt[], + values: Record, +): Record => { + const inputs: Record = {}; + for (const prompt of visiblePrompts(prompts, values)) { + inputs[prompt.key] = (values[prompt.key] ?? '').trim(); + } + return inputs; +}; + +/** + * Normalizes an `authorize` response. + * + * Anything that is not explicitly `code` is treated as `auto`: `auto` only + * means "call back and wait", which is also the safe reading of an unknown + * method, whereas guessing `code` would strand the user at a paste field no + * provider can fill. + * + * Returns `null` when the response carries nothing the user can act on. + */ +export const parseAuthorization = (payload: unknown): OAuthAuthorization | null => { + const outer: Record = isRecord(payload) ? payload : {}; + const record: Record = isRecord(outer.data) ? outer.data : outer; + + const url = + asText(record.url) + ?? asText(record.verification_uri_complete) + ?? asText(record.verification_uri); + const instructions = asText(record.instructions) ?? asText(record.message); + + if (!url && !instructions) { + return null; + } + + const userCode = + asText(record.user_code) + ?? asText(record.userCode) + ?? (instructions ? DEVICE_CODE_PATTERN.exec(instructions)?.[0] : undefined); + + return { + method: record.method === 'code' ? 'code' : 'auto', + ...(url ? { url } : {}), + ...(instructions ? { instructions } : {}), + ...(userCode ? { userCode } : {}), + }; +}; + +/** + * Renders a `ProviderAuthApiError` as user-facing copy. + * + * Validation failures carry a message authored by the auth plugin (a field + * rule such as "URL or domain is required"); it is shown verbatim because only + * the plugin knows which input was rejected. + */ +export const describeOAuthError = ( + error: unknown, + t: ProviderOAuthTranslator, + fallbackKey: I18nKey, +): string => { + const record: Record = isRecord(error) ? error : {}; + const data: Record = isRecord(record.data) ? record.data : {}; + + switch (record.name) { + case 'ProviderAuthOauthMissing': + return t('settings.providers.page.auth.oauth.error.sessionExpired'); + case 'ProviderAuthOauthCodeMissing': + return t('settings.providers.page.auth.oauth.error.codeRequired'); + case 'ProviderAuthOauthCallbackFailed': + return t('settings.providers.page.auth.oauth.error.declined'); + case 'ProviderAuthValidationFailed': + return asText(data.message) ?? t('settings.providers.page.auth.oauth.error.invalidInput'); + default: + return t(fallbackKey); + } +}; diff --git a/packages/ui/src/components/sections/providers/providerAuth.ts b/packages/ui/src/components/sections/providers/providerAuth.ts index 5d253e0b..18ca4a9c 100644 --- a/packages/ui/src/components/sections/providers/providerAuth.ts +++ b/packages/ui/src/components/sections/providers/providerAuth.ts @@ -5,6 +5,8 @@ export interface AuthMethod { description?: string; help?: string; method?: number; + /** Inputs an OAuth method wants answered before authorize; see `provider-oauth.ts`. */ + prompts?: unknown; [key: string]: unknown; } diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index fc3cfa86..94cc8694 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1321,6 +1321,17 @@ export const settingsDict = { 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth-Methode {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Autorisierungscode einfügen', + 'settings.providers.page.auth.oauth.starting': 'Autorisierung wird gestartet …', + 'settings.providers.page.auth.oauth.waiting': 'Warten auf Autorisierung …', + 'settings.providers.page.auth.oauth.waitingHint': 'Schließen Sie die Anmeldung im Browser ab. Lassen Sie diese Seite geöffnet – die Verbindung wird von selbst hergestellt.', + 'settings.providers.page.auth.oauth.codeHint': 'Kopieren Sie den Autorisierungscode aus dem Browser und fügen Sie ihn hier ein.', + 'settings.providers.page.auth.oauth.deviceCodeLabel': 'Gerätecode', + 'settings.providers.page.auth.oauth.linkLabel': 'Autorisierungslink', + 'settings.providers.page.auth.oauth.promptRequired': 'Füllen Sie „{field}“ aus, um fortzufahren', + 'settings.providers.page.auth.oauth.error.sessionExpired': 'Die Autorisierungsanfrage ist abgelaufen. Verbinden Sie erneut, um sie neu zu starten.', + 'settings.providers.page.auth.oauth.error.codeRequired': 'Dieser Anbieter benötigt den Autorisierungscode aus Ihrem Browser.', + 'settings.providers.page.auth.oauth.error.declined': 'Die Autorisierung wurde abgelehnt oder nicht abgeschlossen.', + 'settings.providers.page.auth.oauth.error.invalidInput': 'Die eingegebenen Angaben wurden abgelehnt.', 'settings.providers.page.auth.connected': 'Verbunden', 'settings.providers.page.auth.incomplete': 'Anmeldedaten fehlen', 'settings.providers.page.auth.incompleteHint': '· Fügen Sie einen API-Schlüssel oder {env:VAR} hinzu, bevor Sie diesen Anbieter im Chat verwenden', @@ -1351,6 +1362,9 @@ export const settingsDict = { 'settings.providers.page.actions.open': 'Öffnen', 'settings.providers.page.actions.copy': 'Kopieren', 'settings.providers.page.actions.complete': 'Vervollständigen', + 'settings.providers.page.actions.continue': 'Weiter', + 'settings.providers.page.actions.cancel': 'Abbrechen', + 'settings.providers.page.actions.tryAgain': 'Wiederholen', 'settings.providers.page.actions.hide': 'Ausblenden', 'settings.providers.page.actions.reconnect': 'Erneut verbinden', 'settings.providers.page.actions.edit': 'Bearbeiten', @@ -1365,7 +1379,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaved': 'API-Schlüssel gespeichert', 'settings.providers.page.toast.oauthStartFailed': 'Fehler beim Starten des OAuth-Flows', 'settings.providers.page.toast.oauthDetailsMissing': 'Keine OAuth-Details zurückgegeben', - 'settings.providers.page.toast.completeOAuthInBrowser': 'Schließen Sie den OAuth-Flow in Ihrem Browser ab', 'settings.providers.page.toast.oauthCompleteFailed': 'Fehler beim Abschließen des OAuth-Flows', 'settings.providers.page.toast.oauthCompleted': 'OAuth-Verbindung abgeschlossen', 'settings.providers.page.toast.oauthLinkCopied': 'OAuth-Link kopiert', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 4a1deb81..82ef3778 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1386,6 +1386,17 @@ export const settingsDict = { 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth method {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Paste authorization code', + 'settings.providers.page.auth.oauth.starting': 'Starting authorization…', + 'settings.providers.page.auth.oauth.waiting': 'Waiting for authorization…', + 'settings.providers.page.auth.oauth.waitingHint': 'Finish signing in in your browser. Keep this page open — the connection completes on its own.', + 'settings.providers.page.auth.oauth.codeHint': 'Copy the authorization code from your browser and paste it here.', + 'settings.providers.page.auth.oauth.deviceCodeLabel': 'Device code', + 'settings.providers.page.auth.oauth.linkLabel': 'Authorization link', + 'settings.providers.page.auth.oauth.promptRequired': 'Fill in “{field}” to continue', + 'settings.providers.page.auth.oauth.error.sessionExpired': 'The authorization request expired. Connect again to restart it.', + 'settings.providers.page.auth.oauth.error.codeRequired': 'This provider needs the authorization code from your browser.', + 'settings.providers.page.auth.oauth.error.declined': 'Authorization was declined or did not complete.', + 'settings.providers.page.auth.oauth.error.invalidInput': 'The details you entered were rejected.', 'settings.providers.page.auth.connected': 'Connected', 'settings.providers.page.auth.incomplete': 'Credentials missing', 'settings.providers.page.auth.incompleteHint': '· Add an API key or {env:VAR} before using this provider in chat', @@ -1416,6 +1427,9 @@ export const settingsDict = { 'settings.providers.page.actions.open': 'Open', 'settings.providers.page.actions.copy': 'Copy', 'settings.providers.page.actions.complete': 'Complete', + 'settings.providers.page.actions.continue': 'Continue', + 'settings.providers.page.actions.cancel': 'Cancel', + 'settings.providers.page.actions.tryAgain': 'Try again', 'settings.providers.page.actions.hide': 'Hide', 'settings.providers.page.actions.reconnect': 'Reconnect', 'settings.providers.page.actions.edit': 'Edit', @@ -1430,7 +1444,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaved': 'API key saved', 'settings.providers.page.toast.oauthStartFailed': 'Failed to start OAuth flow', 'settings.providers.page.toast.oauthDetailsMissing': 'No OAuth details returned', - 'settings.providers.page.toast.completeOAuthInBrowser': 'Complete the OAuth flow in your browser', 'settings.providers.page.toast.oauthCompleteFailed': 'Failed to complete OAuth flow', 'settings.providers.page.toast.oauthCompleted': 'OAuth connection completed', 'settings.providers.page.toast.oauthLinkCopied': 'OAuth link copied', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 31b07208..b4613162 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1359,6 +1359,17 @@ export const settingsDict = { "settings.providers.page.auth.apiKeyPlaceholder": "sk-...", "settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}", "settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Pegar código de autorización", + "settings.providers.page.auth.oauth.starting": "Iniciando la autorización…", + "settings.providers.page.auth.oauth.waiting": "Esperando la autorización…", + "settings.providers.page.auth.oauth.waitingHint": "Termina de iniciar sesión en el navegador. Mantén esta página abierta: la conexión se completará sola.", + "settings.providers.page.auth.oauth.codeHint": "Copia el código de autorización del navegador y pégalo aquí.", + "settings.providers.page.auth.oauth.deviceCodeLabel": "Código del dispositivo", + "settings.providers.page.auth.oauth.linkLabel": "Enlace de autorización", + "settings.providers.page.auth.oauth.promptRequired": "Completa «{field}» para continuar", + "settings.providers.page.auth.oauth.error.sessionExpired": "La solicitud de autorización caducó. Vuelve a conectar para reiniciarla.", + "settings.providers.page.auth.oauth.error.codeRequired": "Este proveedor necesita el código de autorización de tu navegador.", + "settings.providers.page.auth.oauth.error.declined": "La autorización se rechazó o no se completó.", + "settings.providers.page.auth.oauth.error.invalidInput": "Se rechazaron los datos introducidos.", "settings.providers.page.auth.connected": "Conectado", "settings.providers.page.auth.incomplete": "Faltan credenciales", "settings.providers.page.auth.incompleteHint": "· Añade una clave API o {env:VAR} antes de usar este proveedor en el chat", @@ -1391,6 +1402,9 @@ export const settingsDict = { "settings.providers.page.actions.open": "Abrir", "settings.providers.page.actions.copy": "Copiar", "settings.providers.page.actions.complete": "Completar", + "settings.providers.page.actions.continue": "Continuar", + "settings.providers.page.actions.cancel": "Cancelar", + "settings.providers.page.actions.tryAgain": "Reintentar", "settings.providers.page.actions.hide": "Ocultar", "settings.providers.page.actions.reconnect": "Reconectar", "settings.providers.page.actions.edit": "Editar", @@ -1406,7 +1420,6 @@ export const settingsDict = { "settings.providers.page.toast.apiKeySaved": "Clave API guardada", "settings.providers.page.toast.oauthStartFailed": "No se pudo iniciar el flujo OAuth", "settings.providers.page.toast.oauthDetailsMissing": "No se devolvieron detalles de OAuth", - "settings.providers.page.toast.completeOAuthInBrowser": "Completa el flujo OAuth en tu navegador", "settings.providers.page.toast.oauthCompleteFailed": "No se pudo completar el flujo OAuth", "settings.providers.page.toast.oauthCompleted": "Conexión OAuth completada", "settings.providers.page.toast.oauthLinkCopied": "Enlace de OAuth copiado", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index f5dc26d4..249dfad8 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1280,6 +1280,17 @@ export const settingsDict = { 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'Méthode OAuth {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Coller le code d\'autorisation', + 'settings.providers.page.auth.oauth.starting': 'Démarrage de l’autorisation…', + 'settings.providers.page.auth.oauth.waiting': 'En attente de l’autorisation…', + 'settings.providers.page.auth.oauth.waitingHint': 'Terminez la connexion dans votre navigateur. Laissez cette page ouverte : la connexion se finalisera d’elle-même.', + 'settings.providers.page.auth.oauth.codeHint': 'Copiez le code d’autorisation depuis votre navigateur et collez-le ici.', + 'settings.providers.page.auth.oauth.deviceCodeLabel': 'Code de l’appareil', + 'settings.providers.page.auth.oauth.linkLabel': 'Lien d’autorisation', + 'settings.providers.page.auth.oauth.promptRequired': 'Renseignez « {field} » pour continuer', + 'settings.providers.page.auth.oauth.error.sessionExpired': 'La demande d’autorisation a expiré. Reconnectez-vous pour la relancer.', + 'settings.providers.page.auth.oauth.error.codeRequired': 'Ce fournisseur a besoin du code d’autorisation de votre navigateur.', + 'settings.providers.page.auth.oauth.error.declined': 'L’autorisation a été refusée ou n’a pas abouti.', + 'settings.providers.page.auth.oauth.error.invalidInput': 'Les informations saisies ont été refusées.', 'settings.providers.page.auth.connected': 'Connecté', 'settings.providers.page.auth.incomplete': 'Identifiants manquants', 'settings.providers.page.auth.incompleteHint': '· Ajoutez une clé API ou {env:VAR} avant d’utiliser ce fournisseur dans le chat', @@ -1312,6 +1323,9 @@ export const settingsDict = { 'settings.providers.page.actions.open': 'Ouvrir', 'settings.providers.page.actions.copy': 'Copie', 'settings.providers.page.actions.complete': 'Complet', + 'settings.providers.page.actions.continue': 'Continuer', + 'settings.providers.page.actions.cancel': 'Annuler', + 'settings.providers.page.actions.tryAgain': 'Réessayer', 'settings.providers.page.actions.hide': 'Cacher', 'settings.providers.page.actions.reconnect': 'Reconnecter', 'settings.providers.page.actions.edit': 'Modifier', @@ -1327,7 +1341,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaved': 'Clé API enregistrée', 'settings.providers.page.toast.oauthStartFailed': 'Échec du démarrage du flux OAuth', 'settings.providers.page.toast.oauthDetailsMissing': 'Aucun détail OAuth renvoyé', - 'settings.providers.page.toast.completeOAuthInBrowser': 'Complétez le flux OAuth dans votre navigateur', 'settings.providers.page.toast.oauthCompleteFailed': 'Échec de la réalisation du flux OAuth', 'settings.providers.page.toast.oauthCompleted': 'Connexion OAuth terminée', 'settings.providers.page.toast.oauthLinkCopied': 'Lien OAuth copié', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 3d7565a9..f2c58bdd 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1392,6 +1392,17 @@ export const settingsDict = { 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方法 {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '認証コードを貼り付け', + 'settings.providers.page.auth.oauth.starting': '認証を開始しています…', + 'settings.providers.page.auth.oauth.waiting': '認証を待っています…', + 'settings.providers.page.auth.oauth.waitingHint': 'ブラウザーでサインインを完了してください。このページは開いたままにしてください。接続は自動的に完了します。', + 'settings.providers.page.auth.oauth.codeHint': 'ブラウザーから認証コードをコピーして、ここに貼り付けてください。', + 'settings.providers.page.auth.oauth.deviceCodeLabel': 'デバイスコード', + 'settings.providers.page.auth.oauth.linkLabel': '認証リンク', + 'settings.providers.page.auth.oauth.promptRequired': '続行するには「{field}」を入力してください', + 'settings.providers.page.auth.oauth.error.sessionExpired': '認証リクエストの有効期限が切れました。もう一度接続してやり直してください。', + 'settings.providers.page.auth.oauth.error.codeRequired': 'このプロバイダーにはブラウザーの認証コードが必要です。', + 'settings.providers.page.auth.oauth.error.declined': '認証が拒否されたか、完了しませんでした。', + 'settings.providers.page.auth.oauth.error.invalidInput': '入力された内容は拒否されました。', 'settings.providers.page.auth.connected': '接続済み', 'settings.providers.page.auth.incomplete': '認証情報が不足しています', 'settings.providers.page.auth.incompleteHint': '· チャットでこのプロバイダーを使う前に API キーまたは {env:VAR} を追加してください', @@ -1424,6 +1435,9 @@ export const settingsDict = { 'settings.providers.page.actions.open': '開く', 'settings.providers.page.actions.copy': 'コピー', 'settings.providers.page.actions.complete': '完了', + 'settings.providers.page.actions.continue': '続行', + 'settings.providers.page.actions.cancel': 'キャンセル', + 'settings.providers.page.actions.tryAgain': '再試行', 'settings.providers.page.actions.hide': '非表示', 'settings.providers.page.actions.reconnect': '再接続', 'settings.providers.page.actions.edit': '編集', @@ -1439,7 +1453,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaved': 'API キーを保存しました', 'settings.providers.page.toast.oauthStartFailed': 'OAuth フローの開始に失敗しました', 'settings.providers.page.toast.oauthDetailsMissing': 'OAuth の詳細が返されませんでした', - 'settings.providers.page.toast.completeOAuthInBrowser': 'ブラウザで OAuth フローを完了してください', 'settings.providers.page.toast.oauthCompleteFailed': 'OAuth フローの完了に失敗しました', 'settings.providers.page.toast.oauthCompleted': 'OAuth 接続が完了しました', 'settings.providers.page.toast.oauthLinkCopied': 'OAuth リンクをコピーしました', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index df181c60..0f9b0aba 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1359,6 +1359,17 @@ export const settingsDict = { 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 방식 {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'authorization code 붙여넣기', + 'settings.providers.page.auth.oauth.starting': '인증을 시작하는 중…', + 'settings.providers.page.auth.oauth.waiting': '인증을 기다리는 중…', + 'settings.providers.page.auth.oauth.waitingHint': '브라우저에서 로그인을 완료하세요. 이 페이지를 열어 두면 연결이 자동으로 완료됩니다.', + 'settings.providers.page.auth.oauth.codeHint': '브라우저에서 인증 코드를 복사해 여기에 붙여넣으세요.', + 'settings.providers.page.auth.oauth.deviceCodeLabel': '기기 코드', + 'settings.providers.page.auth.oauth.linkLabel': '인증 링크', + 'settings.providers.page.auth.oauth.promptRequired': '계속하려면 “{field}”을(를) 입력하세요', + 'settings.providers.page.auth.oauth.error.sessionExpired': '인증 요청이 만료되었습니다. 다시 연결해 처음부터 시작하세요.', + 'settings.providers.page.auth.oauth.error.codeRequired': '이 제공자에는 브라우저의 인증 코드가 필요합니다.', + 'settings.providers.page.auth.oauth.error.declined': '인증이 거부되었거나 완료되지 않았습니다.', + 'settings.providers.page.auth.oauth.error.invalidInput': '입력한 정보가 거부되었습니다.', 'settings.providers.page.auth.connected': '연결됨', 'settings.providers.page.auth.incomplete': '자격 증명 없음', 'settings.providers.page.auth.incompleteHint': '· 채팅에서 이 공급자를 사용하기 전에 API 키 또는 {env:VAR}을(를) 추가하세요', @@ -1391,6 +1402,9 @@ export const settingsDict = { 'settings.providers.page.actions.open': '열기', 'settings.providers.page.actions.copy': '복사', 'settings.providers.page.actions.complete': '완료', + 'settings.providers.page.actions.continue': '계속', + 'settings.providers.page.actions.cancel': '취소', + 'settings.providers.page.actions.tryAgain': '다시 시도', 'settings.providers.page.actions.hide': '숨기기', 'settings.providers.page.actions.reconnect': '재연결', 'settings.providers.page.actions.edit': '편집', @@ -1406,7 +1420,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaved': 'API key가 저장되었습니다', 'settings.providers.page.toast.oauthStartFailed': 'OAuth flow를 시작하지 못했습니다', 'settings.providers.page.toast.oauthDetailsMissing': '반환된 OAuth 세부 정보가 없습니다', - 'settings.providers.page.toast.completeOAuthInBrowser': '브라우저에서 OAuth flow를 완료하세요', 'settings.providers.page.toast.oauthCompleteFailed': 'OAuth flow를 완료하지 못했습니다', 'settings.providers.page.toast.oauthCompleted': 'OAuth 연결이 완료되었습니다', 'settings.providers.page.toast.oauthLinkCopied': 'OAuth 링크가 복사되었습니다', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index dcc3d774..89bafd5b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1360,6 +1360,9 @@ export const settingsDict = { 'settings.projects.sidebar.actions.addProject': 'Dodaj projekt', 'settings.projects.sidebar.total': 'Suma: {count}', 'settings.providers.page.actions.complete': 'Zakończ', + 'settings.providers.page.actions.continue': 'Kontynuuj', + 'settings.providers.page.actions.cancel': 'Anuluj', + 'settings.providers.page.actions.tryAgain': 'Spróbuj ponownie', 'settings.providers.page.actions.connect': 'Połącz', 'settings.providers.page.actions.copy': 'Kopiuj', 'settings.providers.page.actions.copyCode': 'Kopiuj kod', @@ -1385,6 +1388,17 @@ export const settingsDict = { 'settings.providers.page.auth.loadingMethods': 'Ładowanie metod uwierzytelniania...', 'settings.providers.page.auth.oauthMethodFallback': 'Metoda OAuth {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': 'Wklej kod autoryzacyjny', + 'settings.providers.page.auth.oauth.starting': 'Rozpoczynanie autoryzacji…', + 'settings.providers.page.auth.oauth.waiting': 'Oczekiwanie na autoryzację…', + 'settings.providers.page.auth.oauth.waitingHint': 'Dokończ logowanie w przeglądarce. Zostaw tę stronę otwartą — połączenie zakończy się samo.', + 'settings.providers.page.auth.oauth.codeHint': 'Skopiuj kod autoryzacji z przeglądarki i wklej go tutaj.', + 'settings.providers.page.auth.oauth.deviceCodeLabel': 'Kod urządzenia', + 'settings.providers.page.auth.oauth.linkLabel': 'Link autoryzacyjny', + 'settings.providers.page.auth.oauth.promptRequired': 'Wypełnij pole „{field}”, aby kontynuować', + 'settings.providers.page.auth.oauth.error.sessionExpired': 'Żądanie autoryzacji wygasło. Połącz ponownie, aby zacząć od nowa.', + 'settings.providers.page.auth.oauth.error.codeRequired': 'Ten dostawca wymaga kodu autoryzacji z przeglądarki.', + 'settings.providers.page.auth.oauth.error.declined': 'Autoryzacja została odrzucona lub nie została ukończona.', + 'settings.providers.page.auth.oauth.error.invalidInput': 'Wprowadzone dane zostały odrzucone.', 'settings.providers.page.auth.title': 'Uwierzytelnianie', 'settings.providers.page.auth.useReconnectHint': '· Użyj Połącz ponownie, aby zaktualizować dane logowania', 'settings.providers.page.custom.optionLabel': 'Inny / Niestandardowy', @@ -1474,7 +1488,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaveFailed': 'Nie udało się zapisać klucza API', 'settings.providers.page.toast.apiKeySaved': 'Klucz API został zapisany', 'settings.providers.page.toast.authMethodsLoadFailed': 'Nie udało się załadować metod uwierzytelniania dostawcy', - 'settings.providers.page.toast.completeOAuthInBrowser': 'Dokończ proces OAuth w przeglądarce', 'settings.providers.page.toast.deviceCodeCopied': 'Kod urządzenia został skopiowany', 'settings.providers.page.toast.deviceCodeCopyFailed': 'Nie udało się skopiować kodu urządzenia', 'settings.providers.page.toast.oauthCompleteFailed': 'Nie udało się dokończyć procesu OAuth', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 5950ec10..73386454 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1359,6 +1359,17 @@ export const settingsDict = { "settings.providers.page.auth.apiKeyPlaceholder": "sk-...", "settings.providers.page.auth.oauthMethodFallback": "Método OAuth {index}", "settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Colar código de autorização", + "settings.providers.page.auth.oauth.starting": "Iniciando a autorização…", + "settings.providers.page.auth.oauth.waiting": "Aguardando a autorização…", + "settings.providers.page.auth.oauth.waitingHint": "Conclua o login no navegador. Mantenha esta página aberta — a conexão será concluída sozinha.", + "settings.providers.page.auth.oauth.codeHint": "Copie o código de autorização do navegador e cole aqui.", + "settings.providers.page.auth.oauth.deviceCodeLabel": "Código do dispositivo", + "settings.providers.page.auth.oauth.linkLabel": "Link de autorização", + "settings.providers.page.auth.oauth.promptRequired": "Preencha “{field}” para continuar", + "settings.providers.page.auth.oauth.error.sessionExpired": "A solicitação de autorização expirou. Conecte novamente para reiniciá-la.", + "settings.providers.page.auth.oauth.error.codeRequired": "Este provedor precisa do código de autorização do seu navegador.", + "settings.providers.page.auth.oauth.error.declined": "A autorização foi recusada ou não foi concluída.", + "settings.providers.page.auth.oauth.error.invalidInput": "Os dados informados foram recusados.", "settings.providers.page.auth.connected": "Conectado", "settings.providers.page.auth.incomplete": "Credenciais ausentes", "settings.providers.page.auth.incompleteHint": "· Adicione uma chave de API ou {env:VAR} antes de usar este provedor no chat", @@ -1391,6 +1402,9 @@ export const settingsDict = { "settings.providers.page.actions.open": "Abrir", "settings.providers.page.actions.copy": "Copiar", "settings.providers.page.actions.complete": "Completar", + "settings.providers.page.actions.continue": "Continuar", + "settings.providers.page.actions.cancel": "Cancelar", + "settings.providers.page.actions.tryAgain": "Tentar novamente", "settings.providers.page.actions.hide": "Ocultar", "settings.providers.page.actions.reconnect": "Reconectar", "settings.providers.page.actions.edit": "Editar", @@ -1406,7 +1420,6 @@ export const settingsDict = { "settings.providers.page.toast.apiKeySaved": "Chave API salva", "settings.providers.page.toast.oauthStartFailed": "Não foi possível iniciar o fluxo OAuth", "settings.providers.page.toast.oauthDetailsMissing": "Não se devolvieron detalhes de OAuth", - "settings.providers.page.toast.completeOAuthInBrowser": "Complete o fluxo OAuth no navegador", "settings.providers.page.toast.oauthCompleteFailed": "Não foi possível concluir o fluxo OAuth", "settings.providers.page.toast.oauthCompleted": "Conexão OAuth concluída", "settings.providers.page.toast.oauthLinkCopied": "Link de OAuth copiado", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 306608d2..42d29d22 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1359,6 +1359,17 @@ export const settingsDict = { "settings.providers.page.auth.apiKeyPlaceholder": "sk-...", "settings.providers.page.auth.oauthMethodFallback": "OAuth метод {index}", "settings.providers.page.auth.pasteAuthorizationCodePlaceholder": "Вставити код авторизації", + "settings.providers.page.auth.oauth.starting": "Запускаємо авторизацію…", + "settings.providers.page.auth.oauth.waiting": "Очікуємо на авторизацію…", + "settings.providers.page.auth.oauth.waitingHint": "Завершіть вхід у браузері. Не закривайте цю сторінку — підключення завершиться саме.", + "settings.providers.page.auth.oauth.codeHint": "Скопіюйте код авторизації з браузера і вставте його сюди.", + "settings.providers.page.auth.oauth.deviceCodeLabel": "Код пристрою", + "settings.providers.page.auth.oauth.linkLabel": "Посилання для авторизації", + "settings.providers.page.auth.oauth.promptRequired": "Заповніть «{field}», щоб продовжити", + "settings.providers.page.auth.oauth.error.sessionExpired": "Термін дії запиту на авторизацію минув. Підключіться ще раз, щоб почати заново.", + "settings.providers.page.auth.oauth.error.codeRequired": "Цьому провайдеру потрібен код авторизації з браузера.", + "settings.providers.page.auth.oauth.error.declined": "Авторизацію відхилено або не завершено.", + "settings.providers.page.auth.oauth.error.invalidInput": "Введені дані відхилено.", "settings.providers.page.auth.connected": "Підключено", "settings.providers.page.auth.incomplete": "Облікові дані відсутні", "settings.providers.page.auth.incompleteHint": "· Додайте API-ключ або {env:VAR} перед використанням цього провайдера в чаті", @@ -1391,6 +1402,9 @@ export const settingsDict = { "settings.providers.page.actions.open": "Відкрити", "settings.providers.page.actions.copy": "Копіювати", "settings.providers.page.actions.complete": "Завершити", + "settings.providers.page.actions.continue": "Продовжити", + "settings.providers.page.actions.cancel": "Скасувати", + "settings.providers.page.actions.tryAgain": "Повторити спробу", "settings.providers.page.actions.hide": "Сховати", "settings.providers.page.actions.reconnect": "Перепідключити", "settings.providers.page.actions.edit": "Редагувати", @@ -1406,7 +1420,6 @@ export const settingsDict = { "settings.providers.page.toast.apiKeySaved": "Ключ API збережено", "settings.providers.page.toast.oauthStartFailed": "Не вдалося запустити потік OAuth", "settings.providers.page.toast.oauthDetailsMissing": "Деталі OAuth не повернуто", - "settings.providers.page.toast.completeOAuthInBrowser": "Завершіть процес OAuth у вашому браузері", "settings.providers.page.toast.oauthCompleteFailed": "Не вдалося завершити потік OAuth", "settings.providers.page.toast.oauthCompleted": "Підключення OAuth завершено", "settings.providers.page.toast.oauthLinkCopied": "Посилання OAuth скопійовано", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 42aa7d0a..36f85a43 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1359,6 +1359,17 @@ export const settingsDict = { 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '粘贴授权码', + 'settings.providers.page.auth.oauth.starting': '正在启动授权…', + 'settings.providers.page.auth.oauth.waiting': '正在等待授权…', + 'settings.providers.page.auth.oauth.waitingHint': '请在浏览器中完成登录。保持此页面打开,连接会自动完成。', + 'settings.providers.page.auth.oauth.codeHint': '从浏览器复制授权码并粘贴到此处。', + 'settings.providers.page.auth.oauth.deviceCodeLabel': '设备码', + 'settings.providers.page.auth.oauth.linkLabel': '授权链接', + 'settings.providers.page.auth.oauth.promptRequired': '请填写“{field}”后继续', + 'settings.providers.page.auth.oauth.error.sessionExpired': '授权请求已过期。请重新连接以重新开始。', + 'settings.providers.page.auth.oauth.error.codeRequired': '此提供方需要浏览器中的授权码。', + 'settings.providers.page.auth.oauth.error.declined': '授权被拒绝或未完成。', + 'settings.providers.page.auth.oauth.error.invalidInput': '输入的信息被拒绝。', 'settings.providers.page.auth.connected': '已连接', 'settings.providers.page.auth.incomplete': '缺少凭据', 'settings.providers.page.auth.incompleteHint': '· 在聊天中使用此提供商之前,请添加 API 密钥或 {env:VAR}', @@ -1391,6 +1402,9 @@ export const settingsDict = { 'settings.providers.page.actions.open': '打开', 'settings.providers.page.actions.copy': '复制', 'settings.providers.page.actions.complete': '完成', + 'settings.providers.page.actions.continue': '继续', + 'settings.providers.page.actions.cancel': '取消', + 'settings.providers.page.actions.tryAgain': '重试', 'settings.providers.page.actions.hide': '隐藏', 'settings.providers.page.actions.reconnect': '重新连接', 'settings.providers.page.actions.edit': '编辑', @@ -1406,7 +1420,6 @@ export const settingsDict = { 'settings.providers.page.toast.apiKeySaved': 'API Key 已保存', 'settings.providers.page.toast.oauthStartFailed': '启动 OAuth 流程失败', 'settings.providers.page.toast.oauthDetailsMissing': '未返回 OAuth 详情', - 'settings.providers.page.toast.completeOAuthInBrowser': '请在浏览器中完成 OAuth 流程', 'settings.providers.page.toast.oauthCompleteFailed': '完成 OAuth 流程失败', 'settings.providers.page.toast.oauthCompleted': 'OAuth 连接已完成', 'settings.providers.page.toast.oauthLinkCopied': 'OAuth 链接已复制', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index a1d814b0..892dab37 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1265,6 +1265,17 @@ 'settings.providers.page.auth.apiKeyPlaceholder': 'sk-...', 'settings.providers.page.auth.oauthMethodFallback': 'OAuth 方式 {index}', 'settings.providers.page.auth.pasteAuthorizationCodePlaceholder': '貼上授權碼', + 'settings.providers.page.auth.oauth.starting': '正在啟動授權…', + 'settings.providers.page.auth.oauth.waiting': '正在等待授權…', + 'settings.providers.page.auth.oauth.waitingHint': '請在瀏覽器中完成登入。保持此頁面開啟,連線會自動完成。', + 'settings.providers.page.auth.oauth.codeHint': '從瀏覽器複製授權碼並貼上到這裡。', + 'settings.providers.page.auth.oauth.deviceCodeLabel': '裝置碼', + 'settings.providers.page.auth.oauth.linkLabel': '授權連結', + 'settings.providers.page.auth.oauth.promptRequired': '請填寫「{field}」後繼續', + 'settings.providers.page.auth.oauth.error.sessionExpired': '授權請求已過期。請重新連線以重新開始。', + 'settings.providers.page.auth.oauth.error.codeRequired': '此提供者需要瀏覽器中的授權碼。', + 'settings.providers.page.auth.oauth.error.declined': '授權遭拒或未完成。', + 'settings.providers.page.auth.oauth.error.invalidInput': '輸入的資訊遭拒。', 'settings.providers.page.auth.connected': '已連線', 'settings.providers.page.auth.incomplete': '缺少憑證', 'settings.providers.page.auth.incompleteHint': '· 在聊天中使用此提供者之前,請新增 API 金鑰或 {env:VAR}', @@ -1297,6 +1308,9 @@ 'settings.providers.page.actions.open': '開啟', 'settings.providers.page.actions.copy': '複製', 'settings.providers.page.actions.complete': '完成', + 'settings.providers.page.actions.continue': '繼續', + 'settings.providers.page.actions.cancel': '取消', + 'settings.providers.page.actions.tryAgain': '重試', 'settings.providers.page.actions.hide': '隱藏', 'settings.providers.page.actions.reconnect': '重新連線', 'settings.providers.page.actions.edit': '編輯', @@ -1312,7 +1326,6 @@ 'settings.providers.page.toast.apiKeySaved': 'API Key 已儲存', 'settings.providers.page.toast.oauthStartFailed': '啟動 OAuth 流程失敗', 'settings.providers.page.toast.oauthDetailsMissing': '未回傳 OAuth 詳情', - 'settings.providers.page.toast.completeOAuthInBrowser': '請在瀏覽器中完成 OAuth 流程', 'settings.providers.page.toast.oauthCompleteFailed': '完成 OAuth 流程失敗', 'settings.providers.page.toast.oauthCompleted': 'OAuth 連線已完成', 'settings.providers.page.toast.oauthLinkCopied': 'OAuth 連結已複製', diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 1c625beb..dfd520c0 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -375,6 +375,8 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`. - SSE forwarders: `GET /api/global/event`, `GET /api/event` - Downstream heartbeats keep clients and intermediaries alive, while a separate upstream-only stall watchdog closes the downstream response when OpenCode stops producing bytes so clients reconnect instead of trusting synthetic heartbeats indefinitely. Each watchdog reset uses the current load-aware timeout, matching the shared event transport. - Session message forwarder: `POST /api/session/:sessionId/message` + - Interactive OAuth forwarder: `POST /api/provider/:providerID/oauth/callback` + - Upstream blocks inside this call for the whole browser sign-in (device-code polling or a loopback redirect), so it is exempt from the ordinary request deadline and uses a 15-minute proxy timeout instead of `LONG_REQUEST_TIMEOUT_MS`. All other `/api/provider/*` routes, including `oauth/authorize`, keep the ordinary deadline. - Generic `/api/*` forwarding with hop-by-hop header filtering - Windows `/session` merge fallback path behavior - OpenCode readiness gate for proxied `/api` requests diff --git a/packages/web/server/lib/opencode/proxy.js b/packages/web/server/lib/opencode/proxy.js index 04fecf54..9d1be85b 100644 --- a/packages/web/server/lib/opencode/proxy.js +++ b/packages/web/server/lib/opencode/proxy.js @@ -309,6 +309,16 @@ export const registerOpenCodeProxy = (app, deps) => { const PROXY_REQUEST_TIMEOUT_MS = normalizeProxyTimeout(LONG_REQUEST_TIMEOUT_MS); const PROXY_TIMEOUT_MARKER = Symbol('openchamberProxyTimedOut'); + // A provider OAuth callback blocks upstream for as long as the user takes to + // sign in in their browser (device-code polling, or a loopback redirect), so + // it cannot share the ordinary request deadline. Bounded by the shortest + // upstream expiry we know of — GitHub device codes last ~15 minutes. + const INTERACTIVE_OAUTH_TIMEOUT_MS = 15 * 60 * 1000; + const INTERACTIVE_OAUTH_PATH = /^\/provider\/[^/]+\/oauth\/callback\/?$/; + + const isInteractiveOAuthCallback = (req) => + req.method === 'POST' && INTERACTIVE_OAUTH_PATH.test(req.path); + const isProxyTimeoutError = (error) => { const code = typeof error?.code === 'string' ? error.code : ''; const message = typeof error?.message === 'string' ? error.message.toLowerCase() : ''; @@ -327,6 +337,10 @@ export const registerOpenCodeProxy = (app, deps) => { }; const applyProxyResponseDeadline = (req, res, next) => { + if (isInteractiveOAuthCallback(req)) { + return next(); + } + const timeout = setTimeout(() => { req[PROXY_TIMEOUT_MARKER] = true; if (sendProxyErrorResponse(res, 504)) { @@ -753,12 +767,12 @@ export const registerOpenCodeProxy = (app, deps) => { }); // Generic proxy for non-SSE OpenCode API routes. - const apiProxy = createProxyMiddleware({ + const createApiProxy = (timeoutMs) => createProxyMiddleware({ target: resolveProxyTarget(), changeOrigin: true, pathRewrite: { '^/api': '' }, - timeout: PROXY_REQUEST_TIMEOUT_MS, - proxyTimeout: PROXY_REQUEST_TIMEOUT_MS, + timeout: timeoutMs, + proxyTimeout: timeoutMs, // Dynamic target — port can change after restart router: () => resolveProxyTarget(), on: { @@ -805,6 +819,9 @@ export const registerOpenCodeProxy = (app, deps) => { }, }); + const apiProxy = createApiProxy(PROXY_REQUEST_TIMEOUT_MS); + const interactiveOAuthProxy = createApiProxy(INTERACTIVE_OAUTH_TIMEOUT_MS); + // Best-effort fallback for stale clients still sending symlink paths. // Settings and project selection normalize at source; this cached async path // avoids blocking the proxy hot path on every directory-scoped request. @@ -821,5 +838,6 @@ export const registerOpenCodeProxy = (app, deps) => { }); app.use('/api', applyProxyResponseDeadline); + app.post('/api/provider/:providerID/oauth/callback', interactiveOAuthProxy); app.use('/api', apiProxy); }; diff --git a/packages/web/server/opencode-proxy.test.js b/packages/web/server/opencode-proxy.test.js index da8c7f72..2eb1f0cd 100644 --- a/packages/web/server/opencode-proxy.test.js +++ b/packages/web/server/opencode-proxy.test.js @@ -623,4 +623,87 @@ describe('OpenCode proxy SSE forwarding', () => { expect(response.status).toBe(504); await expect(response.json()).resolves.toMatchObject({ error: 'OpenCode upstream timed out' }); }); + + it('exempts interactive provider OAuth callbacks from the request deadline', async () => { + const upstream = express(); + // Stands in for upstream blocking until the user finishes signing in. + upstream.post('/provider/:providerID/oauth/callback', async (_req, res) => { + await new Promise((resolve) => setTimeout(resolve, 250)); + res.json(true); + }); + upstreamServer = await listen(upstream); + const upstreamPort = upstreamServer.address().port; + const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`; + + const app = express(); + registerOpenCodeProxy(app, { + fs: {}, + os: {}, + path, + OPEN_CODE_READY_GRACE_MS: 0, + LONG_REQUEST_TIMEOUT_MS: 50, + getRuntime: () => ({ + openCodePort: upstreamPort, + openCodeBaseUrl: externalBaseUrl, + isOpenCodeReady: true, + openCodeNotReadySince: 0, + isRestartingOpenCode: false, + }), + getOpenCodeAuthHeaders: () => ({}), + buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`, + ensureOpenCodeApiPrefix: () => {}, + }); + proxyServer = await listen(app); + const proxyPort = proxyServer.address().port; + + const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/github-copilot/oauth/callback`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ method: 0 }), + signal: AbortSignal.timeout(5000), + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toBe(true); + }); + + it('still applies the request deadline to the OAuth authorize call', async () => { + const upstream = express(); + upstream.post('/provider/:providerID/oauth/authorize', (_req, _res) => { + // Leave the response open so the proxy timeout path is exercised. + }); + upstreamServer = await listen(upstream); + const upstreamPort = upstreamServer.address().port; + const externalBaseUrl = `http://127.0.0.1:${upstreamPort}`; + + const app = express(); + registerOpenCodeProxy(app, { + fs: {}, + os: {}, + path, + OPEN_CODE_READY_GRACE_MS: 0, + LONG_REQUEST_TIMEOUT_MS: 50, + getRuntime: () => ({ + openCodePort: upstreamPort, + openCodeBaseUrl: externalBaseUrl, + isOpenCodeReady: true, + openCodeNotReadySince: 0, + isRestartingOpenCode: false, + }), + getOpenCodeAuthHeaders: () => ({}), + buildOpenCodeUrl: (requestPath) => `${externalBaseUrl}${requestPath}`, + ensureOpenCodeApiPrefix: () => {}, + }); + proxyServer = await listen(app); + const proxyPort = proxyServer.address().port; + + const response = await fetch(`http://127.0.0.1:${proxyPort}/api/provider/github-copilot/oauth/authorize`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ method: 0 }), + signal: AbortSignal.timeout(2000), + }); + + expect(response.status).toBe(504); + }); }); From 67965ced2ff1ca6678facd1dcf6dc4ec81e385e7 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 4 Aug 2026 19:39:40 +0300 Subject: [PATCH 11/57] release v1.18.1 --- CHANGELOG.md | 12 ++++++++++++ bun.lock | 18 +++++++++--------- package.json | 4 ++-- packages/electron/package.json | 2 +- packages/ui/package.json | 4 ++-- packages/vscode/CHANGELOG.md | 8 ++++++++ packages/vscode/package.json | 4 ++-- packages/web/package.json | 4 ++-- 8 files changed, 38 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22de68b1..ed3da0ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.18.1] - 2026-08-04 + +- **Providers:** signing in to an OAuth-only provider now actually completes — the browser login is stored and the provider list updates instead of remaining signed out. OAuth-only providers show a Connect flow instead of an API key form, and their models stay hidden until you are signed in. +- **Sessions:** archived sessions can now be restored to the active list — from the sidebar context menu, the archived-sessions page, or the bulk-selection bar — instead of only offering permanent deletion (thanks to @makeittech). +- Walkthrough: models without a working provider login no longer appear in the walkthrough picker, and Generate stays disabled until a usable model is selected instead of failing with a raw provider error. +- Providers: sign-ins that need extra details (such as GitHub Copilot Enterprise) now ask for them before opening the browser, and device codes come with a working copy button. +- Walkthrough: connecting to a server older than the app now says the server needs updating instead of showing a raw HTML parsing error, and the "Critical" tag is now "Key change" with a tooltip so it no longer reads as a problem found in your code. +- Chat: Ctrl/Cmd+L now adds the selected text to the chat input, or focuses it when nothing is selected; the toggle-sidebar shortcut moved to Ctrl/Cmd+Alt+L. +- Chat: a manually chosen model now stays selected after a delegated subtask finishes, instead of reverting to the agent's default model. +- Agents/CLI: sending a prompt that never reaches its session is now reported as failed, and an unavailable model, agent, or variant is rejected with a clear error before anything is created. +- Desktop/Linux: "Open in Terminal" no longer launches a non-terminal app that is set as the terminal launcher (thanks to @kydorn). + ## [1.18.0] - 2026-08-04 - **Walkthrough:** a new guided walkthrough reorders a diff into a sequence of stops — the model groups related changes, explains what each one does, and orders them so each builds on the last. Start one from the Changes and pull-request views for uncommitted work, a branch against its base, or a pull request; nothing runs on its own. Walkthroughs are written in your interface language by default, and the panel can generate one in any other supported language. diff --git a/bun.lock b/bun.lock index 370b1124..cd8e95ab 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.11", + "@opencode-ai/sdk": "1.18.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -95,7 +95,7 @@ }, "packages/electron": { "name": "@openchamber/electron", - "version": "1.17.2", + "version": "1.18.0", "dependencies": { "@openchamber/web": "workspace:*", "electron-context-menu": "^4.1.2", @@ -131,7 +131,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.17.2", + "version": "1.18.0", "dependencies": { "@aparajita/capacitor-secure-storage": "^8.0.0", "@base-ui/react": "^1.4.0", @@ -165,7 +165,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "1.18.11", + "@opencode-ai/sdk": "1.18.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", @@ -236,10 +236,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.17.2", + "version": "1.18.0", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "1.18.11", + "@opencode-ai/sdk": "1.18.12", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -259,14 +259,14 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.17.2", + "version": "1.18.0", "bin": { "openchamber": "./bin/cli.js", }, "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.11", + "@opencode-ai/sdk": "1.18.12", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", "bun-pty": "^0.4.5", @@ -997,7 +997,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.11", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-yDImmNv4PhxdMgtiHVNWQWEVwQlAm7Dr0y4XU7CT4dOIbzgO+VP+9I02lAP7Zva1FhGeyI7oKMI2tzB9RUsWaQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.12", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Skjm0uRWqIiL9BQliZSrvnBflT99q1aGhT2pATNwli2WU8XSKm4lhZJFay7dIzjrA5C9SfgK+YSeoES2PbFugA=="], "@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="], diff --git a/package.json b/package.json index ac088390..81c55465 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.18.0", + "version": "1.18.1", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", @@ -112,7 +112,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.11", + "@opencode-ai/sdk": "1.18.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/electron/package.json b/packages/electron/package.json index 67800186..88b3592a 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.18.0", + "version": "1.18.1", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/ui/package.json b/packages/ui/package.json index b5aab832..252ca74a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.18.0", + "version": "1.18.1", "private": true, "type": "module", "main": "src/main.tsx", @@ -43,7 +43,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "1.18.11", + "@opencode-ai/sdk": "1.18.12", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.3.0", "@simplewebauthn/browser": "13.3.0", diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 77f468aa..19bd1fef 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,3 +1,11 @@ +## [1.18.1] - 2026-08-04 + +- **Providers:** signing in to an OAuth-only provider (such as Cursor) now completes in the browser — the login is stored and the provider updates instead of remaining signed out. OAuth-only providers show a Connect flow instead of an API key form, and their models stay hidden until you are signed in. +- **Sessions:** archived sessions can now be restored to the active list — from the sidebar context menu, the archived-sessions page, or the bulk-selection bar (thanks to @makeittech). +- Providers: sign-ins that need extra details (such as GitHub Copilot Enterprise) now ask for them before opening the browser, and device codes come with a working copy button. +- Chat: a manually chosen model now stays selected after a delegated subtask finishes, instead of reverting to the agent's default model. +- Chat: Ctrl/Cmd+L now adds the selected text to the chat input, or focuses it when nothing is selected. + ## [1.18.0] - 2026-08-04 - **Providers:** custom OpenAI-compatible providers can now be added and edited from Settings, including their endpoint, models, credentials, headers, and configuration scope (thanks to @makeittech). diff --git a/packages/vscode/package.json b/packages/vscode/package.json index eb796875..feea88ca 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.18.0", + "version": "1.18.1", "publisher": "fedaykindev", "private": true, "repository": { @@ -244,7 +244,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "1.18.11", + "@opencode-ai/sdk": "1.18.12", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/web/package.json b/packages/web/package.json index ba63d58f..46b04352 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/web", - "version": "1.18.0", + "version": "1.18.1", "private": false, "type": "module", "main": "./server/index.js", @@ -25,7 +25,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.11", + "@opencode-ai/sdk": "1.18.12", "@simplewebauthn/server": "13.3.1", "adm-zip": "^0.5.16", "bun-pty": "^0.4.5", From b4ced01cc70e7e6ab0072efe239c30b07a082d0d Mon Sep 17 00:00:00 2001 From: RyderAsking Date: Tue, 4 Aug 2026 16:48:17 +0000 Subject: [PATCH 12/57] fix(walkthrough): use remote default branch --- .../components/views/git/baseBranch.test.ts | 30 +++++++++++++++++++ .../ui/src/components/views/git/baseBranch.ts | 15 ++++++++++ .../views/walkthrough/WalkthroughView.tsx | 30 +++++++++++++------ packages/ui/src/lib/api/types.ts | 1 + packages/web/server/lib/git/DOCUMENTATION.md | 1 + packages/web/server/lib/git/service.js | 26 +++++++++++++++- packages/web/server/lib/git/service.test.js | 23 ++++++++++++++ .../server/lib/walkthrough/DOCUMENTATION.md | 5 ++++ 8 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 packages/ui/src/components/views/git/baseBranch.test.ts diff --git a/packages/ui/src/components/views/git/baseBranch.test.ts b/packages/ui/src/components/views/git/baseBranch.test.ts new file mode 100644 index 00000000..18aaffb8 --- /dev/null +++ b/packages/ui/src/components/views/git/baseBranch.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from 'bun:test'; +import { deriveBaseBranch, hasResolvableBaseBranch } from './baseBranch'; + +describe('deriveBaseBranch', () => { + test('prefers the remote default branch hint over conventional fallbacks', () => { + expect(deriveBaseBranch({ + remoteNames: new Set(['origin']), + localBranches: ['next'], + rootBranchHint: 'origin/react', + })).toBe('react'); + }); +}); + +describe('hasResolvableBaseBranch', () => { + test('rejects the main fallback when it does not exist', () => { + expect(hasResolvableBaseBranch({ + baseBranch: 'main', + localBranches: ['next', 'react'], + remoteBranches: ['origin/next', 'origin/react'], + })).toBe(false); + }); + + test('accepts a base branch available through a remote-tracking ref', () => { + expect(hasResolvableBaseBranch({ + baseBranch: 'main', + localBranches: ['next'], + remoteBranches: ['origin/main', 'origin/next'], + })).toBe(true); + }); +}); diff --git a/packages/ui/src/components/views/git/baseBranch.ts b/packages/ui/src/components/views/git/baseBranch.ts index 19e22b59..6409597b 100644 --- a/packages/ui/src/components/views/git/baseBranch.ts +++ b/packages/ui/src/components/views/git/baseBranch.ts @@ -62,3 +62,18 @@ export const deriveBaseBranch = (options: { if (localBranches.includes('develop')) return 'develop'; return 'main'; }; + +/** + * Whether a base branch can be resolved locally or through one of the active + * remote-tracking refs. Callers must not offer comparisons against the `main` + * fallback when that ref does not actually exist in the repository. + */ +export const hasResolvableBaseBranch = (options: { + baseBranch: string; + localBranches: readonly string[]; + remoteBranches: readonly string[]; +}): boolean => { + const { baseBranch, localBranches, remoteBranches } = options; + return localBranches.includes(baseBranch) + || remoteBranches.some((branch) => branch.endsWith(`/${baseBranch}`)); +}; diff --git a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx index 246a818c..a2c2527b 100644 --- a/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx +++ b/packages/ui/src/components/views/walkthrough/WalkthroughView.tsx @@ -14,10 +14,10 @@ import { useI18n, type Locale } from '@/lib/i18n'; import { buildWalkthroughView } from '@/lib/walkthrough/model'; import type { WalkthroughSource, WalkthroughWorkingTreeScope } from '@/lib/walkthrough/types'; import { ModelSelector } from '@/components/sections/agents/ModelSelector'; -import { deriveBaseBranch } from '@/components/views/git/baseBranch'; +import { deriveBaseBranch, hasResolvableBaseBranch } from '@/components/views/git/baseBranch'; import { runtimeFetch } from '@/lib/runtime-fetch'; import { useConfigStore } from '@/stores/useConfigStore'; -import { useGitBranches, useGitStatus } from '@/stores/useGitStore'; +import { useGitBranches, useGitStatus, useGitStore } from '@/stores/useGitStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { getFreshestPrStatusForBranch, @@ -152,6 +152,12 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { const status = useGitStatus(directory || null); const branches = useGitBranches(directory || null); + const ensureAll = useGitStore((state) => state.ensureAll); + const { github, git } = useRuntimeAPIs(); + + useEffect(() => { + if (directory) void ensureAll(directory, git); + }, [directory, ensureAll, git]); // The branch source reviews everything on this branch that is not on its // base. Three-dot semantics server-side mean merges from the base are @@ -162,22 +168,28 @@ export const WalkthroughView = ({ directory }: WalkthroughViewProps) => { if (!headRef) return null; const all = branches?.all ?? []; const localBranches = all.filter((name) => !name.startsWith('remotes/')); + const remoteBranches = all + .filter((name) => name.startsWith('remotes/')) + .map((name) => name.slice('remotes/'.length)); const remoteNames = new Set( - all - .filter((name) => name.startsWith('remotes/')) - .map((name) => name.slice('remotes/'.length).split('/')[0]) + remoteBranches + .map((name) => name.split('/')[0]) .filter(Boolean) ); - const baseRef = deriveBaseBranch({ remoteNames, localBranches }); - if (!baseRef || baseRef === headRef) return null; + const trackingRemote = status?.tracking?.split('/')[0]; + const rootBranchHint = (trackingRemote && branches?.defaultBranches?.[trackingRemote]) + ?? branches?.defaultBranches?.origin; + const baseRef = deriveBaseBranch({ remoteNames, localBranches, rootBranchHint }); + if (!baseRef || baseRef === headRef || !hasResolvableBaseBranch({ baseBranch: baseRef, localBranches, remoteBranches })) { + return null; + } return { kind: 'branch', baseRef, headRef }; - }, [branches, currentBranch]); + }, [branches, currentBranch, status?.tracking]); // The pull request for this branch used to appear only after visiting the PR // panel, because nothing else asked GitHub about it. Ask here too: the status // store already dedupes by signature and throttles by TTL, so several panels // wanting the same answer produce one request. - const { github } = useRuntimeAPIs(); const githubConnected = useGitHubAuthStore((state) => state.status?.connected ?? false); const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked); const ensurePrStatusEntry = useGitHubPrStatusStore((state) => state.ensureEntry); diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 9b1ae0b7..9c5859af 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -183,6 +183,7 @@ export interface GitBranch { all: string[]; current: string; branches: Record; + defaultBranches?: Record; } interface GitCommitSummary { diff --git a/packages/web/server/lib/git/DOCUMENTATION.md b/packages/web/server/lib/git/DOCUMENTATION.md index 7dbd31ce..8bcbb964 100644 --- a/packages/web/server/lib/git/DOCUMENTATION.md +++ b/packages/web/server/lib/git/DOCUMENTATION.md @@ -105,6 +105,7 @@ The following functions are internal helpers used by exported functions: - `ahead`: Number of commits ahead of upstream. - `behind`: Number of commits behind upstream. - `upstreamComparison`: Optional comparison against `upstream/`, with `{ remote, branch, ahead, behind }`. +- `defaultBranches`: Remote default branches derived from local symbolic refs such as `remotes/origin/HEAD -> origin/main`, keyed by remote name. Omitted by runtimes that do not provide this Git metadata. - `files`: Array of file objects with `path`, `index`, `working_dir` status codes. - `isClean`: Boolean indicating if working tree is clean. - `diffStats`: Object mapping file paths to `{ insertions, deletions }`. diff --git a/packages/web/server/lib/git/service.js b/packages/web/server/lib/git/service.js index a3a99f62..ed64b7c5 100644 --- a/packages/web/server/lib/git/service.js +++ b/packages/web/server/lib/git/service.js @@ -3367,6 +3367,7 @@ export async function getBranches(directory) { const allBranches = result.all; const remoteBranches = allBranches.filter(branch => branch.startsWith('remotes/')); const activeRemoteBranches = await filterActiveRemoteBranches(git, remoteBranches); + const defaultBranches = await getRemoteDefaultBranches(git); const filteredAll = [ ...allBranches.filter(branch => !branch.startsWith('remotes/')), @@ -3376,7 +3377,8 @@ export async function getBranches(directory) { return { all: filteredAll, current: result.current, - branches: result.branches + branches: result.branches, + defaultBranches, }; } catch (error) { console.error('Failed to get branches:', error); @@ -3384,6 +3386,28 @@ export async function getBranches(directory) { } } +async function getRemoteDefaultBranches(git) { + try { + const refs = await git.raw([ + 'for-each-ref', + '--format=%(refname) %(symref)', + 'refs/remotes', + ]); + return Object.fromEntries( + refs.trim().split('\n').flatMap((line) => { + const [ref, symbolicRef] = line.split(' '); + const match = ref.match(/^refs\/remotes\/([^/]+)\/HEAD$/); + const prefix = match ? `refs/remotes/${match[1]}/` : ''; + return match && typeof symbolicRef === 'string' && symbolicRef.startsWith(prefix) + ? [[match[1], symbolicRef.slice(prefix.length)]] + : []; + }) + ); + } catch { + return {}; + } +} + async function filterActiveRemoteBranches(git, remoteBranches) { try { const remotes = await git.getRemotes(); diff --git a/packages/web/server/lib/git/service.test.js b/packages/web/server/lib/git/service.test.js index e5110995..40f0c054 100644 --- a/packages/web/server/lib/git/service.test.js +++ b/packages/web/server/lib/git/service.test.js @@ -10,6 +10,7 @@ import { cherryPick, createWorktree, getWorktreeBootstrapStatus, + getBranches, getStatus, isGitRepository, populateWorktreeWithLockRecovery, @@ -988,3 +989,25 @@ describe('hash validation', () => { ).rejects.not.toThrow('Invalid commit hash'); }); }); + +describe.runIf(canRunGit())('getBranches', () => { + it('returns a remote default branch whose name is not a conventional fallback', async () => { + const remote = createTempDir(); + const repository = createTempDir(); + runGit(remote, ['init', '--bare', '--initial-branch=react']); + runGit(repository, ['init', '-b', 'next']); + runGit(repository, ['config', 'user.email', 'test@example.com']); + runGit(repository, ['config', 'user.name', 'Test']); + fs.writeFileSync(path.join(repository, 'README.md'), '# Test\n'); + runGit(repository, ['add', 'README.md']); + runGit(repository, ['commit', '-m', 'init']); + runGit(repository, ['remote', 'add', 'origin', remote]); + runGit(repository, ['push', 'origin', 'HEAD:react']); + runGit(repository, ['fetch', 'origin']); + runGit(repository, ['remote', 'set-head', 'origin', '--auto']); + + await expect(getBranches(repository)).resolves.toMatchObject({ + defaultBranches: { origin: 'react' }, + }); + }); +}); diff --git a/packages/web/server/lib/walkthrough/DOCUMENTATION.md b/packages/web/server/lib/walkthrough/DOCUMENTATION.md index 0f23fac5..b2d43ad1 100644 --- a/packages/web/server/lib/walkthrough/DOCUMENTATION.md +++ b/packages/web/server/lib/walkthrough/DOCUMENTATION.md @@ -55,6 +55,11 @@ written against staged code never silently re-anchors onto an unstaged edit. | `branch` | `branch` | `getRangeDiff` uses three-dot `base...head`, so work merged in from the base branch is excluded | | `pr` | `pr:` | GitHub returns the merge-base diff, matching the branch semantics | +For the current-branch source, the UI prefers the default branch of the current +branch's tracking remote (from its local `remote/HEAD` symbolic ref), then uses +the existing conventional-branch fallback. It does not offer the source when the +chosen base cannot be resolved locally or through a remote-tracking ref. + The panel offers the current branch's pull request on its own: it registers with the shared GitHub PR status store (`useGitHubPrStatusStore`) rather than waiting for the pull request panel to have been visited. That store already dedupes From f3dd89420918be162f8308bc94427291927c10ae Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 4 Aug 2026 20:39:45 +0300 Subject: [PATCH 13/57] feat(ui): numbered context-panel surface switching with configurable prefix - Add switch_context_surface shortcut (default Cmd/Ctrl + 1..9, 0 for the 10th surface) that opens/closes/switches context panel rail surfaces by their visible order, configurable and persisted in Settings -> Shortcuts. - Show order-number badges on rail icons while the modifier is held >500ms; dismiss on release, blur, or a number press until the next press-and-hold. - Remove the legacy mod+2/3/4 (diff/terminal/git) and switch_tab_1..9 bindings so numbered surface switching goes only through the new mechanism. - Replace the help-dialog 'Switch Project' row with the surface-switch row and update the shortcuts footer/header icons to the command icon. --- .../components/layout/ContextPanelRail.tsx | 126 +++++++++-- .../openchamber/KeyboardShortcutsSettings.tsx | 45 +++- .../session/sidebar/SidebarFooter.tsx | 2 +- packages/ui/src/components/ui/HelpDialog.tsx | 12 +- packages/ui/src/hooks/useKeyboardShortcuts.ts | 79 ++++++- .../ui/src/lib/i18n/messages/de.settings.ts | 2 + packages/ui/src/lib/i18n/messages/de.ts | 2 +- .../ui/src/lib/i18n/messages/en.settings.ts | 2 + packages/ui/src/lib/i18n/messages/en.ts | 2 +- .../ui/src/lib/i18n/messages/es.settings.ts | 2 + packages/ui/src/lib/i18n/messages/es.ts | 2 +- .../ui/src/lib/i18n/messages/fr.settings.ts | 2 + packages/ui/src/lib/i18n/messages/fr.ts | 2 +- .../ui/src/lib/i18n/messages/ja.settings.ts | 2 + packages/ui/src/lib/i18n/messages/ja.ts | 2 +- .../ui/src/lib/i18n/messages/ko.settings.ts | 2 + packages/ui/src/lib/i18n/messages/ko.ts | 2 +- .../ui/src/lib/i18n/messages/pl.settings.ts | 2 + packages/ui/src/lib/i18n/messages/pl.ts | 2 +- .../src/lib/i18n/messages/pt-BR.settings.ts | 2 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 +- .../ui/src/lib/i18n/messages/uk.settings.ts | 2 + packages/ui/src/lib/i18n/messages/uk.ts | 2 +- .../src/lib/i18n/messages/zh-CN.settings.ts | 2 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 +- .../src/lib/i18n/messages/zh-TW.settings.ts | 2 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 +- packages/ui/src/lib/shortcuts.test.ts | 80 +++++++ packages/ui/src/lib/shortcuts.ts | 213 ++++++++++++------ packages/ui/src/lib/surfaces/DOCUMENTATION.md | 7 + packages/ui/src/lib/surfaces/registry.test.ts | 53 +++++ packages/ui/src/lib/surfaces/registry.ts | 37 +++ 32 files changed, 574 insertions(+), 124 deletions(-) create mode 100644 packages/ui/src/lib/shortcuts.test.ts create mode 100644 packages/ui/src/lib/surfaces/registry.test.ts diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index 5d522e5c..43692cbb 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -24,18 +24,23 @@ import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { + getVisibleContextRailSurfaces, sortContextSurfaces, type ContextSurfaceDescriptor, } from '@/lib/surfaces/registry'; +import { + getEffectiveShortcutPrefix, + isShortcutPrefixHeld, +} from '@/lib/shortcuts'; import { cn } from '@/lib/utils'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; import { useGitStatus } from '@/stores/useGitStore'; import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore'; const RAIL_TOOLTIP_DELAY_MS = 150; -// Tablet width and up: below this the walkthrough cannot show a stop and its -// code side by side, which is the whole point of the surface. -const WALKTHROUGH_MIN_WIDTH = 768; +// Hold the surface-switch modifier for this long before revealing the order +// number badges on the rail icons. +const RAIL_NUMBER_HOLD_DELAY_MS = 500; const EMPTY_TABS: never[] = []; type RailItemProps = { @@ -44,6 +49,8 @@ type RailItemProps = { showActivityDot: boolean; label: string; description: string; + orderNumber?: number | null; + showOrderNumber?: boolean; onSelect: (surface: ContextSurfaceDescriptor) => void; }; @@ -53,6 +60,8 @@ const ContextPanelRailItem: React.FC = ({ showActivityDot, label, description, + orderNumber, + showOrderNumber, onSelect, }) => { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ @@ -86,12 +95,20 @@ const ContextPanelRailItem: React.FC = ({ ) : ( )} - {showActivityDot ? ( + {showActivityDot && !showOrderNumber ? (