You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/09/01 03:18:37 UTC
(maka) 03/03: fix(desktop): show the Host's entries while the connection editor is unedited
This is an automated email from the ASF dual-hosted git repository.
Astro-Han pushed a commit to branch refactor/model-catalog-host-authority
in repository https://gitbox.apache.org/repos/asf/maka.git
commit 53fc939023dc68c4c4f573f7940a30a6b765fca5
Author: AstroHan <le...@outlook.com>
AuthorDate: Tue Sep 1 11:18:23 2026 +0800
fix(desktop): show the Host's entries while the connection editor is unedited
The connection detail sheet rebuilt its catalog from the connection's
stored fields on every render, including before the user has touched
anything. That is the version disagreement the projection exists to end:
a Desktop older or newer than the Host replaced the Host's display names
and eligibility decisions with its own bundled guesses — and could offer
a model the Host had ruled out.
The client-side resolution an editor legitimately needs is narrower: once
the draft diverges — model rows just fetched, ids just ticked — it
describes a connection the Host has never been told about and so cannot
have resolved. `resolveDraftConnectionModelCatalog` makes that the only
branch that resolves, and lives in core beside the resolver it guards.
Divergence compares every stored field an entry is built from, not ids
alone: a re-fetched row can carry different facts under the same id, and
comparing ids would keep showing the Host's stale entry for it.
Generated-by: Claude Code
---
.../main/__tests__/model-catalog-choices.test.ts | 53 +++++++++++++++-
.../settings/provider-enabled-model-manager.tsx | 2 +-
.../src/renderer/settings/providers-panel.tsx | 8 ++-
.../src/renderer/settings/use-connection-detail.ts | 28 ++++-----
packages/core/src/model-catalog.ts | 73 ++++++++++++++++++++++
5 files changed, 143 insertions(+), 21 deletions(-)
diff --git a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts
index fae34ca0bf..382d26e64f 100644
--- a/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts
+++ b/apps/desktop/src/main/__tests__/model-catalog-choices.test.ts
@@ -23,7 +23,11 @@ import type {
IdentifiedLlmConnection,
ProjectedLlmConnection,
} from '@maka/core/llm-connections';
-import { resolveConnectionModelCatalog } from '@maka/core/model-catalog';
+import {
+ resolveConnectionModelCatalog,
+ resolveDraftConnectionModelCatalog,
+ type ModelCatalogEntry,
+} from '@maka/core/model-catalog';
import { buildChatModelChoices } from '@maka/core/chat-model-choice';
import { pickNewChatModel } from '../../renderer/shell-chat-model-selection.js';
import { buildCatalogDailyReviewModelOptions } from '../../renderer/model-catalog-choices.js';
@@ -118,6 +122,53 @@ describe('model catalog picker helpers', () => {
assert.ok(choices.every((choice) => !(choice.connectionName ?? '').includes('@')));
});
+ it('renders the Host entry, not a local rebuild, while the editor is unedited', () => {
+ // A Host that knows this model and a Desktop that does not: the entry says
+ // the model cannot serve as a chat default and carries a name this build
+ // has never heard. An unedited editor must show what the Host decided —
+ // rebuilding locally is exactly the version disagreement the projection
+ // ends, and here it would also offer a model the Host ruled out.
+ const stored = {
+ connectionId: 'connection-relay',
+ slug: 'relay',
+ name: 'Relay',
+ providerType: 'openai-compatible' as const,
+ defaultModel: 'host-only-model',
+ enabled: true,
+ enabledModelIds: ['host-only-model'],
+ models: [{ id: 'host-only-model' }],
+ modelSource: 'fetched' as const,
+ createdAt: 1,
+ updatedAt: 1,
+ };
+ const hostEntry: ModelCatalogEntry = {
+ ...resolveConnectionModelCatalog(stored)[0],
+ displayName: 'Host-only image model',
+ canUseAsChatDefault: false,
+ };
+ const connection: ProjectedLlmConnection = { ...stored, catalogEntries: [hostEntry] };
+ const draft = {
+ models: stored.models,
+ modelSource: stored.modelSource,
+ enabledModelIds: stored.enabledModelIds,
+ };
+
+ const unedited = resolveDraftConnectionModelCatalog(connection, draft);
+ assert.deepEqual(unedited, [hostEntry]);
+
+ // And the exception still applies: a draft the Host has not seen is the
+ // one thing the client resolves for itself.
+ const edited = resolveDraftConnectionModelCatalog(connection, {
+ ...draft,
+ models: [...stored.models, { id: 'just-fetched' }],
+ });
+ assert.deepEqual(
+ edited.map((entry) => entry.id).sort(),
+ ['host-only-model', 'just-fetched'],
+ );
+ assert.notEqual(edited[0]?.displayName, 'Host-only image model');
+ });
+
it('does not offer Daily Review a Codex model the subscription cannot serve', () => {
// A connection saved while `gpt-5-codex` was still picker-visible keeps it
// in `enabledModelIds`. The inventory filter alone left it there, and the
diff --git a/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx b/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx
index b5c1ae4678..4ae778fd61 100644
--- a/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx
+++ b/apps/desktop/src/renderer/settings/provider-enabled-model-manager.tsx
@@ -34,7 +34,7 @@ import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
* `enabledModelIds` remains the only product state.
*/
export function EnabledModelManager(props: {
- modelChoices: ModelCatalogEntry[];
+ modelChoices: readonly ModelCatalogEntry[];
enabledModelIds: string[];
disabled: boolean;
onChange(ids: string[]): void;
diff --git a/apps/desktop/src/renderer/settings/providers-panel.tsx b/apps/desktop/src/renderer/settings/providers-panel.tsx
index 9e95ed559b..b4d7767b9f 100644
--- a/apps/desktop/src/renderer/settings/providers-panel.tsx
+++ b/apps/desktop/src/renderer/settings/providers-panel.tsx
@@ -35,6 +35,7 @@ import {
import { ICON_SIZE, ChevronRight, Cpu } from '@maka/ui/icons';
import {
type IdentifiedLlmConnection,
+ type ProjectedLlmConnection,
type ProviderType,
} from '@maka/core/llm-connections';
import { dotForStatus, useMountedRef, useUiLocale } from '@maka/ui';
@@ -108,7 +109,10 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
onInitialCreateProviderConsumed?: () => void;
}) {
const reportHostError = useRuntimeHostSettingsErrorReporter();
- const [connections, setConnections] = useState<IdentifiedLlmConnection[]>([]);
+ // Projected, not merely identified: the detail editor renders the Host's
+ // resolved entries for a connection the user has not edited, so the catalog
+ // must survive this state rather than being narrowed away here.
+ const [connections, setConnections] = useState<ProjectedLlmConnection[]>([]);
const [defaultSlug, setDefaultSlug] = useState<string | null>(null);
const [route, setRoute] = useState<PanelRoute>({ kind: 'list' });
// Browsing state, not navigation state: it outlives the catalog so that
@@ -223,7 +227,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
setRoute({ kind: 'list' });
}
- function openDetail(connection: IdentifiedLlmConnection) {
+ function openDetail(connection: ProjectedLlmConnection) {
returnFocusRef.current = { level: 'list', connectionId: connection.connectionId };
setRoute({ kind: 'detail', connectionId: connection.connectionId });
}
diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts
index a05ef9eb93..4eacabee5a 100644
--- a/apps/desktop/src/renderer/settings/use-connection-detail.ts
+++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts
@@ -28,10 +28,11 @@ import {
type ConnectionTestResult,
type IdentifiedLlmConnection,
type ModelInfo,
+ type ProjectedLlmConnection,
type ProviderType,
} from '@maka/core/llm-connections';
import { PROVIDER_REGISTRY, connectionEnabledModelIds } from '@maka/core/llm-connections';
-import { buildConnectionModelCatalogEntries } from '@maka/core/model-catalog';
+import { resolveDraftConnectionModelCatalog } from '@maka/core/model-catalog';
import { isRetiredProvider } from '@maka/core/provider-registry';
import {
normalizeRelayModelProfiles,
@@ -119,7 +120,7 @@ export function oauthLoginServiceFor(
export interface ConnectionDetailProps {
bridge: ConnectionsBridge;
- connection: IdentifiedLlmConnection;
+ connection: ProjectedLlmConnection;
isDefault: boolean;
onChanged(): Promise<void>;
onDeleted(): Promise<void>;
@@ -306,21 +307,14 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
setEnabledModelIds(connectionEnabledModelIds(connection));
}, [connection.defaultModel, connection.enabledModelIds, connection.slug]);
- // The one client-side resolution left on a saved connection: the editor
- // shows the unsaved draft — model rows just fetched, ids just ticked — which
- // the Host has not been told about and so cannot have resolved. Everything
- // the user has committed is read from `connection.catalogEntries`; this
- // resolves only what is still in the draft.
- const modelChoices = buildConnectionModelCatalogEntries({
- connection: {
- slug: connection.slug,
- providerType: connection.providerType,
- defaultModel: connection.defaultModel,
- enabledModelIds,
- models: modelSource === 'fetched' || models.length > 0 ? models : undefined,
- modelSource,
- modelsFetchedAt: connection.modelsFetchedAt,
- },
+ // Reads `connection.catalogEntries` while the editor still shows what was
+ // committed, and resolves locally only once the draft diverges — the one
+ // client-side resolution left on a saved connection. The rule itself lives
+ // beside the resolver it guards, in `@maka/core/model-catalog`.
+ const modelChoices = resolveDraftConnectionModelCatalog(connection, {
+ models,
+ modelSource,
+ enabledModelIds,
});
/**
diff --git a/packages/core/src/model-catalog.ts b/packages/core/src/model-catalog.ts
index 52bda9f4dc..92100d553b 100644
--- a/packages/core/src/model-catalog.ts
+++ b/packages/core/src/model-catalog.ts
@@ -27,10 +27,12 @@ import type {
import {
classifyConnectionModelInventory,
CODEX_SUBSCRIPTION_UNSUPPORTED_CHATGPT_MODELS,
+ connectionEnabledModelIds,
PROVIDER_REGISTRY,
providerDefaultsOf,
providerSupportsModelDiscovery,
type ConnectionModelInventory,
+ type HostResolvedConnectionCatalog,
} from './llm-connections.js';
import type { PricingConfig } from './usage-stats/types.js';
import {
@@ -402,6 +404,77 @@ export function resolveConnectionModelCatalog(
});
}
+/** A connection editor's unsaved model state. */
+export interface ConnectionModelDraft {
+ readonly models: readonly ModelInfo[];
+ readonly modelSource: ModelDiscoverySource;
+ readonly enabledModelIds: readonly string[];
+}
+
+/**
+ * The catalog to show for a connection being edited.
+ *
+ * While the draft still matches what is committed, the Host has already
+ * resolved this exact connection and its entries are the answer. Resolving
+ * again against a client's own bundled metadata would replace a possibly
+ * newer Host's display names and eligibility decisions with local guesses —
+ * the disagreement the projection exists to end.
+ *
+ * The other branch is the client-side resolution an editor legitimately needs:
+ * once the draft diverges — model rows just fetched, ids just ticked — it
+ * describes a connection the Host has never been told about and so cannot
+ * have resolved.
+ */
+export function resolveDraftConnectionModelCatalog(
+ connection: BuildConnectionModelCatalogInput['connection'] & HostResolvedConnectionCatalog,
+ draft: ConnectionModelDraft,
+): readonly ModelCatalogEntry[] {
+ if (draftMatchesConnection(connection, draft)) return connection.catalogEntries;
+ return resolveConnectionModelCatalog({
+ ...connection,
+ enabledModelIds: [...draft.enabledModelIds],
+ models:
+ draft.modelSource === 'fetched' || draft.models.length > 0 ? [...draft.models] : undefined,
+ modelSource: draft.modelSource,
+ });
+}
+
+function draftMatchesConnection(
+ connection: Pick<LlmConnection, 'defaultModel' | 'enabledModelIds' | 'models' | 'modelSource'>,
+ draft: ConnectionModelDraft,
+): boolean {
+ if (draft.modelSource !== (connection.modelSource ?? 'fallback')) return false;
+ const enabled = connectionEnabledModelIds(connection);
+ if (draft.enabledModelIds.length !== enabled.length) return false;
+ if (draft.enabledModelIds.some((id, index) => id !== enabled[index])) return false;
+ return modelRowsEqual(draft.models, connection.models ?? []);
+}
+
+/**
+ * Every stored field a catalog entry can be built from. Comparing ids alone
+ * would keep showing the Host's entries for rows the user just re-fetched,
+ * whose facts may differ under the same id.
+ */
+function modelRowsEqual(left: readonly ModelInfo[], right: readonly ModelInfo[]): boolean {
+ if (left.length !== right.length) return false;
+ return left.every((model, index) => {
+ const other = right[index];
+ return (
+ model.id === other.id &&
+ model.displayName === other.displayName &&
+ model.contextWindow === other.contextWindow &&
+ model.inputLimit === other.inputLimit &&
+ model.maxOutputTokens === other.maxOutputTokens &&
+ model.capabilities?.chat === other.capabilities?.chat &&
+ model.capabilities?.vision === other.capabilities?.vision &&
+ model.capabilities?.reasoning === other.capabilities?.reasoning &&
+ model.capabilities?.functionCalling === other.capabilities?.functionCalling &&
+ model.capabilities?.parallelToolCalls === other.capabilities?.parallelToolCalls &&
+ model.capabilities?.imageGeneration === other.capabilities?.imageGeneration
+ );
+ });
+}
+
/**
* The per-build facts every entry in one catalog shares. Threading them as one
* value keeps the entry builders' remaining parameters to what actually varies
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org