You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/08/31 12:41:00 UTC
(maka) branch main updated: feat(desktop): expose OAuth connection accounts (#4314)
This is an automated email from the ASF dual-hosted git repository.
me2seeks pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/maka.git
The following commit(s) were added to refs/heads/main by this push:
new 064c3b299f feat(desktop): expose OAuth connection accounts (#4314)
064c3b299f is described below
commit 064c3b299f5853c08a0af85c5f798dbce3e8b922
Author: Zhang <96...@users.noreply.github.com>
AuthorDate: Mon Aug 31 20:40:56 2026 +0800
feat(desktop): expose OAuth connection accounts (#4314)
* feat(desktop): expose OAuth connection accounts
* test(desktop): cover OAuth multi-account settings
* fix(desktop): preserve exact OAuth account identity
* chore(desktop): remove unused OAuth bridge alias
* test(desktop): align OAuth generation stories
---
apps/desktop/renderer-architecture.json | 8 +-
.../main/__tests__/provider-add-submission.test.ts | 11 +-
.../runtime-host-connections-ipc-main.test.ts | 49 ++++-
.../__tests__/runtime-host-oauth-ipc-main.test.ts | 99 +++++++---
.../src/main/runtime-host-connections-ipc-main.ts | 120 ++++++++++--
.../src/main/runtime-host-oauth-ipc-main.ts | 108 ++++++-----
apps/desktop/src/preload/bridge-contract.d.ts | 110 +++++++----
apps/desktop/src/preload/preload.ts | 74 +++----
.../src/renderer/locales/settings-provider-copy.ts | 26 ++-
.../renderer/settings/provider-add-submission.ts | 10 +-
.../renderer/settings/provider-catalog-page.tsx | 35 +++-
.../settings/provider-connection-detail.tsx | 34 +++-
.../renderer/settings/provider-oauth-section.tsx | 138 ++++++--------
.../src/renderer/settings/provider-panel-shared.ts | 24 ++-
.../src/renderer/settings/providers-panel.tsx | 171 +++++++++++++----
.../settings/runtime-host-settings-bridge.ts | 93 ++++++---
.../src/renderer/settings/use-connection-detail.ts | 68 ++++---
.../src/renderer/settings/use-oauth-login-flow.ts | 105 ++++++----
.../src/shared/desktop-connection-snapshot.ts | 6 +
.../stories/settings/provider-settings.stories.tsx | 212 ++++++++++++++++++---
.../stories/settings/settings-pages.stories.tsx | 50 ++---
21 files changed, 1078 insertions(+), 473 deletions(-)
diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 317c460660..3cad453abf 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -3674,9 +3674,7 @@
"window.maka.githubCopilotSubscription.logout": 1,
"window.maka.githubCopilotSubscription.refreshTokens": 1,
"window.maka.openAiCodex": 1,
- "window.maka.openAiCodex.getAccountState": 1,
- "window.maka.xaiOAuth": 1,
- "window.maka.xaiOAuth.getAccountState": 1
+ "window.maka.xaiOAuth": 1
},
"environmentCapabilities": {},
"hookCalls": {
@@ -3983,9 +3981,7 @@
"window.maka.connections.test": 1,
"window.maka.connections.update": 1
},
- "environmentCapabilities": {
- "window": 2
- },
+ "environmentCapabilities": {},
"hookCalls": {},
"lifecycleMethods": {},
"unresolvedDependencies": 0,
diff --git a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts
index 8d0302037d..5bf2621988 100644
--- a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts
+++ b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts
@@ -29,7 +29,7 @@ import {
PROVIDER_DEFAULTS,
providerSupportsModelDiscovery,
type CreateConnectionInput,
- type LlmConnection,
+ type IdentifiedLlmConnection,
type ProviderType,
} from '@maka/core/llm-connections';
@@ -53,8 +53,9 @@ function draft(over: Partial<AddProviderDraft> = {}): AddProviderDraft {
};
}
-function connection(slug: string): LlmConnection {
+function connection(slug: string): IdentifiedLlmConnection {
return {
+ connectionId: `connection-${slug}`,
slug,
name: slug,
providerType: 'openai-compatible',
@@ -62,12 +63,12 @@ function connection(slug: string): LlmConnection {
enabled: true,
createdAt: 0,
updatedAt: 0,
- } as LlmConnection;
+ } as IdentifiedLlmConnection;
}
function bridge(over: {
- create?: (input: CreateConnectionInput) => Promise<LlmConnection>;
- fetchModels?: (slug: string) => Promise<unknown>;
+ create?: (input: CreateConnectionInput) => Promise<IdentifiedLlmConnection>;
+ fetchModels?: (connection: { readonly connectionId: string; readonly slug: string }) => Promise<unknown>;
}) {
return {
create: over.create ?? (async (input) => connection(input.slug)),
diff --git a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts
index a452275c24..96e730be8b 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts
@@ -93,7 +93,7 @@ test('retries connection delete after a stale revision instead of failing perman
emitConnectionListChanged() {},
});
- await handlers.get('connections:delete')?.({}, 'openrouter');
+ await handlers.get('connections:delete')?.({}, connectionIdentity());
assert.equal(removals, 2);
});
@@ -123,12 +123,41 @@ test('treats a missing connection as a successful delete without calling remove'
},
});
- await handlers.get('connections:delete')?.({}, 'already-gone');
+ await handlers.get('connections:delete')?.({}, {
+ connectionId: 'already-gone',
+ slug: 'already-gone',
+ });
assert.equal(removals, 0);
assert.equal(listChanged, 1);
});
-test('rejects invalid connection slug input instead of treating it as already deleted', async () => {
+test('does not delete a replacement Connection that reused the stale detail slug', async () => {
+ const handlers = new Map<string, (...args: unknown[]) => unknown>();
+ let removals = 0;
+ registerRuntimeHostConnectionsIpc({
+ ipcMain: {
+ handle: (channel, handler) => {
+ handlers.set(channel, handler as (...args: unknown[]) => unknown);
+ },
+ },
+ client: {
+ loadConnectionCatalog: async (): Promise<ConnectionCatalogSnapshot> => ({
+ ...catalog(),
+ connections: [{ ...catalog().connections[0]!, connectionId: 'connection-2' }],
+ }),
+ removeConnection: async () => {
+ removals += 1;
+ return { kind: 'committed', catalogRevision: 8 };
+ },
+ } as never,
+ emitConnectionListChanged() {},
+ });
+
+ await handlers.get('connections:delete')?.({}, connectionIdentity());
+ assert.equal(removals, 0);
+});
+
+test('rejects invalid connection identity input instead of treating it as already deleted', async () => {
const handlers = new Map<string, (...args: unknown[]) => unknown>();
registerRuntimeHostConnectionsIpc({
ipcMain: {
@@ -149,7 +178,7 @@ test('rejects invalid connection slug input instead of treating it as already de
await assert.rejects(
async () => handlers.get('connections:delete')?.({}, 42),
- /Invalid connection slug|connection slug/i,
+ /Invalid Connection identity/i,
);
});
@@ -176,7 +205,7 @@ test('reports an existing but unconfigured credential as missing', async () => {
});
assert.equal(
- await handlers.get('connections:hasSecret')?.({}, 'openrouter'),
+ await handlers.get('connections:hasSecret')?.({}, connectionIdentity()),
false,
);
});
@@ -205,16 +234,16 @@ test('keeps saved custom header values out of the renderer and preserves them by
});
assert.deepEqual(
- await handlers.get('connections:getRequestHeaders')?.({}, 'openrouter'),
+ await handlers.get('connections:getRequestHeaders')?.({}, connectionIdentity()),
{ names: ['HTTP-Referer'] },
);
assert.equal(
- JSON.stringify(await handlers.get('connections:getRequestHeaders')?.({}, 'openrouter')).includes('private.example'),
+ JSON.stringify(await handlers.get('connections:getRequestHeaders')?.({}, connectionIdentity())).includes('private.example'),
false,
);
assert.deepEqual(
- await handlers.get('connections:setRequestHeaders')?.({}, 'openrouter', [
+ await handlers.get('connections:setRequestHeaders')?.({}, connectionIdentity(), [
{ name: 'HTTP-Referer' },
{ name: 'X-Title', value: 'Maka' },
]),
@@ -371,3 +400,7 @@ function catalog(): ConnectionCatalogSnapshot {
],
};
}
+
+function connectionIdentity() {
+ return { connectionId: 'connection-1', slug: 'openrouter' } as const;
+}
diff --git a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts
index 64f824e4be..84fa6dd386 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts
@@ -167,9 +167,18 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => {
const authorization = await invoke(
handlers,
'openai-codex:get-auth-url',
- catalog.connections[0]?.connectionId,
+ { kind: 'existing', connectionId: catalog.connections[0]?.connectionId },
);
- assert.deepEqual(authorization, { authRequestId: attemptId, stateHint: 'STATE-HINT' });
+ const expectedConnection = {
+ connectionId: catalog.connections[0]?.connectionId,
+ slug: 'openai-codex',
+ providerType: provider,
+ };
+ assert.deepEqual(authorization, {
+ authRequestId: attemptId,
+ stateHint: 'STATE-HINT',
+ connection: expectedConnection,
+ });
assert.deepEqual(opened, ['https://codex.example/authorize']);
assert.deepEqual(
await invoke(
@@ -178,7 +187,7 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => {
attemptId,
'authorization-code#state',
),
- { ok: true },
+ { ok: true, connection: expectedConnection },
);
assert.equal(changed, 1);
assert.deepEqual(catalog.defaultTarget, {
@@ -187,7 +196,11 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => {
});
// No quota: reporting it required the retired provider's own client identity,
// so the account state carries the runtime state alone.
- assert.deepEqual(await invoke(handlers, 'openai-codex:get-account-state'), {
+ assert.deepEqual(await invoke(
+ handlers,
+ 'openai-codex:get-account-state',
+ catalog.connections[0]?.connectionId,
+ ), {
provider,
runtimeState: 'authenticated',
});
@@ -231,7 +244,10 @@ test('provider-scoped OAuth IPC rejects a Connection ID owned by another provide
});
assert.deepEqual(
- await invoke(handlers, 'openai-codex:get-auth-url', xaiConnection.connectionId),
+ await invoke(handlers, 'openai-codex:get-auth-url', {
+ kind: 'existing',
+ connectionId: xaiConnection.connectionId,
+ }),
{
ok: false,
reason: 'unknown',
@@ -240,14 +256,18 @@ test('provider-scoped OAuth IPC rejects a Connection ID owned by another provide
);
assert.deepEqual(
await invoke(handlers, 'openai-codex:get-account-state', xaiConnection.connectionId),
- { provider: 'openai-codex', runtimeState: 'not_logged_in' },
+ {
+ ok: false,
+ reason: 'unknown',
+ message: 'OAuth account does not match this provider',
+ },
);
assert.deepEqual(
await invoke(handlers, 'openai-codex:refresh-tokens', xaiConnection.connectionId),
{
ok: false,
reason: 'refresh_failed',
- message: 'OAuth account is not connected',
+ message: 'OAuth account does not match this provider',
},
);
assert.deepEqual(await invoke(handlers, 'openai-codex:logout', xaiConnection.connectionId), {
@@ -271,7 +291,13 @@ test('malformed OAuth Connection IDs fail closed before catalog or credential ac
isProviderEnabled: () => true,
});
- for (const malformed of [null, 7, {}]) {
+ for (const malformed of [
+ null,
+ 7,
+ {},
+ { kind: 'create', connectionId: '00000000-0000-4000-8000-000000000001' },
+ { kind: 'existing', connectionId: '00000000-0000-4000-8000-000000000001', extra: true },
+ ]) {
assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url', malformed), {
ok: false,
reason: 'unknown',
@@ -395,9 +421,9 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a
isProviderEnabled: () => true,
});
- const firstAuthorization = invoke(handlers, 'openai-codex:get-auth-url');
+ const firstAuthorization = invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' });
await firstPresentationPoll;
- assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url'), {
+ assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }), {
ok: false,
reason: 'unknown',
message: 'Another OAuth login is already in progress',
@@ -413,6 +439,11 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a
assert.deepEqual(await firstAuthorization, {
authRequestId: firstAttemptId,
stateHint: 'FIRST',
+ connection: {
+ connectionId,
+ slug: 'openai-codex',
+ providerType: provider,
+ },
});
assert.deepEqual(
await invoke(handlers, 'openai-codex:logout', foreignConnection.connectionId),
@@ -425,7 +456,7 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a
assert.deepEqual(await invoke(handlers, 'openai-codex:logout'), {
ok: false,
reason: 'unknown',
- message: 'Select a specific OAuth account to log out',
+ message: 'Invalid OAuth Connection identity',
});
assert.equal(cancels, 0);
assert.deepEqual(
@@ -435,7 +466,14 @@ test('a second OAuth start cannot replace or cancel a pending active attempt', a
assert.equal(cancels, 0);
assert.deepEqual(
await invoke(handlers, 'openai-codex:complete-authorization', firstAttemptId),
- { ok: true },
+ {
+ ok: true,
+ connection: {
+ connectionId,
+ slug: 'openai-codex',
+ providerType: provider,
+ },
+ },
);
assert.equal(cancels, 0);
assertNoUnexpectedClientCalls();
@@ -475,7 +513,7 @@ test('completion rejects a terminal projection that changes Connection identity'
isProviderEnabled: () => true,
});
- await invoke(handlers, 'openai-codex:get-auth-url');
+ await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' });
assert.deepEqual(await invoke(handlers, 'openai-codex:complete-authorization', attemptId), {
ok: false,
reason: 'unknown',
@@ -486,7 +524,7 @@ test('completion rejects a terminal projection that changes Connection identity'
assertNoUnexpectedClientCalls();
});
-test('keeps a committed OAuth login successful when model discovery fails', async () => {
+test('keeps a committed OAuth login successful when model discovery fails without replacing the existing default', async () => {
const provider = 'openai-codex' as const;
const modelId = PROVIDER_DEFAULTS[provider].fallbackModels[0];
assert.ok(modelId);
@@ -507,7 +545,7 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn
};
let catalog: ConnectionCatalogSnapshot = {
revision: 1,
- defaultTarget: null,
+ defaultTarget: { connectionId: existing.connectionId, modelId },
connections: [existing],
};
const presentation = new RuntimeHostOAuthPresentation(async () => undefined);
@@ -550,11 +588,6 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn
fetchedConnectionIds.push(connectionId);
throw new Error('provider temporarily unavailable');
},
- setDefaultConnectionTarget: async (expectedCatalogRevision, target) => {
- assert.equal(expectedCatalogRevision, catalog.revision);
- catalog = { ...catalog, revision: catalog.revision + 1, defaultTarget: target };
- return { kind: 'committed' as const, catalogRevision: catalog.revision };
- },
queryCredential: async (locator) =>
locator.scope === 'connection' && locator.connectionId === created.connectionId
? {
@@ -575,18 +608,34 @@ test('keeps a committed OAuth login successful when model discovery fails', asyn
isProviderEnabled: () => true,
});
- assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url'), {
+ assert.deepEqual(await invoke(handlers, 'openai-codex:get-auth-url', { kind: 'create' }), {
authRequestId: attemptId,
stateHint: 'DEVICE-CODE',
+ connection: {
+ connectionId: created.connectionId,
+ slug: created.slug,
+ providerType: provider,
+ },
});
assert.deepEqual(
await invoke(handlers, 'openai-codex:complete-authorization', attemptId, undefined),
- { ok: true },
+ {
+ ok: true,
+ connection: {
+ connectionId: created.connectionId,
+ slug: created.slug,
+ providerType: provider,
+ },
+ },
);
assert.equal(changed, 1);
assert.deepEqual(fetchedConnectionIds, [created.connectionId]);
- assert.deepEqual(catalog.defaultTarget, { connectionId: created.connectionId, modelId });
- assert.deepEqual(await invoke(handlers, 'openai-codex:get-account-state'), {
+ assert.deepEqual(catalog.defaultTarget, { connectionId: existing.connectionId, modelId });
+ assert.deepEqual(await invoke(
+ handlers,
+ 'openai-codex:get-account-state',
+ created.connectionId,
+ ), {
provider,
runtimeState: 'authenticated',
});
@@ -655,7 +704,7 @@ function oauthProjection(
attemptId,
connection: {
connectionId,
- slug: 'codex-subscription',
+ slug: 'openai-codex',
providerType: 'openai-codex' as const,
},
phase,
diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts
index 78951d41c4..64ef266438 100644
--- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts
@@ -51,7 +51,10 @@ import {
normalizeConnectionSlugForIpc,
normalizeCreateConnectionInputForIpc,
} from './connections-ipc-validation.js';
-import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js';
+import type {
+ DesktopConnectionIdentity,
+ DesktopConnectionSnapshot,
+} from '../shared/desktop-connection-snapshot.js';
type HostConnectionsClient = Pick<
DesktopRuntimeHostClient,
@@ -89,9 +92,9 @@ export function registerRuntimeHostConnectionsIpc(
chatModelChoices: buildChatModelChoices(connections),
} satisfies DesktopConnectionSnapshot;
});
- handleReconnectableRead(deps.ipcMain, 'connections:hasSecret', async (_event, slug: unknown) => {
+ handleReconnectableRead(deps.ipcMain, 'connections:hasSecret', async (_event, identity: unknown) => {
const catalog = await snapshot();
- const connection = requireConnection(catalog, slug);
+ const connection = requireConnectionIdentity(catalog, identity);
if (!providerAuthRequiresSecret(connection.providerType)) return true;
return (
(await deps.client.queryCredential(connectionCredential(connection)))
@@ -101,8 +104,8 @@ export function registerRuntimeHostConnectionsIpc(
handleReconnectableRead(
deps.ipcMain,
'connections:getRequestHeaders',
- async (_event, slug: unknown) => {
- const connection = requireConnection(await snapshot(), slug);
+ async (_event, identity: unknown) => {
+ const connection = requireConnectionIdentity(await snapshot(), identity);
const result = await deps.client.getConnectionRequestHeaders(connection.connectionId);
if (result.kind !== 'found') throw new Error('Connection no longer exists');
return { names: result.names } satisfies SavedRequestHeaders;
@@ -110,8 +113,8 @@ export function registerRuntimeHostConnectionsIpc(
);
deps.ipcMain.handle(
'connections:setRequestHeaders',
- async (_event, slug: unknown, rawUpdates: unknown) => {
- const connection = requireConnection(await snapshot(), slug);
+ async (_event, identity: unknown, rawUpdates: unknown) => {
+ const connection = requireConnectionIdentity(await snapshot(), identity);
const result = await deps.client.replaceConnectionRequestHeaders(
connection.connectionId,
normalizeRequestHeaderUpdates(rawUpdates),
@@ -121,17 +124,28 @@ export function registerRuntimeHostConnectionsIpc(
return { names: result.names } satisfies SavedRequestHeaders;
},
);
- deps.ipcMain.handle('connections:setDefault', async (_event, slug: unknown) => {
+ deps.ipcMain.handle('connections:setDefault', async (_event, identity: unknown) => {
const catalog = await snapshot();
- const target = slug === null
+ const target = identity === null
? null
- : defaultTargetForConnection(requireConnection(catalog, slug));
+ : defaultTargetForConnection(requireConnectionIdentity(catalog, identity));
requireCommitted(
await deps.client.setDefaultConnectionTarget(catalog.revision, target),
'set default Connection',
);
deps.emitConnectionListChanged();
});
+ deps.ipcMain.handle('connections:setDefaultBySlug', async (_event, slug: unknown) => {
+ const catalog = await snapshot();
+ requireCommitted(
+ await deps.client.setDefaultConnectionTarget(
+ catalog.revision,
+ defaultTargetForConnection(requireConnection(catalog, slug)),
+ ),
+ 'set default Connection',
+ );
+ deps.emitConnectionListChanged();
+ });
deps.ipcMain.handle('connections:setDefaultModel', async (_event, input: unknown) => {
const catalog = await snapshot();
const target = input === null ? null : explicitDefaultTarget(catalog, input);
@@ -193,9 +207,9 @@ export function registerRuntimeHostConnectionsIpc(
deps.emitConnectionListChanged();
return requireProjectedConnection(await snapshot(), input.slug);
});
- deps.ipcMain.handle('connections:update', async (_event, rawSlug: unknown, rawPatch: unknown) => {
+ deps.ipcMain.handle('connections:update', async (_event, rawIdentity: unknown, rawPatch: unknown) => {
const catalog = await snapshot();
- const current = requireConnection(catalog, rawSlug);
+ const current = requireConnectionIdentity(catalog, rawIdentity);
const patch = normalizeUpdateInput(current, rawPatch);
const updated = await deps.client.updateConnection(
{ connectionId: current.connectionId, revision: current.revision },
@@ -227,7 +241,7 @@ export function registerRuntimeHostConnectionsIpc(
if (patch.apiKey !== undefined) await updateCredential(deps.client, current, patch.apiKey);
if (patch.defaultModel !== undefined) {
const latest = await snapshot();
- const entry = requireConnection(latest, current.slug);
+ const entry = requireConnectionIdentity(latest, connectionIdentity(current));
const target = patch.defaultModel
? { connectionId: entry.connectionId, modelId: patch.defaultModel }
: latest.defaultTarget?.connectionId === entry.connectionId
@@ -239,9 +253,10 @@ export function registerRuntimeHostConnectionsIpc(
);
}
deps.emitConnectionListChanged();
- return requireProjectedConnection(await snapshot(), current.slug);
+ return requireProjectedConnectionIdentity(await snapshot(), connectionIdentity(current));
});
- deps.ipcMain.handle('connections:delete', async (_event, slug: unknown) => {
+ deps.ipcMain.handle('connections:delete', async (_event, rawIdentity: unknown) => {
+ const identity = normalizeConnectionIdentity(rawIdentity);
// OAuth/model-fetch can bump the connection revision under the UI. Retry
// on connection_stale with a fresh snapshot so delete does not fail with a
// opaque "service unavailable" after the user already confirmed.
@@ -250,10 +265,11 @@ export function registerRuntimeHostConnectionsIpc(
const catalog = await snapshot();
let current: ReturnType<typeof requireConnection>;
try {
- current = requireConnection(catalog, slug);
+ current = requireConnectionIdentity(catalog, identity);
} catch (error) {
- // Only treat a missing slug as success. Invalid input must still fail.
- if (error instanceof Error && error.message.startsWith('No such Connection:')) {
+ // The exact entity is already gone. A new entity may reuse its slug;
+ // deleting that replacement would violate the detail route's binding.
+ if (error instanceof Error && error.message.startsWith('No such Connection identity:')) {
deps.emitConnectionListChanged();
return;
}
@@ -275,14 +291,14 @@ export function registerRuntimeHostConnectionsIpc(
throw new Error('Unable to delete Connection: connection_stale');
}
});
- deps.ipcMain.handle('connections:fetchModels', async (_event, slug: unknown) => {
- const current = requireConnection(await snapshot(), slug);
+ deps.ipcMain.handle('connections:fetchModels', async (_event, identity: unknown) => {
+ const current = requireConnectionIdentity(await snapshot(), identity);
const result = await deps.client.fetchConnectionModels(current.connectionId);
if (result.kind !== 'committed') {
throw new Error(`Unable to fetch Connection models: ${result.kind}`);
}
deps.emitConnectionListChanged();
- const latest = requireConnection(await snapshot(), current.slug);
+ const latest = requireConnectionIdentity(await snapshot(), connectionIdentity(current));
return {
models: [...latest.models],
source: result.source,
@@ -291,6 +307,19 @@ export function registerRuntimeHostConnectionsIpc(
});
deps.ipcMain.handle(
'connections:test',
+ async (_event, identity: unknown, options?: { model?: unknown }) => {
+ const current = requireConnectionIdentity(await snapshot(), identity);
+ const model = options?.model;
+ if (model !== undefined && (typeof model !== 'string' || model.length === 0)) {
+ throw new Error('Invalid Connection test model');
+ }
+ const result = await deps.client.testConnection(current.connectionId, model);
+ deps.emitConnectionListChanged();
+ return projectHostConnectionTest(result);
+ },
+ );
+ deps.ipcMain.handle(
+ 'connections:testBySlug',
async (_event, slug: unknown, options?: { model?: unknown }) => {
const current = requireConnection(await snapshot(), slug);
const model = options?.model;
@@ -426,6 +455,43 @@ function requireConnection(
return connection;
}
+function normalizeConnectionIdentity(value: unknown): DesktopConnectionIdentity {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
+ throw new Error('Invalid Connection identity');
+ }
+ const record = value as Record<string, unknown>;
+ const keys = Object.keys(record).sort();
+ if (keys.length !== 2 || keys[0] !== 'connectionId' || keys[1] !== 'slug') {
+ throw new Error('Invalid Connection identity');
+ }
+ if (typeof record.connectionId !== 'string' || record.connectionId.length === 0) {
+ throw new Error('Connection identity id is required');
+ }
+ return {
+ connectionId: record.connectionId,
+ slug: normalizeConnectionSlugForIpc(record.slug, 'connection identity slug'),
+ };
+}
+
+function requireConnectionIdentity(
+ catalog: ConnectionCatalogSnapshot,
+ value: unknown,
+): ConnectionCatalogEntry {
+ const identity = normalizeConnectionIdentity(value);
+ const connection = catalog.connections.find(
+ (candidate) => candidate.connectionId === identity.connectionId,
+ );
+ if (!connection) throw new Error(`No such Connection identity: ${identity.connectionId}`);
+ if (connection.slug !== identity.slug) {
+ throw new Error('Connection identity no longer matches its slug');
+ }
+ return connection;
+}
+
+function connectionIdentity(connection: ConnectionCatalogEntry): DesktopConnectionIdentity {
+ return { connectionId: connection.connectionId, slug: connection.slug };
+}
+
function requireProjectedConnection(
catalog: ConnectionCatalogSnapshot,
slug: string,
@@ -435,6 +501,18 @@ function requireProjectedConnection(
return connection;
}
+function requireProjectedConnectionIdentity(
+ catalog: ConnectionCatalogSnapshot,
+ identity: DesktopConnectionIdentity,
+): IdentifiedLlmConnection {
+ const connection = requireConnectionIdentity(catalog, identity);
+ const projected = projectHostConnections(catalog).find(
+ (candidate) => candidate.connectionId === connection.connectionId,
+ );
+ if (!projected) throw new Error(`No such Connection identity: ${identity.connectionId}`);
+ return projected;
+}
+
function defaultTargetForConnection(connection: ConnectionCatalogEntry) {
const modelId = connection.enabledModelIds[0];
if (!modelId) throw new Error(`Connection has no enabled model: ${connection.slug}`);
diff --git a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts
index e151449c56..6fd139b3b3 100644
--- a/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-oauth-ipc-main.ts
@@ -94,9 +94,9 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void
if (provider !== 'xai-oauth') {
deps.ipcMain.handle(channel('is-experimental-enabled'), () => providerEnabled(provider));
}
- deps.ipcMain.handle(channel('get-auth-url'), async (_event, rawConnectionId: unknown) => {
+ deps.ipcMain.handle(channel('get-auth-url'), async (_event, rawTarget: unknown) => {
if (!providerEnabled(provider)) return providerDisabled();
- const selection = decodeOAuthConnectionSelection(rawConnectionId);
+ const selection = decodeOAuthLoginSelection(rawTarget);
if (selection.kind === 'invalid') return invalidConnectionIdentity();
const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined;
if (connectionId) {
@@ -129,7 +129,11 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void
provider,
connection: started.connection,
});
- return { authRequestId: attemptId, stateHint: presented.stateHint };
+ return {
+ authRequestId: attemptId,
+ stateHint: presented.stateHint,
+ connection: started.connection,
+ };
} catch (error) {
expectation?.cancel(error);
if (startedOnHost) {
@@ -176,7 +180,7 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void
terminal.connection.connectionId,
).catch(() => undefined);
deps.emitConnectionListChanged();
- return { ok: true as const };
+ return { ok: true as const, connection: terminal.connection };
} catch {
activeAttempts.delete(attemptId);
return actionFailure('Unable to complete OAuth authorization');
@@ -192,45 +196,44 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void
return { ok: true as const };
});
handleReconnectableRead(deps.ipcMain, channel('get-account-state'), async (_event, rawConnectionId: unknown) => {
- const selection = decodeOAuthConnectionSelection(rawConnectionId);
- if (selection.kind === 'invalid') return invalidConnectionIdentity();
- const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined;
+ const connectionId = decodeExactOAuthConnectionId(rawConnectionId);
+ if (!connectionId) return invalidConnectionIdentity();
const candidates = oauthAccountCandidates(
await deps.client.loadConnectionCatalog(),
provider,
connectionId,
);
+ if (candidates.length === 0) {
+ return actionFailure('OAuth account does not match this provider');
+ }
const authorizing = [...activeAttempts.values()].some(
(attempt) =>
attempt.provider === provider &&
- (connectionId === undefined || attempt.connection.connectionId === connectionId),
+ attempt.connection.connectionId === connectionId,
);
- if (candidates.length === 0) {
- return accountState(provider, authorizing ? 'authorizing' : 'not_logged_in');
- }
if ((await configuredOAuthAccountConnections(deps.client, candidates)).length > 0) {
return accountState(provider, 'authenticated');
}
return accountState(provider, authorizing ? 'authorizing' : 'not_logged_in');
});
deps.ipcMain.handle(channel('refresh-tokens'), async (_event, rawConnectionId: unknown) => {
- const selection = decodeOAuthConnectionSelection(rawConnectionId);
- if (selection.kind === 'invalid') return invalidConnectionIdentity('refresh_failed');
- const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined;
+ const connectionId = decodeExactOAuthConnectionId(rawConnectionId);
+ if (!connectionId) return invalidConnectionIdentity('refresh_failed');
+ const candidates = oauthAccountCandidates(
+ await deps.client.loadConnectionCatalog(),
+ provider,
+ connectionId,
+ );
+ if (candidates.length === 0) {
+ return actionFailure('OAuth account does not match this provider', 'refresh_failed');
+ }
const connections = await configuredOAuthAccountConnections(
deps.client,
- oauthAccountCandidates(
- await deps.client.loadConnectionCatalog(),
- provider,
- connectionId,
- ),
+ candidates,
);
if (connections.length === 0) {
return actionFailure('OAuth account is not connected', 'refresh_failed');
}
- if (connectionId === undefined && connections.length > 1) {
- return actionFailure('Select a specific OAuth account to refresh', 'refresh_failed');
- }
const connection = connections[0]!;
const refreshed = await deps.client.fetchConnectionModels(connection.connectionId);
return refreshed.kind === 'committed'
@@ -238,34 +241,25 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void
: actionFailure('Unable to refresh OAuth account', 'refresh_failed');
});
deps.ipcMain.handle(channel('logout'), async (_event, rawConnectionId: unknown) => {
- const selection = decodeOAuthConnectionSelection(rawConnectionId);
- if (selection.kind === 'invalid') return invalidConnectionIdentity();
- const connectionId = selection.kind === 'exact' ? selection.connectionId : undefined;
+ const connectionId = decodeExactOAuthConnectionId(rawConnectionId);
+ if (!connectionId) return invalidConnectionIdentity();
try {
const candidates = oauthAccountCandidates(
await deps.client.loadConnectionCatalog(),
provider,
connectionId,
);
- if (connectionId !== undefined && candidates.length === 0) {
+ if (candidates.length === 0) {
return actionFailure('OAuth account does not match this provider');
}
- const connections =
- connectionId !== undefined
- ? candidates
- : await configuredOAuthAccountConnections(deps.client, candidates);
- if (connectionId === undefined && connections.length > 1) {
- return actionFailure('Select a specific OAuth account to log out');
- }
- const connection = connections[0];
+ const connection = candidates[0]!;
await cancelProviderAttempts(
deps,
activeAttempts,
provider,
- connection?.connectionId,
+ connection.connectionId,
);
- if (connection)
- await disableRuntimeHostAccountConnectionById(deps.client, connection.connectionId);
+ await disableRuntimeHostAccountConnectionById(deps.client, connection.connectionId);
} catch {
return actionFailure('Unable to remove OAuth account');
}
@@ -370,30 +364,46 @@ function accountState(
function oauthAccountCandidates(
catalog: Awaited<ReturnType<OAuthClient['loadConnectionCatalog']>>,
provider: OAuthLoginProvider,
- connectionId: string | undefined,
+ connectionId: string,
): ConnectionCatalogEntry[] {
- if (connectionId !== undefined) {
- const connection = findRuntimeHostAccountConnectionById(catalog, connectionId);
- return connection?.providerType === provider ? [connection] : [];
- }
- return catalog.connections.filter((connection) => connection.providerType === provider);
+ const connection = findRuntimeHostAccountConnectionById(catalog, connectionId);
+ return connection?.providerType === provider ? [connection] : [];
}
-type OAuthConnectionSelection =
- | { readonly kind: 'aggregate' }
+type OAuthLoginSelection =
+ | { readonly kind: 'create' }
| { readonly kind: 'exact'; readonly connectionId: string }
| { readonly kind: 'invalid' };
-function decodeOAuthConnectionSelection(value: unknown): OAuthConnectionSelection {
- if (value === undefined) return { kind: 'aggregate' };
- if (typeof value !== 'string') return { kind: 'invalid' };
+function decodeOAuthLoginSelection(value: unknown): OAuthLoginSelection {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return { kind: 'invalid' };
+ const candidate = value as { readonly kind?: unknown; readonly connectionId?: unknown };
+ const keys = Object.keys(value).sort();
+ if (candidate.kind === 'create') {
+ return keys.length === 1 && keys[0] === 'kind' ? { kind: 'create' } : { kind: 'invalid' };
+ }
+ if (candidate.kind !== 'existing' || typeof candidate.connectionId !== 'string') {
+ return { kind: 'invalid' };
+ }
+ if (keys.length !== 2 || keys[0] !== 'connectionId' || keys[1] !== 'kind') {
+ return { kind: 'invalid' };
+ }
try {
- return { kind: 'exact', connectionId: decodeRuntimePolicyEntityId(value) };
+ return { kind: 'exact', connectionId: decodeRuntimePolicyEntityId(candidate.connectionId) };
} catch {
return { kind: 'invalid' };
}
}
+function decodeExactOAuthConnectionId(value: unknown): string | undefined {
+ if (typeof value !== 'string') return undefined;
+ try {
+ return decodeRuntimePolicyEntityId(value);
+ } catch {
+ return undefined;
+ }
+}
+
function sameOAuthConnectionIdentity(
left: OAuthConnectionIdentity,
right: OAuthConnectionIdentity,
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 99b2c77a63..1525a4c34e 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -352,6 +352,26 @@ export interface DesktopRuntimeHostRef {
readonly hostId: string;
}
+/** Desktop-local OAuth intent. Runtime Host remains the identity allocator. */
+export type DesktopOAuthLoginTarget =
+ | { readonly kind: 'create' }
+ | { readonly kind: 'existing'; readonly connectionId: string };
+
+/** Secret-free canonical identity returned by the Runtime Host OAuth flow. */
+export interface DesktopOAuthConnectionIdentity {
+ readonly connectionId: string;
+ readonly slug: string;
+ readonly providerType: 'openai-codex' | 'xai-oauth';
+}
+
+export type DesktopOAuthAuthorizationStartResult =
+ | (AuthorizationUrlPayload & { readonly connection: DesktopOAuthConnectionIdentity })
+ | Exclude<SubscriptionActionResult, { readonly ok: true }>;
+
+export type DesktopOAuthAuthorizationResult =
+ | { readonly ok: true; readonly connection: DesktopOAuthConnectionIdentity }
+ | Exclude<SubscriptionActionResult, { readonly ok: true }>;
+
export type DesktopNewTaskHostRef = DesktopRuntimeHostRef;
export interface DesktopNewTaskTarget extends DesktopRuntimeHostRef {
@@ -1315,17 +1335,17 @@ export interface MakaBridge {
};
connections: {
getSnapshot(sessionId?: string, host?: DesktopRuntimeHostRef): Promise<DesktopConnectionSnapshot>;
- setDefault(slug: string | null, host?: DesktopRuntimeHostRef): Promise<void>;
+ setDefault(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef): Promise<void>;
setDefaultModel(input: { slug: string; model: string } | null, host?: DesktopRuntimeHostRef): Promise<void>;
- create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise<LlmConnection>;
- update(slug: string, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise<LlmConnection>;
- delete(slug: string, host?: DesktopRuntimeHostRef): Promise<void>;
- test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise<ConnectionTestResult>;
- fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise<ModelDiscoveryResult>;
- hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise<boolean>;
- getRequestHeaders(slug: string, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').SavedRequestHeaders>;
+ create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').IdentifiedLlmConnection>;
+ update(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise<LlmConnection>;
+ delete(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<void>;
+ test(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise<ConnectionTestResult>;
+ fetchModels(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<ModelDiscoveryResult>;
+ hasSecret(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<boolean>;
+ getRequestHeaders(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').SavedRequestHeaders>;
setRequestHeaders(
- slug: string,
+ connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity,
headers: readonly import('@maka/core/llm-connections').RequestHeaderUpdate[],
host?: DesktopRuntimeHostRef,
): Promise<import('@maka/core/llm-connections').SavedRequestHeaders>;
@@ -1457,45 +1477,51 @@ export interface MakaBridge {
};
openAiCodex: {
isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise<boolean>;
- getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<AuthorizationUrlPayload | SubscriptionActionResult>;
+ getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget): Promise<DesktopOAuthAuthorizationStartResult>;
openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult>;
- completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult>;
+ completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<DesktopOAuthAuthorizationResult>;
cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }>;
- getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{
- provider: 'openai-codex';
- runtimeState:
- | 'not_logged_in'
- | 'authorizing'
- | 'authenticated'
- | 'refreshing'
- | 'refresh_failed';
- accountId?: string;
- email?: string;
- plan?: string;
- picture?: string;
- errorMessage?: string;
- }>;
- refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<SubscriptionActionResult>;
- logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<SubscriptionActionResult>;
+ getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<
+ | {
+ provider: 'openai-codex';
+ runtimeState:
+ | 'not_logged_in'
+ | 'authorizing'
+ | 'authenticated'
+ | 'refreshing'
+ | 'refresh_failed';
+ accountId?: string;
+ email?: string;
+ plan?: string;
+ picture?: string;
+ errorMessage?: string;
+ }
+ | Exclude<SubscriptionActionResult, { readonly ok: true }>
+ >;
+ refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult>;
+ logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult>;
};
xaiOAuth: {
- getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<AuthorizationUrlPayload | SubscriptionActionResult>;
+ getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget): Promise<DesktopOAuthAuthorizationStartResult>;
openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult>;
- completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult>;
+ completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<DesktopOAuthAuthorizationResult>;
cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }>;
- getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{
- provider: 'xai-oauth';
- runtimeState:
- | 'not_logged_in'
- | 'authorizing'
- | 'authenticated'
- | 'refreshing'
- | 'refresh_failed'
- | 'storage_failed';
- errorMessage?: string;
- }>;
- refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<SubscriptionActionResult>;
- logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<SubscriptionActionResult>;
+ getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<
+ | {
+ provider: 'xai-oauth';
+ runtimeState:
+ | 'not_logged_in'
+ | 'authorizing'
+ | 'authenticated'
+ | 'refreshing'
+ | 'refresh_failed'
+ | 'storage_failed';
+ errorMessage?: string;
+ }
+ | Exclude<SubscriptionActionResult, { readonly ok: true }>
+ >;
+ refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult>;
+ logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult>;
};
githubCopilotSubscription: {
connectExistingLogin(host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult>;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 48503d5716..5a64dd317a 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -62,6 +62,8 @@ import type {
DesktopNewTaskHostRef,
DesktopNewTaskTarget,
DesktopRuntimeHostRef,
+ DesktopOAuthLoginTarget,
+ DesktopOAuthAuthorizationResult,
DesktopProjectSnapshot,
DesktopAppInfo,
DesktopSessionTracePage,
@@ -159,10 +161,7 @@ import type {
} from '@maka/core/artifacts';
import type { CapabilitySnapshotCollection, PermissionSnapshot } from '@maka/core/capabilities';
import type { LocalMemoryState } from '@maka/core/local-memory';
-import type {
- AuthorizationUrlPayload,
- SubscriptionActionResult,
-} from '@maka/core/oauth-subscription';
+import type { SubscriptionActionResult } from '@maka/core/oauth-subscription';
import type { CreateScheduledTaskInput, ScheduledTask, UpdateScheduledTaskInput } from '@maka/core/scheduled-task';
import type { ProjectRecord } from '@maka/core/project';
import type {
@@ -2584,39 +2583,48 @@ const makaBridge = {
? invokeRuntimeHostForSession('connections:getSnapshot', sessionId)
: invokeSelectedRuntimeHost(host, 'connections:getSnapshot');
},
- setDefault(slug: string | null, host?: DesktopRuntimeHostRef): Promise<void> {
- return invokeSelectedRuntimeHost(host, 'connections:setDefault', slug);
+ setDefault(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef): Promise<void> {
+ return invokeSelectedRuntimeHost(
+ host,
+ typeof connection === 'string' ? 'connections:setDefaultBySlug' : 'connections:setDefault',
+ connection,
+ );
},
setDefaultModel(input: { slug: string; model: string } | null, host?: DesktopRuntimeHostRef): Promise<void> {
return invokeSelectedRuntimeHost(host, 'connections:setDefaultModel', input);
},
- create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise<LlmConnection> {
+ create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').IdentifiedLlmConnection> {
return invokeSelectedRuntimeHost(host, 'connections:create', input);
},
- update(slug: string, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise<LlmConnection> {
- return invokeSelectedRuntimeHost(host, 'connections:update', slug, patch);
+ update(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise<LlmConnection> {
+ return invokeSelectedRuntimeHost(host, 'connections:update', connection, patch);
},
- delete(slug: string, host?: DesktopRuntimeHostRef): Promise<void> {
- return invokeSelectedRuntimeHost(host, 'connections:delete', slug);
+ delete(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<void> {
+ return invokeSelectedRuntimeHost(host, 'connections:delete', connection);
},
- test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise<ConnectionTestResult> {
- return invokeSelectedRuntimeHost(host, 'connections:test', slug, opts);
+ test(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise<ConnectionTestResult> {
+ return invokeSelectedRuntimeHost(
+ host,
+ typeof connection === 'string' ? 'connections:testBySlug' : 'connections:test',
+ connection,
+ opts,
+ );
},
- fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise<ModelDiscoveryResult> {
- return invokeSelectedRuntimeHost(host, 'connections:fetchModels', slug);
+ fetchModels(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<ModelDiscoveryResult> {
+ return invokeSelectedRuntimeHost(host, 'connections:fetchModels', connection);
},
- hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise<boolean> {
- return invokeSelectedRuntimeHost(host, 'connections:hasSecret', slug);
+ hasSecret(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<boolean> {
+ return invokeSelectedRuntimeHost(host, 'connections:hasSecret', connection);
},
- getRequestHeaders(slug: string, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').SavedRequestHeaders> {
- return invokeSelectedRuntimeHost(host, 'connections:getRequestHeaders', slug);
+ getRequestHeaders(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').SavedRequestHeaders> {
+ return invokeSelectedRuntimeHost(host, 'connections:getRequestHeaders', connection);
},
setRequestHeaders(
- slug: string,
+ connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity,
headers: readonly import('@maka/core/llm-connections').RequestHeaderUpdate[],
host?: DesktopRuntimeHostRef,
): Promise<import('@maka/core/llm-connections').SavedRequestHeaders> {
- return invokeSelectedRuntimeHost(host, 'connections:setRequestHeaders', slug, headers);
+ return invokeSelectedRuntimeHost(host, 'connections:setRequestHeaders', connection, headers);
},
subscribeEvents(handler: (event: ConnectionEvent) => void, host?: DesktopRuntimeHostRef): () => void {
return host
@@ -2822,19 +2830,19 @@ const makaBridge = {
isExperimentalEnabled(host?: DesktopRuntimeHostRef): Promise<boolean> {
return invokeSelectedRuntimeHost(host, 'openai-codex:is-experimental-enabled');
},
- getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<AuthorizationUrlPayload | SubscriptionActionResult> {
- return invokeSelectedRuntimeHost(host, 'openai-codex:get-auth-url', connectionId);
+ getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget) {
+ return invokeSelectedRuntimeHost(host, 'openai-codex:get-auth-url', target);
},
openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'openai-codex:open-auth-url', authRequestId);
},
- completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult> {
+ completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<DesktopOAuthAuthorizationResult> {
return invokeSelectedRuntimeHost(host, 'openai-codex:complete-authorization', authRequestId);
},
cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }> {
return invokeSelectedRuntimeHost(host, 'openai-codex:cancel-authorization', authRequestId);
},
- getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{
+ getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<{
provider: 'openai-codex';
runtimeState: 'not_logged_in' | 'authorizing' | 'authenticated' | 'refreshing' | 'refresh_failed';
accountId?: string;
@@ -2845,27 +2853,27 @@ const makaBridge = {
}> {
return invokeSelectedRuntimeHost(host, 'openai-codex:get-account-state', connectionId);
},
- refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<SubscriptionActionResult> {
+ refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'openai-codex:refresh-tokens', connectionId);
},
- logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<SubscriptionActionResult> {
+ logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'openai-codex:logout', connectionId);
},
},
xaiOAuth: {
- getAuthUrl(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<AuthorizationUrlPayload | SubscriptionActionResult> {
- return invokeSelectedRuntimeHost(host, 'xai-oauth:get-auth-url', connectionId);
+ getAuthUrl(host: DesktopRuntimeHostRef | undefined, target: DesktopOAuthLoginTarget) {
+ return invokeSelectedRuntimeHost(host, 'xai-oauth:get-auth-url', target);
},
openAuthUrl(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'xai-oauth:open-auth-url', authRequestId);
},
- completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<SubscriptionActionResult> {
+ completeAuthorization(authRequestId: string, host?: DesktopRuntimeHostRef): Promise<DesktopOAuthAuthorizationResult> {
return invokeSelectedRuntimeHost(host, 'xai-oauth:complete-authorization', authRequestId);
},
cancelAuthorization(authRequestId?: string, host?: DesktopRuntimeHostRef): Promise<{ ok: true }> {
return invokeSelectedRuntimeHost(host, 'xai-oauth:cancel-authorization', authRequestId);
},
- getAccountState(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<{
+ getAccountState(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<{
provider: 'xai-oauth';
runtimeState:
| 'not_logged_in'
@@ -2878,10 +2886,10 @@ const makaBridge = {
}> {
return invokeSelectedRuntimeHost(host, 'xai-oauth:get-account-state', connectionId);
},
- refreshTokens(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<SubscriptionActionResult> {
+ refreshTokens(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'xai-oauth:refresh-tokens', connectionId);
},
- logout(host?: DesktopRuntimeHostRef, connectionId?: string): Promise<SubscriptionActionResult> {
+ logout(host: DesktopRuntimeHostRef | undefined, connectionId: string): Promise<SubscriptionActionResult> {
return invokeSelectedRuntimeHost(host, 'xai-oauth:logout', connectionId);
},
},
diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts
index 6a2633bd39..c2ad504ded 100644
--- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts
@@ -176,7 +176,9 @@ const zhCopy = {
panel: {
tabs: { all: '全部', recommended: '推荐', accounts: '账号', plans: '模型计划', api: 'API', aggregators: '聚合服务', local: '本地' },
loadFailed: '载入模型连接失败', loadingAria: '正在加载模型供应商', connections: '模型连接',
- retry: '点击重试。', empty: '还没有模型连接',
+ retry: '点击重试。', empty: '还没有模型连接', connectionRemoved: '原连接已被删除或移除,已返回模型连接列表。',
+ connectedLoading: '账号已连接,正在载入新的模型连接…', connectedLoadFailed: '账号已连接,但暂时无法载入新的模型连接。',
+ connectionIdentityChanged: '新连接的身份与登录结果不一致,请返回连接列表后重试。',
emptyHelp: '从下方选择一种连接方式开始。', default: '默认', setDefault: '设为默认', setDefaultTitle: '让新任务默认使用这个连接', setDefaultPending: '设置中…', setDefaultFailed: '设为默认失败', addHelp: '选择账号登录、模型计划、API、聚合服务或本地运行时。',
categoriesAria: '模型供应商分类', searchPlaceholder: '搜索服务商', searchAria: '搜索模型服务商', noMatch: '没有匹配的服务商', clearSearch: '清除搜索',
createSubtitle: '完成必要配置后,连接会出现在模型页上方。', connection: '模型连接',
@@ -214,18 +216,19 @@ const zhCopy = {
logoutTitle: (name: string) => `退出 ${name} 登录?`,
},
oauthSection: {
- signedIn: '已登录', codexDescription: 'ChatGPT Plus / Pro 订阅账号登录。', xaiDescription: 'SuperGrok / X Premium 账号登录。',
+ signedIn: '已登录', codexDescription: '使用 ChatGPT Plus / Pro 账号添加连接。', xaiDescription: '使用 SuperGrok / X Premium 账号添加连接。',
+ configuredConnections: (count: number) => `已有 ${count} 个连接 · 添加另一个账号`,
copilotDescription: '导入兼容 GitHub 凭据连接 Copilot 订阅。', serviceUnavailable: '登录服务暂时不可用,请检查网络后重试。',
aria: 'OAuth 登录',
staleState: 'OAuth 登录状态暂时没刷新成功,已保留上一次状态。',
codexDetail: '点击下方按钮打开设备授权页,并在页面中输入这里显示的登录码。', xaiDetail: '点击下方按钮打开浏览器登录,授权完成后会自动回写。', deviceCode: '登录码:',
- openingBrowser: '打开浏览器…', logout: '退出登录', loggingOut: '退出中…',
+ openingBrowser: '打开浏览器…', waitingAuthorization: '等待浏览器授权…', logout: '退出登录', loggingOut: '退出中…',
copilotSubtitle: '导入兼容的 GitHub 登录;token 不会暴露给渲染进程。', copilotImported: '已导入 GitHub Copilot 订阅账号。',
copilotSetup: '请配置具有 Copilot Requests 权限的 fine-grained PAT;普通 gh auth login 可能不包含该权限。', importing: '导入中…',
reimport: '重新导入', importCredential: '导入兼容凭据', verifying: '验证中…', reverify: '重新验证', removing: '移除中…', removeLocal: '移除本地登录',
loadingAccount: '正在加载账号状态…', authorizing: '请在弹出的浏览器窗口完成登录。', refreshing: '正在刷新访问令牌…', refreshTokenFailed: '令牌刷新失败,请重新登录。',
- cardAria: (name: string, status: string | undefined, description: string) => `打开 OAuth 登录:${name}${status ? `,状态:${status}` : ''},${description.replace(/[。.!!??]+$/u, '')}`,
- connectTitle: (name: string) => `连接 ${name}`, login: (name: string) => `登录 ${name}`, signedOut: (name: string) => `${name} 尚未登录。`,
+ cardAria: (intent: 'add' | 'import' | 'manage', name: string, status: string | undefined, description: string) => `${intent === 'add' ? '添加' : intent === 'import' ? '导入' : '管理'}账号连接:${name}${status ? `,状态:${status}` : ''},${description.replace(/[。.!!??]+$/u, '')}`,
+ connectTitle: (name: string) => `连接 ${name}`, addAccountTitle: (name: string) => `添加 ${name} 账号`, login: (name: string) => `登录 ${name}`, loginAndAdd: '登录并添加', signedOut: (name: string) => `${name} 尚未登录。`,
storageFailed: (name: string) => `${name} 本地凭据读取失败,请重新登录。`, providerUnavailable: (name: string) => `${name} 已登录,但当前 provider 状态不可用。`,
},
} as const;
@@ -329,7 +332,9 @@ const enCopy: ProviderSettingsCopy = {
panel: {
tabs: { all: 'All', recommended: 'Recommended', accounts: 'Accounts', plans: 'Model plans', api: 'API', aggregators: 'Aggregators', local: 'Local' },
loadFailed: 'Failed to load model connections', loadingAria: 'Loading model providers', connections: 'Connections',
- retry: 'Select to retry.', empty: 'No model connections yet',
+ retry: 'Select to retry.', empty: 'No model connections yet', connectionRemoved: 'The original connection was deleted or removed. Returned to the connection list.',
+ connectedLoading: 'Account connected. Loading the new model connection…', connectedLoadFailed: 'Account connected, but the new model connection could not be loaded yet.',
+ connectionIdentityChanged: 'The new connection identity did not match the sign-in result. Return to the connection list and try again.',
emptyHelp: 'Choose a connection method below to begin.', default: 'Default', setDefault: 'Set as default', setDefaultTitle: 'New chats will use this connection', setDefaultPending: 'Setting…', setDefaultFailed: 'Could not set as default', addHelp: 'Choose account sign-in, a model plan, API, aggregator, or local runtime.',
categoriesAria: 'Model provider categories', searchPlaceholder: 'Search providers', searchAria: 'Search model providers', noMatch: 'No matching providers', clearSearch: 'Clear search',
createSubtitle: 'After required setup, the connection appears above on the Models page.', connection: 'Model connection',
@@ -367,18 +372,19 @@ const enCopy: ProviderSettingsCopy = {
logoutTitle: (name: string) => `Sign out of ${name}?`,
},
oauthSection: {
- signedIn: 'Signed in', codexDescription: 'Sign in with a ChatGPT Plus / Pro subscription.', xaiDescription: 'Sign in with SuperGrok or X Premium.',
+ signedIn: 'Signed in', codexDescription: 'Use a ChatGPT Plus / Pro account to add a connection.', xaiDescription: 'Use a SuperGrok or X Premium account to add a connection.',
+ configuredConnections: (count: number) => `${count} ${count === 1 ? 'connection' : 'connections'} configured · Add another account`,
copilotDescription: 'Import compatible GitHub credentials to connect a Copilot subscription.', serviceUnavailable: 'The sign-in service is temporarily unavailable. Check the network and try again.',
aria: 'OAuth sign-in',
staleState: 'OAuth sign-in status could not be refreshed. The last known state is preserved. ',
codexDetail: 'Open the device page below and enter the sign-in code shown here.', xaiDetail: 'Open the browser below to sign in. Authorization is written back automatically.', deviceCode: 'Sign-in code:',
- openingBrowser: 'Opening browser…', logout: 'Sign out', loggingOut: 'Signing out…',
+ openingBrowser: 'Opening browser…', waitingAuthorization: 'Waiting for browser authorization…', logout: 'Sign out', loggingOut: 'Signing out…',
copilotSubtitle: 'Import a compatible GitHub sign-in. The token is never exposed to the renderer.', copilotImported: 'GitHub Copilot subscription account imported.',
copilotSetup: 'Configure a fine-grained PAT with Copilot Requests permission. A normal gh auth login may not include it.', importing: 'Importing…',
reimport: 'Reimport', importCredential: 'Import compatible credentials', verifying: 'Verifying…', reverify: 'Verify again', removing: 'Removing…', removeLocal: 'Remove local sign-in',
loadingAccount: 'Loading account status…', authorizing: 'Complete sign-in in the browser window.', refreshing: 'Refreshing access token…', refreshTokenFailed: 'Token refresh failed. Sign in again.',
- cardAria: (name: string, status: string | undefined, description: string) => `Open OAuth sign-in: ${name}${status ? `; status: ${status}` : ''}; ${description.replace(/[。.!!??]+$/u, '')}`,
- connectTitle: (name: string) => `Connect ${name}`, login: (name: string) => `Sign in to ${name}`, signedOut: (name: string) => `${name} is signed out.`,
+ cardAria: (intent: 'add' | 'import' | 'manage', name: string, status: string | undefined, description: string) => `${intent === 'add' ? 'Add' : intent === 'import' ? 'Import' : 'Manage'} account connection: ${name}${status ? `; status: ${status}` : ''}; ${description.replace(/[。.!!??]+$/u, '')}`,
+ connectTitle: (name: string) => `Connect ${name}`, addAccountTitle: (name: string) => `Add ${name} account`, login: (name: string) => `Sign in to ${name}`, loginAndAdd: 'Sign in and add', signedOut: (name: string) => `${name} is signed out.`,
storageFailed: (name: string) => `Could not read local credentials for ${name}. Sign in again.`, providerUnavailable: (name: string) => `${name} is signed in, but the provider status is currently unavailable.`,
},
};
diff --git a/apps/desktop/src/renderer/settings/provider-add-submission.ts b/apps/desktop/src/renderer/settings/provider-add-submission.ts
index 0b14d65eab..8b3b8ec734 100644
--- a/apps/desktop/src/renderer/settings/provider-add-submission.ts
+++ b/apps/desktop/src/renderer/settings/provider-add-submission.ts
@@ -25,7 +25,7 @@ import {
validateSlug,
type ProviderType,
} from '@maka/core/llm-connections';
-import type { CreateConnectionInput, LlmConnection } from '@maka/core/llm-connections';
+import type { CreateConnectionInput, IdentifiedLlmConnection } from '@maka/core/llm-connections';
/**
* The two decisions 添加连接 makes that are not layout: which fields a provider
@@ -93,7 +93,7 @@ export function validateAddProviderDraft(draft: AddProviderDraft): AddProviderIs
}
export interface CreatedProvider {
- readonly connection: LlmConnection;
+ readonly connection: IdentifiedLlmConnection;
/**
* Present when the catalog fetch that follows creation threw. The connection
* exists either way — discovery is a convenience on top of a successful
@@ -104,8 +104,8 @@ export interface CreatedProvider {
}
export interface ProviderCreationBridge {
- create(input: CreateConnectionInput): Promise<LlmConnection>;
- fetchModels(slug: string): Promise<unknown>;
+ create(input: CreateConnectionInput): Promise<IdentifiedLlmConnection>;
+ fetchModels(connection: { readonly connectionId: string; readonly slug: string }): Promise<unknown>;
}
/**
@@ -123,7 +123,7 @@ export async function createProviderWithDiscovery(
const connection = await bridge.create(input);
if (!providerSupportsModelDiscovery(input.providerType)) return { connection };
try {
- await bridge.fetchModels(connection.slug);
+ await bridge.fetchModels({ connectionId: connection.connectionId, slug: connection.slug });
} catch (modelDiscoveryError) {
return { connection, modelDiscoveryError };
}
diff --git a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx
index 364407fcfa..b1d3c8ecad 100644
--- a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx
+++ b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx
@@ -34,7 +34,7 @@ import {
type ProviderCatalogGroup,
type ProviderType,
} from '@maka/core/provider-registry';
-import { PROVIDER_DEFAULTS } from '@maka/core/llm-connections';
+import { PROVIDER_DEFAULTS, type LlmConnection } from '@maka/core/llm-connections';
import { Button, TextInput, useUiLocale } from '@maka/ui';
import { AddProviderForm } from './provider-add-form';
import { ProviderLogo, providerDisplay } from './provider-display';
@@ -66,6 +66,12 @@ export interface CatalogFilter {
export const CATALOG_INITIAL_FILTER: CatalogFilter = { query: '', category: 'all' };
+export interface CreatedOAuthConnectionIdentity {
+ connectionId: string;
+ slug: string;
+ providerType: 'openai-codex' | 'xai-oauth';
+}
+
/**
* One provider being set up. `credentials` is a form, `account` is a browser
* login — two bodies of one level, not two levels: they are reached the same
@@ -87,6 +93,7 @@ export type SetupTarget =
*/
export function ProviderCatalogPage(props: {
filter: CatalogFilter;
+ connections: readonly LlmConnection[];
onFilterChange(filter: CatalogFilter): void;
onPick(target: SetupTarget): void;
}) {
@@ -96,7 +103,10 @@ export function ProviderCatalogPage(props: {
const catalogCopy = providerCopy.catalog;
const { query, category } = props.filter;
const showsOAuth = category === 'all' || category === 'recommended' || category === 'accounts';
- const oauth = useOAuthCards({ query: showsOAuth ? query : undefined });
+ const oauth = useOAuthCards({
+ query: showsOAuth ? query : undefined,
+ connections: props.connections,
+ });
// Category is a Selector, not a second TabList: the page header already owns
// one level of navigation, and six tabs beside a search field made the
@@ -161,7 +171,12 @@ export function ProviderCatalogPage(props: {
data-status="ready"
data-logged-in={card.isLoggedIn ? 'true' : undefined}
startContent={<ProviderLogo type={card.providerType} />}
- label={/* a11y-allow: this label names the ROW, not the span. Astryx's Item puts consumer props on its outer wrapper and renders a separate invisible <button> for the click target, so an aria-label on the Item never reaches that button — measured. The button is named from its content, and this span is how the status reaches that name. Removing it drops the runtime error from the row's accessible name (settings.spec:226).*/ <span aria-label={providerCopy.oauthSection.cardAri [...]
+ label={/* a11y-allow: this label names the ROW, not the span. Astryx's Item puts consumer props on its outer wrapper and renders a separate invisible <button> for the click target, so an aria-label on the Item never reaches that button — measured. The button is named from its content, and this span is how the status reaches that name. Removing it drops the runtime error from the row's accessible name (settings.spec:226).*/ <span aria-label={providerCopy.oauthSection.cardAria(
+ card.id === 'github-copilot' ? card.isLoggedIn ? 'manage' : 'import' : 'add',
+ card.name,
+ card.status,
+ card.description,
+ )}>{card.name}</span>}
description={card.description}
endContent={(
<HStack gap={2} vAlign="center">
@@ -205,9 +220,8 @@ export function ProviderCatalogPage(props: {
}
/**
- * Level 3: one provider being set up — its credential form, or its account
- * login. Named `setup` rather than `create` because the account body also
- * signs out and re-authorizes; it is not a one-way create.
+ * Level 3: one provider being set up — its credential form, or a new account
+ * enrollment. Existing-account actions live on that Connection's detail page.
*/
export function ProviderSetupPage(props: {
bridge: ConnectionsBridge;
@@ -215,17 +229,18 @@ export function ProviderSetupPage(props: {
existingSlugs: string[];
onCancel(): void;
onCreated(slug: string, modelDiscoveryError?: unknown): Promise<void>;
- onAccountChanged(): Promise<void>;
+ onAccountCreated(connection?: CreatedOAuthConnectionIdentity): Promise<void>;
+ labelledBy?: string;
}) {
if (props.target.method === 'account') {
return (
- <div tabIndex={-1} className="settingsRouteLevel" data-maka-contract="provider-setup">
- <OAuthLoginPanel cardId={props.target.cardId} onLoginSuccess={props.onAccountChanged} />
+ <div tabIndex={-1} role="region" aria-labelledby={props.labelledBy} className="settingsRouteLevel" data-maka-contract="provider-setup">
+ <OAuthLoginPanel cardId={props.target.cardId} onLoginSuccess={props.onAccountCreated} />
</div>
);
}
return (
- <div tabIndex={-1} className="settingsRouteLevel" data-maka-contract="provider-setup">
+ <div tabIndex={-1} role="region" aria-labelledby={props.labelledBy} className="settingsRouteLevel" data-maka-contract="provider-setup">
<AddProviderForm
key={props.target.providerType}
bridge={props.bridge}
diff --git a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
index d54149911e..c3d69a8627 100644
--- a/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
+++ b/apps/desktop/src/renderer/settings/provider-connection-detail.tsx
@@ -116,7 +116,7 @@ function UnknownConnectionDetail({ props }: { props: ConnectionDetailProps }) {
if (!mounted.current || !ok) return;
setDeleting(true);
try {
- await props.bridge.delete(connection.slug);
+ await props.bridge.delete({ connectionId: connection.connectionId, slug: connection.slug });
if (!mounted.current) return;
await props.onDeleted();
} catch (error) {
@@ -257,7 +257,7 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
setHeaderDrafts([]);
setBodyDraft(formatRequestBodyOverlay(connection.requestBodyOverlay));
void props.bridge
- .getRequestHeaders(connection.slug)
+ .getRequestHeaders({ connectionId: connection.connectionId, slug: connection.slug })
.then(({ names }) => {
if (!current) return;
setSavedHeaderNames(names);
@@ -301,7 +301,10 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
}
setRequestCustomizationBusy(true);
try {
- const saved = await props.bridge.setRequestHeaders(connection.slug, updates);
+ const saved = await props.bridge.setRequestHeaders(
+ { connectionId: connection.connectionId, slug: connection.slug },
+ updates,
+ );
if (!mounted.current) return true;
setSavedHeaderNames(saved.names);
setHeaderDrafts(savedRequestHeaderDrafts(saved.names));
@@ -330,7 +333,10 @@ function ConnectionDetailInner(props: ConnectionDetailProps) {
}
setRequestCustomizationBusy(true);
try {
- await props.bridge.update(connection.slug, { requestBodyOverlay: overlay ?? null });
+ await props.bridge.update(
+ { connectionId: connection.connectionId, slug: connection.slug },
+ { requestBodyOverlay: overlay ?? null },
+ );
await props.onChanged();
return true;
} catch (error) {
@@ -1104,9 +1110,12 @@ function OAuthReloginNoticeForCurrentGeneration(props: {
const providerCopy = getProviderSettingsCopy(useUiLocale());
const copy = providerCopy.detail;
const flow = useOAuthLoginFlow({
- bridge: props.service.bridge,
+ mode: 'existing',
+ authorizationBridge: props.service.authorizationBridge,
+ accountBridge: props.service.accountBridge,
display: props.service.display,
- onLoginSuccess: props.onRelogin,
+ onLoginSuccess: () => props.onRelogin(),
+ onAccountChanged: props.onRelogin,
});
const { hasSecret } = props;
const loggedIn = hasSecret === true;
@@ -1139,6 +1148,7 @@ function OAuthReloginNoticeForCurrentGeneration(props: {
</>
) : detail}
endContent={!loading ? (
+ <HStack gap={2}>
<Button
variant="primary"
size="sm"
@@ -1146,6 +1156,18 @@ function OAuthReloginNoticeForCurrentGeneration(props: {
onClick={() => void flow.startLogin()}
label={flow.pendingAction === 'login' ? copy.loggingIn : loggedIn ? copy.relogin : copy.login}
/>
+ {loggedIn && flow.logout && (
+ <Button
+ variant="ghost"
+ size="sm"
+ isDisabled={flow.actionBusy}
+ onClick={() => void flow.logout?.()}
+ label={flow.pendingAction === 'logout'
+ ? providerCopy.oauthSection.loggingOut
+ : providerCopy.oauthSection.logout}
+ />
+ )}
+ </HStack>
) : undefined} />
);
}
diff --git a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx
index 10d86d72f6..d1eb34de33 100644
--- a/apps/desktop/src/renderer/settings/provider-oauth-section.tsx
+++ b/apps/desktop/src/renderer/settings/provider-oauth-section.tsx
@@ -19,7 +19,7 @@
import { useEffect, useRef, useState } from 'react';
import { Banner, HStack, Text, VStack } from '@astryxdesign/core';
-import { type ProviderType } from '@maka/core/llm-connections';
+import { type LlmConnection, type ProviderType } from '@maka/core/llm-connections';
import type { DesktopRuntimeHostRef } from '../../preload/bridge-contract.js';
import {
Badge,
@@ -31,8 +31,7 @@ import { getProviderSettingsCopy, type ProviderSettingsCopy } from '../locales/s
import {
useOAuthLoginFlow,
subscriptionActionErrorMessage,
- subscriptionResultMessage,
- type OAuthLoginFlowBridge,
+ type OAuthConnectionIdentity,
type SubscriptionSnapshot,
} from './use-oauth-login-flow';
import {
@@ -40,7 +39,9 @@ import {
useRuntimeHostSettingsGenerationKey,
useRuntimeHostSettingsTarget,
} from './runtime-host-settings-target.js';
-import { runtimeHostOAuthLoginBridge } from './runtime-host-settings-bridge.js';
+import {
+ runtimeHostOAuthAuthorizationBridge,
+} from './runtime-host-settings-bridge.js';
export type OAuthCardId = 'codex' | 'github-copilot' | 'xai';
@@ -48,7 +49,7 @@ export interface OAuthCard {
id: OAuthCardId;
providerType: ProviderType;
name: string;
- /** Account email once signed in, the static pitch otherwise. */
+ /** Enrollment summary, or the singleton Copilot account summary. */
description: string;
/** A meaningful account state; routine availability stays in the description. */
status?: string;
@@ -64,8 +65,8 @@ function emptyOAuthCardStates(): Record<OAuthCardId, SubscriptionSnapshot | null
}
/**
- * Account sign-in rows for the provider catalog, plus the refresh that keeps
- * their badges live.
+ * Account enrollment rows for the provider catalog. Codex and xAI are pure
+ * add intents; only the singleton Copilot import retains aggregate state.
*
* This used to be a self-contained `ModelOAuthSection` that rendered both the
* rows and a Dialog per service. The rows and the login body are now two
@@ -76,7 +77,10 @@ function emptyOAuthCardStates(): Record<OAuthCardId, SubscriptionSnapshot | null
* login remounts it, which re-reads every account state; that is the refresh,
* and it is why nothing here has to be pushed across a level boundary.
*/
-export function useOAuthCards(props: { query?: string }) {
+export function useOAuthCards(props: {
+ query?: string;
+ connections: readonly LlmConnection[];
+}) {
const host = useRuntimeHostSettingsTarget();
const generationKey = useRuntimeHostSettingsGenerationKey();
const locale = useUiLocale();
@@ -84,12 +88,9 @@ export function useOAuthCards(props: { query?: string }) {
const cards = modelOAuthCards(copy);
const mountedRef = useMountedRef();
const refreshTicketRef = useRef(0);
- // PR-OAUTH-CARD-LIVE-STATE-0 (WAWQAQ msg d79fd115 follow-up): before this
- // lift the cards stayed at their static catalog copy even after the user
- // finished the OAuth flow — there was no parent re-fetch. Each service now
- // carries a runtimeState + email so its row can show the account email inline,
- // re-fetched whenever a login step closes (success OR
- // cancel — the user may have signed out from inside it).
+ // Copilot remains a singleton local import, so its catalog row retains live
+ // account state. Codex and xAI are pure enrollment intents and derive only
+ // their Connection counts from the catalog.
const [cardStates, setCardStates] = useState(emptyOAuthCardStates);
const [refreshError, setRefreshError] = useState<string | null>(null);
const normalizedQuery = props.query?.trim().toLocaleLowerCase() ?? '';
@@ -104,7 +105,7 @@ export function useOAuthCards(props: { query?: string }) {
const ticket = refreshTicketRef.current + 1;
refreshTicketRef.current = ticket;
const results = await Promise.all(
- cards.map(async (card) => {
+ cards.filter((card) => card.id === 'github-copilot').map(async (card) => {
try {
const snapshot = await getSubscriptionSnapshot(card.id, host);
return { id: card.id, snapshot } as const;
@@ -152,24 +153,32 @@ export function useOAuthCards(props: { query?: string }) {
}, [generationKey]);
const visibleCards: OAuthCard[] = cards
- .filter(matchesQuery)
.map((card) => {
const snapshot = cardStates[card.id];
+ const connectionCount = props.connections.filter(
+ (connection) => connection.providerType === card.providerType,
+ ).length;
const runtimeState = snapshot?.runtimeState ?? 'unknown';
- const isLoggedIn =
+ const isLoggedIn = card.id === 'github-copilot' && (
runtimeState === 'authenticated' ||
runtimeState === 'refreshing' ||
runtimeState === 'quota_unavailable' ||
- runtimeState === 'provider_rejected';
+ runtimeState === 'provider_rejected'
+ );
return {
id: card.id,
providerType: card.providerType,
name: card.name,
- description: isLoggedIn && snapshot?.email ? snapshot.email : card.description,
+ description: card.id !== 'github-copilot' && connectionCount > 0
+ ? copy.configuredConnections(connectionCount)
+ : isLoggedIn && snapshot?.email
+ ? snapshot.email
+ : card.description,
...(isLoggedIn ? { status: copy.signedIn } : {}),
isLoggedIn,
};
- });
+ })
+ .filter(matchesQuery);
return { cards: visibleCards, refreshError };
}
@@ -179,7 +188,10 @@ export function useOAuthCards(props: { query?: string }) {
* this as its setup level; the header and the back affordance belong to that
* level, the same ones the catalog and the connection detail use.
*/
-export function OAuthLoginPanel(props: { cardId: OAuthCardId; onLoginSuccess(): void | Promise<void> }) {
+export function OAuthLoginPanel(props: {
+ cardId: OAuthCardId;
+ onLoginSuccess(connection?: OAuthConnectionIdentity): void | Promise<void>;
+}) {
return (
<RuntimeHostSettingsGenerationBoundary>
<OAuthLoginPanelForCurrentGeneration {...props} />
@@ -189,7 +201,7 @@ export function OAuthLoginPanel(props: { cardId: OAuthCardId; onLoginSuccess():
function OAuthLoginPanelForCurrentGeneration(props: {
cardId: OAuthCardId;
- onLoginSuccess(): void | Promise<void>;
+ onLoginSuccess(connection?: OAuthConnectionIdentity): void | Promise<void>;
}) {
if (props.cardId === 'github-copilot') {
return <GitHubCopilotLoginPanel onLoginSuccess={props.onLoginSuccess} />;
@@ -219,11 +231,10 @@ function modelOAuthCards(copy: ProviderSettingsCopy['oauthSection']): ReadonlyAr
function SubscriptionLoginPanel(props: {
service: 'codex' | 'xai';
- onLoginSuccess(): void | Promise<void>;
+ onLoginSuccess(connection: OAuthConnectionIdentity): void | Promise<void>;
}) {
const host = useRuntimeHostSettingsTarget();
- const locale = useUiLocale();
- const copy = getProviderSettingsCopy(locale).oauthSection;
+ const copy = getProviderSettingsCopy(useUiLocale()).oauthSection;
const isXai = props.service === 'xai';
const display: SubscriptionDisplay = isXai
? { name: 'xAI Grok', shortName: 'SuperGrok / X Premium', detail: copy.xaiDetail }
@@ -234,9 +245,11 @@ function SubscriptionLoginPanel(props: {
// localized toast copy) lives in useOAuthLoginFlow so the connection detail
// page can drive the exact same flow behind its relogin button.
const flow = useOAuthLoginFlow({
- bridge: runtimeHostOAuthLoginBridge(
+ mode: 'create',
+ authorizationBridge: runtimeHostOAuthAuthorizationBridge(
isXai ? window.maka.xaiOAuth : window.maka.openAiCodex,
host,
+ { kind: 'create' },
),
display: { name: display.name, shortName: display.shortName },
onLoginSuccess: props.onLoginSuccess,
@@ -244,31 +257,26 @@ function SubscriptionLoginPanel(props: {
return (
<VStack gap={3} data-status={flow.runtimeState}>
- <Text type="body">{presentSnapshotDetail(flow.state, display, locale)}</Text>
- {!isXai && flow.stateHint && (
- <Text type="supporting" color="secondary">
- {copy.deviceCode} {flow.stateHint}
+ <Text type="body">{display.detail}</Text>
+ {flow.authRequestId && (
+ <Text type="supporting" color="secondary" role="status" aria-live="polite">
+ {!isXai && flow.stateHint
+ ? <>{copy.deviceCode} {flow.stateHint}</>
+ : copy.waitingAuthorization}
</Text>
)}
{flow.errorMessage && (
<Banner status="error" role="alert" title={flow.errorMessage} />
)}
<HStack gap={2} hAlign="end">
- {!flow.isLoggedIn ? (
- <Button
- variant="primary"
- onClick={() => void flow.startLogin()}
- isDisabled={flow.actionBusy}
- label={flow.pendingAction === 'login' ? copy.openingBrowser : copy.login(display.shortName)}
- />
- ) : (
- <Button
- variant="ghost"
- onClick={() => void flow.logout()}
- isDisabled={flow.actionBusy}
- label={flow.pendingAction === 'logout' ? copy.loggingOut : copy.logout}
- />
- )}
+ <Button
+ variant="primary"
+ onClick={() => void flow.startLogin()}
+ isDisabled={flow.actionBusy}
+ label={flow.pendingAction === 'login'
+ ? flow.authRequestId ? copy.waitingAuthorization : copy.openingBrowser
+ : copy.loginAndAdd}
+ />
</HStack>
</VStack>
);
@@ -283,10 +291,11 @@ function GitHubCopilotLoginPanel(props: { onLoginSuccess(): void | Promise<void>
// no browser handoff, no logout confirm) instead of owning a separate
// pending-action state machine here (#1042).
const flow = useOAuthLoginFlow({
- bridge: {
+ mode: 'direct',
+ accountBridge: {
getAccountState: () => window.maka.githubCopilotSubscription.getAccountState(host),
logout: () => window.maka.githubCopilotSubscription.logout(host),
- } as OAuthLoginFlowBridge,
+ },
display: { name: 'GitHub Copilot', shortName: 'GitHub Copilot' },
onLoginSuccess: props.onLoginSuccess,
direct: {
@@ -310,7 +319,7 @@ function GitHubCopilotLoginPanel(props: { onLoginSuccess(): void | Promise<void>
{loggedIn && (
<>
<Button variant="secondary" onClick={() => void refreshTokens?.()} isDisabled={flow.actionBusy} label={flow.pendingAction === 'refresh' ? copy.verifying : copy.reverify} />
- <Button variant="ghost" onClick={() => void flow.logout()} isDisabled={flow.actionBusy} label={flow.pendingAction === 'logout' ? copy.removing : copy.removeLocal} />
+ <Button variant="ghost" onClick={() => void flow.logout?.()} isDisabled={flow.actionBusy} label={flow.pendingAction === 'logout' ? copy.removing : copy.removeLocal} />
</>
)}
</HStack>
@@ -325,10 +334,7 @@ async function getSubscriptionSnapshot(
if (serviceId === 'github-copilot') {
return window.maka.githubCopilotSubscription.getAccountState(host);
}
- if (serviceId === 'xai') {
- return window.maka.xaiOAuth.getAccountState(host);
- }
- return (await window.maka.openAiCodex.getAccountState(host)) as SubscriptionSnapshot;
+ throw new Error(`Aggregate OAuth state is not available for ${serviceId}`);
}
interface SubscriptionDisplay {
@@ -336,31 +342,3 @@ interface SubscriptionDisplay {
shortName: string;
detail: string;
}
-
-function presentSnapshotDetail(state: SubscriptionSnapshot | null, display: SubscriptionDisplay, locale: 'zh' | 'en'): string {
- const copy = getProviderSettingsCopy(locale).oauthSection;
- if (!state) return copy.loadingAccount;
- switch (state.runtimeState) {
- case 'not_logged_in':
- return copy.signedOut(display.name);
- case 'authorizing':
- return copy.authorizing;
- case 'authenticated': {
- const parts = [copy.signedIn];
- if (state.email) parts.push(state.email);
- if (state.plan) parts.push(state.plan);
- return parts.join(' · ');
- }
- case 'refreshing':
- return copy.refreshing;
- case 'refresh_failed':
- return subscriptionResultMessage(state.errorMessage, copy.refreshTokenFailed, locale);
- case 'storage_failed':
- return subscriptionResultMessage(state.errorMessage, copy.storageFailed(display.name), locale);
- case 'quota_unavailable':
- case 'provider_rejected':
- return subscriptionResultMessage(state.errorMessage, copy.providerUnavailable(display.name), locale);
- }
- const _exhaustive: never = state.runtimeState;
- return _exhaustive;
-}
diff --git a/apps/desktop/src/renderer/settings/provider-panel-shared.ts b/apps/desktop/src/renderer/settings/provider-panel-shared.ts
index ec77a98ce6..7c9e6454a9 100644
--- a/apps/desktop/src/renderer/settings/provider-panel-shared.ts
+++ b/apps/desktop/src/renderer/settings/provider-panel-shared.ts
@@ -21,6 +21,7 @@ import { generalizedErrorMessage, generalizedErrorMessageChinese, redactSecrets
import {
type ConnectionTestResult,
type CreateConnectionInput,
+ type IdentifiedLlmConnection,
type LlmConnection,
type ModelDiscoveryResult,
type RequestHeaderUpdate,
@@ -32,20 +33,23 @@ import {
import { type UiLocale } from '@maka/core/ui-locale';
import { getProviderSettingsCopy } from '../locales/settings-provider-copy.js';
import { cleanErrorMessage } from '../model-connection-errors.js';
-import type { DesktopConnectionSnapshot } from '../../shared/desktop-connection-snapshot.js';
+import type {
+ DesktopConnectionIdentity,
+ DesktopConnectionSnapshot,
+} from '../../shared/desktop-connection-snapshot.js';
export interface ConnectionsBridge {
getSnapshot(): Promise<DesktopConnectionSnapshot>;
- setDefault(slug: string | null): Promise<void>;
- create(input: CreateConnectionInput): Promise<LlmConnection>;
- update(slug: string, patch: UpdateConnectionInput): Promise<LlmConnection>;
- delete(slug: string): Promise<void>;
- test(slug: string, opts?: { model?: string }): Promise<ConnectionTestResult>;
- fetchModels(slug: string): Promise<ModelDiscoveryResult>;
- hasSecret(slug: string): Promise<boolean>;
- getRequestHeaders(slug: string): Promise<SavedRequestHeaders>;
+ setDefault(connection: DesktopConnectionIdentity | null): Promise<void>;
+ create(input: CreateConnectionInput): Promise<IdentifiedLlmConnection>;
+ update(connection: DesktopConnectionIdentity, patch: UpdateConnectionInput): Promise<LlmConnection>;
+ delete(connection: DesktopConnectionIdentity): Promise<void>;
+ test(connection: DesktopConnectionIdentity, opts?: { model?: string }): Promise<ConnectionTestResult>;
+ fetchModels(connection: DesktopConnectionIdentity): Promise<ModelDiscoveryResult>;
+ hasSecret(connection: DesktopConnectionIdentity): Promise<boolean>;
+ getRequestHeaders(connection: DesktopConnectionIdentity): Promise<SavedRequestHeaders>;
setRequestHeaders(
- slug: string,
+ connection: DesktopConnectionIdentity,
headers: readonly RequestHeaderUpdate[],
): Promise<SavedRequestHeaders>;
subscribeEvents?(handler: () => void): () => void;
diff --git a/apps/desktop/src/renderer/settings/providers-panel.tsx b/apps/desktop/src/renderer/settings/providers-panel.tsx
index f38af09d46..9e95ed559b 100644
--- a/apps/desktop/src/renderer/settings/providers-panel.tsx
+++ b/apps/desktop/src/renderer/settings/providers-panel.tsx
@@ -34,7 +34,7 @@ import {
} from '@astryxdesign/core';
import { ICON_SIZE, ChevronRight, Cpu } from '@maka/ui/icons';
import {
- type LlmConnection,
+ type IdentifiedLlmConnection,
type ProviderType,
} from '@maka/core/llm-connections';
import { dotForStatus, useMountedRef, useUiLocale } from '@maka/ui';
@@ -45,6 +45,7 @@ import {
ProviderCatalogPage,
ProviderSetupPage,
type CatalogFilter,
+ type CreatedOAuthConnectionIdentity,
type SetupTarget,
} from './provider-catalog-page';
import { isRetiredProvider } from '@maka/core/provider-registry';
@@ -80,7 +81,8 @@ type PanelRoute =
| { kind: 'list' }
| { kind: 'catalog' }
| { kind: 'setup'; target: SetupTarget; origin: 'list' | 'catalog' }
- | { kind: 'detail'; slug: string };
+ | { kind: 'adopting-oauth'; identity: CreatedOAuthConnectionIdentity }
+ | { kind: 'detail'; connectionId: string };
function backTarget(route: PanelRoute): PanelRoute {
if (route.kind === 'setup' && route.origin === 'catalog') return { kind: 'catalog' };
@@ -106,7 +108,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
onInitialCreateProviderConsumed?: () => void;
}) {
const reportHostError = useRuntimeHostSettingsErrorReporter();
- const [connections, setConnections] = useState<LlmConnection[]>([]);
+ const [connections, setConnections] = useState<IdentifiedLlmConnection[]>([]);
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
@@ -119,29 +121,34 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
const addButtonRef = useRef<HTMLButtonElement>(null);
// Which row the user left the list from, so returning puts focus back where
// they were rather than on the page's primary action.
- const listReturnFocusRef = useRef<string | null>(null);
+ const returnFocusRef = useRef<
+ | { level: 'list'; connectionId: string }
+ | { level: 'catalog'; providerType: ProviderType }
+ | null
+ >(null);
const detailTitleId = useId();
+ const setupTitleId = useId();
const locale = useUiLocale();
const providerCopy = getProviderSettingsCopy(locale);
const copy = providerCopy.panel;
- async function reload(): Promise<boolean> {
+ async function reload(): Promise<Awaited<ReturnType<ConnectionsBridge['getSnapshot']>> | null> {
const ticket = ++providersReloadTicketRef.current;
try {
const snapshot = await bridge.getSnapshot();
- if (!providersPanelMountedRef.current || providersReloadTicketRef.current !== ticket) return false;
+ if (!providersPanelMountedRef.current || providersReloadTicketRef.current !== ticket) return null;
setConnections(snapshot.connections);
setDefaultSlug(snapshot.defaultConnection);
setLoadError(null);
setLoading(false);
- return true;
+ return snapshot;
} catch (error) {
- if (!providersPanelMountedRef.current || providersReloadTicketRef.current !== ticket) return false;
+ if (!providersPanelMountedRef.current || providersReloadTicketRef.current !== ticket) return null;
const message = providerPanelActionErrorMessage(error, locale);
setLoadError(message);
setLoading(false);
reportHostError(copy.loadFailed, message);
- return false;
+ return null;
}
}
@@ -166,11 +173,29 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
const initialConnectionDetailOpenedRef = useRef(false);
useEffect(() => {
+ if (route.kind === 'adopting-oauth') {
+ const created = connections.find(
+ (connection) => connection.connectionId === route.identity.connectionId,
+ );
+ if (!created) return;
+ if (
+ created.slug !== route.identity.slug ||
+ created.providerType !== route.identity.providerType
+ ) {
+ setRoute({ kind: 'list' });
+ reportHostError(copy.loadFailed, copy.connectionIdentityChanged);
+ return;
+ }
+ returnFocusRef.current = { level: 'list', connectionId: created.connectionId };
+ setRoute({ kind: 'detail', connectionId: created.connectionId });
+ return;
+ }
if (loading || !initialConnectionSlug || initialConnectionDetailOpenedRef.current) return;
- if (!connections.some((candidate) => candidate.slug === initialConnectionSlug)) return;
+ const connection = connections.find((candidate) => candidate.slug === initialConnectionSlug);
+ if (!connection?.connectionId) return;
initialConnectionDetailOpenedRef.current = true;
- setRoute({ kind: 'detail', slug: initialConnectionSlug });
- }, [loading, initialConnectionSlug, connections]);
+ setRoute({ kind: 'detail', connectionId: connection.connectionId });
+ }, [loading, initialConnectionSlug, connections, route, copy.connectionIdentityChanged, copy.loadFailed, reportHostError]);
const initialCreateOpenedRef = useRef(false);
useEffect(() => {
@@ -198,24 +223,29 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
setRoute({ kind: 'list' });
}
- function openDetail(slug: string) {
- listReturnFocusRef.current = slug;
- setRoute({ kind: 'detail', slug });
+ function openDetail(connection: IdentifiedLlmConnection) {
+ returnFocusRef.current = { level: 'list', connectionId: connection.connectionId };
+ setRoute({ kind: 'detail', connectionId: connection.connectionId });
}
function openCatalog() {
- listReturnFocusRef.current = null;
+ returnFocusRef.current = null;
setRoute({ kind: 'catalog' });
}
const selected = route.kind === 'detail'
- ? connections.find((connection) => connection.slug === route.slug) ?? null
+ ? connections.find((connection) => connection.connectionId === route.connectionId) ?? null
: null;
+ const pendingOAuthIdentity = route.kind === 'adopting-oauth' ? route.identity : null;
// A detail route whose connection vanished (deleted in another window) is an
// unsatisfiable route, not a state to correct: the list is what it renders
// as. Deriving that beats scheduling a setState from inside render.
- const level: PanelRoute['kind'] = route.kind === 'detail' && !selected ? 'list' : route.kind;
+ const level: Exclude<PanelRoute['kind'], 'adopting-oauth'> = route.kind === 'adopting-oauth'
+ ? 'list'
+ : route.kind === 'detail' && !selected
+ ? 'list'
+ : route.kind;
useSettingsRouteFocus({
level,
@@ -223,7 +253,13 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
isReady: !loading,
resolveTarget: (current) => {
const find = (selector: string) => document.querySelector<HTMLElement>(selector);
- if (current === 'catalog') return find('[data-maka-contract="provider-catalog"] input');
+ if (current === 'catalog') {
+ const target = returnFocusRef.current;
+ returnFocusRef.current = null;
+ return (target?.level === 'catalog'
+ ? find(`[data-provider="${target.providerType}"] button`)
+ : null) ?? find('[data-maka-contract="provider-catalog"] input');
+ }
if (current === 'setup') {
return find('[data-maka-contract="provider-setup"] input')
?? find('[data-maka-contract="provider-setup"]');
@@ -235,9 +271,11 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
// survive the levels in between. The row the user came from may be gone —
// that is exactly what a deletion does — so the primary action is the
// fallback, not the default.
- const returnToSlug = listReturnFocusRef.current;
- listReturnFocusRef.current = null;
- return (returnToSlug ? find(`[data-connection-slug="${returnToSlug}"] button`) : null)
+ const target = returnFocusRef.current;
+ returnFocusRef.current = null;
+ return (target?.level === 'list'
+ ? find(`[data-connection-id="${target.connectionId}"] button`)
+ : null)
?? addButtonRef.current;
},
});
@@ -268,7 +306,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
backLabel={copy.backToList}
logo={<ProviderLogo type={selected.providerType} compact />}
titleId={detailTitleId}
- title={selected.name}
+ title={connectionDisplayName(selected, connections)}
/* The control sits in the slot that shows the state. Setting the
default connection was reachable only from the command palette,
while this page displayed a 默认 Badge and offered no way to
@@ -306,7 +344,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
tooltip={copy.setDefaultTitle}
clickAction={async () => {
try {
- await bridge.setDefault(selected.slug);
+ await bridge.setDefault({ connectionId: selected.connectionId, slug: selected.slug });
await reload();
} catch (error) {
// The state is unchanged on failure, so the Badge stays
@@ -322,7 +360,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
subtitle={connectionSubtitle(selected, locale)}
/>
<ConnectionDetail
- key={selected.slug}
+ key={selected.connectionId ?? selected.slug}
bridge={bridge}
connection={selected}
isDefault={selected.slug === defaultSlug}
@@ -344,8 +382,12 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
/>
<ProviderCatalogPage
filter={catalogFilter}
+ connections={connections}
onFilterChange={setCatalogFilter}
- onPick={(target) => setRoute({ kind: 'setup', target, origin: 'catalog' })}
+ onPick={(target) => {
+ returnFocusRef.current = { level: 'catalog', providerType: target.providerType };
+ setRoute({ kind: 'setup', target, origin: 'catalog' });
+ }}
/>
</VStack>
) : level === 'setup' && route.kind === 'setup' ? (
@@ -354,7 +396,10 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
onBack={goBack}
backLabel={route.origin === 'catalog' ? copy.backToCatalog : copy.backToList}
logo={<ProviderLogo type={route.target.providerType} compact />}
- title={copy.connectTitle(route.target.name)}
+ titleId={setupTitleId}
+ title={route.target.method === 'account' && route.target.cardId !== 'github-copilot'
+ ? providerCopy.oauthSection.addAccountTitle(route.target.name)
+ : copy.connectTitle(route.target.name)}
subtitle={route.target.method === 'account'
? oauthPanelSubtitle(route.target.cardId, providerCopy.oauthSection)
: copy.createSubtitle}
@@ -363,16 +408,26 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
bridge={bridge}
target={route.target}
existingSlugs={connections.map((connection) => connection.slug)}
+ labelledBy={setupTitleId}
onCancel={goBack}
- onAccountChanged={async () => { await reload(); }}
- onCreated={async (slug, modelDiscoveryError) => {
+ onAccountCreated={async (identity) => {
+ if (!identity) return;
+ setRoute({ kind: 'adopting-oauth', identity });
await reload();
- if (!providersPanelMountedRef.current) return;
+ }}
+ onCreated={async (slug, modelDiscoveryError) => {
+ const snapshot = await reload();
+ if (!snapshot || !providersPanelMountedRef.current) return;
// The new connection's detail, not the list: creating it is the
// start of setting it up, and every next move — pick the default
// model, enable models, fix the endpoint the discovery error just
// complained about — is on that page.
- setRoute({ kind: 'detail', slug });
+ const created = snapshot.connections.find((connection) => connection.slug === slug);
+ if (!created?.connectionId) {
+ reportHostError(copy.loadFailed, copy.connectionRemoved);
+ return;
+ }
+ setRoute({ kind: 'detail', connectionId: created.connectionId });
if (modelDiscoveryError) {
const providerName = providerDisplay(route.target.providerType, locale).name;
reportHostError(
@@ -388,6 +443,9 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
</VStack>
) : (
<>
+ {route.kind === 'detail' && !selected && (
+ <Banner status="warning" role="status" title={copy.connectionRemoved} />
+ )}
<HStack gap={2} vAlign="center" hAlign="between">
<HStack gap={2} vAlign="center">
<Heading level={3}>{copy.connections}</Heading>
@@ -400,10 +458,26 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
variant="primary"
label={copy.addConnection}
onClick={openCatalog}
+ isDisabled={pendingOAuthIdentity !== null}
data-maka-contract="add-connection"
/>
</HStack>
- {loadError ? (
+ {pendingOAuthIdentity && loadError ? (
+ <Banner
+ status="warning"
+ role="status"
+ title={copy.connectedLoadFailed}
+ description={loadError}
+ endContent={<Button variant="ghost" label={copy.retry} onClick={() => void reload()} />}
+ />
+ ) : pendingOAuthIdentity ? (
+ <Banner
+ status="info"
+ role="status"
+ title={copy.connectedLoading}
+ endContent={<Button variant="ghost" label={copy.retry} onClick={() => void reload()} />}
+ />
+ ) : loadError ? (
<Banner
status="error"
title={copy.loadFailed}
@@ -415,7 +489,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
icon={<Cpu size={ICON_SIZE.empty} />}
title={copy.empty}
description={copy.emptyHelp}
- actions={<Button variant="primary" label={copy.addConnection} onClick={openCatalog} />}
+ actions={<Button variant="primary" label={copy.addConnection} onClick={openCatalog} isDisabled={pendingOAuthIdentity !== null} />}
/>
) : (
<List hasDividers>
@@ -424,15 +498,16 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
const isDefault = connection.slug === defaultSlug;
return (
<ListItem
- key={connection.slug}
+ key={connection.connectionId ?? connection.slug}
className="connectionRow"
+ data-connection-id={connection.connectionId}
data-connection-slug={connection.slug}
data-disabled={connection.enabled ? undefined : 'true'}
startContent={<ProviderLogo type={connection.providerType} compact />}
label={(
<HStack gap={2} vAlign="center">
{/* a11y-allow: this label names the ROW, not the span. Astryx's Item puts consumer props on its outer wrapper and renders a separate invisible <button> for the click target, so an aria-label on the Item never reaches that button — measured. The button is named from its content, and this span is how the status reaches that name. Removing it drops the runtime error from the row's accessible name (settings.spec:226).*/}
- <span aria-label={chipAriaLabel(connection, isDefault)}>{connection.name}</span>
+ <span aria-label={chipAriaLabel(connection, isDefault)}>{connectionDisplayName(connection, connections)}</span>
{isDefault && <Badge variant="neutral" label={copy.default} />}
</HStack>
)}
@@ -448,7 +523,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
<ChevronRight size={ICON_SIZE.chrome} aria-hidden="true" />
</HStack>
)}
- onClick={() => openDetail(connection.slug)}
+ onClick={() => openDetail(connection)}
/>
);
})}
@@ -459,17 +534,35 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
</VStack>
);
- function chipAriaLabel(connection: LlmConnection, isDefault: boolean): string {
+ function chipAriaLabel(connection: IdentifiedLlmConnection, isDefault: boolean): string {
const provider = providerDisplay(connection.providerType, locale).name;
const status = connectionChipStatus(connection, locale);
- return copy.chipAria(connection.name, provider, isDefault, status?.label);
+ return copy.chipAria(
+ connectionDisplayName(connection, connections),
+ provider,
+ isDefault,
+ status?.label,
+ );
}
}
/** Provider · default model — the row's second line, and the detail's subtitle. */
-function connectionSubtitle(connection: LlmConnection, locale: 'zh' | 'en'): string {
+function connectionSubtitle(connection: IdentifiedLlmConnection, locale: 'zh' | 'en'): string {
const providerName = providerDisplay(connection.providerType, locale).name;
const parts = [providerName];
if (connection.defaultModel) parts.push(connection.defaultModel);
return parts.join(' · ');
}
+
+function connectionDisplayName(
+ connection: IdentifiedLlmConnection,
+ connections: readonly IdentifiedLlmConnection[],
+): string {
+ const ambiguous = connections.some(
+ (candidate) =>
+ candidate.connectionId !== connection.connectionId &&
+ candidate.providerType === connection.providerType &&
+ candidate.name === connection.name,
+ );
+ return ambiguous ? `${connection.name} · ${connection.slug}` : connection.name;
+}
diff --git a/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts b/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts
index a4a4aa1b20..041bd4d09c 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts
+++ b/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts
@@ -17,9 +17,16 @@
* under the License.
*/
-import type { DesktopRuntimeHostRef } from '../../preload/bridge-contract.js';
+import type {
+ DesktopOAuthLoginTarget,
+ DesktopRuntimeHostRef,
+ MakaBridge,
+} from '../../preload/bridge-contract.js';
import type { ConnectionsBridge } from './provider-panel-shared.js';
-import type { OAuthLoginFlowBridge } from './use-oauth-login-flow.js';
+import type {
+ OAuthAccountFlowBridge,
+ OAuthAuthorizationFlowBridge,
+} from './use-oauth-login-flow.js';
export type RuntimeHostSettingsConnectionsBridge = ConnectionsBridge & {
setDefaultModel(input: { slug: string; model: string } | null): Promise<void>;
@@ -30,36 +37,78 @@ export function runtimeHostConnectionsBridge(
): RuntimeHostSettingsConnectionsBridge {
return {
getSnapshot: () => window.maka.connections.getSnapshot(undefined, host),
- setDefault: (slug) => window.maka.connections.setDefault(slug, host),
+ setDefault: (connection) => window.maka.connections.setDefault(connection, host),
setDefaultModel: (input) => window.maka.connections.setDefaultModel(input, host),
create: (input) => window.maka.connections.create(input, host),
- update: (slug, patch) => window.maka.connections.update(slug, patch, host),
- delete: (slug) => window.maka.connections.delete(slug, host),
- test: (slug, options) => window.maka.connections.test(slug, options, host),
- fetchModels: (slug) => window.maka.connections.fetchModels(slug, host),
- hasSecret: (slug) => window.maka.connections.hasSecret(slug, host),
- getRequestHeaders: (slug) => window.maka.connections.getRequestHeaders(slug, host),
- setRequestHeaders: (slug, headers) =>
- window.maka.connections.setRequestHeaders(slug, headers, host),
+ update: (connection, patch) => window.maka.connections.update(connection, patch, host),
+ delete: (connection) => window.maka.connections.delete(connection, host),
+ test: (connection, options) => window.maka.connections.test(connection, options, host),
+ fetchModels: (connection) => window.maka.connections.fetchModels(connection, host),
+ hasSecret: (connection) => window.maka.connections.hasSecret(connection, host),
+ getRequestHeaders: (connection) => window.maka.connections.getRequestHeaders(connection, host),
+ setRequestHeaders: (connection, headers) =>
+ window.maka.connections.setRequestHeaders(connection, headers, host),
subscribeEvents: (handler) =>
window.maka.connections.subscribeEvents(handler, host),
};
}
-export function runtimeHostOAuthLoginBridge(
- bridge: typeof window.maka.openAiCodex | typeof window.maka.xaiOAuth,
+type RuntimeHostOAuthBridge = MakaBridge['openAiCodex'] | MakaBridge['xaiOAuth'];
+
+export function runtimeHostOAuthAuthorizationBridge(
+ bridge: RuntimeHostOAuthBridge,
host: DesktopRuntimeHostRef,
- connectionId?: string,
-): OAuthLoginFlowBridge {
+ target: DesktopOAuthLoginTarget,
+): OAuthAuthorizationFlowBridge {
return {
- getAuthUrl: () =>
- bridge.getAuthUrl(host, connectionId) as ReturnType<OAuthLoginFlowBridge['getAuthUrl']>,
+ getAuthUrl: () => bridge.getAuthUrl(host, target),
openAuthUrl: (authRequestId) => bridge.openAuthUrl(authRequestId, host),
- completeAuthorization: (authRequestId) =>
- bridge.completeAuthorization(authRequestId, host),
- cancelAuthorization: (authRequestId) =>
- bridge.cancelAuthorization(authRequestId, host),
- getAccountState: () => bridge.getAccountState(host, connectionId),
+ completeAuthorization: (authRequestId) => bridge.completeAuthorization(authRequestId, host),
+ cancelAuthorization: (authRequestId) => bridge.cancelAuthorization(authRequestId, host),
+ };
+}
+
+export function runtimeHostOAuthAccountBridge(
+ bridge: RuntimeHostOAuthBridge,
+ host: DesktopRuntimeHostRef,
+ connectionId: string,
+): OAuthAccountFlowBridge {
+ return {
+ getAccountState: async () => requireOAuthAccountState(
+ await bridge.getAccountState(host, connectionId),
+ ),
logout: () => bridge.logout(host, connectionId),
};
}
+
+function requireOAuthAccountState(value: unknown): unknown {
+ if (
+ value &&
+ typeof value === 'object' &&
+ 'ok' in value &&
+ value.ok === false &&
+ 'message' in value &&
+ typeof value.message === 'string'
+ ) {
+ throw new Error(value.message);
+ }
+ return value;
+}
+
+export function runtimeHostOAuthExistingLoginBridges(
+ bridge: RuntimeHostOAuthBridge,
+ host: DesktopRuntimeHostRef,
+ connectionId: string,
+): {
+ authorizationBridge: OAuthAuthorizationFlowBridge;
+ accountBridge: OAuthAccountFlowBridge;
+} {
+ return {
+ authorizationBridge: runtimeHostOAuthAuthorizationBridge(
+ bridge,
+ host,
+ { kind: 'existing', connectionId },
+ ),
+ accountBridge: runtimeHostOAuthAccountBridge(bridge, host, connectionId),
+ };
+}
diff --git a/apps/desktop/src/renderer/settings/use-connection-detail.ts b/apps/desktop/src/renderer/settings/use-connection-detail.ts
index e9e9859a04..8e24f0ac8e 100644
--- a/apps/desktop/src/renderer/settings/use-connection-detail.ts
+++ b/apps/desktop/src/renderer/settings/use-connection-detail.ts
@@ -26,7 +26,7 @@ import {
} from './connection-name-draft.js';
import {
type ConnectionTestResult,
- type LlmConnection,
+ type IdentifiedLlmConnection,
type ModelInfo,
type ProviderType,
} from '@maka/core/llm-connections';
@@ -50,7 +50,10 @@ import { connectionChipStatus } from './provider-connection-status';
import { relayProfileDraftReseedPlan, relayProfileDraftSeed } from './relay-profile-draft';
import { applyBulkThinkingLevel, relayProfileWithThinkingLevels } from './relay-thinking-bulk';
import { useKeyedActionGuard } from './use-action-guard';
-import type { OAuthLoginFlowBridge } from './use-oauth-login-flow';
+import type {
+ OAuthAccountFlowBridge,
+ OAuthAuthorizationFlowBridge,
+} from './use-oauth-login-flow';
import {
connectionLastTestMessageDisplay,
connectionTestFailureMessage,
@@ -62,7 +65,9 @@ import {
useRuntimeHostSettingsErrorReporter,
useRuntimeHostSettingsTarget,
} from './runtime-host-settings-target.js';
-import { runtimeHostOAuthLoginBridge } from './runtime-host-settings-bridge.js';
+import {
+ runtimeHostOAuthExistingLoginBridges,
+} from './runtime-host-settings-bridge.js';
// Maps an OAuth model-connection provider type to the browser-assisted login
// service that can re-run its authorization from inside the connection dialog. Only
@@ -70,7 +75,8 @@ import { runtimeHostOAuthLoginBridge } from './runtime-host-settings-bridge.js';
// here; plain API-key providers return null so the notice falls back to
// prose instead of rendering a dead button.
export interface OAuthLoginService {
- bridge: OAuthLoginFlowBridge;
+ authorizationBridge: OAuthAuthorizationFlowBridge;
+ accountBridge: OAuthAccountFlowBridge;
display: { name: string; shortName: string };
// Codex's device-authorization page requires the user to type the code the
// flow surfaces as `stateHint` — the verification URL does not embed it, so
@@ -83,18 +89,27 @@ export function oauthLoginServiceFor(
providerType: ProviderType,
host: import('../../preload/bridge-contract.js').DesktopRuntimeHostRef,
connectionId: string,
+ connectionLabel?: string,
): OAuthLoginService | null {
switch (providerType) {
case 'openai-codex':
return {
- bridge: runtimeHostOAuthLoginBridge(window.maka.openAiCodex, host, connectionId),
- display: { name: 'OpenAI Codex', shortName: 'Codex' },
+ ...runtimeHostOAuthExistingLoginBridges(
+ window.maka.openAiCodex,
+ host,
+ connectionId,
+ ),
+ display: { name: connectionLabel ?? 'OpenAI Codex', shortName: 'Codex' },
showsDeviceCode: true,
};
case 'xai-oauth':
return {
- bridge: runtimeHostOAuthLoginBridge(window.maka.xaiOAuth, host, connectionId),
- display: { name: 'xAI Grok', shortName: 'SuperGrok / X Premium' },
+ ...runtimeHostOAuthExistingLoginBridges(
+ window.maka.xaiOAuth,
+ host,
+ connectionId,
+ ),
+ display: { name: connectionLabel ?? 'xAI Grok', shortName: 'SuperGrok / X Premium' },
showsDeviceCode: false,
};
default:
@@ -104,7 +119,7 @@ export function oauthLoginServiceFor(
export interface ConnectionDetailProps {
bridge: ConnectionsBridge;
- connection: LlmConnection;
+ connection: IdentifiedLlmConnection;
isDefault: boolean;
onChanged(): Promise<void>;
onDeleted(): Promise<void>;
@@ -123,6 +138,10 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
const locale = useUiLocale();
const copy = getProviderSettingsCopy(locale).detail;
const { connection } = props;
+ const connectionIdentity = {
+ connectionId: connection.connectionId,
+ slug: connection.slug,
+ } as const;
const defaults = PROVIDER_DEFAULTS[connection.providerType];
const [apiKey, setApiKey] = useState('');
const [hasSecret, setHasSecret] = useState<CredentialPresenceStatus>(
@@ -160,7 +179,12 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
// notice itself.
const retired = isRetiredProvider(connection.providerType);
const oauthLoginService = needsOAuth && !retired && connection.connectionId
- ? oauthLoginServiceFor(connection.providerType, host, connection.connectionId)
+ ? oauthLoginServiceFor(
+ connection.providerType,
+ host,
+ connection.connectionId,
+ `${connection.name} · ${connection.slug}`,
+ )
: null;
const usesGitHubCopilotLogin = connection.providerType === 'github-copilot';
const supportsRemoteDiscovery = providerSupportsModelDiscovery(connection.providerType);
@@ -224,7 +248,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
}
setHasSecret('loading');
void props.bridge
- .hasSecret(connection.slug)
+ .hasSecret(connectionIdentity)
.then((next) => {
if (isConnectionDetailCurrent(lifecycle)) setHasSecret(next);
})
@@ -236,7 +260,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
providerPanelActionErrorMessage(error, locale),
);
});
- }, [props.bridge, connection.slug, probesCredential, reportHostError]);
+ }, [props.bridge, connection.connectionId, connection.slug, probesCredential, reportHostError]);
useEffect(() => {
const nextSnapshot = connectionDetailSnapshot(connection, defaults.baseUrl);
@@ -314,14 +338,14 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
let saved = false;
try {
await props.bridge.update(
- connection.slug,
+ connectionIdentity,
field === 'key' ? { apiKey } : field === 'name' ? { name: draftName } : { baseUrl },
);
saved = true;
if (!isConnectionDetailCurrent(lifecycle)) return true;
const wroteNewKey = field === 'key' && apiKey.length > 0;
if (wroteNewKey) setApiKey('');
- const nextHasSecret = probesCredential ? await props.bridge.hasSecret(connection.slug) : true;
+ const nextHasSecret = probesCredential ? await props.bridge.hasSecret(connectionIdentity) : true;
if (!isConnectionDetailCurrent(lifecycle)) return true;
setHasSecret(nextHasSecret);
await props.onChanged();
@@ -375,7 +399,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
setEnabledModelIds(next);
let saved = false;
try {
- await props.bridge.update(connection.slug, { enabledModelIds: next });
+ await props.bridge.update(connectionIdentity, { enabledModelIds: next });
saved = true;
if (!isConnectionDetailCurrent(lifecycle)) return;
await props.onChanged();
@@ -546,7 +570,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
// Send the whole table — the update contract is whole-table replace —
// after the write-path sanitizer so a hand-assembled draft degrades the
// same way a saved document would.
- await props.bridge.update(connection.slug, {
+ await props.bridge.update(connectionIdentity, {
relayModelProfiles: draftedRelayProfiles ?? null,
});
if (!isConnectionDetailCurrent(lifecycle)) return true;
@@ -598,7 +622,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
setEnabledModelIds(next);
let saved = false;
try {
- await props.bridge.update(connection.slug, {
+ await props.bridge.update(connectionIdentity, {
enabledModelIds: next,
relayModelProfiles: { ...(savedRelayProfiles ?? {}), [modelId]: { contextWindow } },
});
@@ -643,7 +667,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
// to a field this page no longer owns, which is '' once the user enables
// no models. Left unset, a zero-model connection still verifies its
// credential against a fallback instead of failing with 'No model to test'.
- const result: ConnectionTestResult = await props.bridge.test(connection.slug);
+ const result: ConnectionTestResult = await props.bridge.test(connectionIdentity);
if (!isConnectionDetailCurrent(lifecycle)) return;
if (result.ok) {
// The backend probes the enabled models first, then the provider
@@ -708,7 +732,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
// Backend returns a `ModelDiscoveryResult` envelope and rejects empty or
// malformed catalogs before persistence. Trust its explicit source
// instead of reconstructing cache provenance in the renderer.
- const result = await props.bridge.fetchModels(connection.slug);
+ const result = await props.bridge.fetchModels(connectionIdentity);
fetched = true;
if (!isConnectionDetailCurrent(lifecycle)) return;
setModels(result.models);
@@ -764,7 +788,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
}
let deleted = false;
try {
- await props.bridge.delete(connection.slug);
+ await props.bridge.delete(connectionIdentity);
deleted = true;
if (!isConnectionDetailCurrent(lifecycle)) return;
await props.onDeleted();
@@ -786,7 +810,7 @@ export function useConnectionDetail(props: ConnectionDetailProps) {
async function refreshAfterRelogin() {
const lifecycle = connectionDetailLifecycleRef.current;
try {
- const nextHasSecret = await props.bridge.hasSecret(connection.slug);
+ const nextHasSecret = await props.bridge.hasSecret(connectionIdentity);
if (!isConnectionDetailCurrent(lifecycle)) return;
setHasSecret(nextHasSecret);
} catch (error) {
@@ -859,7 +883,7 @@ type ConnectionDetailSnapshot = {
};
function connectionDetailSnapshot(
- connection: LlmConnection,
+ connection: IdentifiedLlmConnection,
defaultBaseUrl: string | undefined,
): ConnectionDetailSnapshot {
return {
diff --git a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts
index 57006a0c82..42ab18ee47 100644
--- a/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts
+++ b/apps/desktop/src/renderer/settings/use-oauth-login-flow.ts
@@ -61,13 +61,26 @@ export interface SubscriptionSnapshot {
errorMessage?: string;
}
-export interface OAuthLoginFlowBridge {
+export interface OAuthConnectionIdentity {
+ connectionId: string;
+ slug: string;
+ providerType: 'openai-codex' | 'xai-oauth';
+}
+
+export interface OAuthAuthorizationFlowBridge {
getAuthUrl(): Promise<
- { authRequestId: string; stateHint: string } | { ok: boolean; reason?: string; message: string }
+ { authRequestId: string; stateHint: string; connection: OAuthConnectionIdentity }
+ | { ok: boolean; reason?: string; message: string }
>;
openAuthUrl(authRequestId: string): Promise<{ ok: true } | { ok: false; reason: string; message: string }>;
- completeAuthorization(authRequestId: string): Promise<{ ok: true } | { ok: false; reason: string; message: string }>;
+ completeAuthorization(authRequestId: string): Promise<
+ { ok: true; connection: OAuthConnectionIdentity }
+ | { ok: false; reason: string; message: string }
+ >;
cancelAuthorization(authRequestId?: string): Promise<{ ok: true }>;
+}
+
+export interface OAuthAccountFlowBridge {
getAccountState(): Promise<unknown>;
logout(): Promise<{ ok: true } | { ok: false; reason: string; message: string }>;
}
@@ -103,30 +116,43 @@ export interface OAuthLoginFlowController {
errorMessage: string | null;
actionBusy: boolean;
startLogin(): Promise<void>;
- logout(): Promise<void>;
+ logout: (() => Promise<void>) | undefined;
refresh(): Promise<boolean>;
// Direct account flows only (GitHub Copilot 重新验证); undefined for the
// browser-assisted services so they cannot render a dead action.
refreshTokens: (() => Promise<void>) | undefined;
}
-export function useOAuthLoginFlow(params: {
- bridge: OAuthLoginFlowBridge;
- display: OAuthLoginFlowDisplay;
- // Fired after a successful completeAuthorization (browser handoff done).
- // The detail sheet uses it to re-probe hasSecret + reload connection status;
- // catalog modals use it to refresh both their account card and the shared
- // model connection list without waiting for the modal to close.
- onLoginSuccess?: () => void | Promise<void>;
- // When present, startLogin runs this one-shot import instead of the
- // getAuthUrl -> openAuthUrl -> completeAuthorization handoff, and the
- // controller exposes the extra `refreshTokens` action.
- direct?: OAuthDirectAccountFlow;
-}): OAuthLoginFlowController {
- const { bridge, display } = params;
+type OAuthLoginFlowParams =
+ | {
+ mode: 'create';
+ authorizationBridge: OAuthAuthorizationFlowBridge;
+ display: OAuthLoginFlowDisplay;
+ onLoginSuccess(connection: OAuthConnectionIdentity): void | Promise<void>;
+ }
+ | {
+ mode: 'existing';
+ authorizationBridge: OAuthAuthorizationFlowBridge;
+ accountBridge: OAuthAccountFlowBridge;
+ display: OAuthLoginFlowDisplay;
+ onLoginSuccess?: (connection: OAuthConnectionIdentity) => void | Promise<void>;
+ onAccountChanged?: () => void | Promise<void>;
+ }
+ | {
+ mode: 'direct';
+ accountBridge: OAuthAccountFlowBridge;
+ display: OAuthLoginFlowDisplay;
+ onLoginSuccess?: () => void | Promise<void>;
+ direct: OAuthDirectAccountFlow;
+ };
+
+export function useOAuthLoginFlow(params: OAuthLoginFlowParams): OAuthLoginFlowController {
+ const { display } = params;
const locale = useUiLocale();
const copy = getProviderSettingsCopy(locale).oauthFlow;
- const direct = params.direct;
+ const direct = params.mode === 'direct' ? params.direct : undefined;
+ const authorizationBridge = params.mode === 'direct' ? undefined : params.authorizationBridge;
+ const accountBridge = params.mode === 'create' ? undefined : params.accountBridge;
const toast = useToast();
const reportHostError = useRuntimeHostSettingsErrorReporter();
const [state, setState] = useState<SubscriptionSnapshot | null>(null);
@@ -139,8 +165,9 @@ export function useOAuthLoginFlow(params: {
const oauthLoginFlowMountedRef = useMountedRef();
async function refresh(): Promise<boolean> {
+ if (!accountBridge) return true;
try {
- const next = (await bridge.getAccountState()) as SubscriptionSnapshot;
+ const next = (await accountBridge.getAccountState()) as SubscriptionSnapshot;
if (!oauthLoginFlowMountedRef.current) return false;
setState(next);
setErrorMessage(null);
@@ -155,10 +182,15 @@ export function useOAuthLoginFlow(params: {
}
useEffect(() => {
- void refresh();
+ if (accountBridge) void refresh();
return () => {
pendingGuard.finish();
- teardownPendingAuthorization(authRequestIdRef, (id) => void bridge.cancelAuthorization(id));
+ if (authorizationBridge) {
+ teardownPendingAuthorization(
+ authRequestIdRef,
+ (id) => void authorizationBridge.cancelAuthorization(id),
+ );
+ }
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -191,7 +223,9 @@ export function useOAuthLoginFlow(params: {
}
await refresh();
if (!oauthLoginFlowMountedRef.current) return;
- if (result.ok && params.onLoginSuccess) await params.onLoginSuccess();
+ if (result.ok && params.mode === 'direct' && params.onLoginSuccess) {
+ await params.onLoginSuccess();
+ }
} catch (error) {
if (!oauthLoginFlowMountedRef.current) return;
reportHostError(
@@ -204,7 +238,8 @@ export function useOAuthLoginFlow(params: {
return;
}
try {
- const payload = await bridge.getAuthUrl();
+ if (!authorizationBridge || params.mode === 'direct') return;
+ const payload = await authorizationBridge.getAuthUrl();
if ('ok' in payload) {
if (!oauthLoginFlowMountedRef.current) return;
const failureMessage = payload.ok ? copy.retry : subscriptionResultMessage(payload.message, copy.startFailedRetry, locale);
@@ -215,18 +250,18 @@ export function useOAuthLoginFlow(params: {
authRequestIdRef.current = payload.authRequestId;
if (!oauthLoginFlowMountedRef.current) {
authRequestIdRef.current = null;
- void bridge.cancelAuthorization(payload.authRequestId);
+ void authorizationBridge.cancelAuthorization(payload.authRequestId);
return;
}
setAuthRequestId(payload.authRequestId);
setStateHint(payload.stateHint);
- const opened = await bridge.openAuthUrl(payload.authRequestId);
+ const opened = await authorizationBridge.openAuthUrl(payload.authRequestId);
if (!oauthLoginFlowMountedRef.current) return;
if (!opened.ok) {
const message = subscriptionResultMessage(opened.message, copy.openFailedRetry, locale);
reportHostError(copy.openFailed, message);
setErrorMessage(message);
- void bridge.cancelAuthorization(payload.authRequestId);
+ void authorizationBridge.cancelAuthorization(payload.authRequestId);
authRequestIdRef.current = null;
setAuthRequestId(null);
setStateHint(null);
@@ -235,7 +270,7 @@ export function useOAuthLoginFlow(params: {
const refreshed = await refresh();
if (!oauthLoginFlowMountedRef.current || !refreshed) return;
// Wait for the backend to finish polling the provider.
- const result = await bridge.completeAuthorization(payload.authRequestId);
+ const result = await authorizationBridge.completeAuthorization(payload.authRequestId);
if (!oauthLoginFlowMountedRef.current) return;
authRequestIdRef.current = null;
setAuthRequestId(null);
@@ -244,7 +279,7 @@ export function useOAuthLoginFlow(params: {
toast.success(copy.loginSuccess, copy.bound(display.name));
await refresh();
if (!oauthLoginFlowMountedRef.current) return;
- if (params.onLoginSuccess) await params.onLoginSuccess();
+ if (params.onLoginSuccess) await params.onLoginSuccess(result.connection);
} else {
const message = subscriptionResultMessage(result.message, copy.incompleteRetry, locale);
reportHostError(copy.incomplete, message);
@@ -254,7 +289,9 @@ export function useOAuthLoginFlow(params: {
if (!oauthLoginFlowMountedRef.current) return;
const pendingAuthRequestId = authRequestIdRef.current;
authRequestIdRef.current = null;
- if (pendingAuthRequestId) void bridge.cancelAuthorization(pendingAuthRequestId);
+ if (pendingAuthRequestId && authorizationBridge) {
+ void authorizationBridge.cancelAuthorization(pendingAuthRequestId);
+ }
setAuthRequestId(null);
setStateHint(null);
const message = subscriptionActionErrorMessage(error, locale);
@@ -266,6 +303,7 @@ export function useOAuthLoginFlow(params: {
}
async function logout() {
+ if (!accountBridge) return;
if (!beginPendingAction('logout')) return;
try {
// Direct-import flows keep their original no-confirm, silent-success
@@ -281,13 +319,16 @@ export function useOAuthLoginFlow(params: {
});
if (!ok) return;
}
- const result = await bridge.logout();
+ const result = await accountBridge.logout();
if (!oauthLoginFlowMountedRef.current) return;
if (result.ok) {
if (!direct) {
toast.success(copy.loggedOut, copy.credentialsCleared);
}
await refresh();
+ if (params.mode === 'existing' && params.onAccountChanged) {
+ await params.onAccountChanged();
+ }
} else if (direct) {
reportHostError(
copy.accountActionFailed(display.name),
@@ -355,7 +396,7 @@ export function useOAuthLoginFlow(params: {
errorMessage,
actionBusy,
startLogin,
- logout,
+ logout: accountBridge ? logout : undefined,
refresh,
refreshTokens: direct ? refreshTokens : undefined,
};
diff --git a/apps/desktop/src/shared/desktop-connection-snapshot.ts b/apps/desktop/src/shared/desktop-connection-snapshot.ts
index 3a75acff13..3fe184bd4f 100644
--- a/apps/desktop/src/shared/desktop-connection-snapshot.ts
+++ b/apps/desktop/src/shared/desktop-connection-snapshot.ts
@@ -20,6 +20,12 @@
import type { ChatModelChoice } from '@maka/core/chat-model-choice';
import type { IdentifiedLlmConnection } from '@maka/core/llm-connections';
+/** Immutable identity plus the human-readable locator last shown by Desktop. */
+export interface DesktopConnectionIdentity {
+ readonly connectionId: string;
+ readonly slug: string;
+}
+
export interface DesktopConnectionSnapshot {
readonly connections: IdentifiedLlmConnection[];
readonly defaultConnection: string | null;
diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx
index c83957e808..d8b9b296ec 100644
--- a/apps/desktop/stories/settings/provider-settings.stories.tsx
+++ b/apps/desktop/stories/settings/provider-settings.stories.tsx
@@ -214,6 +214,37 @@ const problemConnections = [
}),
];
+const oauthConnections = [
+ makeConnection({
+ slug: 'openai-codex',
+ name: 'OpenAI Codex',
+ providerType: 'openai-codex',
+ defaultModel: 'gpt-5',
+ lastTestStatus: 'verified',
+ }),
+ makeConnection({
+ slug: 'openai-codex-2',
+ name: 'OpenAI Codex',
+ providerType: 'openai-codex',
+ defaultModel: 'gpt-5',
+ lastTestStatus: 'verified',
+ }),
+ makeConnection({
+ slug: 'openai-codex-3',
+ name: 'OpenAI Codex',
+ providerType: 'openai-codex',
+ defaultModel: 'gpt-5',
+ lastTestStatus: 'verified',
+ }),
+ makeConnection({
+ slug: 'xai-oauth',
+ name: 'xAI Grok',
+ providerType: 'xai-oauth',
+ defaultModel: 'grok-4',
+ lastTestStatus: 'verified',
+ }),
+];
+
function createBridge(input: {
connections?: IdentifiedLlmConnection[];
defaultSlug?: string | null;
@@ -233,8 +264,8 @@ function createBridge(input: {
chatModelChoices: buildChatModelChoices(connections),
};
},
- async setDefault(slug) {
- defaultSlug = slug;
+ async setDefault(connection) {
+ defaultSlug = connection?.slug ?? null;
},
async create(next) {
const connection = makeConnection({
@@ -249,8 +280,8 @@ function createBridge(input: {
defaultSlug ??= connection.slug;
return connection;
},
- async update(slug, patch) {
- const current = connections.find((connection) => connection.slug === slug);
+ async update(identity, patch) {
+ const current = connections.find((connection) => connection.connectionId === identity.connectionId && connection.slug === identity.slug);
if (!current) throw new Error('连接不存在');
const updated: IdentifiedLlmConnection = {
...current,
@@ -267,15 +298,15 @@ function createBridge(input: {
: (patch.requestBodyOverlay ?? undefined),
updatedAt: NOW,
};
- connections = connections.map((connection) => connection.slug === slug ? updated : connection);
+ connections = connections.map((connection) => connection.connectionId === identity.connectionId ? updated : connection);
return updated;
},
- async delete(slug) {
- connections = connections.filter((connection) => connection.slug !== slug);
- if (defaultSlug === slug) defaultSlug = connections[0]?.slug ?? null;
+ async delete(identity) {
+ connections = connections.filter((connection) => connection.connectionId !== identity.connectionId);
+ if (defaultSlug === identity.slug) defaultSlug = connections[0]?.slug ?? null;
},
- async test(slug): Promise<ConnectionTestResult> {
- if (slug.includes('rate-limit')) {
+ async test(identity): Promise<ConnectionTestResult> {
+ if (identity.slug.includes('rate-limit')) {
return {
ok: false,
statusCode: 429,
@@ -285,11 +316,11 @@ function createBridge(input: {
}
return { ok: true, latencyMs: 328, modelTested: 'glm-4.7' };
},
- async fetchModels(slug): Promise<ModelDiscoveryResult> {
+ async fetchModels(identity): Promise<ModelDiscoveryResult> {
return {
models: [
- { id: slug.includes('openai') ? 'gpt-5' : 'glm-4.7' },
- { id: slug.includes('openai') ? 'gpt-4o' : 'glm-4.6' },
+ { id: identity.slug.includes('openai') ? 'gpt-5' : 'glm-4.7' },
+ { id: identity.slug.includes('openai') ? 'gpt-4o' : 'glm-4.6' },
],
source: 'fetched',
fetchedAt: NOW,
@@ -310,17 +341,65 @@ function createBridge(input: {
};
}
-function installSubscriptionFixtures() {
+function createOAuthSuccessLifecycleFixture() {
+ const bridge = createBridge({ connections: oauthConnections });
+ let eventHandler: (() => void) | undefined;
+ let delayNextSnapshot = false;
+ let releaseSupersededSnapshot: (() => void) | undefined;
+ return {
+ bridge: {
+ ...bridge,
+ async getSnapshot() {
+ const snapshot = await bridge.getSnapshot();
+ if (delayNextSnapshot) {
+ delayNextSnapshot = false;
+ return new Promise<typeof snapshot>((resolve) => {
+ releaseSupersededSnapshot = () => resolve(snapshot);
+ });
+ }
+ const release = releaseSupersededSnapshot;
+ releaseSupersededSnapshot = undefined;
+ queueMicrotask(() => release?.());
+ return snapshot;
+ },
+ subscribeEvents(handler: () => void) {
+ eventHandler = handler;
+ return () => {
+ if (eventHandler === handler) eventHandler = undefined;
+ };
+ },
+ } satisfies ConnectionsBridge,
+ onOAuthComplete() {
+ // `create` mutates the fixture before its already-resolved Promise is
+ // observed. Delay the completion callback's reload, then emit the Host
+ // event so its newer reload wins the ticket and releases the older one:
+ // this is the exact ordering that used to strand the setup page.
+ void bridge.create({
+ slug: 'openai-codex-4',
+ name: 'OpenAI Codex',
+ providerType: 'openai-codex',
+ defaultModel: 'gpt-5',
+ });
+ delayNextSnapshot = true;
+ window.setTimeout(() => eventHandler?.(), 0);
+ },
+ };
+}
+
+function installSubscriptionFixtures(onOAuthComplete?: () => void) {
const target = window as unknown as {
maka?: Record<string, unknown>;
};
target.maka = {
...(target.maka ?? {}),
- openAiCodex: browserSubscriptionFixture({
- runtimeState: 'authenticated',
- email: 'codex@example.com',
- plan: 'Plus',
- }),
+ openAiCodex: browserSubscriptionFixture(
+ {
+ runtimeState: 'authenticated',
+ email: 'codex@example.com',
+ plan: 'Plus',
+ },
+ onOAuthComplete,
+ ),
githubCopilotSubscription: browserSubscriptionFixture({
runtimeState: 'not_logged_in',
}),
@@ -329,9 +408,14 @@ function installSubscriptionFixtures() {
}
function xaiDeviceSubscriptionFixture() {
+ const connection = {
+ connectionId: 'connection-xai-oauth-2',
+ slug: 'xai-oauth-2',
+ providerType: 'xai-oauth' as const,
+ };
return {
getAccountState: async () => ({ provider: 'xai-oauth', runtimeState: 'authorizing' }),
- getAuthUrl: async () => ({ authRequestId: 'storybook-xai', stateHint: 'ABCD-EFGH' }),
+ getAuthUrl: async () => ({ authRequestId: 'storybook-xai', stateHint: 'ABCD-EFGH', connection }),
openAuthUrl: async () => ({ ok: true }),
completeAuthorization: async () => new Promise<never>(() => undefined),
cancelAuthorization: async () => ({ ok: true }),
@@ -344,12 +428,20 @@ function browserSubscriptionFixture(state: {
email?: string;
plan?: string;
errorMessage?: string;
-}) {
+}, onComplete?: () => void) {
+ const connection = {
+ connectionId: 'connection-openai-codex-4',
+ slug: 'openai-codex-4',
+ providerType: 'openai-codex' as const,
+ };
return {
getAccountState: async () => state,
- getAuthUrl: async () => ({ authRequestId: 'storybook-oauth', stateHint: 'storybook' }),
+ getAuthUrl: async () => ({ authRequestId: 'storybook-oauth', stateHint: 'storybook', connection }),
openAuthUrl: async () => ({ ok: true }),
- completeAuthorization: async () => ({ ok: true }),
+ completeAuthorization: async () => {
+ onComplete?.();
+ return { ok: true as const, connection };
+ },
cancelAuthorization: async () => ({ ok: true }),
logout: async () => ({ ok: true }),
};
@@ -358,13 +450,14 @@ function browserSubscriptionFixture(state: {
function ProviderStoryFrame(props: {
bridge: ConnectionsBridge;
autoOpen?: AutoOpenTarget;
+ onOAuthComplete?: () => void;
}) {
const rootRef = useRef<HTMLDivElement>(null);
const clickedRef = useRef(false);
useEffect(() => {
- installSubscriptionFixtures();
- }, []);
+ installSubscriptionFixtures(props.onOAuthComplete);
+ }, [props.onOAuthComplete]);
useEffect(() => {
const autoOpen = props.autoOpen;
@@ -496,10 +589,11 @@ function clickAutoOpenTarget(root: HTMLElement, target: AutoOpenTarget): boolean
function ProviderStory(props: {
bridge: ConnectionsBridge;
autoOpen?: AutoOpenTarget;
+ onOAuthComplete?: () => void;
}): ReactNode {
return (
<RuntimeHostSettingsTarget host={{ profileId: 'local', hostId: 'storybook-local-host' }}>
- <ProviderStoryFrame bridge={props.bridge} autoOpen={props.autoOpen} />
+ <ProviderStoryFrame bridge={props.bridge} autoOpen={props.autoOpen} onOAuthComplete={props.onOAuthComplete} />
</RuntimeHostSettingsTarget>
);
}
@@ -601,6 +695,72 @@ export const AddConnectionCatalog: Story = {
),
};
+// Real path: 设置 → 模型 → 添加连接. OAuth rows are enrollment intents and
+// describe the number of configured Connection entities, never provider-wide
+// login state.
+export const OAuthCatalogNoAccounts: Story = {
+ render: () => <ProviderStory bridge={createBridge({ connections: [] })} autoOpen="catalog" />,
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).findByText('使用 ChatGPT Plus / Pro 账号添加连接。')).resolves.toBeTruthy();
+ },
+};
+
+export const OAuthCatalogOneAccount: Story = {
+ render: () => (
+ <ProviderStory
+ bridge={createBridge({ connections: oauthConnections.slice(0, 1) })}
+ autoOpen="catalog"
+ />
+ ),
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).findByText('已有 1 个连接 · 添加另一个账号')).resolves.toBeTruthy();
+ },
+};
+
+export const OAuthCatalogMultipleAccounts: Story = {
+ render: () => (
+ <ProviderStory
+ bridge={createBridge({ connections: oauthConnections })}
+ autoOpen="catalog"
+ />
+ ),
+ play: async ({ canvasElement }) => {
+ await expect(within(canvasElement).findByText('已有 3 个连接 · 添加另一个账号')).resolves.toBeTruthy();
+ await expect(within(canvasElement).findByText('已有 1 个连接 · 添加另一个账号')).resolves.toBeTruthy();
+ },
+};
+
+export const OAuthConnectionsDisambiguated: Story = {
+ render: () => <ProviderStory bridge={createBridge({ connections: oauthConnections })} />,
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await expect(canvas.findByText('OpenAI Codex · openai-codex')).resolves.toBeTruthy();
+ await expect(canvas.findByText('OpenAI Codex · openai-codex-2')).resolves.toBeTruthy();
+ await expect(canvas.findByText('OpenAI Codex · openai-codex-3')).resolves.toBeTruthy();
+ },
+};
+
+export const OAuthCreateAdoptsExactConnection: Story = {
+ render: () => {
+ const fixture = createOAuthSuccessLifecycleFixture();
+ return <ProviderStory bridge={fixture.bridge} onOAuthComplete={fixture.onOAuthComplete} />;
+ },
+ play: async ({ canvasElement }) => {
+ const canvas = within(canvasElement);
+ await userEvent.click(await canvas.findByRole('button', { name: '添加连接' }));
+ await userEvent.click(await canvas.findByRole('button', { name: /添加账号连接:OpenAI Codex/ }));
+ await userEvent.click(await canvas.findByRole('button', { name: '登录并添加' }));
+
+ await expect(canvas.findByRole('region', { name: 'OpenAI Codex · openai-codex-4' })).resolves.toBeTruthy();
+ await userEvent.click(await canvas.findByRole('button', { name: '返回模型连接' }));
+ const createdRow = canvasElement.querySelector<HTMLElement>(
+ '[data-connection-id="connection-openai-codex-4"]',
+ );
+ await expect(createdRow).not.toBeNull();
+ await expect(within(createdRow!).getByRole('button')).toHaveFocus();
+ },
+};
+
// Real path: 设置 → 模型 → 添加连接 → pick a provider — level three, its form.
export const AddProvider: Story = {
render: () => (
diff --git a/apps/desktop/stories/settings/settings-pages.stories.tsx b/apps/desktop/stories/settings/settings-pages.stories.tsx
index 7acf3501b1..7fcfa3af03 100644
--- a/apps/desktop/stories/settings/settings-pages.stories.tsx
+++ b/apps/desktop/stories/settings/settings-pages.stories.tsx
@@ -148,8 +148,8 @@ const connectionsBridge: ConnectionsBridge = {
async create(next) {
return makeConnection({ slug: next.slug, name: next.name, providerType: next.providerType });
},
- async update(slug, patch) {
- const current = connections.find((c) => c.slug === slug)!;
+ async update(identity, patch) {
+ const current = connections.find((c) => c.connectionId === identity.connectionId && c.slug === identity.slug)!;
return {
...current,
...patch,
@@ -172,9 +172,9 @@ const connectionsBridge: ConnectionsBridge = {
async test() {
return { ok: true, latencyMs: 210, modelTested: 'glm-4.7' };
},
- async fetchModels(slug) {
+ async fetchModels(identity) {
return {
- models: slug.includes('openai') ? [{ id: 'gpt-5' }] : [{ id: 'glm-4.7' }],
+ models: identity.slug.includes('openai') ? [{ id: 'gpt-5' }] : [{ id: 'glm-4.7' }],
source: 'fetched',
fetchedAt: NOW,
};
@@ -185,7 +185,7 @@ const connectionsBridge: ConnectionsBridge = {
async getRequestHeaders() {
return { names: [] };
},
- async setRequestHeaders(_slug, headers) {
+ async setRequestHeaders(_identity, headers) {
return { names: headers.map(({ name }) => name) };
},
subscribeEvents() {
@@ -965,8 +965,8 @@ const withGeneralCachedRevalidationBridge = withScopedMakaBridge({
let generationStoryCatalogPending = false;
let generationStoryRuntimeHostProfiles = runtimeHostProfiles;
let generationStoryConnectionsPending = false;
-let generationStoryCodexEmail = 'old-generation@example.com';
-let generationStoryCodexAccountReads = 0;
+let generationStoryCopilotEmail = 'old-generation@example.com';
+let generationStoryCopilotAccountReads = 0;
let generationStoryOpenedAuthIds: string[] = [];
let generationStoryCancelledAuthIds: string[] = [];
let generationStoryCopilotImportAttempts = 0;
@@ -984,8 +984,8 @@ function resetGenerationStoryBridge(
generationStoryCatalogPending = false;
generationStoryRuntimeHostProfiles = snapshot;
generationStoryConnectionsPending = false;
- generationStoryCodexEmail = 'old-generation@example.com';
- generationStoryCodexAccountReads = 0;
+ generationStoryCopilotEmail = 'old-generation@example.com';
+ generationStoryCopilotAccountReads = 0;
generationStoryOpenedAuthIds = [];
generationStoryCancelledAuthIds = [];
generationStoryCopilotImportAttempts = 0;
@@ -1025,13 +1025,13 @@ const withGeneralHostGenerationRevalidationBridge = withScopedMakaBridge({
const withModelsOAuthGenerationRevalidationBridge = withScopedMakaBridge({
...makaBridge,
runtimeHostProfiles: generationStoryRuntimeHostProfilesBridge,
- openAiCodex: {
- ...makaBridge.openAiCodex,
+ githubCopilotSubscription: {
+ ...makaBridge.githubCopilotSubscription,
getAccountState: async () => {
- generationStoryCodexAccountReads += 1;
+ generationStoryCopilotAccountReads += 1;
return {
runtimeState: 'authenticated' as const,
- email: generationStoryCodexEmail,
+ email: generationStoryCopilotEmail,
plan: 'Plus',
};
},
@@ -1058,6 +1058,11 @@ const withModelsOAuthAuthorizationGenerationBridge = withScopedMakaBridge({
getAuthUrl: async () => ({
authRequestId: 'authorization-from-generation-1',
stateHint: 'GEN1-CODE',
+ connection: {
+ connectionId: 'connection-openai-codex-generation-1',
+ slug: 'openai-codex-generation-1',
+ providerType: 'openai-codex' as const,
+ },
}),
openAuthUrl: async (authRequestId: string) => {
generationStoryOpenedAuthIds.push(authRequestId);
@@ -2261,9 +2266,10 @@ export const ModelsConnectionsHostGenerationRevalidation: Story = {
};
// A ready event can replace the Runtime Host without changing
-// profileId:hostId. The catalog route stays mounted, but its OAuth account
-// snapshot belongs to the Host generation and must be read again before the
-// previous account can be presented as current.
+// profileId:hostId. The catalog route stays mounted, but Copilot's singleton
+// import state belongs to the Host generation and must be read again before
+// the previous account can be presented as current. Codex and xAI instead
+// project their Connection counts from the connection catalog.
export const ModelsOAuthHostGenerationRevalidation: Story = {
decorators: [withModelsOAuthGenerationRevalidationBridge],
render: () => {
@@ -2279,11 +2285,11 @@ export const ModelsOAuthHostGenerationRevalidation: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await canvas.findByText('old-generation@example.com');
- const readsBeforeReplacement = generationStoryCodexAccountReads;
+ const readsBeforeReplacement = generationStoryCopilotAccountReads;
const listener = generationStoryProfileListener;
if (!listener) throw new Error('Runtime Host generation listener did not subscribe');
- generationStoryCodexEmail = 'new-generation@example.com';
+ generationStoryCopilotEmail = 'new-generation@example.com';
listener({
epoch: 'storybook-generation-2',
profileId: 'local',
@@ -2297,7 +2303,7 @@ export const ModelsOAuthHostGenerationRevalidation: Story = {
await canvas.findByText('new-generation@example.com');
await expect(canvas.queryByText('old-generation@example.com')).not.toBeInTheDocument();
- await expect(generationStoryCodexAccountReads).toBeGreaterThan(readsBeforeReplacement);
+ await expect(generationStoryCopilotAccountReads).toBeGreaterThan(readsBeforeReplacement);
await expect(
canvasElement.querySelector('[data-maka-contract="provider-catalog"]'),
).toBeInTheDocument();
@@ -2322,9 +2328,9 @@ export const ModelsOAuthAuthorizationHostGenerationRevalidation: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole('button', {
- name: /打开 OAuth 登录:OpenAI Codex/,
+ name: /添加账号连接:OpenAI Codex/,
}));
- await userEvent.click(await canvas.findByRole('button', { name: '登录 Codex' }));
+ await userEvent.click(await canvas.findByRole('button', { name: '登录并添加' }));
await waitForStoryCondition(
() => generationStoryOpenedAuthIds.includes('authorization-from-generation-1'),
'OAuth authorization did not reach the browser handoff',
@@ -2350,7 +2356,7 @@ export const ModelsOAuthAuthorizationHostGenerationRevalidation: Story = {
await expect(
canvasElement.querySelector('[data-maka-contract="provider-setup"]'),
).toBeInTheDocument();
- await expect(await canvas.findByRole('button', { name: '登录 Codex' })).toBeEnabled();
+ await expect(await canvas.findByRole('button', { name: '登录并添加' })).toBeEnabled();
await expect(generationStoryCancelledAuthIds).toEqual([
'authorization-from-generation-1',
]);
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org