Remove verified dead declarations (#2714)

* chore: remove verified dead declarations

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: narrow unused internal exports

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: remove newly exposed dead helpers

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: remove unused deep-link serializer

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: drop two tests that assert on copies of the code

mainLayoutMobileSidebarMount read MainLayout.tsx and SessionSidebar.tsx as
strings and asserted on source substrings down to exact indentation, so it
failed on formatting rather than behaviour. useProjectSessionSelection.test
reimplemented the hook's visitNodes logic inside the test file and asserted
against that copy, so it could not observe the hook at all.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: repair sync suites that had rotted while unrunnable

No runner executed packages/ui, so these drifted from the source unnoticed:
two imported helpers that are no longer exported, one directory-store stub
predated the session field routeMessage reads, and the WebSocket fake missed
the mandatory url-token mint plus the close event the socket wrapper reads.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: stop the web suite failing on timeouts and a hand-copied mock

The Git suites drive a real git binary, so the 5s default made a valid suite
fail differently per run. The gitApiHttp mock listed ~70 export names by hand
and fell behind the source; it now derives every stub from the real module,
which the added shared-UI aliases make resolvable.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: run every suite from one command and in CI

packages/ui (232 files) and packages/vscode (22) had no test script at all, CI
ran neither, and 9 vscode files could never run because Node cannot resolve
their extensionless TypeScript imports. Three electron files sat outside every
script list, one of them importing vitest, which that package does not depend
on. A runner gives each file its own process, since these suites keep
module-level singletons and fail by load order when sharing one.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* chore: delete a superseded repro harness and a completed plan

The issue-2638 harness needed lsof, overrode process.platform and spawned real
servers, and nothing referenced it; event-stream/rebind.test.js now covers the
same hub-pinned-to-the-old-port behaviour. The pairing v2 plan described relay
and the pairing UI as out of scope, both of which shipped.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* docs: point at the theme tools and record the github barrel invariant

convert-vscode-theme and harmonize-theme were referenced nowhere, so the
theme-authoring reference now names them. The github barrel is loaded through
await import('./index.js') and destructured per route, which no static report
can see; documenting that is what stops the next cleanup from deleting it.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>

* test: repair merge drift in bridge and route-registry mocks

upstream/main gained upsertProviderConfig on bridge-system-runtime and a
PATCH scheduled-task route after this branch forked. Their test doubles
were never updated to match:
- bridge-system-runtime.test.js: add upsertProviderConfig to the
  opencodeConfig mock so the import resolves.
- sse-routes.test.js: add app.patch to the route registry stub.

---------

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Serhii Dziupin
2026-08-13 15:30:54 +03:00
committed by GitHub
co-authored by Serhii Dziupin
parent 61533ed881
commit 86e6a2ae76
65 changed files with 238 additions and 2509 deletions
@@ -43,6 +43,15 @@ export const presetThemes: Theme[] = [
bun run type-check && bun run lint && bun run build
```
## Authoring Tools
Both do the mechanical work of steps 12 and are run by hand:
- `node scripts/convert-vscode-theme.cjs <vscode-theme.json>` converts a VS Code
theme into this format and registers it in `presets.ts`.
- `node scripts/harmonize-theme.mjs <theme.json> [--write]` aligns accent roles
to one chroma/lightness target in OKLCH so borrowed colors read as one family.
## Key Files
- Theme types: `packages/ui/src/types/theme.ts`
+3
View File
@@ -32,6 +32,9 @@ jobs:
- name: Lint
run: bun run lint
- name: Tests
run: bun run test
- name: Electron Linux packaging unit tests
working-directory: packages/electron
run: |
+7
View File
@@ -113,9 +113,16 @@ The final AppImage verifier checks desktop identity and the architecture of Elec
```bash
bun run type-check # Must pass
bun run lint # Must pass
bun run test # Must pass
bun run build # Must succeed
```
`bun run test` runs every suite in the repository: shared UI, VS Code, Electron,
web/server, and the root scripts. The UI, VS Code, and Electron suites keep
module-level singletons, so `scripts/run-isolated-tests.mjs` gives each test file
its own process instead of letting load order decide the result. Run a single
file directly while iterating (`bun test <file>`).
For docs-only changes, validation may be enough:
```bash
-948
View File
@@ -1,948 +0,0 @@
# Pairing v2 Trusted-Device Issuance Backend Plan
## Scope
Implement the Pairing v2 mechanism without UI.
Included:
- Backend pairing session runtime.
- Pairing create/redeem/cancel routes.
- Trusted-device token issuance through the existing remote client auth runtime.
- Backward-compatible remote client metadata extension.
- Password/passkey issuance metadata alignment.
- Shared v2 `openchamber://connect` payload helpers.
Not included:
- Settings page.
- QR modal.
- Pair Device button.
- Device list UI.
- Translations/copy.
- Relay implementation.
- LAN discovery.
- End-user polished mobile/desktop screens.
## Naming
Use the existing `client-auth` domain.
New module:
```text
packages/web/server/lib/client-auth/pairing.js
```
Existing durable token module remains:
```text
packages/web/server/lib/client-auth/remote-clients.js
```
Conceptual names:
```text
Remote client
Trusted-device client token
Pairing session
Pairing secret
Pairing redeem
```
Deep link stays:
```text
openchamber://connect
```
Versions:
```text
v=1 => legacy server + long-lived token import
v=2 => one-time pairing handshake
```
## New Files
### 1. `packages/web/server/lib/client-auth/pairing.js`
Create a new backend runtime module for short-lived pairing sessions.
Responsibilities:
```text
createPairingSession
getPairingSession
cancelPairingSession
redeemPairingSession
sweepExpiredSessions
```
Store file:
```text
OPENCHAMBER_DATA_DIR/client-pairing-sessions.json
```
Suggested store shape:
```json
{
"version": 1,
"sessions": [
{
"id": "pair_...",
"secretHash": "...",
"createdAt": "...",
"expiresAt": "...",
"usedAt": null,
"cancelledAt": null,
"clientId": null,
"label": "Pair new device",
"fingerprint": "ABCD-1234",
"allowedClientKinds": ["mobile", "desktop"],
"createdByClientId": null
}
]
}
```
Security requirements:
```text
Persist only secretHash.
Return plaintext secret only from createPairingSession.
Redeem is one-time.
Redeem is expiry-aware.
Redeem is cancellation-aware.
Redeem must be mutation-serialized to avoid double issuance.
No raw token/secret logging.
```
Public methods should accept injected dependencies, following `remote-clients.js` style:
```js
createClientPairingRuntime({
fsPromises,
path,
crypto,
storePath,
remoteClientAuthRuntime,
})
```
## Existing Files To Update
### 2. `packages/web/server/lib/client-auth/remote-clients.js`
Extend trusted-device metadata backward-compatibly.
Current `createClient` input:
```js
{
label,
expiresAt,
clientKind,
dedupeKey,
}
```
Extend to:
```js
{
label,
expiresAt,
clientKind,
dedupeKey,
authMethod,
pairingId,
deviceName,
devicePlatform,
deviceModel,
appVersion,
}
```
Add normalized public fields:
```text
authMethod
pairingId
deviceName
devicePlatform
deviceModel
appVersion
```
Backward compatibility rules:
```text
Existing remote-clients.json remains valid.
Missing new fields normalize to null.
Existing tokens continue authenticating.
Public client output never exposes tokenHash.
Raw token is returned only from createClient.
```
Recommended `authMethod` values:
```text
pairing
password
passkey
desktop-local
manual
legacy
```
Do not force migration for old records. Treat missing `authMethod` as legacy/null.
### 3. `packages/web/server/index.js`
Instantiate the new pairing runtime next to `remoteClientAuthRuntime`.
Existing:
```js
const remoteClientAuthRuntime = createRemoteClientAuthRuntime({
fsPromises,
path,
crypto,
storePath: REMOTE_CLIENTS_FILE_PATH,
});
```
Add:
```js
const CLIENT_PAIRING_SESSIONS_FILE_PATH = path.join(
OPENCHAMBER_DATA_DIR,
'client-pairing-sessions.json',
);
```
Then:
```js
const clientPairingRuntime = createClientPairingRuntime({
fsPromises,
path,
crypto,
storePath: CLIENT_PAIRING_SESSIONS_FILE_PATH,
remoteClientAuthRuntime,
});
```
Pass `clientPairingRuntime` into `registerAuthAndAccessRoutes` dependencies.
### 4. `packages/web/server/lib/opencode/core-routes.js`
Add pairing routes near existing client-auth routes:
```text
/api/client-auth/clients
```
Add:
```http
POST /api/client-auth/pairing/sessions
DELETE /api/client-auth/pairing/sessions/:id
POST /api/client-auth/pairing/redeem
```
Optional, can be deferred:
```http
GET /api/client-auth/pairing/sessions/:id
```
Since UI polling is out of scope, `GET` is not required for this phase.
#### Route: `POST /api/client-auth/pairing/sessions`
Purpose:
```text
Create one short-lived pairing session and return data needed to build QR/deep link.
```
Auth:
```text
Require UI session auth.
Allow desktop-local client only if consistent with existing client-create exception.
Reject arbitrary remote client tokens.
Reject url-token auth.
```
Request:
```json
{
"label": "Pair new device",
"allowedClientKinds": ["mobile", "desktop"]
}
```
Response:
```json
{
"pairing": {
"id": "pair_...",
"secret": "one_time_secret",
"expiresAt": "...",
"fingerprint": "ABCD-1234",
"label": "Pair new device"
},
"server": {
"label": "OpenChamber",
"candidates": [
{
"type": "lan",
"url": "http://192.168.1.20:4096",
"priority": 10
},
{
"type": "tunnel",
"url": "https://abc.ngrok.app",
"priority": 20
}
]
}
}
```
Headers:
```http
Cache-Control: no-store
```
Note:
```text
This route does not render QR.
UI can later encode the returned data into openchamber://connect?v=2&p=...
```
#### Route: `DELETE /api/client-auth/pairing/sessions/:id`
Purpose:
```text
Cancel an unused pairing session.
```
Auth:
```text
Require owner/session auth.
```
Behavior:
```text
Set cancelledAt.
Do not delete immediately.
If already used, cancellation should not revoke the issued client.
```
Response:
```json
{
"cancelled": true
}
```
#### Route: `POST /api/client-auth/pairing/redeem`
Purpose:
```text
Exchange pairingId + one-time secret for a trusted-device client token.
```
Auth:
```text
No existing auth required.
The one-time pairing secret is the authentication factor.
```
Request:
```json
{
"pairingId": "pair_...",
"secret": "one_time_secret",
"clientLabel": "Iryna iPhone",
"clientKind": "mobile",
"deviceName": "Iryna iPhone",
"devicePlatform": "ios",
"deviceModel": "iPhone",
"appVersion": "1.12.0",
"dedupeKey": "optional-stable-device-key"
}
```
Server behavior:
```text
Validate pairing exists.
Validate secret using constant-time comparison.
Validate not expired.
Validate not cancelled.
Validate not used.
Validate clientKind is allowed.
Mark pairing used.
Create remote client through remoteClientAuthRuntime.createClient.
Return clientToken once.
```
Create client with:
```js
{
label: clientLabel || deviceName || 'Remote client',
clientKind,
dedupeKey,
authMethod: 'pairing',
pairingId,
deviceName,
devicePlatform,
deviceModel,
appVersion,
}
```
Response:
```json
{
"ok": true,
"server": {
"label": "OpenChamber",
"url": "https://selected-or-current-url",
"fingerprint": "ABCD-1234"
},
"client": {
"id": "device_...",
"label": "Iryna iPhone",
"clientKind": "mobile",
"authMethod": "pairing",
"createdAt": "..."
},
"clientToken": "oc_client_..."
}
```
Headers:
```http
Cache-Control: no-store
```
Failure response should be generic:
```json
{
"error": "Invalid or expired pairing session"
}
```
Do not reveal whether id, secret, expiry, used, or cancellation caused failure.
### 5. `packages/web/server/lib/ui-auth/ui-auth.js`
Preserve existing password/passkey behavior.
Only add metadata to client token issuance when `issueClientToken === true`.
Password issuance should pass:
```js
authMethod: 'password'
clientKind: req.body?.clientKind
dedupeKey: req.body?.dedupeKey
deviceName: req.body?.deviceName
devicePlatform: req.body?.devicePlatform
deviceModel: req.body?.deviceModel
appVersion: req.body?.appVersion
```
Passkey issuance should pass:
```js
authMethod: 'passkey'
clientKind: req.body?.clientKind
dedupeKey: req.body?.dedupeKey
deviceName: req.body?.deviceName
devicePlatform: req.body?.devicePlatform
deviceModel: req.body?.deviceModel
appVersion: req.body?.appVersion
```
Backward compatibility:
```text
Existing POST /auth/session payload still works.
Existing response shape still works.
Existing clientToken issuance still works.
Password login remains disabled for tunnel/public scope.
```
### 6. `packages/ui/src/lib/connectionPayload.ts`
Extend existing connect payload helpers.
Keep current v1 behavior:
```text
openchamber://connect?v=1&server=...&token=...&label=...
```
Add v2 payload types and helpers.
Suggested types:
```ts
export type ClientConnectionPayloadV1 = {
v: 1;
serverUrl: string;
token: string;
label?: string;
};
export type PairingEndpointCandidate = {
type: 'lan' | 'tunnel' | 'relay';
url: string;
priority?: number;
};
export type PairingConnectionPayloadV2 = {
v: 2;
pairingId: string;
secret: string;
label?: string;
fingerprint?: string;
expiresAt?: string;
candidates: PairingEndpointCandidate[];
};
```
Suggested helpers:
```ts
encodePairingConnectionPayload(payload: PairingConnectionPayloadV2): string
parsePairingConnectionPayload(value: string): PairingConnectionPayloadV2 | null
```
Use deep link format:
```text
openchamber://connect?v=2&p=<base64url-json>
```
Validation:
```text
Require v=2.
Require pairingId.
Require secret.
Require at least one valid http/https candidate.
Reject malformed URL.
Reject oversized payload.
Reject expired payload locally if expiresAt is clearly in the past.
```
Do not break current exports used by mobile QR/manual connect.
### 7. `packages/ui/src/apps/mobileQrScan.ts`
Update parser only.
Current scan parser recognizes legacy fields like:
```text
server
label
```
Add support for v2 connect links.
Output should be able to distinguish:
```text
legacy v1 token import
pairing v2 payload
plain URL
```
Do not implement full mobile UI flow in this scope unless there is already a non-UI callable path.
### 8. `packages/ui/src/apps/mobileConnections.ts`
Add non-visual callable mechanism for redeeming pairing payload.
Add a function conceptually like:
```ts
redeemPairingConnection(payload: PairingConnectionPayloadV2): Promise<void>
```
Responsibilities:
```text
Try endpoint candidates.
POST /api/client-auth/pairing/redeem.
Persist issued token securely.
Persist connection metadata.
Switch runtime only after token write succeeds.
```
No new screens/buttons.
Existing password flow remains unchanged.
Candidate selection:
```text
Normalize candidates.
Probe /health with timeout.
Try candidates by priority.
Prefer HTTPS when priority ties.
If network failure, try next candidate.
If server says invalid/expired/used, stop.
```
Mobile native should reuse existing native HTTP fallback path for LAN HTTP.
### 9. `packages/electron/main.mjs`
Extend existing connect deep-link handling.
Current v1 behavior:
```text
openchamber://connect?v=1&server=...&token=...
```
Keep it.
Add v2 branch:
```text
openchamber://connect?v=2&p=...
```
Behavior:
```text
Parse v2 payload.
Show confirmation before redeem/write/switch.
Probe candidates.
Redeem pairing secret.
Store returned clientToken in desktop hosts config.
Ask/switch according to existing remote host behavior.
Never show token.
Never write config before confirmation.
```
If this phase is strictly backend-only, this file can be deferred. But if desktop app as client must be functionally supported by deep link in this phase, include this change.
### 10. `packages/electron/preload.mjs`
No change expected unless a renderer-side desktop API is needed for pairing redeem.
Prefer keeping pairing redeem in main process only for deep-link handling if desktop v2 is implemented there.
### 11. `packages/web/server/lib/ui-auth/DOCUMENTATION.md`
Update module documentation to reflect the unified issuance model:
```text
Password, passkey, and pairing are issuance methods.
Trusted-device client token is the durable credential.
Pairing v2 uses one-time secrets and issues remote client tokens.
```
Optionally add:
```text
packages/web/server/lib/client-auth/DOCUMENTATION.md
```
if the client-auth module needs ownership docs.
## Route Registration Summary
Add to `registerAuthAndAccessRoutes`:
```http
POST /api/client-auth/pairing/sessions
DELETE /api/client-auth/pairing/sessions/:id
POST /api/client-auth/pairing/redeem
```
Optional later:
```http
GET /api/client-auth/pairing/sessions/:id
```
Route placement:
```text
Register before generic OpenCode proxy.
Place near existing /api/client-auth/clients routes.
```
## Execution Sequence
### Step 1: Extend Remote Client Metadata
Files:
```text
packages/web/server/lib/client-auth/remote-clients.js
```
Do:
```text
Add metadata normalization.
Extend createClient input.
Extend publicClient output.
Keep old records valid.
Do not change token generation/authentication behavior.
```
### Step 2: Add Password/Passkey Metadata Issuance
Files:
```text
packages/web/server/lib/ui-auth/ui-auth.js
```
Do:
```text
When issueClientToken is true, pass authMethod='password' from password login.
When issueClientToken is true, pass authMethod='passkey' from passkey auth.
Pass optional device metadata through.
Preserve response shape.
```
### Step 3: Create Pairing Runtime Module
Files:
```text
packages/web/server/lib/client-auth/pairing.js
```
Do:
```text
Implement session creation.
Implement hashed secret storage.
Implement cancel.
Implement redeem.
Implement expiry/used/cancelled checks.
Integrate remoteClientAuthRuntime.createClient in redeem.
```
### Step 4: Instantiate Pairing Runtime
Files:
```text
packages/web/server/index.js
```
Do:
```text
Define CLIENT_PAIRING_SESSIONS_FILE_PATH.
Instantiate createClientPairingRuntime.
Pass clientPairingRuntime to registerAuthAndAccessRoutes.
```
### Step 5: Add Pairing Routes
Files:
```text
packages/web/server/lib/opencode/core-routes.js
```
Do:
```text
Destructure clientPairingRuntime from dependencies.
Add POST /api/client-auth/pairing/sessions.
Add DELETE /api/client-auth/pairing/sessions/:id.
Add POST /api/client-auth/pairing/redeem.
Use correct auth gates.
Set Cache-Control: no-store where secrets/tokens are returned.
Keep error responses generic for redeem.
```
### Step 6: Add v2 Payload Helpers
Files:
```text
packages/ui/src/lib/connectionPayload.ts
```
Do:
```text
Keep v1 helpers unchanged.
Add v2 payload type.
Add encode v2 helper.
Add parse v2 helper.
Use openchamber://connect?v=2&p=<base64url-json>.
Validate candidates.
Reject malformed/expired/oversized payloads.
```
### Step 7: Update QR Scan Parser Shape
Files:
```text
packages/ui/src/apps/mobileQrScan.ts
```
Do:
```text
Recognize v2 connect payload.
Return structured v2 result.
Do not add new UI.
Do not break v1/manual URL behavior.
```
### Step 8: Add Non-UI Mobile Redeem Plumbing
Files:
```text
packages/ui/src/apps/mobileConnections.ts
```
Do:
```text
Add callable redeem pairing function.
Try endpoint candidates.
Redeem via /api/client-auth/pairing/redeem.
Persist token before runtime switch.
Reuse existing storage model.
Keep password/manual connect unchanged.
```
### Step 9: Add Desktop Deep-Link v2 Handling If In Scope
Files:
```text
packages/electron/main.mjs
```
Do:
```text
Extend connect deep-link parser to recognize v2.
Confirm before redeem.
Redeem against candidate endpoint.
Store remote host config with returned token.
Switch only after confirmation and successful storage.
Keep v1 behavior unchanged.
```
If desktop client deep-link support is deferred, skip this step and document that v2 backend/shared payload exists but desktop consumer is not wired yet.
### Step 10: Update Documentation
Files:
```text
packages/web/server/lib/ui-auth/DOCUMENTATION.md
```
Optionally add:
```text
packages/web/server/lib/client-auth/DOCUMENTATION.md
```
Document:
```text
Unified trusted-device token issuance.
Pairing v2 flow.
Password/passkey/pairing authMethod values.
Security rules.
Backward compatibility guarantees.
```
## Important Non-Goals
Do not implement:
```text
Settings page
Pair Device button
QR modal
Device list UI
Translations
Visual design
Relay transport
LAN discovery
Account/cloud sync
Token migration to OS keychain on desktop
```
## Backward Compatibility Requirements
Must remain true:
```text
Existing v1 openchamber://connect links keep working.
Existing password login with issueClientToken keeps working.
Existing passkey issueClientToken keeps working.
Existing remote-clients.json keeps loading.
Existing client tokens keep authenticating.
Existing mobile saved connections keep working.
Existing desktop remote hosts keep working.
```
## Security Requirements
Must hold:
```text
No long-lived token in v2 link.
Pairing secret persisted only as hash.
Pairing secret returned only once.
Client token returned only once.
Token hash persisted server-side.
Redeem is one-time.
Redeem is expiry-aware.
Redeem is cancellation-aware.
Redeem errors are generic.
Password login remains disabled for tunnel/public scope.
Pairing session creation requires owner/session auth.
Pairing redeem requires no prior auth but requires valid one-time secret.
Desktop v2 connect confirms before writing host config or switching runtime.
```
+1
View File
@@ -38,6 +38,7 @@
"lint:ui": "bun run --cwd packages/ui lint",
"lint:electron": "bun run --cwd packages/electron lint",
"lint:mobile": "bun run --cwd packages/mobile lint",
"test": "node scripts/run-isolated-tests.mjs scripts && bun run --cwd packages/ui test && bun run --cwd packages/vscode test && bun run --cwd packages/electron test && bun run --cwd packages/web test",
"clean": "bun run --filter '*' clean",
"changelog-card": "node scripts/changelog-card/generate.mjs",
"postinstall": "node ./fix-deprecation.js && patch-package && node ./packages/electron/scripts/ensure-electron.mjs --best-effort",
+5 -7
View File
@@ -8,7 +8,7 @@ const DEFAULT_XDG_DATA_DIRS = ['/usr/local/share', '/usr/share'];
const TARGET_FIELD_CODES = new Set(['f', 'F', 'u', 'U']);
const TERMINAL_APP_IDS = new Set(['terminal', 'iterm2', 'ghostty']);
export const LINUX_CLI_BY_APP_ID = {
const LINUX_CLI_BY_APP_ID = {
vscode: 'code',
cursor: 'cursor',
vscodium: 'codium',
@@ -43,7 +43,7 @@ const normalizeComparable = (value) => String(value || '')
.trim();
const normalizeCompactComparable = (value) => normalizeComparable(value).replace(/\s+/g, '');
export const stripDesktopExecFieldCodes = (execValue) => String(execValue || '')
const stripDesktopExecFieldCodes = (execValue) => String(execValue || '')
.replace(/%%/g, '\^@')
.replace(/%[fFuUdDnNickvm]/g, '')
.replace(/%./g, '')
@@ -142,9 +142,7 @@ export const readLinuxDesktopEntries = async (options = {}) => {
return entries.sort((left, right) => left.name.localeCompare(right.name));
};
export const discoverLinuxDesktopApps = readLinuxDesktopEntries;
export const desktopEntryMatchesApp = (entry, appName, appId = '') => {
const desktopEntryMatchesApp = (entry, appName, appId = '') => {
const needles = uniqueStrings([appName, appId]).flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]).filter(Boolean);
const haystacks = [entry.name, entry.id, path.basename(entry.filePath || ''), entry.exec]
.flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]);
@@ -331,7 +329,7 @@ const pathExistsSync = (candidate) => {
}
};
export const linuxIconThemeDirs = ({ env = process.env, homeDir = os.homedir() } = {}) => {
const linuxIconThemeDirs = ({ env = process.env, homeDir = os.homedir() } = {}) => {
const dataHome = typeof env.XDG_DATA_HOME === 'string' && env.XDG_DATA_HOME.trim()
? env.XDG_DATA_HOME.trim()
: path.join(homeDir || os.homedir(), '.local', 'share');
@@ -419,7 +417,7 @@ export const resolveLinuxIconFile = (iconName, options = {}) => {
return null;
};
export const resolveDefaultLinuxFileManagerId = ({ env = process.env, execFileSyncImpl = execFileSync } = {}) => {
const resolveDefaultLinuxFileManagerId = ({ env = process.env, execFileSyncImpl = execFileSync } = {}) => {
try {
const output = String(execFileSyncImpl('xdg-mime', ['query', 'default', 'inode/directory'], {
encoding: 'utf8',
+1 -1
View File
@@ -5,7 +5,7 @@ import path from 'node:path';
const AUTOSTART_FILE_NAME = 'openchamber.desktop';
export const resolveLinuxAutostartDirectory = ({
const resolveLinuxAutostartDirectory = ({
env = process.env,
homeDir = os.homedir(),
} = {}) => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from 'bun:test';
import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs';
+1
View File
@@ -40,6 +40,7 @@
"bundle:main": "bun ./scripts/bundle-main.mjs",
"generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs",
"rebuild:native": "node ./scripts/rebuild-native.mjs",
"test": "node ../../scripts/run-isolated-tests.mjs .",
"test:architecture": "node --test ./startup-url-selection.test.mjs ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs ./scripts/ensure-electron.test.mjs",
"test:updater": "node --test ./updater-capability.test.mjs ./updater-channel.test.mjs ./updater-check.test.mjs ./updater-feed.test.mjs ./scripts/finalize-latest-yml.test.mjs ./scripts/updater-e2e-fixture.test.mjs",
"test:linux-desktop": "node --test ./linux-autostart.test.mjs && node ./scripts/smoke-linux-app-discovery.mjs && node ./scripts/smoke-path-open-utils.mjs",
+1 -1
View File
@@ -12,7 +12,7 @@ const accessErrorMessage = (label, targetPath, error) => {
return `${label} could not be checked: ${error?.message || String(error)}`;
};
export const normalizeRequiredPath = (rawPath, label = 'Path') => {
const normalizeRequiredPath = (rawPath, label = 'Path') => {
const targetPath = typeof rawPath === 'string' ? rawPath.trim() : '';
if (!targetPath) {
throw new Error(`${label} is required`);
+1 -1
View File
@@ -1,7 +1,7 @@
const MISSING_UPDATE_FEED_RE =
/404|ENOTFOUND|Cannot find (?:channel|latest)|latest-linux(?:-arm64)?\.yml|HttpError:\s*404|status code 404/i;
export const isMissingUpdateFeedError = (error) => {
const isMissingUpdateFeedError = (error) => {
const message = error instanceof Error ? error.message : String(error ?? '');
return MISSING_UPDATE_FEED_RE.test(message);
};
+2 -1
View File
@@ -8,7 +8,8 @@
"dev": "tsc --noEmit --watch",
"build": "tsc --noEmit",
"type-check": "tsc --noEmit",
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js"
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js",
"test": "node ../../scripts/run-isolated-tests.mjs src"
},
"dependencies": {
"@aparajita/capacitor-secure-storage": "^8.0.0",
+3 -7
View File
@@ -3,7 +3,7 @@ import React from 'react';
import { isCapacitorApp } from '@/lib/platform';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks';
import { parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks';
/**
* Navigation layer for {@link DeepLinkIntent}s — the only place that knows how to *apply* a
@@ -93,13 +93,13 @@ const flush = (): void => {
};
/** Apply an intent now if possible, otherwise stash it until the app is ready / a handler appears. */
export const applyDeepLinkIntent = (intent: DeepLinkIntent): void => {
const applyDeepLinkIntent = (intent: DeepLinkIntent): void => {
pending = intent;
flush();
};
/** Convenience: parse a raw `openchamber://…` URL and apply it. No-op for unrecognised URLs. */
export const applyDeepLinkUrl = (raw: string | null | undefined): void => {
const applyDeepLinkUrl = (raw: string | null | undefined): void => {
const intent = parseDeepLink(raw);
if (intent) {
applyDeepLinkIntent(intent);
@@ -192,7 +192,3 @@ export const useDeepLinkSource = (options: { ready: boolean }): void => {
};
}, []);
};
// Re-export so producers (notifications, future widgets) have one import for the whole vocabulary.
export { buildDeepLink, parseDeepLink };
export type { DeepLinkIntent, SessionsFilter, ViewTarget };
+1 -44
View File
@@ -10,7 +10,7 @@
* context — including, eventually, a tiny encoder shared with the native widget/extension.
*/
export const DEEP_LINK_SCHEME = 'openchamber';
const DEEP_LINK_SCHEME = 'openchamber';
export type SessionsFilter = 'all' | 'attention' | 'recent';
export type ViewTarget = 'files' | 'mcp' | 'instances' | 'update';
@@ -124,46 +124,3 @@ export function parseDeepLink(raw: string | null | undefined): DeepLinkIntent |
return null;
}
}
/**
* Build a canonical `openchamber://…` URL for an intent. Used by anything that needs to hand
* a deep link to iOS — notification payloads, `widgetURL(...)`, Live Activity tap targets —
* so every producer emits the exact shape {@link parseDeepLink} understands.
*/
export function buildDeepLink(intent: DeepLinkIntent): string {
const base = `${DEEP_LINK_SCHEME}://`;
const withQuery = (path: string, params: Record<string, string | undefined>): string => {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (typeof value === 'string' && value.length > 0) {
search.set(key, value);
}
}
const query = search.toString();
return query ? `${base}${path}?${query}` : `${base}${path}`;
};
switch (intent.type) {
case 'session':
return withQuery(`session/${encodeURIComponent(intent.sessionId)}`, { dir: intent.directory });
case 'new-session':
return withQuery('new', {
dir: intent.directory,
project: intent.projectId,
agent: intent.agent,
model: intent.model,
});
case 'sessions':
return withQuery('sessions', { filter: intent.filter });
case 'status':
return `${base}status`;
case 'settings':
return intent.section ? `${base}settings/${encodeURIComponent(intent.section)}` : `${base}settings`;
case 'changes':
return withQuery(intent.path ? `changes/${intent.path}` : 'changes', {
staged: intent.staged ? 'true' : undefined,
});
case 'view':
return `${base}view/${intent.target}`;
}
}
+7 -7
View File
@@ -164,7 +164,7 @@ type PairingRedeemResponse = {
// URL helpers
// ---------------------------------------------------------------------------
export const normalizeConnectionUrl = (value: string): string => {
const normalizeConnectionUrl = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) return '';
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
@@ -175,7 +175,7 @@ export const normalizeConnectionUrl = (value: string): string => {
return url.toString().replace(/\/+$/, '');
};
export const getConnectionLabel = (url: string): string => {
const getConnectionLabel = (url: string): string => {
try {
return new URL(url).host;
} catch {
@@ -191,7 +191,7 @@ const getConnectionStorageKey = (url: string): string => {
}
};
export const isSameConnectionUrl = (left: string, right: string): boolean =>
const isSameConnectionUrl = (left: string, right: string): boolean =>
getConnectionStorageKey(left) === getConnectionStorageKey(right);
// ---------------------------------------------------------------------------
@@ -201,7 +201,7 @@ export const isSameConnectionUrl = (left: string, right: string): boolean =>
// Stable identity for a relay connection. Also used as the runtime key passed
// to switchRuntimeEndpoint so "is this saved entry the active runtime?" checks
// can compare against getRuntimeKey().
export const relayConnectionRuntimeKey = (relay: MobileRelayConfig): string =>
const relayConnectionRuntimeKey = (relay: MobileRelayConfig): string =>
`relay:${relay.serverId}@${relay.relayUrl.trim()}`;
// Stable, non-fetchable pseudo-URL for a relay-only device (display only).
@@ -790,7 +790,7 @@ export const upsertMobileConnection = async (
return next;
};
export const deleteMobileConnection = async (id: string): Promise<MobileSavedConnection[]> => {
const deleteMobileConnection = async (id: string): Promise<MobileSavedConnection[]> => {
const connections = readConnections();
const removed = connections.find((connection) => connection.id === id) ?? null;
const next = connections.filter((connection) => connection.id !== id);
@@ -1147,7 +1147,7 @@ const establishLiveTransport = async (
// tunnel via runtimeFetch. A transport failure/timeout is transient (the tunnel
// reconnects on its own) and must not masquerade as a revoked session, so only
// an explicit auth rejection reports invalid.
export const validateActiveRuntimeSession = async (input: {
const validateActiveRuntimeSession = async (input: {
url: string;
clientToken?: string | null;
}, options?: { fast?: boolean }): Promise<boolean> => {
@@ -1283,7 +1283,7 @@ let candidateRefreshInFlight = false;
// Only runs for relay-paired connections: their token/runtime key derives from
// the stable relay identity, so rewriting direct URLs cannot orphan the stored
// token. The response must echo the connection's serverId or it is ignored.
export const refreshActiveConnectionCandidates = async (): Promise<CandidateRefreshResult> => {
const refreshActiveConnectionCandidates = async (): Promise<CandidateRefreshResult> => {
if (candidateRefreshInFlight) return 'skipped';
const active = findActiveConnection();
if (!active) {
-7
View File
@@ -1,5 +1,3 @@
import type { ProjectEntry } from '@/lib/api/types';
export const normalizePath = (value?: string | null): string =>
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
@@ -9,8 +7,3 @@ export const getProjectLabel = (path: string): string => {
const segments = normalized.split('/').filter(Boolean);
return segments[segments.length - 1]?.replace(/[-_]/g, ' ') || normalized;
};
export const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: string): string => {
if (project) return project.label?.trim() || getProjectLabel(project.path);
return getProjectLabel(fallbackDirectory);
};
+1 -1
View File
@@ -70,7 +70,7 @@ const projectLabelForDirectory = (directory: string | null, projects: ProjectEnt
return basename(directory);
};
export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
const sessions = useGlobalSessionsStore.getState().activeSessions;
const unseenBySession = useNotificationStore.getState().index.session.unseenCount;
const notifyOnSubtasks = useUIStore.getState().notifyOnSubtasks;
@@ -41,7 +41,7 @@ const languageContextField = StateField.define<ComposerLanguageContext>({
},
});
export const EMPTY_CONTEXT: ComposerLanguageContext = {
const EMPTY_CONTEXT: ComposerLanguageContext = {
inputMode: 'normal',
knownAgentNames: new Set(),
confirmedMentions: new Set(),
@@ -90,8 +90,3 @@ export function composerLanguage(initial: ComposerLanguageContext = EMPTY_CONTEX
decorationField,
];
}
/** The context currently in effect, for callers that need to read it back. */
export function readLanguageContext(view: EditorView): ComposerLanguageContext {
return view.state.field(languageContextField);
}
@@ -152,7 +152,7 @@ export const NATIVE_SELECTION_THEME_SPEC = {
},
};
export const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
const composerNativeSelectionTheme = EditorView.theme(NATIVE_SELECTION_THEME_SPEC);
/**
* The native-selection arrangement, installed on every device: the theme
@@ -114,5 +114,3 @@ function matchMention(
});
return query === null ? null : { kind: 'mention', query };
}
export type { FileMentionAutocompleteInputSource };
@@ -91,7 +91,7 @@ export function buildImagePasteInsertion(pastedText: string, citationText: strin
* A single-line URL pasted over a selection becomes a markdown link rather
* than replacing the selected text.
*/
export const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
const PASTE_LINK_URL_PATTERN = /^(https?:\/\/|mailto:)\S+$/i;
/**
* Whether a pasted URL should wrap the selection as `[selected](url)`. A URL
@@ -54,7 +54,7 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
projectColor ? PROJECT_COLOR_MAP[projectColor] ?? undefined : undefined;
/** A project's icon (custom image, configured icon, or a folder) plus its name. */
export function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconColor = getProjectIconColor(project.color);
const fallbackIcon = projectIconName ? (
@@ -58,7 +58,7 @@ const toSelectionNode = (node: Node): SelectionNode | null => {
};
};
export const trimSelectionNodes = (nodes: SelectionNode[]): SelectionNode[] => {
const trimSelectionNodes = (nodes: SelectionNode[]): SelectionNode[] => {
return nodes
.filter((node) => node.type === 'text' || !node.isCodeLineNumber)
.map((node) => node.type === 'text'
@@ -1,48 +0,0 @@
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const mainLayoutSource = readFileSync(
join(__dirname, '..', 'MainLayout.tsx'),
'utf-8',
);
const sessionSidebarSource = readFileSync(
join(__dirname, '..', '..', 'session', 'SessionSidebar.tsx'),
'utf-8',
);
describe('MainLayout mobile SessionSidebar mount (issue #1695 regression guard)', () => {
test('mobile SessionSidebar is not conditionally mounted on mobileLeftDrawerVisible', () => {
const mobileSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar mobileVariant');
expect(mobileSidebarIndex).toBeGreaterThan(-1);
const windowStart = Math.max(0, mobileSidebarIndex - 400);
const precedingWindow = mainLayoutSource.slice(windowStart, mobileSidebarIndex);
expect(/\{\s*mobileLeftDrawerVisible\s*&&\s*\(/.test(precedingWindow)).toBe(false);
expect(precedingWindow.includes('pointer-events-none')).toBe(true);
expect(mainLayoutSource.slice(mobileSidebarIndex, mobileSidebarIndex + 120)).toContain('isVisible={mobileLeftDrawerVisible}');
});
test('desktop SessionSidebar is rendered inside Sidebar without drawer-visibility gating', () => {
const desktopSidebarIndex = mainLayoutSource.indexOf('<SessionSidebar isVisible={isSidebarOpen} />');
expect(desktopSidebarIndex).toBeGreaterThan(-1);
const windowStart = Math.max(0, desktopSidebarIndex - 300);
const precedingWindow = mainLayoutSource.slice(windowStart, desktopSidebarIndex);
expect(precedingWindow).toContain('<Sidebar');
expect(/mobileLeftDrawerVisible\s*&&/.test(precedingWindow)).toBe(false);
});
test('hidden sidebars disable render-only subscriptions and effects', () => {
expect(sessionSidebarSource).toContain('useGitAllBranches(isVisible)');
expect(sessionSidebarSource).toContain('useGitRepoStatusMap(isVisible ? normalizedProjectPaths : EMPTY_STRING_ARRAY)');
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isSessionSearchOpen');
expect(sessionSidebarSource).toContain('enabled: isVisible,\n isDesktopShellRuntime');
expect(sessionSidebarSource).toContain('if (!isVisible) return EMPTY_STRING_ARRAY;');
});
});
@@ -7,7 +7,7 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
const HEX_COLOR_PATTERN = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
export const normalizeProjectIconBackground = (value: string | null | undefined): string | null => {
const normalizeProjectIconBackground = (value: string | null | undefined): string | null => {
if (!value) {
return null;
}
@@ -6,9 +6,9 @@
export const CUSTOM_PROVIDER_NPM = '@ai-sdk/openai-compatible';
export const CUSTOM_PROVIDER_ID = '__custom_provider__';
export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
export const BASE_URL_PATTERN = /^https?:\/\//;
export const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9-_]*$/;
const BASE_URL_PATTERN = /^https?:\/\//;
const ENV_KEY_PATTERN = /^\{env:([^}]+)\}$/;
export type CustomProviderTranslator = (
key: string,
@@ -126,7 +126,7 @@ export const createEmptyCustomProviderForm = (): CustomProviderFormState => ({
headers: [createHeaderRow()],
});
export function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
function parseEnvApiKey(apiKey: string): { env?: string; key?: string } {
const trimmed = apiKey.trim();
if (!trimmed) {
return {};
@@ -59,7 +59,7 @@ export const SETTINGS_SECTION_TITLE_CLASS =
/** Split-pane sidebar panel title — same level as section titles. */
export const SETTINGS_PANEL_TITLE_CLASS = SETTINGS_SECTION_TITLE_CLASS;
/** L3 — control-group heading inside a section. */
export const SETTINGS_GROUP_TITLE_CLASS =
const SETTINGS_GROUP_TITLE_CLASS =
'typography-settings-group-title text-foreground';
/** L4 — field / control labels. */
export const SETTINGS_FIELD_LABEL_CLASS =
@@ -1,547 +0,0 @@
import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, describe, expect, mock, test } from 'bun:test';
import type { Session } from '@opencode-ai/sdk/v2';
import type { SessionGroup, SessionNode } from '../types';
let currentSessionId: string | null = null;
let newSessionDraftOpen = false;
let isNewWorktreeDialogOpen = false;
mock.module('@/stores/useUIStore', () => ({
useUIStore: Object.assign(
(selector: (state: { isNewWorktreeDialogOpen: boolean }) => unknown) =>
selector({ isNewWorktreeDialogOpen }),
{ getState: () => ({ isNewWorktreeDialogOpen }) },
),
}));
mock.module('@/sync/session-ui-store', () => ({
useSessionUIStore: (selector: (state: {
currentSessionId: string | null;
newSessionDraft: { open: boolean };
}) => unknown) => selector({
currentSessionId,
newSessionDraft: { open: newSessionDraftOpen },
}),
}));
const {
resolveMissingProjectSessionSelection,
ProjectSessionSelectionEffect,
} = await import('./useProjectSessionSelection');
// ---------------------------------------------------------------------------
// Helper: simulate the projectSessionMeta computation from the hook
// (same visitNodes logic as useProjectSessionSelection.ts)
// ---------------------------------------------------------------------------
type ProjectSection = {
project: { id: string; normalizedPath: string };
groups: SessionGroup[];
};
function computeProjectMeta(projectSections: ProjectSection[]) {
const metaByProject = new Map<string, Map<string, { directory: string | null }>>();
const firstSessionByProject = new Map<string, { id: string; directory: string | null }>();
const visitNodes = (
projectId: string,
projectRoot: string,
fallbackDirectory: string | null,
nodes: SessionNode[],
) => {
if (!metaByProject.has(projectId)) {
metaByProject.set(projectId, new Map());
}
const projectMap = metaByProject.get(projectId)!;
nodes.forEach((node) => {
const sessionDirectory = (
node.worktree?.path
?? (node.session as Session & { directory?: string | null }).directory
?? fallbackDirectory
?? projectRoot
).replace(/\\/g, '/').replace(/\/+$/, '');
projectMap.set(node.session.id, { directory: sessionDirectory });
if (!firstSessionByProject.has(projectId)) {
firstSessionByProject.set(projectId, { id: node.session.id, directory: sessionDirectory });
}
if (node.children.length > 0) {
visitNodes(projectId, projectRoot, sessionDirectory, node.children);
}
});
};
projectSections.forEach((section) => {
section.groups.forEach((group) => {
visitNodes(section.project.id, section.project.normalizedPath, group.directory, group.sessions);
});
});
return { metaByProject, firstSessionByProject };
}
// ---------------------------------------------------------------------------
// Test data
// ---------------------------------------------------------------------------
const makeSession = (id: string, directory?: string): Session =>
({ id, directory } as unknown as Session);
const rootSession1 = makeSession('root-session-1', '/workspace/project');
const rootSession2 = makeSession('root-session-2', '/workspace/project');
const worktreeSession1 = makeSession('wt-session-1', '/workspace/project-wt');
const project2Session1 = makeSession('project-2-session-1', '/workspace/project-2');
const project2Session2 = makeSession('project-2-session-2', '/workspace/project-2');
const WORKTREE_PATH = '/workspace/project-wt';
// staleSections: root group only, no worktree group
const staleSections: ProjectSection[] = [
{
project: { id: 'project-1', normalizedPath: '/workspace/project' },
groups: [
{
id: 'root',
label: 'Main',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: '/workspace/project',
sessions: [
{ session: rootSession1, children: [], worktree: null },
{ session: rootSession2, children: [], worktree: null },
],
},
],
},
];
// updatedSections: includes the worktree group
const updatedSections: ProjectSection[] = [
{
project: { id: 'project-1', normalizedPath: '/workspace/project' },
groups: [
{
id: 'root',
label: 'Main',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: '/workspace/project',
sessions: [
{ session: rootSession1, children: [], worktree: null },
{ session: rootSession2, children: [], worktree: null },
],
},
{
id: 'wt-group',
label: 'feature-branch',
branch: 'feature-branch',
description: 'Worktree at ' + WORKTREE_PATH,
isMain: false,
worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' },
directory: WORKTREE_PATH,
sessions: [
{ session: worktreeSession1, children: [], worktree: { path: WORKTREE_PATH, projectDirectory: '/workspace/project', branch: 'feature-branch', label: 'feature-branch' } },
],
},
],
},
];
// project-2Sections: separate project for project-switching tests
const project2Sections: ProjectSection[] = [
{
project: { id: 'project-2', normalizedPath: '/workspace/project-2' },
groups: [
{
id: 'root',
label: 'Main',
branch: null,
description: null,
isMain: true,
worktree: null,
directory: '/workspace/project-2',
sessions: [
{ session: project2Session1, children: [], worktree: null },
{ session: project2Session2, children: [], worktree: null },
],
},
],
},
];
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('useProjectSessionSelection — worktree session click race', () => {
test('stale projectSections (no worktree group) excludes worktree sessions from projectMap', () => {
const { metaByProject } = computeProjectMeta(staleSections);
const projectMap = metaByProject.get('project-1');
// Root sessions are present
expect(projectMap?.has('root-session-1')).toBe(true);
expect(projectMap?.has('root-session-2')).toBe(true);
// Worktree session is NOT present — this is what triggers the bug
expect(projectMap?.has('wt-session-1')).toBe(false);
});
test('stale data firstSessionByProject points to first root session, not worktree session', () => {
const { firstSessionByProject } = computeProjectMeta(staleSections);
// Path C would fall back to firstSessionByProject, which is the first ROOT session
const first = firstSessionByProject.get('project-1');
expect(first?.id).toBe('root-session-1');
expect(first?.id).not.toBe('wt-session-1');
});
test('updated projectSections includes all sessions including worktree', () => {
const { metaByProject } = computeProjectMeta(updatedSections);
const projectMap = metaByProject.get('project-1');
expect(projectMap?.has('root-session-1')).toBe(true);
expect(projectMap?.has('root-session-2')).toBe(true);
expect(projectMap?.has('wt-session-1')).toBe(true);
});
test('second click works correctly when projectSections is updated', () => {
const { metaByProject } = computeProjectMeta(updatedSections);
const projectMap = metaByProject.get('project-1')!;
const currentSessionId = 'wt-session-1';
// After data arrives, Path A succeeds — no guard needed
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(true);
});
test('project switch: Path A succeeds when currentSessionId matches the new project', () => {
const { metaByProject } = computeProjectMeta(project2Sections);
const projectMap = metaByProject.get('project-2')!;
const currentSessionId = 'project-2-session-1';
const pathAHit = Boolean(currentSessionId && projectMap?.has(currentSessionId));
expect(pathAHit).toBe(true);
});
});
describe('resolveMissingProjectSessionSelection', () => {
test('A → B selects B remembered session when the current session is owned by A', () => {
const projectBMap = new Map([
['project-b-first-session', null],
['project-b-remembered-session', null],
]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-b',
currentSessionId: 'stale-worktree-session-a',
currentSessionOwnerProjectId: 'project-a',
projectMap: projectBMap,
metaByProject: new Map([['project-b', projectBMap]]),
rememberedSessionId: 'project-b-remembered-session',
fallbackSessionId: 'project-b-first-session',
})).toEqual({ kind: 'select-session', sessionId: 'project-b-remembered-session' });
});
test('A → B falls back to B first session when none is remembered', () => {
const projectAMap = new Map([['project-a-session', null]]);
const projectBMap = new Map([['project-b-first-session', null]]);
const metaByProject = new Map([
['project-a', projectAMap],
['project-b', projectBMap],
]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-b',
currentSessionId: 'project-a-session',
currentSessionOwnerProjectId: 'project-a',
projectMap: projectBMap,
metaByProject,
rememberedSessionId: undefined,
fallbackSessionId: 'project-b-first-session',
})).toEqual({ kind: 'select-session', sessionId: 'project-b-first-session' });
});
test('A → B opens a B-scoped draft when B is empty', () => {
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-b',
currentSessionId: 'project-a-session',
currentSessionOwnerProjectId: 'project-a',
projectMap: undefined,
metaByProject: new Map([['project-a', new Map([['project-a-session', null]])]]),
rememberedSessionId: undefined,
fallbackSessionId: null,
})).toEqual({ kind: 'open-draft' });
});
test('preserves a same-project worktree session missing from a stale projectMap', () => {
const projectMap = new Map([['root-session-1', null]]);
const metaByProject = new Map([['project-1', projectMap]]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-1',
currentSessionId: 'wt-session-1',
currentSessionOwnerProjectId: 'project-1',
projectMap,
metaByProject,
rememberedSessionId: undefined,
fallbackSessionId: 'root-session-1',
})).toEqual({ kind: 'preserve-current' });
});
test('preserves an unknown session while worktree metadata may still be loading', () => {
const projectMap = new Map([['root-session-1', null]]);
const metaByProject = new Map([['project-1', projectMap]]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-1',
currentSessionId: 'wt-session-1',
currentSessionOwnerProjectId: null,
projectMap,
metaByProject,
rememberedSessionId: undefined,
fallbackSessionId: 'root-session-1',
})).toEqual({ kind: 'preserve-current' });
});
test('unknown ownership still switches when the session already appears under another project', () => {
const projectAMap = new Map([['project-a-session', null]]);
const projectBMap = new Map([
['project-b-first-session', null],
['project-b-remembered-session', null],
]);
const metaByProject = new Map([
['project-a', projectAMap],
['project-b', projectBMap],
]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-b',
currentSessionId: 'project-a-session',
currentSessionOwnerProjectId: null,
projectMap: projectBMap,
metaByProject,
rememberedSessionId: 'project-b-remembered-session',
fallbackSessionId: 'project-b-first-session',
})).toEqual({ kind: 'select-session', sessionId: 'project-b-remembered-session' });
});
test('deleted or missing currentSessionId falls through to remembered/fallback selection', () => {
const projectMap = new Map([['root-session-1', null]]);
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'project-1',
currentSessionId: null,
currentSessionOwnerProjectId: null,
projectMap,
metaByProject: new Map([['project-1', projectMap]]),
rememberedSessionId: undefined,
fallbackSessionId: 'root-session-1',
})).toEqual({ kind: 'select-session', sessionId: 'root-session-1' });
});
test('empty projects resolve to opening a draft', () => {
expect(resolveMissingProjectSessionSelection({
activeProjectId: 'empty-project',
currentSessionId: 'some-session-id',
currentSessionOwnerProjectId: null,
projectMap: undefined,
metaByProject: new Map<string, Map<string, null>>(),
rememberedSessionId: undefined,
fallbackSessionId: null,
})).toEqual({ kind: 'open-draft' });
});
});
// ---------------------------------------------------------------------------
// Hook-level: ProjectSessionSelectionEffect recovery / preserve
// ---------------------------------------------------------------------------
const installMinimalDom = () => {
const descriptors = new Map<string, PropertyDescriptor | undefined>();
const setGlobal = (name: string, value: unknown) => {
descriptors.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { configurable: true, writable: true, value });
};
class ElementStub {}
const documentStub: Record<string, unknown> = {
nodeType: 9,
defaultView: globalThis,
activeElement: null,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
const container = {
nodeType: 1,
tagName: 'DIV',
nodeName: 'DIV',
namespaceURI: 'http://www.w3.org/1999/xhtml',
ownerDocument: documentStub,
addEventListener: () => undefined,
removeEventListener: () => undefined,
};
documentStub.documentElement = container;
documentStub.body = container;
setGlobal('document', documentStub);
setGlobal('window', globalThis);
setGlobal('location', { search: '', protocol: 'http:', hostname: 'localhost' });
setGlobal('Element', ElementStub);
setGlobal('HTMLElement', ElementStub);
setGlobal('HTMLIFrameElement', ElementStub);
setGlobal('IS_REACT_ACT_ENVIRONMENT', true);
setGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => setTimeout(() => callback(Date.now()), 0));
setGlobal('cancelAnimationFrame', (id: ReturnType<typeof setTimeout>) => clearTimeout(id));
return {
container: container as unknown as Element,
restore: () => {
for (const [name, descriptor] of descriptors) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
};
type SelectionEffectProps = React.ComponentProps<typeof ProjectSessionSelectionEffect>;
const bothProjectSections: ProjectSection[] = [staleSections[0]!, project2Sections[0]!];
function mountSelectionEffect(initial: {
activeProjectId: string;
projectSections: ProjectSection[];
sessionId: string | null;
sessionOwnerBySessionId?: ReadonlyMap<string, { projectId: string }>;
rememberedByProject?: Map<string, string>;
}) {
currentSessionId = initial.sessionId;
newSessionDraftOpen = false;
isNewWorktreeDialogOpen = false;
const sessionSelectCalls: Array<[string, string | null]> = [];
const draftCalls: Array<{ selectedProjectId?: string | null; directoryOverride?: string | null } | undefined> = [];
const dom = installMinimalDom();
const root: Root = createRoot(dom.container);
const props: SelectionEffectProps = {
projectSections: initial.projectSections,
activeProjectId: initial.activeProjectId,
initialActiveSessionByProject: initial.rememberedByProject ?? new Map(),
persistActiveSessionByProject: () => undefined,
handleSessionSelect: (sessionId, sessionDirectory) => {
sessionSelectCalls.push([sessionId, sessionDirectory]);
},
mobileVariant: false,
openNewSessionDraft: (options) => {
draftCalls.push(options);
},
setActiveMainTab: () => undefined,
setSessionSwitcherOpen: () => undefined,
sessionOwnerBySessionId: initial.sessionOwnerBySessionId,
};
act(() => {
root.render(React.createElement(ProjectSessionSelectionEffect, props));
});
return {
sessionSelectCalls,
draftCalls,
rerender: (next: Partial<SelectionEffectProps> & { sessionId?: string | null }) => {
const { sessionId, ...effectProps } = next;
if (sessionId !== undefined) currentSessionId = sessionId;
Object.assign(props, effectProps);
act(() => {
root.render(React.createElement(ProjectSessionSelectionEffect, props));
});
},
teardown: () => {
act(() => {
root.unmount();
});
dom.restore();
},
};
}
describe('ProjectSessionSelectionEffect — ownership recovery', () => {
let teardown: (() => void) | null = null;
afterEach(() => {
teardown?.();
teardown = null;
currentSessionId = null;
newSessionDraftOpen = false;
isNewWorktreeDialogOpen = false;
});
test('A → B with later foreign ownership selects B remembered session', () => {
const missingASessionId = 'session-a-missing-from-maps';
const mounted = mountSelectionEffect({
activeProjectId: 'project-1',
projectSections: bothProjectSections,
sessionId: missingASessionId,
sessionOwnerBySessionId: new Map([[missingASessionId, { projectId: 'project-1' }]]),
rememberedByProject: new Map([['project-2', 'project-2-session-2']]),
});
teardown = mounted.teardown;
expect(mounted.sessionSelectCalls).toEqual([]);
mounted.rerender({
activeProjectId: 'project-2',
sessionOwnerBySessionId: new Map(),
});
expect(mounted.sessionSelectCalls).toEqual([]);
mounted.rerender({
sessionOwnerBySessionId: new Map([[missingASessionId, { projectId: 'project-1' }]]),
});
expect(mounted.sessionSelectCalls).toEqual([
['project-2-session-2', '/workspace/project-2'],
]);
});
test('A → B with known foreign ownership selects B remembered session', () => {
const mounted = mountSelectionEffect({
activeProjectId: 'project-1',
projectSections: bothProjectSections,
sessionId: 'root-session-1',
sessionOwnerBySessionId: new Map([['root-session-1', { projectId: 'project-1' }]]),
rememberedByProject: new Map([['project-2', 'project-2-session-2']]),
});
teardown = mounted.teardown;
expect(mounted.sessionSelectCalls).toEqual([]);
mounted.rerender({ activeProjectId: 'project-2' });
expect(mounted.sessionSelectCalls).toEqual([
['project-2-session-2', '/workspace/project-2'],
]);
});
test('stale same-project worktree selection stays put when ownership arrives', () => {
const mounted = mountSelectionEffect({
activeProjectId: 'project-1',
projectSections: staleSections,
sessionId: 'wt-session-1',
sessionOwnerBySessionId: new Map(),
rememberedByProject: new Map([['project-1', 'root-session-1']]),
});
teardown = mounted.teardown;
expect(mounted.sessionSelectCalls).toEqual([]);
expect(mounted.draftCalls).toEqual([]);
mounted.rerender({
sessionOwnerBySessionId: new Map([['wt-session-1', { projectId: 'project-1' }]]),
});
expect(mounted.sessionSelectCalls).toEqual([]);
expect(mounted.draftCalls).toEqual([]);
});
});
@@ -254,7 +254,6 @@ export const useProjectSessionSelection = (args: Args): void => {
return next;
});
}, [activeProjectId, currentSessionId, projectSessionMeta, setActiveSessionByProject]);
};
type ProjectSessionSelectionEffectProps = Omit<
@@ -28,7 +28,7 @@ const sessionDirectory = (session: Session | null | undefined): string | null =>
return typeof directory === 'string' && directory.trim() ? directory : null;
};
export const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
const useSessionPrefetch = ({ enabled = true, currentSessionId, sortedSessions, recentSessions = [], prefetchSession }: Args): void => {
const sessionPrefetchTimersRef = React.useRef<Map<string, number>>(new Map());
const sessionPrefetchQueueRef = React.useRef<PrefetchRequest[]>([]);
const sessionPrefetchInFlightRef = React.useRef<Set<string>>(new Set());
@@ -1,4 +1,4 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { cva } from 'class-variance-authority';
/**
* Single source of truth for every dropdown-style trigger surface in the app:
@@ -34,6 +34,3 @@ export const dropdownTriggerVariants = cva(
},
},
);
export type DropdownTriggerVariantProps = VariantProps<typeof dropdownTriggerVariants>;
export type DropdownTriggerSize = NonNullable<DropdownTriggerVariantProps['size']>;
@@ -52,7 +52,7 @@ interface PierreDiffViewerProps {
* and enables touch-friendly line interactions. Re-exported so plain
* <PierreFile> consumers (e.g. `MobileFilesSurface`) can inject the same.
*/
export const PIERRE_RUNTIME_BASE_CSS = `
const PIERRE_RUNTIME_BASE_CSS = `
:host {
font-family: var(--font-mono);
font-size: var(--text-code);
+1 -1
View File
@@ -41,7 +41,7 @@ const MAX_CHUNK_CHARS = 400;
* Sentences are merged until MIN_CHUNK_CHARS and hard-split at
* MAX_CHUNK_CHARS so a single run-on sentence cannot stall the pipeline.
*/
export function splitTextForSynthesis(text: string): string[] {
function splitTextForSynthesis(text: string): string[] {
const normalized = text.replace(/\s+/g, ' ').trim();
if (!normalized) {
return [];
+1 -1
View File
@@ -5,7 +5,7 @@ import { useUIStore } from '@/stores/useUIStore';
// How long the chat must sit untouched before the recap becomes visible.
// The suggestion has no such delay — it shows as soon as it arrives.
export const RECAP_VISIBILITY_DELAY_MS = 60 * 1000;
const RECAP_VISIBILITY_DELAY_MS = 60 * 1000;
interface LastMessageSnapshot {
id: string;
+1 -1
View File
@@ -253,7 +253,7 @@ const getDesktopBridge = (): DesktopBridgeGlobal | null => {
export const isElectronShell = (): boolean => getElectronRuntime()?.runtime === 'electron';
export const getElectronPlatform = (): string | null => {
const getElectronPlatform = (): string | null => {
if (typeof window === 'undefined') return null;
const platform = (window as unknown as { __OPENCHAMBER_PLATFORM__?: string }).__OPENCHAMBER_PLATFORM__;
return typeof platform === 'string' ? platform : null;
+1 -1
View File
@@ -28,7 +28,7 @@ let candidateRefreshInFlight = false;
* (stable tunnel hostname) is never overwritten: the DHCP problem does not apply
* to it and the server does not know its own public hostnames.
*/
export const refreshDesktopHostCandidates = async (hostId: string): Promise<void> => {
const refreshDesktopHostCandidates = async (hostId: string): Promise<void> => {
if (!isElectronShell() || candidateRefreshInFlight) return;
const runtimeKey = `host:${hostId}`;
// The candidates fetch rides the active runtime's transport — only meaningful
-25
View File
@@ -9,32 +9,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
export type {
GitStatus,
GitDiffResponse,
GetGitDiffOptions,
GitBranchDetails,
GitBranch,
GitCommitResult,
GitPushResult,
GitPullResult,
GitIdentityProfile,
GitIdentityAuthType,
GitIdentitySummary,
GitLogEntry,
GitLogResponse,
GitWorktreeInfo,
CreateGitWorktreePayload,
GitWorktreeCreateResult,
RemoveGitWorktreePayload,
GitWorktreeValidationError,
GitWorktreeValidationResult,
GitDeleteBranchPayload,
GitDeleteRemoteBranchPayload,
GitRemoveRemotePayload,
DiscoveredGitCredential,
GitRemote,
GitMergeResult,
GitRebaseResult,
MergeConflictDetails,
CommitFileDiffResponse,
} from './api/types';
+2 -2
View File
@@ -139,9 +139,9 @@ export const resetHardwareKeyboardDetection = (): void => {
setHardwareKeyboardAttached(false);
};
export const isHardwareKeyboardAttached = (): boolean => hardwareKeyboardAttached;
const isHardwareKeyboardAttached = (): boolean => hardwareKeyboardAttached;
export const subscribeHardwareKeyboard = (listener: () => void): (() => void) => {
const subscribeHardwareKeyboard = (listener: () => void): (() => void) => {
subscribers.add(listener);
return () => {
subscribers.delete(listener);
-2
View File
@@ -3036,5 +3036,3 @@ export const dict = {
'settings.mcp.page.connection.hintCommand': 'Sexécute sur cette machine. Collez une commande entière : elle est découpée en un argument par ligne.',
'settings.mcp.page.connection.hintLink': 'Se connecte à un serveur hébergé par quelquun dautre. Collez son adresse https.',
} as const;
export type I18nKey = keyof typeof dict;
@@ -1,8 +1,7 @@
/**
* Provider Circuit-Breaker & Retry Tracker
* Provider Circuit-Breaker Tracker
*
* Tracks per-provider error state to enable:
* - Transparent retry with exponential backoff for transient errors
* - Circuit breaking (pause requests to a provider during error storms)
*
* Inspired by HiveMind (arXiv:2604.17111) OS-inspired scheduling primitives.
@@ -12,9 +11,7 @@ import { getRuntimeKey } from '@/lib/runtime-switch'
const DEFAULT_CIRCUIT_BREAK_THRESHOLD = 3
const DEFAULT_CIRCUIT_COOLDOWN_MS = 30_000
const DEFAULT_RETRY_BASE_DELAY_MS = 1000
const DEFAULT_RETRY_MAX_DELAY_MS = 32_000
const DEFAULT_RETRY_MAX_ATTEMPTS = 3
const PROVIDER_EVICTION_TTL_MS = 60 * 60 * 1000
const PROVIDER_EVICTION_INTERVAL_MS = 10 * 60 * 1000
const PROVIDER_MAX_ENTRIES = 200
@@ -113,19 +110,7 @@ function isCircuitOpen(providerID: string): boolean {
return true
}
export function shouldRetry(providerID: string, status: number, attempt: number): boolean {
if (!RETRYABLE_STATUS_CODES.has(status)) return false
if (attempt >= DEFAULT_RETRY_MAX_ATTEMPTS - 1) return false
if (isCircuitOpen(providerID)) return false
return true
}
export function assertProviderCircuitClosed(providerID: string): void {
if (!providerID || !isCircuitOpen(providerID)) return
throw new Error(`Provider ${providerID} is temporarily unavailable after repeated errors. Please retry shortly.`)
}
export function getRetryDelayMs(attempt: number): number {
const delay = DEFAULT_RETRY_BASE_DELAY_MS * 2 ** attempt
return Math.min(delay, DEFAULT_RETRY_MAX_DELAY_MS)
}
-11
View File
@@ -83,10 +83,6 @@ export interface TunnelWsOpenPayload {
protocols?: string[];
}
export interface TunnelWsOpenedPayload {
protocol?: string;
}
export interface TunnelWsClosePayload {
code: number;
reason: string;
@@ -111,13 +107,6 @@ export interface E2eeReadyMessage {
batch?: boolean;
}
// Layer 1 control messages (relay <-> host control socket).
export type RelayControlMessage =
| { type: 'sync'; connectionIds: string[] }
| { type: 'connected'; connectionId: string }
| { type: 'disconnected'; connectionId: string }
| { type: 'limit'; reason: string };
// Relay-assigned WebSocket close codes.
export const RelayCloseCode = {
ControlReplaced: 4001,
-3
View File
@@ -3,12 +3,9 @@ import { configureRuntimeUrlResolver } from '@/lib/runtime-url';
import {
activateRelayTunnel,
deactivateRelayTunnel,
getActiveRelayTunnel,
type RelayRuntimeDescriptor,
} from '@/lib/relay/runtime-tunnel';
export { getActiveRelayTunnel };
export type RuntimeEndpointChangedDetail = {
apiBaseUrl: string;
previousApiBaseUrl: string;
+1 -1
View File
@@ -29,7 +29,7 @@ const isTouchOrCoarsePointer = (): boolean => {
* shell (always the mobile surface) desktop shells phone heuristic
* gated by the stored mobile layout preference.
*/
export const detectHostedSurface = (): HostedSurface => {
const detectHostedSurface = (): HostedSurface => {
if (typeof window === 'undefined') return 'desktop';
const explicitSurface = window.__OPENCHAMBER_SURFACE__;
-1
View File
@@ -189,7 +189,6 @@ Good:
- `useGitBranches(directory)`
- `useGitBranchLabel(directory)`
- `useGitRepoStatusMap(directories)`
- `usePrVisualSummaryByKeys(keys)`
Bad:
@@ -932,11 +932,8 @@ const deriveSummary = (entry: PrStatusEntry): PrVisualSummary | null => {
const summarySignature = (s: PrVisualSummary): string =>
`${s.number}:${s.visualState}:${s.prState}:${s.draft}:${s.title ?? ''}:${s.url ?? ''}:${s.base ?? ''}:${s.head ?? ''}:${s.canMerge ?? ''}:${s.mergeableState ?? ''}:${s.checks?.state ?? ''}:${s.checks?.total ?? ''}:${s.checks?.success ?? ''}:${s.checks?.failure ?? ''}:${s.checks?.pending ?? ''}:${s.repo?.owner ?? ''}:${s.repo?.repo ?? ''}`;
let prKeyedCacheSigs = new Map<string, string>();
let prKeyedCacheResult: Map<string, PrVisualSummary> = new Map();
// Per-key summary cache so many independent row subscribers (one key each)
// keep referential stability without fighting over the multi-key cache above.
// keep referential stability.
// Practically bounded by the number of worktree branches observed in a
// session; the explicit cap below guards long-running documents that rotate
// through many branches/runtimes (entries are tiny; insertion-order eviction
@@ -964,34 +961,3 @@ export const usePrVisualSummary = (key: string | null): PrVisualSummary | null =
return summary;
});
};
export const usePrVisualSummaryByKeys = (keys: string[]) => {
return useGitHubPrStatusStore((state) => {
// Derive summaries for requested keys only
const nextSigs = new Map<string, string>();
const nextSummaries = new Map<string, PrVisualSummary>();
for (const key of keys) {
const entry = state.entries[key];
if (!entry) continue;
const summary = deriveSummary(entry);
if (!summary) continue;
const sig = summarySignature(summary);
nextSigs.set(key, sig);
nextSummaries.set(key, summary);
}
// Compare with cached signatures
if (nextSigs.size === prKeyedCacheSigs.size) {
let same = true;
for (const [k, sig] of nextSigs) {
if (prKeyedCacheSigs.get(k) !== sig) { same = false; break; }
}
if (same) return prKeyedCacheResult;
}
prKeyedCacheSigs = nextSigs;
prKeyedCacheResult = nextSummaries;
return nextSummaries;
});
};
@@ -77,4 +77,4 @@ export const useSessionDisplayStore = create<SessionDisplayStore>()(
),
);
export type { ProjectSortOrder, SessionGroupingMode };
export type { ProjectSortOrder };
@@ -1,5 +1,15 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { createEventPipeline } from '../event-pipeline';
import { afterEach, describe, expect, it, mock } from 'bun:test';
// A WebSocket attempt mints an `oc_url_token` before connecting, because a WS
// upgrade cannot carry an Authorization header. Stub only that mint so the
// socket assertions below exercise the transport rather than the auth round-trip.
const actualRuntimeAuth = await import('@/lib/runtime-auth');
mock.module('@/lib/runtime-auth', () => ({
...actualRuntimeAuth,
refreshRuntimeUrlAuthToken: async () => 'test-url-token',
}));
const { createEventPipeline } = await import('../event-pipeline');
const originalDocument = globalThis.document;
const originalWindow = globalThis.window;
@@ -48,9 +58,9 @@ class FakeWebSocket {
this.onmessage?.({ data: JSON.stringify(payload) });
}
emitClose() {
emitClose(code = 1006, reason = '') {
this.readyState = 3;
this.onclose?.();
this.onclose?.({ code, reason });
}
}
@@ -7,7 +7,6 @@ import {
findLiveSession,
findLiveSessionStatus,
} from '../live-aggregate.ts'
import { deriveRecentSessions, RECENT_SESSION_MAX_AGE_MS } from '../../components/session/sidebar/activitySections.ts'
const session = (id, directory, updated, extra = {}) => ({
id,
@@ -94,19 +93,4 @@ describe('live aggregate', () => {
)).toBe(false)
})
it('derives recent sessions from the 48h window, excluding archived/subtasks', () => {
const now = 1_000_000_000
const sessions = [
session('ses-1', '/a', now - 1_000),
session('ses-2', '/b', now - 500),
session('ses-3', '/c', now - 10, { time: { created: now - 11, updated: now - 10, archived: now - 5 } }),
session('ses-4', '/d', now - 200, { parentID: 'ses-parent' }),
session('ses-5', '/e', now - RECENT_SESSION_MAX_AGE_MS - 1),
]
const recent = deriveRecentSessions(sessions, now)
// ses-3 archived, ses-4 subtask, ses-5 older than 48h -> excluded; rest newest-first
expect(recent.map((item) => item.id)).toEqual(['ses-2', 'ses-1'])
})
})
+1 -1
View File
@@ -236,7 +236,7 @@ function updateLiveSession(session: Session, directory?: string): boolean {
return false
}
export function mirrorSessionIntoLiveStores(session: Session, directory?: string): void {
function mirrorSessionIntoLiveStores(session: Session, directory?: string): void {
if (directory && updateLiveSession(session, directory)) {
return
}
-3
View File
@@ -255,9 +255,6 @@ function notifyMessageSent(sessionId: string): void {
// Types
// ---------------------------------------------------------------------------
export type { SyntheticContextPart } from "./input-store"
export type { SessionMemoryState } from "./viewport-store"
export type NewSessionDraftState = {
open: boolean
selectedProjectId?: string | null
@@ -6,30 +6,9 @@ import {
formatSessionWorktreeBadge,
getSessionWorktreeRepairActions,
getMutationBlockingReasons,
isWithinWorktreeRoot,
buildSessionTargetOptions,
} from './session-worktree-contract';
describe('isWithinWorktreeRoot', () => {
test('returns true when candidate equals root', () => {
expect(isWithinWorktreeRoot('/repo/worktrees/feat-a', '/repo/worktrees/feat-a')).toBe(true);
});
test('returns true when candidate is a subdirectory of root', () => {
expect(isWithinWorktreeRoot('/repo/worktrees/feat-a/src', '/repo/worktrees/feat-a')).toBe(true);
});
test('returns false when candidate is outside root', () => {
expect(isWithinWorktreeRoot('/tmp/outside', '/repo/worktrees/feat-a')).toBe(false);
});
test('returns false when either is null/empty', () => {
expect(isWithinWorktreeRoot(null, '/repo')).toBe(false);
expect(isWithinWorktreeRoot('/repo', null)).toBe(false);
expect(isWithinWorktreeRoot('', '/repo')).toBe(false);
});
});
describe('getAttachedSessionDirectory', () => {
test('prefers canonical cwd when attachment is healthy', () => {
expect(getAttachedSessionDirectory({
-33
View File
@@ -2769,26 +2769,6 @@ export function useChildStoreManager() {
return useSyncSystem().childStores
}
export type SessionTextMessage = {
id: string
role: string | null
text: string
}
const getPartText = (part: Part): string => {
if (part?.type !== "text") return ""
const text = (part as { text?: unknown }).text
return typeof text === "string" ? text : ""
}
const getConcatenatedTextFromParts = (parts: Part[]): string => {
let text = ""
for (const part of parts) {
text += getPartText(part)
}
return text
}
type SessionMessageRecord = { info: Message; parts: Part[] }
const EMPTY_SESSION_MESSAGE_RECORDS: SessionMessageRecord[] = []
@@ -3114,19 +3094,6 @@ export function useSessionRenderable(sessionID: string, directory?: string): boo
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
export function useSessionTextMessages(sessionID: string, directory?: string): SessionTextMessage[] {
const records = useSessionMessageRecords(sessionID, directory)
return useMemo(
() => records.map((record) => ({
id: record.info.id,
role: typeof record.info.role === "string" ? record.info.role : null,
text: getConcatenatedTextFromParts(record.parts),
})),
[records],
)
}
export function useUserMessageHistory(sessionID: string, directory?: string): string[] {
const store = useDirectoryStore(directory)
const snapshotRef = useRef<UserMessageHistorySnapshot>(EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT)
+1
View File
@@ -230,6 +230,7 @@
"watch:webview": "vite build --watch",
"type-check": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.webview.json",
"lint": "bun x eslint --ext .ts,.tsx src webview",
"test": "node ../../scripts/run-isolated-tests.mjs src webview",
"package": "vsce package --no-dependencies"
},
"devDependencies": {
@@ -31,6 +31,7 @@ mock.module('vscode', () => ({
mock.module('./opencodeConfig', () => ({
removeProviderConfig: mock(),
getProviderSources: mock(),
upsertProviderConfig: mock(),
}));
mock.module('./opencodeAuth', () => ({
getProviderAuth: mock(),
+1 -1
View File
@@ -60,4 +60,4 @@ async function modelsCommand(options = {}, action = 'show') {
process.stdout.write(formatModelsOutput(result));
}
export { modelsCommand, formatModelsOutput, formatDefaultLine, formatModelRef };
export { modelsCommand, formatModelsOutput };
@@ -39,36 +39,3 @@ export function discoverGitCredentials() {
return credentials;
}
export function getCredentialForHost(host) {
if (!fs.existsSync(GIT_CREDENTIALS_PATH)) {
return null;
}
try {
const content = fs.readFileSync(GIT_CREDENTIALS_PATH, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const url = new URL(line.trim());
const hostname = url.hostname;
const pathname = url.pathname && url.pathname !== '/' ? url.pathname : '';
const credHost = hostname + pathname;
if (credHost === host) {
return {
username: url.username || '',
token: url.password || ''
};
}
} catch {
continue;
}
}
} catch (error) {
console.error('Failed to read .git-credentials for host lookup:', error);
}
return null;
}
@@ -7,7 +7,7 @@
## Entrypoints and structure
- `packages/web/server/lib/github/index.js`: public server entrypoint.
- `packages/web/server/lib/github/index.js`: public server entrypoint. `routes.js` loads it lazily with `await import('./index.js')` and destructures the handler it needs, so a re-export removed from here breaks a route at request time rather than at build time. Static "unused export" reports do not see these consumers.
- `packages/web/server/lib/github/routes.js`: Express route registration for `/api/github/*` endpoints.
- `packages/web/server/lib/github/auth.js`: auth storage, multi-account support, client id, scope config.
- `packages/web/server/lib/github/device-flow.js`: OAuth device flow.
@@ -111,8 +111,6 @@ const jobKey = (repoRoot, sourceKeyValue) => `${repoRoot}\0${sourceKeyValue}`;
* would imply progress where there is none. `retrying` appears only when a
* provider rejects the schema and the prompt-side fallback runs.
*/
export const GENERATION_STAGES = ['collecting', 'asking', 'retrying', 'assembling'];
const setStage = (repoRoot, sourceKeyValue, stage) => {
const job = jobs.get(jobKey(repoRoot, sourceKeyValue));
if (job) job.stage = stage;
+3
View File
@@ -17,6 +17,9 @@ const createRouteRegistry = () => {
put(path, handler) {
routes.set(`PUT ${path}`, handler);
},
patch(path, handler) {
routes.set(`PATCH ${path}`, handler);
},
delete(path, handler) {
routes.set(`DELETE ${path}`, handler);
},
+7 -69
View File
@@ -1,74 +1,12 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('@openchamber/ui/lib/gitApiHttp', () => ({
checkIsGitRepository: vi.fn(),
getGitStatus: vi.fn(),
getGitDiff: vi.fn(),
getGitFileDiff: vi.fn(),
revertGitFile: vi.fn(),
stageGitFile: vi.fn(),
stageGitFiles: vi.fn(),
unstageGitFile: vi.fn(),
unstageGitFiles: vi.fn(),
stageGitHunk: vi.fn(),
unstageGitHunk: vi.fn(),
revertGitHunk: vi.fn(),
isLinkedWorktree: vi.fn(),
getGitBranches: vi.fn(),
deleteGitBranch: vi.fn(),
deleteRemoteBranch: vi.fn(),
removeRemote: vi.fn(),
generateCommitMessage: vi.fn(),
generatePullRequestDescription: vi.fn(),
listGitWorktrees: vi.fn(),
validateGitWorktree: vi.fn(),
createGitWorktree: vi.fn(),
deleteGitWorktree: vi.fn(),
validateWorktreeDirectory: vi.fn(),
canonicalizeWorktreeState: vi.fn(),
createGitCommit: vi.fn(),
gitPush: vi.fn(),
gitPull: vi.fn(),
gitFetch: vi.fn(),
listGitStashes: vi.fn(),
countGitStashFiles: vi.fn(),
stashGitChanges: vi.fn(),
applyGitStash: vi.fn(),
popGitStash: vi.fn(),
dropGitStash: vi.fn(),
checkoutBranch: vi.fn(),
createBranch: vi.fn(),
renameBranch: vi.fn(),
getGitLog: vi.fn(),
getCommitFiles: vi.fn(),
getCurrentGitIdentity: vi.fn(),
hasLocalIdentity: vi.fn(),
setGitIdentity: vi.fn(),
getGitIdentities: vi.fn(),
createGitIdentity: vi.fn(),
updateGitIdentity: vi.fn(),
deleteGitIdentity: vi.fn(),
getRemotes: vi.fn(),
rebase: vi.fn(),
abortRebase: vi.fn(),
continueRebase: vi.fn(),
merge: vi.fn(),
abortMerge: vi.fn(),
continueMerge: vi.fn(),
stash: vi.fn(),
stashPop: vi.fn(),
getConflictDetails: vi.fn(),
checkoutCommit: vi.fn(),
cherryPick: vi.fn(),
revertCommit: vi.fn(),
resetToCommit: vi.fn(),
getCommitFileDiff: vi.fn(),
previewGitWorktree: vi.fn(),
getGitWorktreeBootstrapStatus: vi.fn(),
discoverGitCredentials: vi.fn(),
getGlobalGitIdentity: vi.fn(),
getRemoteUrl: vi.fn(),
}));
// Every export is auto-stubbed from the real module. The previous hand-written
// list of ~70 names silently fell behind the source: `getGitRangeDiff` was added
// upstream, the list was not, and the whole file failed on an unrelated change.
vi.mock('@openchamber/ui/lib/gitApiHttp', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return Object.fromEntries(Object.keys(actual).map((name) => [name, vi.fn()]));
});
describe('createWebGitAPI', () => {
it('exposes bulk stage and unstage methods', async () => {
+23 -3
View File
@@ -1,11 +1,31 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
const here = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: {
'bun:test': fileURLToPath(new URL('./test/bun-test-shim.ts', import.meta.url)),
'@openchamber/ui': fileURLToPath(new URL('../ui/src', import.meta.url)),
alias: [
{ find: 'bun:test', replacement: path.resolve(here, './test/bun-test-shim.ts') },
// The same shared-UI aliases the app build uses. Without them a test can
// reference `@openchamber/ui/...` in a mock factory but not resolve the
// real module behind it, which is what forced mocks to hand-copy export
// lists that then fell behind the source.
{ find: '@opencode-ai/sdk/v2', replacement: path.resolve(here, '../../node_modules/@opencode-ai/sdk/dist/v2/client.js') },
{ find: '@openchamber/ui', replacement: path.resolve(here, '../ui/src') },
{ find: '@web', replacement: path.resolve(here, './src') },
// Anchored to `@/` on purpose: a bare `@` prefix would also swallow
// scoped dependencies the server tests rely on, such as `@octokit/rest`.
{ find: /^@\//, replacement: `${path.resolve(here, '../ui/src')}/` },
],
},
test: {
// The Git suites drive a real `git` binary against temporary repositories.
// Those subprocess round-trips routinely pass the 5s default, and which
// cases exceed it shifts with machine load, so the default made a valid
// suite fail differently on every run.
testTimeout: 30_000,
hookTimeout: 30_000,
},
});
-47
View File
@@ -1,47 +0,0 @@
# Reproduction: Chat UI stops updating until the desktop app is restarted (#2638)
Reproduces https://github.com/openchamber/openchamber/issues/2638 using the
real server modules (`lifecycle.js`, `global-hub.js`, `network-runtime.js`),
real processes, and real ports — no mocks.
## Run
```sh
# Windows-orphan scenario (the reported bug):
node scripts/repro/issue-2638/reproduce-2638.mjs
# Control: healthy restart on Linux (hub reconnects, UI keeps updating):
node scripts/repro/issue-2638/reproduce-2638.mjs --baseline
```
Requires `lsof` (used only for cleanup). The default run simulates Windows
(`process.platform` is temporarily overridden to `win32`) because the bug is
specific to the Windows process-lifecycle path.
## What it demonstrates
1. A managed OpenCode process starts; the global message-stream hub connects to
its `/global/event` SSE stream and chat events flow to the UI.
2. The managed process "exits" but the actual server process survives on the
old port (on Windows `killProcessOnPort` is a no-op and `taskkill` cannot
reach the orphaned tree — the report shows leftover `opencode.exe serve`
processes on historical ports).
3. `restartOpenCode()` gives up after 5 s — logs
`Timed out waiting for OpenCode port <old> to be released` — and spawns a
fresh server on a NEW port, leaving the orphaned process running.
4. HTTP/proxy traffic follows `state.openCodePort` to the new server, but the
hub's upstream SSE reader stays pinned to the OLD server's `/global/event`
stream (that connection never closed), so events emitted by the new server
never reach the UI: the chat UI goes stale while the new server keeps
persisting session data — visible only after restarting the app.
The `--baseline` control proves the reconnect logic itself is fine: when the
old process dies and the port is properly released, the hub reconnects to the
new port and events are delivered.
## Files
- `reproduce-2638.mjs` — the reproduction driver (assertions + summary).
- `fake-opencode-serve.mjs` — a fake `opencode serve` binary whose launcher
spawns a detached server core that survives the launcher's death
(Windows-style orphan), plus an in-process mode for the baseline control.
@@ -1,151 +0,0 @@
#!/usr/bin/env node
// Fake `opencode serve` used to reproduce https://github.com/openchamber/openchamber/issues/2638
//
// Modes (controlled by env):
// FAKE_OPENCODE_CORE=1 server-core mode: binds the port, serves
// /global/health + SSE /global/event, ignores
// SIGTERM so it survives its launcher's death
// (Windows-style orphaned server process).
// FAKE_OPENCODE_BASELINE=1 in-process mode: the server runs inside the
// managed process and dies with it (normal
// Linux behavior used as a control).
// default (launcher) spawns a detached core grandchild, waits for
// it to bind, prints the `opencode server
// listening on ...` line the lifecycle greps
// for, stays alive, and on SIGTERM exits WITHOUT
// killing the core (mimics opencode.exe dying
// while its server child survives).
import http from 'node:http';
import net from 'node:net';
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
const args = process.argv.slice(2);
const portIndex = args.indexOf('--port');
const hostnameIndex = args.indexOf('--hostname');
const port = portIndex >= 0 ? Number(args[portIndex + 1]) : 0;
const hostname = hostnameIndex >= 0 ? args[hostnameIndex + 1] : '127.0.0.1';
const pidDir = process.env.FAKE_OPENCODE_PID_DIR || null;
function writePidFile(label) {
if (!pidDir) return;
try {
fs.mkdirSync(pidDir, { recursive: true });
fs.writeFileSync(path.join(pidDir, `${label}-${port}.pid`), String(process.pid));
} catch {
// best effort
}
}
function createServer() {
const clients = new Set();
const emitted = [];
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${hostname}:${port}`);
if (url.pathname === '/global/health') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ healthy: true }));
return;
}
if (url.pathname === '/global/event') {
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
});
clients.add(res);
req.on('close', () => clients.delete(res));
// SSE keep-alive comments — exactly what a real OpenCode server sends,
// which prevents the upstream reader's 20s stall timer from firing.
const keepalive = setInterval(() => {
res.write(': keepalive\n\n');
}, 1000);
req.on('close', () => clearInterval(keepalive));
return;
}
if (url.pathname === '/emit') {
const type = url.searchParams.get('type') || 'session.updated';
const id = url.searchParams.get('id') || `evt-${Date.now()}`;
emitted.push({ id, type });
const block = `id: ${id}\ndata: ${JSON.stringify({ type, id })}\n\n`;
for (const client of clients) client.write(block);
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, id }));
return;
}
if (url.pathname === '/events') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(emitted));
return;
}
res.writeHead(404);
res.end('not found');
});
return { server };
}
const runServer = (label) => {
createServer().server.listen(port, hostname, () => {
console.log(`opencode server listening on http://${hostname}:${port}`);
});
writePidFile(label);
setInterval(() => {}, 1 << 30);
};
const main = async () => {
// Server-core mode: the orphaned server. Survives its launcher's death.
if (process.env.FAKE_OPENCODE_CORE === '1') {
runServer('core');
process.on('SIGTERM', () => {});
process.on('SIGINT', () => {});
return;
}
// Baseline in-process mode (control): dies with the managed process, so the
// port is properly released on restart.
if (process.env.FAKE_OPENCODE_BASELINE === '1') {
runServer('baseline');
return;
}
// Launcher mode: spawn a detached core grandchild, wait for it to bind,
// print the listening line, and on SIGTERM exit leaving the core running.
const core = spawn(process.execPath, [process.argv[1], ...args], {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
env: { ...process.env, FAKE_OPENCODE_CORE: '1' },
});
core.unref();
for (let i = 0; i < 200; i += 1) {
if (core.exitCode !== null) throw new Error('core exited early');
const ok = await new Promise((resolve) => {
const socket = net.connect({ port, host: hostname });
const timer = setTimeout(() => {
socket.destroy();
resolve(false);
}, 200);
socket.once('connect', () => {
clearTimeout(timer);
socket.destroy();
resolve(true);
});
socket.once('error', () => {
clearTimeout(timer);
resolve(false);
});
});
if (ok) break;
await new Promise((r) => setTimeout(r, 50));
}
writePidFile('launcher');
console.log(`opencode server listening on http://${hostname}:${port}`);
// On SIGTERM exit ourselves, leaving the detached core running.
process.on('SIGTERM', () => process.exit(0));
process.on('SIGINT', () => process.exit(0));
setInterval(() => {}, 1 << 30);
};
await main();
-376
View File
@@ -1,376 +0,0 @@
// Reproduction for https://github.com/openchamber/openchamber/issues/2638
// "[Bug] Chat UI stops updating until the desktop app is restarted"
//
// Run with: node reproduce-2638.mjs (Windows-orphan scenario)
// node reproduce-2638.mjs --baseline (control: healthy restart)
//
// What it wires up (real repo modules, real processes, real ports):
// - createOpenCodeLifecycleRuntime (packages/web/server/lib/opencode/lifecycle.js)
// - createGlobalMessageStreamHub (packages/web/server/lib/event-stream/global-hub.js)
// - createOpenCodeNetworkRuntime (packages/web/server/lib/opencode/network-runtime.js)
// - a fake `opencode serve` binary (fake-opencode-serve.mjs)
//
// Scenario (issue #2638):
// 1. OpenCode starts; the global message-stream hub connects to its
// /global/event SSE stream. Chat UI updates flow (baseline event e1
// reaches the hub).
// 2. The managed OpenCode process "exits" but the actual server process
// survives on the old port (on Windows killProcessOnPort is a no-op and
// taskkill cannot reach the orphaned tree — the report shows leftover
// `opencode.exe serve` processes on historical ports).
// 3. restartOpenCode() gives up after 5 s
// ("Timed out waiting for OpenCode port <old> to be released") and
// spawns a fresh server on a NEW port.
// 4. HTTP/proxy traffic follows state.openCodePort to the NEW server, but
// the hub's upstream SSE reader is still pinned to the OLD server's
// /global/event stream (that connection never closed), so events from
// the new server never reach the UI. Chat UI goes stale while the new
// server keeps persisting session data — visible only after restarting
// the app, exactly as reported.
import { spawnSync } from 'node:child_process';
import net from 'node:net';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Repository root (override with REPO=/path/to/openchamber if needed). Default:
// walk up from this script until we find the repo root (AGENTS.md + package.json).
const findRepoRoot = () => {
let dir = __dirname;
for (let i = 0; i < 8; i += 1) {
if (fs.existsSync(path.join(dir, 'AGENTS.md')) && fs.existsSync(path.join(dir, 'package.json'))) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return '/home/runner/work/openchamber/openchamber';
};
const REPO = process.env.REPO || findRepoRoot();
const BASELINE = process.argv.includes('--baseline');
// Simulate the Windows behavior reported in #2638 (process.platform is read
// at call time inside lifecycle.js: killProcessOnPort no-ops on win32 and
// terminateChildProcess takes the taskkill path, which cannot exist here).
if (!BASELINE) {
Object.defineProperty(process, 'platform', { value: 'win32' });
}
const { createOpenCodeLifecycleRuntime } = await import(
path.join(REPO, 'packages/web/server/lib/opencode/lifecycle.js')
);
// --- shared state + real network runtime -----------------------------------
const state = {
openCodeWorkingDirectory: '/tmp',
openCodeProcess: null,
openCodePort: null,
openCodeBaseUrl: null,
currentRestartPromise: null,
isRestartingOpenCode: false,
openCodeApiPrefix: '',
openCodeApiPrefixDetected: false,
openCodeApiDetectionTimer: null,
lastOpenCodeError: null,
isOpenCodeReady: false,
openCodeNotReadySince: 0,
isExternalOpenCode: false,
isShuttingDown: false,
healthCheckInterval: null,
expressApp: null,
useWslForOpencode: false,
resolvedWslBinary: null,
resolvedWslOpencodePath: null,
resolvedWslDistro: null,
lastOpenCodeLaunchDiagnostics: null,
};
const { createOpenCodeNetworkRuntime } = await import(
path.join(REPO, 'packages/web/server/lib/opencode/network-runtime.js')
);
const networkRuntime = createOpenCodeNetworkRuntime({
state,
getOpenCodeAuthHeaders: () => ({}),
configuredOpenCodeHostname: '127.0.0.1',
});
const fakeBinary = path.join(__dirname, 'fake-opencode-serve.mjs');
const pidDir = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-repro-2638-'));
process.env.OPENCODE_BINARY = fakeBinary;
process.env.FAKE_OPENCODE_PID_DIR = pidDir;
if (BASELINE) process.env.FAKE_OPENCODE_BASELINE = '1';
const lifecycle = createOpenCodeLifecycleRuntime({
state,
env: {
ENV_CONFIGURED_OPENCODE_PORT: 0,
ENV_CONFIGURED_OPENCODE_HOST: null,
ENV_EFFECTIVE_PORT: 0,
ENV_CONFIGURED_OPENCODE_HOSTNAME: '127.0.0.1',
ENV_SKIP_OPENCODE_START: false,
},
syncToHmrState: () => {},
syncFromHmrState: () => {},
getOpenCodeAuthHeaders: () => ({}),
buildOpenCodeUrl: (...args) => networkRuntime.buildOpenCodeUrl(...args),
waitForReady: (...args) => networkRuntime.waitForReady(...args),
normalizeApiPrefix: (...args) => networkRuntime.normalizeApiPrefix(...args),
applyOpencodeBinaryFromSettings: async () => {},
ensureOpencodeCliEnv: () => {},
ensureLocalOpenCodeServerPassword: async () => 'password',
resolveManagedOpenCodeLaunchSpec: (binary) => ({ binary, args: [], wrapperType: null }),
setOpenCodePort: (port) => { state.openCodePort = port; },
setDetectedOpenCodeApiPrefix: () => {},
setupProxy: () => {},
ensureOpenCodeApiPrefix: () => {},
clearResolvedOpenCodeBinary: () => {},
buildAugmentedPath: () => process.env.PATH,
buildManagedOpenCodePath: () => process.env.PATH,
getManagedOpenCodeShellEnvSnapshot: async () => ({}),
getManagedOpenCodeEnv: async () => ({}),
reapManagedOrphanedProcesses: async () => ({ reaped: 0 }),
getWarmupDirectories: async () => [],
// Production index.js wires this to the message-stream runtime's
// rebindUpstream(); mirror it here so the harness exercises the fix.
onOpenCodeRestarted: () => {
try {
rebindHub?.();
} catch {
}
},
});
const { createGlobalMessageStreamHub } = await import(
path.join(REPO, 'packages/web/server/lib/event-stream/global-hub.js')
);
// The hub represents the server→OpenCode SSE push pipeline that feeds the
// renderer (both the server-side PushWatcher and the browser WS bridge).
const received = [];
const statuses = [];
let rebindHub = null;
const hub = createGlobalMessageStreamHub({
buildOpenCodeUrl: (p) => networkRuntime.buildOpenCodeUrl(p, ''),
getOpenCodeAuthHeaders: () => ({}),
upstreamStallTimeoutMs: 20000,
upstreamReconnectDelayMs: 250,
});
hub.subscribeEvent(({ eventId, payload }) => {
received.push({ eventId, type: payload?.type, id: payload?.id });
});
hub.subscribeStatus((status) => statuses.push(status));
rebindHub = () => {
hub.stop();
hub.start();
};
// --- helpers ----------------------------------------------------------------
const warnLog = [];
const origWarn = console.warn;
console.warn = (...args) => {
warnLog.push(args.map(String).join(' '));
origWarn(...args);
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitFor(fn, timeoutMs, what) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await fn()) return true;
await sleep(50);
}
throw new Error(`timeout waiting for ${what}`);
}
const emitEvent = async (port, id, type = 'session.updated') => {
const res = await fetch(`http://127.0.0.1:${port}/emit?type=${type}&id=${id}`);
if (!res.ok) throw new Error(`emit ${id} failed on port ${port}`);
return res.json();
};
const persistedEvents = async (port) => {
const res = await fetch(`http://127.0.0.1:${port}/events`);
return res.ok ? res.json() : [];
};
const portOpen = (port) => new Promise((resolve) => {
const socket = net.connect({ port, host: '127.0.0.1' });
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 300);
socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolve(true); });
socket.once('error', () => { clearTimeout(timer); resolve(false); });
});
const pidFilePids = () => {
const out = [];
for (const file of fs.readdirSync(pidDir)) {
if (!file.endsWith('.pid')) continue;
try {
out.push({ label: file.replace(/\.pid$/, ''), pid: Number(fs.readFileSync(path.join(pidDir, file), 'utf8')) });
} catch {
// ignore
}
}
return out;
};
const killPortPids = (port) => {
try {
const result = spawnSync('lsof', ['-ti', `:${port}`], { encoding: 'utf8' });
const pids = String(result.stdout || '').trim().split(/\s+/).map(Number).filter(Boolean);
for (const pid of pids) {
if (pid === process.pid) continue; // never kill ourselves (TIME_WAIT client sockets)
try { process.kill(pid, 'SIGKILL'); } catch { /* already gone */ }
}
} catch { /* lsof unavailable */ }
};
const cleanup = async () => {
for (const { label, pid } of pidFilePids()) {
if (label.startsWith('launcher') || label.startsWith('baseline')) {
try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
} else {
try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
}
}
if (state.openCodePort) killPortPids(state.openCodePort);
// Belt and braces: kill any surviving fake-opencode processes from this run.
try {
const result = spawnSync('pgrep', ['-f', 'fake-opencode-serve.mjs'], { encoding: 'utf8' });
const pids = String(result.stdout || '').trim().split(/\s+/).map(Number).filter(Boolean);
for (const pid of pids) {
if (pid === process.pid) continue;
try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ }
}
} catch { /* pgrep unavailable */ }
await sleep(300);
try { fs.rmSync(pidDir, { recursive: true, force: true }); } catch { /* ignore */ }
console.warn = origWarn;
};
// --- run ---------------------------------------------------------------------
console.log(`\n=== reproduce-2638 (${BASELINE ? 'BASELINE control' : 'Windows-orphan scenario'}) ===\n`);
let failures = 0;
const check = (label, ok, detail = '') => {
console.log(` ${ok ? 'PASS' : 'FAIL'} ${label}${detail ? `${detail}` : ''}`);
if (!ok) failures += 1;
};
try {
// 1. Bootstrapping starts the managed OpenCode (launcher + server core) on P1.
await lifecycle.bootstrapOpenCodeAtStartup();
const p1 = state.openCodePort;
console.log(`[1] bootstrap OK — managed OpenCode listening on port ${p1} (pid ${state.openCodeProcess?.pid})`);
// 2. Connect the message-stream hub (server→OpenCode SSE push pipeline).
hub.start();
await waitFor(() => statuses.some((s) => s.type === 'connect'), 10000, 'hub connect to P1');
console.log('[2] message-stream hub connected to /global/event');
// 3. Baseline delivery: an event emitted by P1 reaches the UI pipeline.
await emitEvent(p1, 'evt-before-restart');
await waitFor(() => received.some((r) => r.eventId === 'evt-before-restart'), 5000, 'event delivery');
check('events flow to the UI before the restart (baseline)', received.some((r) => r.eventId === 'evt-before-restart'));
// 4. The managed process "exits" while the actual server survives on P1
// (simulates the Windows orphan: launcher dies, server core keeps the
// port and the SSE stream). In baseline mode the server runs in-process
// and dies with the managed process instead.
const launcherPid = state.openCodeProcess.pid;
process.kill(launcherPid, 'SIGTERM');
await waitFor(async () => {
try { process.kill(launcherPid, 0); return false; } catch { return true; }
}, 5000, 'launcher exit');
console.log(`[4] managed process (pid ${launcherPid}) exited; ${BASELINE ? 'server process died with it' : `orphaned server core still listening on ${p1}`}`);
// 5. Trigger the reported restart path ("Refreshing OpenCode after manual
// configuration reload" / periodic health check).
console.log('[5] triggering restart (refreshOpenCodeAfterConfigChange)...');
await lifecycle.refreshOpenCodeAfterConfigChange('manual configuration reload');
const p2 = state.openCodePort;
console.log(`[5] restarted — new managed OpenCode listening on port ${p2}`);
// 6. Assert the reported log line: the old port was never released. In the
// baseline control the port IS released, so the warning must be absent.
const timeoutWarn = warnLog.find((line) => line.includes('Timed out waiting for OpenCode port') && line.includes(String(p1)));
if (BASELINE) {
check(`no "Timed out waiting for OpenCode port ${p1}" warning in baseline control`, !timeoutWarn);
} else {
check(`"Timed out waiting for OpenCode port ${p1} to be released" is logged`, Boolean(timeoutWarn), timeoutWarn || '');
}
check('new port differs from old port (leaked process pinned the old one)', p2 !== p1, `p1=${p1} p2=${p2}`);
// 7. Assert the orphaned old server is still running (process pile-up from
// the report: "six opencode.exe serve processes were still running").
// In baseline mode we instead expect the port to be properly released.
const oldCoreStillUp = await portOpen(p1);
const pids = pidFilePids();
const orphanPids = pids.filter(({ label }) => label.startsWith('core')).map(({ pid }) => pid);
const orphanAlive = orphanPids.length > 0 && orphanPids.every((pid) => {
try { process.kill(pid, 0); return true; } catch { return false; }
});
if (BASELINE) {
check('old port properly released (no orphan in baseline control)', !oldCoreStillUp,
`old port ${p1} ${oldCoreStillUp ? 'still open' : 'released'}`);
} else {
check('orphaned server process still running on the old port', oldCoreStillUp && orphanAlive,
`old port ${p1} still open; orphan core pids ${orphanPids.join(', ')}`);
}
// 8. The stale-UI reproduction: the new server persists events, but in the
// orphan scenario they never reach the UI because the hub is still pinned
// to the old SSE stream. In baseline mode the hub must reconnect to the
// new port and deliver them (wait for the reconnect before emitting —
// the upstream reader only learns about the new port on its next attempt).
const connectsBefore = statuses.filter((s) => s.type === 'connect').length;
if (!BASELINE) {
await sleep(1000);
} else {
await waitFor(() => statuses.filter((s) => s.type === 'connect').length >= connectsBefore + 1, 15000, 'hub reconnect to new port');
}
await emitEvent(p2, 'evt-after-restart');
console.log(`[8] emitted evt-after-restart on new port ${p2} — waiting to see if the UI receives it...`);
await sleep(2500);
const deliveredAfter = received.filter((r) => r.eventId === 'evt-after-restart').length;
if (BASELINE) {
check('NEW server event IS delivered to the UI (hub reconnected in baseline)', deliveredAfter === 1,
`delivered=${deliveredAfter}, total hub events=${received.length}`);
} else {
// Fixed: the lifecycle hook rebinds the hub after the managed restart,
// so the UI receives events from the new port even when the old port is
// orphaned. The wait mirrors the baseline branch (the reader re-dials on
// its next attempt after the rebind).
await waitFor(() => statuses.filter((s) => s.type === 'connect').length >= connectsBefore + 1, 15000, 'hub reconnect to new port after rebind');
check('NEW server event IS delivered to the UI (rebound after restart)', deliveredAfter === 1,
`delivered=${deliveredAfter}, total hub events=${received.length}`);
}
const persistedOnNew = await persistedEvents(p2);
check('NEW server persisted the event (data survives, UI does not show it)', persistedOnNew.some((e) => e.id === 'evt-after-restart'),
`persisted on port ${p2}: ${JSON.stringify(persistedOnNew)}`);
// 9. Orphan scenario: with the rebind the hub is no longer pinned to the
// old server — events emitted by the old (zombie) server must NOT reach
// the UI anymore (it left the previous upstream behind).
if (!BASELINE) {
await emitEvent(p1, 'evt-zombie-old-server');
await sleep(2000);
check('OLD zombie server events no longer reach the UI (hub rebound to new upstream)', !received.some((r) => r.eventId === 'evt-zombie-old-server'));
}
console.log(`\n=== ${failures === 0 ? 'REPRODUCED (all checks passed)' : `${failures} check(s) FAILED`} ===\n`);
console.log(`hub statuses observed: ${JSON.stringify(statuses)}`);
} catch (error) {
console.error('\nReproduction script error:', error);
failures += 1;
} finally {
await cleanup();
}
process.exit(failures === 0 ? 0 : 1);
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env node
// Runs every test file under the given roots, each in its own process.
//
// Two properties of this repository make that the working arrangement rather
// than a preference:
//
// - The shared UI and extension suites keep module-level singletons (runtime
// endpoint, relay tunnel, stores, registries). Executed in one process they
// leak state into each other and fail by load order, which is why the relay
// guidance already says to run those files one at a time.
// - The same directories mix `bun:test` and `node:test` files, so no single
// runner command covers them. The framework is read from the file's imports
// instead of being listed here, so adding a test never requires editing a
// list that then rots.
//
// Usage: node scripts/run-isolated-tests.mjs <root> [...roots]
import { spawn } from 'node:child_process';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
const TEST_FILE = /\.(test|spec)\.(js|cjs|mjs|jsx|ts|tsx)$/;
const SKIP_DIRS = new Set(['node_modules', 'dist', 'dist-bundle', 'build', 'out', '.git', 'ios', 'android']);
const MAX_PARALLEL = 4;
const collect = (root, found = []) => {
for (const entry of readdirSync(root, { withFileTypes: true })) {
if (entry.name.startsWith('.') && entry.name !== '.') continue;
const full = path.join(root, entry.name);
if (entry.isDirectory()) {
if (!SKIP_DIRS.has(entry.name)) collect(full, found);
continue;
}
if (TEST_FILE.test(entry.name)) found.push(full);
}
return found;
};
/** `null` when the file names no known runner, so it is reported instead of skipped silently. */
const resolveCommand = (file) => {
const source = readFileSync(file, 'utf8');
const isTypeScript = /\.tsx?$/.test(file);
// TypeScript goes to Bun even when the file imports `node:test`, which Bun
// implements. Node's ESM loader cannot resolve the extensionless local
// specifiers these files use (`./sseProxy`), so it never ran them at all.
if (isTypeScript || /from\s+['"]bun:test['"]/.test(source)) {
return { label: 'bun', command: 'bun', args: ['test', file] };
}
if (/from\s+['"]node:test['"]/.test(source)) {
return { label: 'node', command: 'node', args: ['--test', file] };
}
return null;
};
const run = ({ command, args }) => new Promise((resolve) => {
const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let output = '';
child.stdout.on('data', (chunk) => { output += chunk; });
child.stderr.on('data', (chunk) => { output += chunk; });
child.on('error', (error) => resolve({ code: 1, output: `${output}${error.message}` }));
child.on('close', (code) => resolve({ code: code ?? 1, output }));
});
const roots = process.argv.slice(2);
if (roots.length === 0) {
console.error('run-isolated-tests: expected at least one root directory');
process.exit(1);
}
const files = [];
for (const root of roots) {
const resolved = path.resolve(root);
if (!statSync(resolved).isDirectory()) {
console.error(`run-isolated-tests: not a directory: ${root}`);
process.exit(1);
}
files.push(...collect(resolved));
}
files.sort();
const failures = [];
const unknown = [];
let passed = 0;
let next = 0;
const worker = async () => {
while (next < files.length) {
const file = files[next++];
const relative = path.relative(process.cwd(), file);
const resolved = resolveCommand(file);
if (!resolved) {
unknown.push(relative);
continue;
}
const { code, output } = await run(resolved);
if (code === 0) {
passed += 1;
} else {
failures.push({ relative, label: resolved.label, output });
console.error(`FAIL (${resolved.label}) ${relative}`);
}
}
};
await Promise.all(Array.from({ length: Math.min(MAX_PARALLEL, files.length) }, worker));
for (const failure of failures) {
console.error(`\n===== ${failure.relative} (${failure.label}) =====\n${failure.output}`);
}
for (const file of unknown) {
console.error(`UNKNOWN RUNNER ${file}: imports neither bun:test nor node:test`);
}
console.log(`\n${passed}/${files.length} test files passed${failures.length ? `, ${failures.length} failed` : ''}${unknown.length ? `, ${unknown.length} with no known runner` : ''}`);
process.exit(failures.length > 0 || unknown.length > 0 ? 1 : 0);