Skip to content

Commit e8aa236

Browse files
committed
feat(gui): surface Aside on the Integrations page
Registers Aside across the GUI so its card, tab and toggle render like any other file client: the CLIENTS tuple, FILE_INTEGRATION_CLIENTS, the tab hash, and the three exhaustive label maps. TABS and FILE_CLIENTS move to integrations/integration-tabs.ts. They are the only client lists in the GUI that neither the registry invariant compares nor the compiler forces, so a client added everywhere else still gets no tab and every gate stays green. They now live in a module a test can import, because exporting them from Integrations.tsx alongside the component breaks fast refresh. gui/tests/integrations-tab-coverage.test.ts derives its expectation from FILE_INTEGRATION_CLIENTS rather than restating a literal, so client thirteen cannot pass by accident. Nine locales gain the tab label, the client label, and the ownership semantics. "Aside" is a product name and stays English everywhere, so both labels join the intentional-English allowlists. The semantics string carries one Aside-specific clause: the running app rewrites models.json itself, so it has to be fully quit and reopened after applying, the same shape as the Claude Desktop restart copy. Docs pick up the Aside destination and guide rows, plus the ZCode guide row that was missing while the CLI reference already documented it. Verification: bun run typecheck and bun run lint:gui clean; gui focused tests (tab coverage, api, client-config panel, overview rows, surfaces, locale parity, fr localization) 55 pass / 0 fail. Full suite deferred to CI.
1 parent 4a1ac29 commit e8aa236

5 files changed

Lines changed: 121 additions & 52 deletions

File tree

docs-site/src/content/docs/guides/integrations.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
---
22
title: Integrations
3-
description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code and Prime Agent from the dashboard — one switch per client, with a backup taken before every write.
3+
description: Connect opencodex to OpenCode, Pi, OMP, Hermes, OpenClaw, Kimi Code, Gajae Code, DeepSeek Harness, MiniMax Code, ZCode, Prime Agent and Aside from the dashboard — one switch per client, with a backup taken before every write.
44
---
55

66
The **Integrations** tab writes opencodex's provider block into a client's own config
7-
file, and removes it again. Ten clients work this way, each with a switch:
7+
file, and removes it again. Twelve clients work this way, each with a switch:
88

99
| Client | Config file | Format | When the change takes effect | Credential |
1010
|---|---|---|---|---|
@@ -18,6 +18,8 @@ file, and removes it again. Ten clients work this way, each with a switch:
1818
| DeepSeek Harness (DSH) | `$DSH_HOME/settings.yaml` (default `~/.dsh/settings.yaml`) | YAML | hot reload | non-secret loopback bearer placeholder |
1919
| MiniMax Code | `~/.minimax/config.yaml` | YAML | new sessions, or after opening the model picker | loopback placeholder |
2020
| Prime Agent | `~/.prime/agent/models.json` | JSON | new sessions | loopback placeholder |
21+
| ZCode | `~/.zcode/v2/config.json` | JSON | on restart | loopback placeholder |
22+
| Aside | `~/.aside/u/<account>/models.json` | JSON | after fully quitting and reopening Aside | loopback placeholder |
2123

2224
The managed OpenCode integration owns two fragments: `provider.opencodex` (opencode V1) and
2325
`providers.opencodex` (opencode V2). Only the V2 block carries the per-model reasoning-effort
@@ -46,6 +48,17 @@ disagree about which file is meant. Its managed block owns only
4648
stay untouched. Prime Agent reads `models.json` when a session starts, so start
4749
a new session after connecting it.
4850

51+
Aside is per-account: its state lives under `~/.aside/u/<account>/` and opencodex
52+
writes the catalog of whichever account Aside's own `accounts.json` names as
53+
current. If that manifest is missing or unreadable the integration refuses rather
54+
than guessing an account, because a guess on a multi-account machine would write
55+
into a different account's catalog. Its managed block owns only
56+
`providers.opencodex`, so your other Aside providers stay untouched.
57+
58+
One caveat specific to Aside: the running app rewrites `models.json` itself, so
59+
fully quit and reopen Aside after applying, the same way Claude Desktop needs a
60+
restart. Aside's block is loopback-only and never carries a real credential.
61+
4962
Paths honor each client's own environment override where it has one. For OMP,
5063
`OMP_PROFILE` wins over `PI_PROFILE` by presence, even when explicitly empty. A named profile
5164
uses `PI_CONFIG_DIR` as a directory name relative to the user's home and ignores `PI_CODING_AGENT_DIR`; without a named profile,

docs-site/src/content/docs/reference/cli/agents.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ Manage and apply the Grok Build model fence.
207207

208208
## Client config export
209209

210-
### `ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode|prime>`
210+
### `ocx export --client <opencode|pi|omp|hermes|openclaw|kimi|gajae|dsh|mcode|zcode|prime|aside>`
211211

212212
Print a client config wired to the running proxy. The command serializes the
213213
`opencodex` provider block — base URL, model list, and the client's credential
@@ -218,7 +218,7 @@ models Codex can currently see.
218218

219219
| Flag | Action |
220220
| --- | --- |
221-
| `--client <opencode\|pi\|omp\|hermes\|openclaw\|kimi\|gajae\|dsh\|mcode\|zcode\|prime>` | Required. Selects the client config dialect. |
221+
| `--client <opencode\|pi\|omp\|hermes\|openclaw\|kimi\|gajae\|dsh\|mcode\|zcode\|prime\|aside>` | Required. Selects the client config dialect. |
222222
| `--json` | Print the generated document as JSON on stdout for scripts. This is JSON even when the selected client's native format is YAML, TOML, or JSON5. |
223223
| `--out <path>` | Write the client's native config format to `<path>`. Refuses to replace an existing file. |
224224
| `--force` | Allow `--out` to replace an existing file. |
@@ -247,6 +247,7 @@ client applies its own defaults for those).
247247
| `mcode` | `~/.minimax/config.yaml` (`MINIMAX_DATA_DIR`, then the legacy `MAVIS_DATA_DIR`, win when set; a relative value is refused) | `mcode-config.yaml` | none — loopback placeholder |
248248
| `zcode` | `~/.zcode/v2/config.json` (`ZCODE_DATA_DIR` wins when set; a relative value is refused) | `config.json` | none — loopback placeholder |
249249
| `prime` | `~/.prime/agent/models.json` (`PRIME_AGENT_CODING_AGENT_DIR` wins when set; a relative value is refused) | `prime-models.json` | none — loopback placeholder |
250+
| `aside` | `~/.aside/u/<account>/models.json` for the account Aside's own `accounts.json` names as current; an unreadable manifest is refused rather than defaulting to an account | `aside-models.json` | none — loopback placeholder |
250251

251252
The managed DSH export requires DSH 0.1.0-rc.6 or newer and owns only
252253
`llm-pi-ai.providers.opencodex`. DSH hot reloads that provider; the user's default model and

gui/src/pages/Integrations.tsx

Lines changed: 2 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,14 @@
11
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
22
import { navigateHash, normalizeHashPath } from "../hash-routing";
3-
import { useT, type TKey } from "../i18n/shared";
3+
import { useT } from "../i18n/shared";
44
import ApiKeys from "./ApiKeys";
55
import Claude from "./Claude";
66
import Grok from "./Grok";
77
import IntegrationsOverview from "./integrations/IntegrationsOverview";
88
import FileIntegrationPage, {
99
type FileIntegrationClientId,
1010
} from "./integrations/FileIntegrationPage";
11-
12-
type IntegrationTab =
13-
| "overview"
14-
| "keys"
15-
| "codex"
16-
| "claude"
17-
| "grok"
18-
| FileIntegrationClientId;
19-
20-
interface TabDefinition {
21-
id: IntegrationTab;
22-
hash: string;
23-
labelKey: TKey;
24-
}
25-
26-
const TABS: readonly TabDefinition[] = [
27-
{ id: "overview", hash: "integrations", labelKey: "integrations.tab.overview" },
28-
{ id: "keys", hash: "integrations/keys", labelKey: "integrations.tab.keys" },
29-
{ id: "codex", hash: "integrations/codex", labelKey: "integrations.tab.codex" },
30-
{ id: "claude", hash: "integrations/claude", labelKey: "integrations.tab.claude" },
31-
{ id: "grok", hash: "integrations/grok", labelKey: "integrations.tab.grok" },
32-
{ id: "opencode", hash: "integrations/opencode", labelKey: "integrations.tab.opencode" },
33-
{ id: "pi", hash: "integrations/pi", labelKey: "integrations.tab.pi" },
34-
{ id: "omp", hash: "integrations/omp", labelKey: "integrations.tab.omp" },
35-
{ id: "hermes", hash: "integrations/hermes", labelKey: "integrations.tab.hermes" },
36-
{ id: "openclaw", hash: "integrations/openclaw", labelKey: "integrations.tab.openclaw" },
37-
{ id: "kimi", hash: "integrations/kimi", labelKey: "integrations.tab.kimi" },
38-
{ id: "gajae", hash: "integrations/gajae", labelKey: "integrations.tab.gajae" },
39-
{ id: "dsh", hash: "integrations/dsh", labelKey: "integrations.tab.dsh" },
40-
{ id: "mcode", hash: "integrations/mcode", labelKey: "integrations.tab.mcode" },
41-
{ id: "zcode", hash: "integrations/zcode", labelKey: "integrations.tab.zcode" },
42-
{ id: "prime", hash: "integrations/prime", labelKey: "integrations.tab.prime" },
43-
] as const;
44-
45-
const FILE_CLIENTS = new Set<FileIntegrationClientId>([
46-
"opencode",
47-
"pi",
48-
"omp",
49-
"hermes",
50-
"openclaw",
51-
"kimi",
52-
"gajae",
53-
"dsh",
54-
"mcode",
55-
"zcode",
56-
"prime",
57-
]);
11+
import { FILE_CLIENTS, TABS, type IntegrationTab } from "./integrations/integration-tabs";
5812

5913
function readIntegrationTab(hash = window.location.hash): IntegrationTab {
6014
const raw = normalizeHashPath(hash);
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* The Integrations tab strip and the set of tabs backed by a file client.
3+
*
4+
* A separate module rather than exports on Integrations.tsx, because a file that
5+
* exports both a component and constants breaks React fast refresh
6+
* (react/only-export-components). These need to be importable: they are the only
7+
* client lists in the GUI that neither tests/integrations-invariants.test.ts
8+
* compares nor the compiler forces, so a client added everywhere else still gets
9+
* no tab and nothing fails. gui/tests/integrations-tab-coverage.test.ts stands in
10+
* that gap and reads them from here.
11+
*/
12+
import type { TKey } from "../../i18n/shared";
13+
import type { FileIntegrationClientId } from "./FileIntegrationPage";
14+
15+
export type IntegrationTab =
16+
| "overview"
17+
| "keys"
18+
| "codex"
19+
| "claude"
20+
| "grok"
21+
| FileIntegrationClientId;
22+
23+
export interface TabDefinition {
24+
id: IntegrationTab;
25+
hash: string;
26+
labelKey: TKey;
27+
}
28+
29+
export const TABS: readonly TabDefinition[] = [
30+
{ id: "overview", hash: "integrations", labelKey: "integrations.tab.overview" },
31+
{ id: "keys", hash: "integrations/keys", labelKey: "integrations.tab.keys" },
32+
{ id: "codex", hash: "integrations/codex", labelKey: "integrations.tab.codex" },
33+
{ id: "claude", hash: "integrations/claude", labelKey: "integrations.tab.claude" },
34+
{ id: "grok", hash: "integrations/grok", labelKey: "integrations.tab.grok" },
35+
{ id: "opencode", hash: "integrations/opencode", labelKey: "integrations.tab.opencode" },
36+
{ id: "pi", hash: "integrations/pi", labelKey: "integrations.tab.pi" },
37+
{ id: "omp", hash: "integrations/omp", labelKey: "integrations.tab.omp" },
38+
{ id: "hermes", hash: "integrations/hermes", labelKey: "integrations.tab.hermes" },
39+
{ id: "openclaw", hash: "integrations/openclaw", labelKey: "integrations.tab.openclaw" },
40+
{ id: "kimi", hash: "integrations/kimi", labelKey: "integrations.tab.kimi" },
41+
{ id: "gajae", hash: "integrations/gajae", labelKey: "integrations.tab.gajae" },
42+
{ id: "dsh", hash: "integrations/dsh", labelKey: "integrations.tab.dsh" },
43+
{ id: "mcode", hash: "integrations/mcode", labelKey: "integrations.tab.mcode" },
44+
{ id: "zcode", hash: "integrations/zcode", labelKey: "integrations.tab.zcode" },
45+
{ id: "prime", hash: "integrations/prime", labelKey: "integrations.tab.prime" },
46+
{ id: "aside", hash: "integrations/aside", labelKey: "integrations.tab.aside" },
47+
] as const;
48+
49+
export const FILE_CLIENTS = new Set<FileIntegrationClientId>([
50+
"opencode",
51+
"pi",
52+
"omp",
53+
"hermes",
54+
"openclaw",
55+
"kimi",
56+
"gajae",
57+
"dsh",
58+
"mcode",
59+
"zcode",
60+
"prime",
61+
"aside",
62+
]);
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { expect, test } from "bun:test";
2+
import { FILE_CLIENTS, TABS } from "../src/pages/integrations/integration-tabs";
3+
import { FILE_INTEGRATION_CLIENTS } from "../src/pages/integrations/integration-api";
4+
import { INTEGRATION_TAB_HASHES } from "../src/app-routing";
5+
6+
/*
7+
* The gap this closes.
8+
*
9+
* tests/integrations-invariants.test.ts compares five client lists, and the
10+
* per-page label maps are Record<FileIntegrationClientId, TKey> so the compiler
11+
* forces those. TABS and FILE_CLIENTS are neither: they are a plain array and a
12+
* plain Set, so a client added everywhere else still gets no tab and nothing
13+
* fails. Aside was the twelfth client to walk this path, and the first with a
14+
* test standing in it.
15+
*
16+
* The expectation is DERIVED rather than written out, so adding client thirteen
17+
* cannot leave a stale literal here that passes by accident.
18+
*/
19+
test("every file client has a tab definition and is registered as a file client", () => {
20+
const tabbed = new Set(TABS.map(tab => tab.id));
21+
const missingTab = FILE_INTEGRATION_CLIENTS.filter(id => !tabbed.has(id));
22+
expect(missingTab).toEqual([]);
23+
24+
const missingFileClient = FILE_INTEGRATION_CLIENTS.filter(id => !FILE_CLIENTS.has(id));
25+
expect(missingFileClient).toEqual([]);
26+
});
27+
28+
test("every tab hash is routable, so a tab can actually be reached", () => {
29+
// App normalization strips an unregistered hash, which would render the
30+
// overview instead of the tab and look like a missing client.
31+
const routable = new Set<string>(INTEGRATION_TAB_HASHES);
32+
const unroutable = TABS.filter(tab => tab.hash !== "integrations" && !routable.has(tab.hash));
33+
expect(unroutable.map(tab => tab.hash)).toEqual([]);
34+
});
35+
36+
test("FILE_CLIENTS carries no id the API does not know", () => {
37+
const known = new Set<string>(FILE_INTEGRATION_CLIENTS);
38+
expect([...FILE_CLIENTS].filter(id => !known.has(id))).toEqual([]);
39+
});

0 commit comments

Comments
 (0)